Vis

Extension API

Reference for registering Python extensions: declarations, tool contracts, callbacks and host operations.

When to use

Start with the tutorial for a complete entry file or Extension design for authoring and test guidance.

Find an API

Task Section
Declare and register an extension Registration
Export a callable or object namespace Tools
Explain when the agent should use it Prompts and discovery
Read types, defaults and introspection limits Tool contracts
Add a user command or guard Slash commands · Op hooks
Add configurable behavior Settings
Persist data or report status Durable state · Logging and notifications
Store or read files that a tool makes Attachments
Read environment or add session context Environment · Session context
Access files or start a process Filesystem and processes
Resolve the active working copy Workspace root · Python environment
Show forms, live work or register a provider Forms · Live views · Providers

Build metadata

Python extension entry files and python_execution receive these globals without imports:

Global Meaning
VIS_VERSION Version of the loaded Vis build
VIS_SHA_RELEASE Full Vis build commit, with -dirty for an uncommitted native build, or None if unavailable
VIS_PYTHON_RUNTIME_VERSION Version of Vis' embedded Python runtime library, not the CPython interpreter version
VIS_PYTHON_SDK_VERSION PyPI version of the bundled SDK: VIS_VERSION for a release, a development release such as 0.2.47.dev7678 for a beta

The model's system context reports the same values. A source build can report dev. These values describe the loaded build, which is not always a published release. Use them for diagnostics. Extensions and python_execution always use the bundled SDK, also when pip or an editable install has another copy. Installing another SDK does not change these globals.

Editor types

Your editor and type checker, for example Pyright or Pylance, read the vis-agent package in your Python environment. Vis runs its bundled SDK instead. To get the same types, install the version in VIS_PYTHON_SDK_VERSION:

pip install "vis-agent==0.2.46"

PyPI has the SDK of every release and every beta. A new beta reaches PyPI some minutes after its release. For a beta, give the exact version, because pip skips development versions otherwise. Pin the version in your development environment only, not in an extension that you publish.

A lock file keeps the old SDK when you update Vis. Your editor then shows old types, but your extension still runs with the bundled SDK. vis-agent doctor reports each vis-agent in the project .venv or the shared packages that differs from the bundled SDK. To update a uv project, set the new version in pyproject.toml. Then run uv lock --upgrade-package vis-agent and uv sync.

A dev value, or a value that ends in +dirty, comes from a source run or a local build. PyPI does not have these versions. For them, install packages/vis-agent from the same checkout.

In python_execution, you can import blockether.vis.extension to inspect public types and construct declarations without registering them. Registration and host callbacks such as state and shell raise RuntimeError. Native filesystem helpers such as fs.read raise PermissionError. No standalone host is initialized, and blockether.vis.extension is the only import under blockether.vis there. See sandbox experimentation for an example. The registration and host API examples below require a trusted extension context.

Registration

Examples on this page use import blockether.vis.extension as vis inside a trusted extension, not in python_execution. Call vis.register_extension(vis.Extension(...)) once per entry file. The declaration constructors validate and copy values without IO. Registration applies them and resolves the declared environment.

Extension arguments are keyword-only. Its collection arguments accept sequences of the corresponding SDK declarations and are stored as tuples, not mutable registries.

Argument Default Meaning
name: str Required Unique extension identity, which also keys durable state
description: str Required One-line summary for the extension list and model snapshot
alias: str None (required with symbols) Registry identity, not a tool-name prefix
symbols () vis.Symbol declarations for callable tools
prompt: str or callable None Model instructions. A callable receives the env dict each turn and returns text or None
activation None Optional (env) -> bool. False hides the extension for that turn
slash_commands () vis.SlashCommand declarations for user commands
op_hooks () vis.OpHook guards or observers
network_filters () vis.NetworkFilter request/response policy (see network filters)
providers () vis.Provider declarations
ctx None Optional (env) -> dict merged into the model's session context
env () Names of host environment variables this entry may read
grants () vis.Grant toolchain paths for this extension's jailed children (see toolchain grants)
kind, version None Display metadata. An installed package supplies its manifest metadata

The callback env dict contains cwd, workspace_root, project_root, session_id and channel. cwd and workspace_root are the session root. project_root is the extension's project. Keep prompt, activation and ctx short-running. Calls into one extension instance are serialized. Dependency and skill metadata belong to the package manifest, not Extension.

Settings

Declare vis.Setting objects in Extension(settings=[...]) to use the app and TUI's shared settings rows. Read setting.value() inside a callback. It returns that response's effective snapshot without changing the global setting. Outside Vis, it returns the declared default. Type checkers give value() the type of the default: bool for a boolean setting, str for a choice setting. Registration validates declarations but does not create overrides.

import blockether.vis.extension as vis

include_details = vis.Setting(
    id="report_details",
    label="Report details",
    default=False,
    scopes=["global", "project", "group", "session"],
    description="Include the supporting detail in reports.",
)

def report() -> str:
    """Prepare a report with the selected level of detail."""
    return "Detailed report" if include_details.value() else "Summary"

vis.register_extension(vis.Extension(
    name="reports",
    alias="reports",
    description="Prepare reports.",
    settings=[include_details],
    symbols=[vis.Symbol(report, activity=vis.Activity(
        label="Prepare report", show_start=False,
    ))],
))

To show a setting under another setting, set parent to the id of that setting. Settings shows the nested setting one step in, below its parent. A nested setting can have its own nested settings, at any depth. If the parent is not in the same settings section, the setting stays at the top level.

detail_level = vis.Setting(
    id="report_detail_level",
    label="Detail level",
    type="enum",
    default="short",
    choices=["short", "full"],
    parent="report_details",
)

id is a lower-case snake_case identifier. It must be unique among the settings of Vis and of the other loaded extensions. If another owner already uses your id, your extension does not load. The load error names that owner.

type defaults to "boolean" with a boolean default. For a choice row, use type="enum", string choices and a default from those choices.

For one line of text, such as a server address, use type="string" with a string default. Vis removes spaces at the start and the end. A value has at most 4096 characters and no control characters, such as a line break.

For a password or an access token, also set is_secret=True. A secret setting needs default="" and only the global scope. Settings then shows only Saved or Not set, never the value. Vis keeps the value on this computer and writes [REDACTED] in its place in trajectories.

server_url = vis.Setting(
    id="reports_server_url",
    label="Report server",
    type="string",
    default="https://reports.example.com",
)

server_token = vis.Setting(
    id="reports_server_token",
    label="Report server token",
    type="string",
    default="",
    is_secret=True,
)

A project extension cannot save a secret setting. Settings does not show it, and value() returns the default. The --toggles command line option also refuses a secret, because other programs can read a command line.

Read a secret setting while Vis calls your extension, for example in one of its tools. Another extension that reads it gets the default. At import time and in a thread that your extension starts, value() can also return the default.

To act when a person changes a setting, give on_change. Vis calls on_change(old, new, scope) once after the app or the TUI saves the setting, if the effective value changed. scope is the level of the change: global, project, group or session. Use it for a side effect, such as updating a file. The Settings request waits for the hook, so keep it short-running.

from pathlib import Path

def record_mode(old: str, new: str, scope: str) -> None:
    Path("steering-mode.txt").write_text(new)

steering_mode = vis.Setting(
    id="steering_mode",
    label="Steering mode",
    type="enum",
    default="vibe",
    choices=["vibe", "control"],
    scopes=["global", "project"],
    on_change=record_mode,
)

If the hook raises an error, the value stays saved. The app and the TUI show the error, and the gateway log records it. Vis does not call the hook when the extension loads. To read the current value at another time, call value() in a callback.

value() reads the current value in every callback, also outside a turn, such as in a background thread. In a session, it uses the session's settings. Without a session, a project extension's setting resolves at project scope, as Settings shows it there. Project values, such as toggles: in the project's vis.yml, win over global values if scopes includes project. Vis reads a vis.yml edit after /reload. A file edit does not call on_change.

The app and the TUI show your settings under Extensions, in a group with your extension's name. That group also holds the extension's engine choice and its packaged skills. A project or global tag shows where your extension is installed.

If your extension fails to load, the group stays and shows the error. After you change the file, select Reload in the Extensions header of Settings. Opening Settings does not run your code. See extension settings.

scopes accepts any non-empty, duplicate-free subset of global, project, group and session. Without it, only global is permitted. Vis checks the scope when it saves or reads a value, not only in Settings. A missing override inherits, but False does not. Reloading or temporarily removing an extension keeps its stored choices.

A project extension exists only in its project. For a project extension, global means the whole project. Its settings show only in the settings of that project.

See scoped settings for inheritance, submission timing, engine activation and access limits.

Tools

vis.Symbol(fn_or_object, name=None, tag="observation", is_hidden=False, activity=None)

name=None uses a function's name. For an object namespace, set name explicitly. tag is observation, mutation, verification or external. See Report checks and outside effects. is_hidden=True removes a callable from model-facing discovery. It does not make the callable inaccessible, and it does not authorize its use.

Every exported callable needs a nonblank docstring and an explicit activity presentation. activity=None leaves only engine execution evidence. It does not create a result view. Do not rely on this omission for a finished extension.

Source functions can use def or async def. The model calls their proxies with await in python_execution. The trusted worker awaits an asynchronous result before it returns it. Execution-watchdog parking covers that wait too. Each async call uses its own event loop, so create and close loop-bound resources within that call.

Arguments and completed results stay Python values. Exceptions are ordinary tool failures. The execution boundary determines which values can cross to the sandbox.

Typed catalog

vis.Catalog(symbols) accepts a sequence of the same Symbol declarations used by Extension. Construction snapshots public callable contracts without registration or IO. Duplicate declaration names are rejected. Hidden tools are not discoverable through the catalog. Visibility is not access control. Rebuild the snapshot after declaration changes.

API Result
catalog.spec() Tuple of top-level ToolSpec or NamespaceSpec values
catalog.spec("store.items.read") One callable's ToolSpec
catalog.spec("store.items") NamespaceSpec(name, members): the fully named ToolSpec of every public callable descendant
catalog.help("store.items.read") HelpDocument(tool, text) generated from the registered documentation
catalog.help("store") Generated reference for all public descendants

ToolSpec carries version, name, tag, description, signature, parameters and returns. Each ParameterSpec has name, kind, type, required, has_default and default_is_none, plus default_source (Python source text or None). TypeSpec has kind, name, description, arguments, fields, values, variadic and sequence_field. Result fields use FieldSpec.

These are frozen, slotted records. All nested collections that the adapter produces are tuples. The adapter keeps no host default object or callable. It copies literal defaults as source text. sequence_field is None unless that record declares field-backed sequence behavior. The portable .contract includes this key only for opted-in records.

spec(None) lists roots. Other non-string names raise TypeError. Unknown or hidden names raise ValueError. help requires a string. No lookup configures, authenticates, dispatches or executes the described operation. Public Catalog.spec and Catalog.help methods own explicit end-only Activities, so a catalog can itself be an object namespace.

vis.testing.assert_catalog(catalog, names=..., mutations=...) checks the catalog against public registered callable names and expected mutation names, including help/signature parity and unresolved nested types. It does not call tools, validate argument values or prove cancellation. See the tested recipe and boundary checks.

Return typed objects

A dict is enough for simple results. Use frozen dataclasses with annotated public fields when shape and meaning matter. The tested example shows field descriptions with Annotated. Private fields and original methods do not cross into the sandbox.

Derived data and a text report can cross with the fields:

  • Each public @property or functools.cached_property of the dataclass becomes a field of the record. Vis reads it once when the tool answers. A property that raises is left out.
  • If the class defines its own __str__, its text becomes the record's str. Then print(result) shows your report, and repr(result) still shows the fields.
@dataclass(frozen=True)
class Status:
    env: str
    phases: tuple[int, ...] = ()

    @property
    def next_phase(self) -> int:
        return len(self.phases) + 1

    def __str__(self) -> str:
        return f"{self.env}: next phase {self.next_phase}"

In the sandbox, status.next_phase is a field and print(status) shows DEV04: next phase 1. Other methods, such as status.text_report(), stay in your process. Expose the value as a property, or as __str__, or expose the operation as a tool. When the record goes back to a tool as an argument, Vis ignores the property fields and calls your constructor with the declared fields.

Generated records support both result.url and result["url"], including nested records. An unknown name raises KeyError (result["missing"]) or AttributeError (result.missing) listing the available fields, so hasattr and getattr(result, name, default) keep their usual meaning. By default, records are not iterable, have no length, and reject non-string indices with TypeError. Field assignment remains unsupported. These records are not mappings: names such as items, keys and get remain available for your fields rather than becoming mapping methods.

Accept records as arguments

A tool can take a record that another tool returned. Annotate the parameter with the dataclass, and your function receives an instance of that class:

from dataclasses import dataclass


@dataclass(frozen=True)
class TriggerResult:
    http_code: int
    job_path: str


def trigger(job_path: str) -> TriggerResult:
    """Start a build."""
    return TriggerResult(201, job_path)


def status(result: TriggerResult) -> str:
    """Report the job of a started build."""
    return f"{result.job_path}: {result.http_code}"

Sandbox code can pass the result straight back, as in await status(await trigger("docs/build")). Arguments cross the sandbox boundary as JSON, so the record travels as a dict of its public fields. Before your function runs, Vis rebuilds the record with your dataclass's constructor. A dataclass defined in sandbox code, or a dict with the record's field names, is rebuilt the same way.

  • Records are also rebuilt inside Optional, unions, list, tuple, set, frozenset and dict annotations, and in nested record fields. A union uses the first member that fits, in the order you declared.
  • The dict can leave out fields that have defaults. Vis skips fields declared with field(init=False), because the constructor sets them.
  • A value that does not match the record, such as a dict with unknown keys or without a required field, reaches your function unchanged. An error raised by the constructor, for example in __post_init__, fails the call.
  • Private fields, whose names start with _, do not cross the boundary. Give them defaults if the record must be rebuilt.
  • After you reload the extension, a record returned before the reload is rebuilt as the reloaded class, so isinstance checks keep working.
  • Vis resolves the annotation the same way as for tool contracts. If it cannot resolve the class, the argument stays a dict.

Field-backed sequences

If your result wraps a collection, use @vis.sequence(field="results") to let sandbox callers iterate it directly. Apply the decorator outside @dataclass:

from dataclasses import dataclass

import blockether.vis.extension as vis


@dataclass(frozen=True, slots=True)
class Page:
    title: str


@vis.sequence(field="results")
@dataclass(frozen=True, slots=True)
class PageList:
    results: tuple[Page, ...]
    total: int

When a tool returns PageList, sandbox callers can use for page in result, len(result), if result, result[0], result[-1] and result[1:]. result.results and result["results"] still expose the received list, and nested items keep their generated record types. Slices return lists. Field and item assignment on the wrapper remain unsupported. Its backing list is ordinary local data.

The field must be a public dataclass field holding a built-in list or tuple at return time. Strings, mappings, sets, generators and list/tuple subclasses are rejected. Vis does not consume lazy iterators. Length and truth testing describe the received items, not a total field. Iteration never fetches another page.

This declaration adds metadata, not methods to your original Python class. Keep any methods you need when calling the class directly outside the sandbox. Original methods never cross the boundary: Vis builds the supported operations locally over the received field. Every type, including subclasses, must opt in explicitly. An existing __iter__ method or a field named results does not opt in. You can adapt third-party values into a decorated dataclass without changing the third-party type.

The backing field appears as sequence_field in the callable's .contract and typed catalog, and generated doc() text describes the supported sequence operations.

Object namespaces

vis.Symbol(Greeter(), name="greet") exports greet.hello(...) in the packaged example. Public methods become tools. Object attributes become nested namespaces. Names beginning with _ are excluded. Public scalars, modules, classes, cycles and repeated object references are rejected with their path.

vis.method(tag="observation", is_hidden=False, activity=None) overrides metadata for one method. Metadata never changes the method's Python signature. If you keep a tag in a variable or a helper parameter, annotate it with vis.SymbolTag.

Activity presentation

Declare presentation beside each tool binding: vis.Symbol(fn, activity=vis.Activity(label="Run checks", render=render_checks)). For an object namespace, put @vis.method(activity=...) on each exported method, including nested methods. Namespace-level Activity is rejected.

An Activity explains a tool call to the person who follows the session. Put the action in the headline, the target or outcome in the summary, and useful evidence in the content. Use sentence-case labels such as "Read file", "Search files" or "Check service health". Do not use search_files, qualified method names, object representations or all-caps sentences. Keep proper names such as GitHub and acronyms such as SDK. Use consistent terms and no profanity or vulgarity.

Do not change the case of filenames, code, commands and returned content.

The host shortens filesystem paths in Activity headlines, summaries, plain text, headings and table cells. Paths in the session workspace become relative. Other paths under the host user's home use ~/…. Other absolute paths stay unambiguous. This applies to built-ins, Python methods, callbacks and published sections before TUI or Companion rendering. Tool arguments, return values, resource identities, code, Markdown and diff bodies keep their original paths.

vis.Activity(presenter="tests", label="Run checks", render=render_checks) selects a presenter and a synchronous callback. presenter only classifies the operation, and the default is "generic". It does not draw any content. Only your render callback does. The tool's return value stays available to Python.

Not every operation needs a visible start. Set show_start=False for fast local actions such as reading a file, applying a patch or looking up a cached record:

activity = vis.Activity(
    label="Read record", show_start=False, render=render_record
)

With end-only presentation, the person sees the result without an earlier running row. Vis still records start/end order, duration, errors and cancellation. The callback skips start and runs on success or failure. Content published during the call is saved internally and shown when the call ends, including on failure or cancellation. Returning None from a start callback does not hide the running row. Only show_start=False hides it.

The final presentation must make sense without any earlier progress. Name the operation and target, state the outcome, and keep useful counts, errors or changes. An empty result should say what was absent, such as "No jobs reported".

A successful tool return does not mean a successful external operation. For example, a workflow watcher can return normally with a failed workflow. Show that failure in the final summary. Do not say only "Completed". Label partial lists and excerpts, so nobody mistakes them for complete results.

Keep the default show_start=True for work a person waits for: tests, builds, network requests, file transfers or user input. Publish intermediate updates only when they communicate a meaningful change. Choose the policy at each binding, not from a fixed duration threshold. A remote read can need progress, even when a local read does not. Internal counts still include all observed calls.

The callback receives keyword arguments phase (start, success, failure), args, kwargs, result and error. It returns vis.ActivityPresentation(headline, summary, content=(), sections=()) or None to keep the current presentation. The engine supplies identity, state, timing, errors and observed file changes. Callbacks must not invent them. Callback errors do not change the tool's return value or exception.

The headline and one-line summary stay visible when collapsed. Only content waits behind disclosure. Show meaningful counts, targets and failure context, not a serialized result or a duplicate output preview.

Choose typed ActivityText, ActivityHeading, ActivityMarkdown, ActivityCode, ActivityDiff, ActivityTable, ActivityProgress, ActivityTerminal, ActivityImage, ActivityVideo, ActivityAudio and ActivityFile blocks. Media references existing attachments. ActivitySection(headline, summary, content=()) groups related results without nesting sections. Each section has its own disclosure. You can scan its headline and summary before you open the complete details.

vis.publish_activity(presentation) replaces the current snapshot while a tool runs. Empty content clears the body.

When a tool returns a persistent handle and later calls check its progress, pass the same handle_id in each ActivityPresentation. For example, an extension can report successive visual findings without showing a separate current card for every check:

import blockether.vis.extension as vis


def check_visual_differences(handle: str, finding: str) -> str:
    """Report one finding for the named visual comparison."""
    vis.publish_activity(
        vis.ActivityPresentation(
            "Checking visual differences",
            finding,
            (vis.ActivityText(finding),),
            handle_id=handle,
        )
    )
    return finding


vis.register_extension(
    vis.Extension(
        name="visual-comparisons",
        description="Report visual comparison findings.",
        alias="visual",
        symbols=[
            vis.Symbol(
                check_visual_differences,
                activity=vis.Activity(label="Check visual differences", show_start=False),
            )
        ],
    )
)

Call visual.check_visual_differences("comparison-7", "One difference") and then visual.check_visual_differences("comparison-7", "No differences") in the same Python form. Activity shows the latest finding, with the earlier finding in its details. Expanding the receipt still shows both calls. A different handle stays separate. The handle links calls only within the same extension and form, not across later forms or other extensions.

Use a stable, non-secret handle that is unique to that operation. Do not use its label or arguments, and do not reuse it for unrelated work. Omit handle_id for standalone calls. It is an optional nonblank, single-line string of at most 256 characters and 512 UTF-8 bytes. It identifies a receipt. It does not replace tool arguments or results.

The engine stores the handle on each Activity row, not in displayed presentation content. It keeps the newest outcome current and keeps earlier distinct details and errors. It also counts and saves every call in history. Shell handles use the same grouping with command-specific output and exit reconciliation.

To make summary links clickable in the app and terminal, pass summary_format="markdown" to ActivityPresentation or ActivitySection:

vis.ActivityPresentation(
    "Find issues",
    "Found [#252](https://github.com/Blockether/vis/issues/252)",
    summary_format="markdown",
)

Markdown summaries support inline formatting and HTTP(S) links, not images, raw HTML or block layouts. Links stay available with details collapsed or expanded. Omit summary_format, or use "inline", to display the summary literally.

In both clients, you can press the files that a step reports: the resources it read and the files it patched. Pressing one opens that file in the editor on the machine that runs the session. So the reader can open a path in a transcript and does not have to type it again. Report real paths in your file resources. A label that only looks like a path opens nothing.

A table row can name a file of its own. Pass ActivityTable(headers, rows, paths=[...]) with one entry per row. The first cell of that row becomes the press target and keeps the words you gave it. The press and its hover title carry the whole path. Use an empty string for a row that names no file, such as a directory or a total. The list must be exactly as long as rows, or the block is rejected.

Files that Vis finds itself are pressable in the same way:

  • The file that a failing test reports in a run_tests result.
  • The file that a patch header names when you read a diff artifact.

Each headline and summary is one line of at most 512 UTF-8 bytes, including any Markdown source. Put the complete result in content blocks and non-nested sections. Vis does not cut their text, table rows or block counts to fit a presentation budget. In both clients, test declared start visibility, success, failure, cancellation, empty results, replacement, redaction and disclosure. The tested greeter entrypoint shows the callback beside registration. The Activity schema has the exact portable limits and block shapes.

Vis saves every call and its complete presentation in the session, without a size or count limit. The app and the terminal load small pages as you scroll. Search, Copy all and Export cover the full history. A restart does not remove saved Activity. To read or export the history from your own program, use the Sessions API.

Report checks and outside effects

The tag on a tool tells Vis what kind of work each call did. Each Activity counts its steps in one line, such as 1 mutation · 6 observations · 2 verifications. Short summaries use ver for verification. The tags decide where each call is counted:

Tag Use it for
observation Reads that change nothing, such as searches and status queries.
mutation Changes to local state, such as writing files or starting a process.
verification Checks of the work, such as tests, lint and CI status.
external Work that reaches people or systems outside the session, such as messages, publications and remote hosts.

A check can finish without an error and still find problems: a test run that reports two failures did its job. Set verdict on the top-level ActivityPresentation to say what the check found:

vis.ActivityPresentation(
    "Run tests",
    "38 passed, 2 failed",
    verdict="failed",
)

verdict is "passed" or "failed". Sections do not accept it. Leave it out for calls that check nothing.

A check's verdict describes its result, not the call's execution state. Verification counters count calls, not individual tests, without a failure count. Show the result in the step's summary and content.

A call that fails or is cancelled shows that state on its own step. Like mutation, the other tags describe the operation. They grant no permission and enforce no policy.

To show a command and what it printed, use an ActivityTerminal block. Give the command as a list of stages. Start each later stage with its operator, such as && or |. Give each output line as an ActivityTerminalLine with its stream, stdout or stderr. A plain string is a stdout line. Set omitted_lines when you keep only the last lines of a long output.

Two optional fields of the top-level ActivityPresentation complete the step's head line:

  • outcome is one line that tells what the work produced, such as 41 passed. It shows after the headline when summary is empty.
  • aside is quiet context, such as the working directory and the exit code. It shows at the end of the head line.
vis.ActivityPresentation(
    "npm test && npm run lint",
    "",
    content=(
        vis.ActivityTerminal(
            ("npm test", "&& npm run lint"),
            lines=(
                "41 passed",
                vis.ActivityTerminalLine("warning: slow test", stream="stderr"),
            ),
            exit=0,
        ),
    ),
    outcome="41 passed",
    aside="~/vis · exit 0",
)

The Companion app and the TUI show each stage on its own line and show stderr lines in the error color. A closed step that is still running shows its last three output lines. Copy text starts with $ and the command.

Prompts and discovery

Use prompt for a short explanation of when to choose this extension and the public names to search. For the packaged greeter, a suitable value is:

Use greet.hello to generate greeting text; it never sends messages.
Discover the tools with apropos(r"^greet\.") and read doc("greet.hello") before calling.

Assign this text to Extension.prompt. It is not Python code and does not execute at registration. A prompt callable computes text each turn. It should not log in, start background work or repeat the entire API. description supplies the extension summary even when prompt is omitted.

apropos(pattern) filters public symbol names by regular expression, ignoring case. A bundled skill also matches by its description and headings. doc(name) or doc(hit) reads the complete matching document.

Signatures and resolved annotations supply call structure. Short docstrings and Annotated descriptions supply semantics, not duplicate signatures or schemas. Keep a useful first-line summary for discovery. Put an optional multi-step procedure in a skill. Neither prompt text nor reading a skill enforces permissions. Use explicit policy mechanisms for guards.

Tool contracts

Symbol.contract returns fresh portable data without registration or a tool call. In the sandbox, each callable exposes its contract as an attribute, for example greet.hello.contract. Changing that local dictionary does not change the declared tool or its permissions.

Contract Fields
Callable version, name, tag, description, signature, parameters, returns
Namespace version, name, members with full public member names
Parameter Name, parameter kind, required, has_default, default_is_none, default_source, and type description

The registered signature and resolved type metadata are authoritative for invocation shape. doc() renders that same contract: signature, parameter kinds, required/default status, return type and fields, mutation tag, and semantic description. The signature text is the full Python signature, for example (name: str, /, *, loud: bool = False) -> 'Results'. A method with no arguments has the signature (). That does not mean metadata is missing.

A short semantic docstring is enough. Do not repeat the signature, defaults or full return schema in it.

The agent reads the arguments with inspect.signature(tool) and reads doc("tool") only for semantics. So put preconditions, side effects, safety limits, units and retry behavior in the docstring. For nested types, the agent selects fields from tool.contract. Catalog.spec(name) gives the same metadata as an SDK ToolSpec. A mutation tag describes an effect. It does not authorize it.

This is a documentation contract, not JSON invocation or runtime validation. Python binds arguments. The implementation validates domain constraints. There is no manual schema/signature override. The exact portable shape is the symbol schema.

Compact model schemas

When parameter or return types contain records, doc() defines each shared model once and refers to it by name. A type such as ToolSpec | tuple[ToolSpec, ...] therefore has one ToolSpec field definition, not one per union branch. Distinct model shapes with the same name get numbered display labels.

The generated Model schemas section has a 2,048-character budget, including its heading and any abbreviation notice. Definitions are visited breadth-first. Field lines are never cut in the middle. This limit does not shorten the callable's semantic description, signature, parameter kinds and default markers, return type, or effect tag.

If the section is abbreviated, its notice points to the callable's .contract. Traverse its parameters or returns branch in Python and print only the leaf fields you need, rather than entire schema branches. The complete type metadata remains available there and through Catalog.spec(name). The display budget does not change the contract or the returned values.

Defaults and introspection

You can inspect ordinary declared defaults directly: False, 200, "1h" and () appear without an author opt-in. The same values appear in tool.contract, Catalog, generated help, doc() and inspect.signature().

Vis renders exact built-in literals: None, Ellipsis, booleans, integers, finite floats and complex numbers, strings, bytes, and recursively representable tuples, lists, dictionaries and sets. Strings are escaped as Python source, not filtered by their contents or parameter names. Declared defaults are public metadata. Resolve credentials inside the tool rather than putting them in its parameter defaults.

The default_source field preserves the literal's Python spelling and type without transporting host objects. Its value is source text, not the default itself.

Python declaration has_default default_is_none default_source Rendered default
Required argument False False None No default
Argument with = None True True "None" None
Argument with = False True False "False" False
Argument with = "1h" True False "'1h'" '1h'
Argument with = () True False "()" ()
Opaque or unrepresentable default True False None ...

Opaque objects, subclasses, nonfinite numbers and cyclic containers remain .... Rendering also uses an inspection budget of 4,096 source characters and eight container levels. Values outside that budget remain .... Vis never calls custom repr() methods or factories to render a default.

*args and **kwargs are not required, even though they have no default. When arguments are omitted, the original defaults apply. Inspection never puts a copied literal or Ellipsis into a call. A placeholder ... means that you can omit the argument. It does not mean that you should pass Ellipsis.

Dataclass-field defaults and factories are still described, without exporting values or running factories. Explain contextual omission behavior in prose. See documenting defaults.

Sandbox inspection Supported result
tool.contract Portable parameter/result types, fields and documented meaning
doc("tool") Human-readable rendering of that contract
inspect.signature(tool) Names, parameter kinds and annotations, representable literal defaults or Ellipsis placeholders, and the return annotation when declared
tool.__annotations__ Annotation objects resolved statically from the signature text. Builtins, typing and collections.abc names, unions and subscripts become the real objects. Record, opaque and unresolved names stay quoted forward-reference strings such as 'Results'
typing.get_type_hints(tool) Resolves the same annotations. Pass localns for forward-reference strings, or it raises NameError for them
tool.__signature__ Not supplied (inspect follows tool.__wrapped__)

Engine-provided tools also expose registered option keys as keyword-only parameters. For example, inspect.signature(council.publish) names the required kind argument, while inspect.signature(grep) names query, paths and the other search options. Optional keys with undisclosed defaults show Ellipsis. Omit them to use the default.

A host signature describes the canonical named call, not every accepted overload. Existing positional and options-dictionary calls still work. Signature.bind() cannot validate all those overloads. Extension functions and methods keep their Python parameter kinds, including positional-only and variadic arguments. MCP's remote tool schemas remain separate from the dispatcher's server, tool and args parameters.

The original host classes and their identity do not cross the sandbox boundary. Type names describe host annotations. A record that sandbox code passes back to an extension tool becomes an instance of the tool's own class again. See Accept records as arguments.

Sandbox sequences are list-like. Use len(), indexing or iteration, not isinstance(value, tuple). The callable's .contract is a dictionary. For type discovery, select ['parameters'] or ['returns']. Do not try to reconstruct host annotations.

Supported types and unresolved annotations

Descriptions cover every parameter kind, return types, dataclass fields, unions, common containers, Literal and string metadata in Annotated. Recursive records use references rather than expanding forever.

  • tuple[T, ...] has variadic: true and one type in arguments. A fixed-length tuple keeps each item type and omits variadic.
  • Name (unresolved) means the annotation could not be resolved safely. Vis does not guess, import or evaluate an expression to discover a type.
  • Name (opaque) means the class is known but its structure is not described. Both labels appear in nested types and record fields, not only in top-level returns.

Vis loads each extension file as if it began with from __future__ import annotations. Annotations stay strings, and Vis resolves them against the module's own names, so define result classes at module level. Local forward references absent from the defining module remain unresolved.

Modules that your extension file imports follow normal Python rules. Add from __future__ import annotations to a helper or package module that defines tools. When tests import the extension file directly, keep the import in that file too. Without it, Python 3.14 defers annotations into functions that can run code, even when asked for strings. So Vis reports them as unresolved and does not evaluate them.

Cross-module decorators

functools.wraps chains resolve annotations in the wrapped function's defining module, including bound namespace methods and qualified names such as models.Result. Cyclic __wrapped__ chains raise ValueError. These two complete modules illustrate cross-module wrapping and a variadic tuple result:

# decorators.py
from functools import wraps


def traced(fn):
    @wraps(fn)
    def call(*args, **kwargs):
        return fn(*args, **kwargs)
    return call
# tools.py
from __future__ import annotations
from dataclasses import dataclass
from decorators import traced


@dataclass(frozen=True)
class Result:
    text: str


class Tools:
    @traced
    def read(self, text: str = "ready") -> tuple[Result, ...]:
        """Read one result without changing state."""
        return (Result(text),)

vis.Symbol(Tools(), name="tools").contract expands Result.text beneath the variadic tuple. Omitting text uses the public string "ready". This is contract inspection, not registration. When you bind Tools.read, declare its Activity in the entrypoint as in the greeter example. This immediate local operation can use show_start=False.

Slash commands

vis.SlashCommand(name, run, doc=None, usage=None, saveable=False)

run(ctx) receives {"channel", "args", "raw", "session_id", "workspace_root", "project_root"} and returns vis.ok(title, body=None, data=None), vis.err(title, body=None, data=None) or a plain string, which counts as an ok title. body is Markdown.

Vis runs run in the session that sent the command, as it does for a tool call. In run, vis.workspace_root() returns the working copy of that session, including its draft.

Set saveable=True when the command text is real prompt content that a person can want to run again, such as a goal. The terminal then keeps the full command in its prompt history, so ↑ recalls it. The default is False. Vis does not save control commands, such as /reload, unless their definition sets the flag. The built-in /goal command is saveable.

Op hooks

vis.OpHook(ops, fn, phase="before")

ops names operations such as "patch", "shell" or "python_execution". With phase="before", fn(call) receives {"op", "args"}. Return None to allow the call or vis.block(reason) to refuse it. The model sees the reason as a tool failure. For "patch", return vis.repair(source, notes=[...]) to propose corrected source.

With phase="after", fn receives {"op", "args", "result"} and its return value is ignored. Ordinary tool-hook errors are logged and do not block the operation. An after hook cannot undo its effects.

For "patch", a before hook also receives preview: {"path", "before", "after", "spans"}. It holds the current file and the proposed result of all edits. Each span is [start, end], with inclusive, one-based lines in the proposed result. Use the preview to validate a repair before anything is written. preview is absent when the patch would be refused, for example when the edited lines changed.

A repair replaces the complete proposed source, not the edit list. Vis checks that the original file still matches, then writes the final source once. Later guards see the repaired preview. The result includes correction notes, the actual diff and the new file version. The hook must validate its candidate before returning it.

vis.repair requires string source and a nonempty list or tuple of nonblank notes. Invalid repair markers fail without running the operation. Only "patch" accepts repairs. Vis repairs "python_execution" blocks itself, so a block hook that returns vis.repair fails the block. Give vis.block(reason, hint=...) a hint to tell the model what to do next. Without a hint, the failure tells the model to ask the user before a retry.

For "python_execution", args is [{"code": <Python source>}]. The before hook runs before the block. The after hook runs when evaluation returns. result is the execution result map, including captured stdout, evaluated forms and any error. It also runs when a normal Python exception ends the block, since earlier statements may already have written files. If a before hook refuses the block, the after hook receives result=None.

This is a block boundary, not a hook on each open() or Path.write_text(). It does not continuously observe background work or guarantee a callback after a process is killed. Register for both "patch" and "python_execution" when you want post-edit checks to cover host patches and plain Python writes. Use vis.state and a ctx contribution to make findings available on the next model request, not the callback's return value. See the complete complexity-check example.

ops also names the draft lifecycle: "draft/create", "draft/sync", "draft/approve" and "draft/discard" run for the sandbox's draft_create(), draft_sync(), draft_approve() and draft_discard(). Their args carry the draft's workspace_id, label, root, repo_root, backend and, for approval, branch, target_branch, files and message. Approval commits and merges into the default branch. Each new commit also crosses git/commit. A before hook that returns vis.block(reason) refuses the operation and the user sees the reason. See Drafts.

"fs_access" checks paths used by the host file tools (cat, grep, patch, ls). It is not a tool itself and takes no phase. Its callback receives {"operation": "file-read" | "file-write", "path": <absolute path>}. An error in the callback refuses the operation. This check does not apply to open() in the sandbox, which uses the sandbox's filesystem policy.

vis.strings_of(value) collects strings from a nested structure, for example to check paths in tool arguments.

Durable state

vis.state is a dict-like store persisted in the Vis database. It survives /reload and restarts. It is keyed by extension name. So a project override shares state with the global extension that it replaces, and two different extensions never share state.

vis.state["repo"] = "acme/widgets"
vis.state.get("count", 0)
"repo" in vis.state
del vis.state["repo"]
vis.state.update({"repo": "acme/widgets", "count": 0})

It is a collections.abc.MutableMapping, so pop, setdefault, clear, keys, items, len and iteration behave as on a dict. Values must be plain data: dicts, lists, strings, numbers and booleans. Writing None removes the key.

Logging and notifications

vis.log("info", "loaded 3 rules")        # trace, debug, info, warn, error
vis.notify("Rules reloaded", "success")  # info, success, warn, error

vis.log writes to the gateway log in ~/.vis/logs/YYYY-MM-DD/. See Logs and diagnostics for filenames, retention and sharing. vis.notify shows a toast in the active channel.

Asking the human and showing live work

vis.ask(title, nodes) pauses the extension and shows a typed form in the TUI or the Companion app. vis.live(title, nodes) opens a view that the extension updates while a job runs. Both take the same components. Both are documented on their own pages: Forms and user input and Live views. For concurrent read-only polling with fail-fast, Stop and owned cleanup, use the fixed-build monitoring recipe.

  • vis.live(..., placement="panel", key=...) opens a view in the side panel of a session. Choose a placement explains its lifetime.
  • on_event=callback gets each vis.LiveEvent of a panel view as an extension callback.
  • view.values(), view.events(timeout=None) and view.on_event(callback) read the inputs and the actions of a person. Collect input in a view shows them.

Environment

An extension does not automatically receive the full host environment. Declare the variables it needs in env. vis.register_extension() resolves them and adds them to the extension's os.environ. Read them after registration.

import os

vis.register_extension(vis.Extension(
    name="acme",
    description="Acme integration.",
    env=["ACME_API_KEY"],
))

key = os.environ.get("ACME_API_KEY")   # absent when nothing resolves it

Each name resolves through the project's environment: block, then .env and .env.local, then the environment that started Vis (see Configuration). Names defined by the project itself need no declaration. env= affects only the extension's os.environ, not the environment of jailed child processes. To pass a variable to a jailed child, declare it under environment:.

Session context

A ctx callable adds data to the model's session dict each turn:

def _ctx(env):
    return {"session_env": {"todo": {"open": len(vis.state.get("todos", []))}}}

vis.register_extension(vis.Extension(name="todo", description="Todo list.", ctx=_ctx))

Return a string-keyed dict under a key unique to your extension. Vis exposes custom keys directly in session. Core fields and the engine_ and session_ prefixes are reserved. Use session_env to add nested data under session["env"].

Results from all extensions are deep-merged. A non-dict return or exception adds no context and does not block the turn.

Workspace root

Call vis.workspace_root() from a session-bound extension to get a pathlib.Path for the session’s current primary working copy. Relative file and process paths should start there. The value switches to the draft when the session enters one, and follows the live workspace if it changes. Do not cache it across tool calls.

import blockether.vis.extension as vis

source = vis.workspace_root() / "src" / "main.py"

This is not a language-specific project root. When you need a narrower root, locate package markers beneath it. Absolute paths remain absolute. Outside a Vis process, it returns the process working directory. A hosted call without a bound session fails and does not use the gateway directory.

It does not grant filesystem permissions. The model sandbox uses project_root_path for the corresponding root. Extension code uses this SDK function.

Project root

Call vis.project_root() to get a pathlib.Path for the project that holds your extension. The project is the nearest directory above the extension file that holds a .vis directory. Vis maps it into the session's current working copy, so it follows the draft like vis.workspace_root().

import blockether.vis.extension as vis

config = vis.project_root() / "tools.toml"

For an extension in the workspace's own .vis/extensions, both functions return the same path. For an extension that a glob declaration loads from repositories/api/.vis/extensions, vis.project_root() returns the repositories/api directory of the session. Use it instead of a path calculation from __file__. Outside a Vis process, it returns the process working directory.

The project root also changes these values:

  • vis.shell() and vis.jailed_shell() start a relative or empty cwd at the project root.
  • The env dict of activation, prompt and context callbacks has workspace_root and project_root.
  • A slash command's run(ctx) also receives workspace_root and project_root.

A setting of a nested extension uses the configuration of its own project, as a session at that project does. A toggles: value in repositories/api/vis.yml wins over the workspace configuration. A value that you set for the session or its group wins over the project files. A vis run --toggles value wins over all of them.

List workspace files

Call vis.workspace_files() when your extension needs the files of the session working copy. For example, a language check can watch every source file that a block can change. The function returns the paths that Git sees under vis.workspace_root(): tracked files and untracked files that Git does not ignore.

import blockether.vis.extension as vis

files = vis.workspace_files() or []
sources = [path for path in files if path.suffix == ".py"]

The host lists the working copy once and gives the same list to each extension that asks within two seconds. This is faster than a git child process in each extension, also when the jail is on. The function returns None when the workspace is not in a Git working tree. Outside a Vis process, it lists the process working directory.

Python environment

Call vis.python_environment() before your extension starts project Python, such as a test runner or a REPL. It returns project or shared from python.environment in the session project's configuration.

import shutil

import blockether.vis.extension as vis

if vis.python_environment() == "shared":
    command = [shutil.which("vis-agent"), "python", "--shared", "-m", "pytest"]
else:
    command = ["uv", "run", "python", "-m", "pytest"]

With shared, start Python with vis-agent python --shared. That interpreter imports Vis shared packages and does not use the project's .venv. With project, the default, use the project's own environment. Outside a Vis process, the function returns project.

Find and read sessions

Call vis.list_sessions(search=…) to find a conversation, and vis.read_session(id) to read it. For example, your extension can turn a failed turn into a test case. Both functions return the same string-keyed data as the sandbox tools with the same names.

import blockether.vis.extension as vis

rows = vis.list_sessions(search="release")
data = vis.read_session(rows[0]["id"]) if rows else vis.read_session()
last = data["transcript"]["turns"][-1]
request, status = last["user_request"], last["status"]

vis.list_sessions() lists every session, newest first. With search, it runs the session search of the TUI and the Companion app. Matches in the title come first, then the request, the reply and the thinking. Keep that order. Each row has id, title, turn_count, created_at and modified_at. A match adds rank, the is_in_* flags and optional snippets.

vis.read_session() without an argument reads the session that runs your extension. An argument takes a bare id, an unambiguous prefix or the vis_session_id#<uuid> marker that a person copies from the TUI or the Companion app. The dict has session, current_turn, failures, diagnosis, session_forks, turn_retries, usage and transcript. Each turn has position, user_request, status, content and iterations. Each iteration has blocks with code, stdout and error. In a fork, session.forked_from has the source session_id, the last copied turn_id and the current outcome of that turn.

The introspection setting does not apply, because an extension is trusted code. The setting controls only the model's own session tools. Outside a Vis process, both functions raise an error. There, use the SDK client.

Create and control sessions

Use vis.sessions, vis.run and vis.session when your extension must start work in other sessions. For example, a review tool can ask a new session for a second opinion. These calls return the Session handles of the Sessions API.

import blockether.vis.extension as vis


def second_opinion(question: str) -> str:
    """Ask a new independent session and return its answer."""
    turn = vis.run(question, timeout=300)
    return turn["content"] if turn["status"] == "completed" else turn["status"]


def start_review(path: str) -> str:
    """Start a review in a new session and return the session ID."""
    session = vis.sessions.create()
    session.send(f"Review {path}. Do not change files.")
    return session.id
  • vis.session is the Session of the bound session, the session that called your extension. vis.council is its Council handle. Without a bound session, both raise RuntimeError.
  • vis.sessions.create(provider=..., model=..., **options) creates an independent session. Without root, it starts in the root of the bound session. The options are those of create_session.
  • vis.sessions.get(session_id) returns a handle without a request. vis.sessions.list(search=None) returns the sessions of the first page, or the matches of search.
  • vis.sessions.export(session_id=None) returns a snapshot of the bound session or of session_id. vis.sessions.restore(data, root=..., group_id=..., key=...) restores one.
  • vis.run(request, ...) creates an independent session and runs one turn in it. It takes the arguments of Agent.run, also response_model. Without response_model, check the status of the turn.
  • vis.speech speaks text, transcribes audio and adds, changes or removes voices. It has the methods of client.speech in the Speech API.

The host records your extension as the origin of each session that it creates. Such a session has no parent. Choose the model of a session shows the model list with prices and limits. Each turn of a new session costs model tokens.

Where each call works

Vis checks each call against the thread that makes it:

Call Where it works
Reads: get, list, export, models(), transcripts, turns and Council reads In a callback and in a thread that your extension created.
Session.send() and Turn.cancel() In a callback. A thread of your extension can use them only in sessions that your extension created.
Other changes: create, restore, run, fork, set_model and publish Only in an active callback, such as a tool call or a command.
vis.state and vis.log In a callback and in a thread that your extension created.
Patch, read and close a panel view that your extension opened In a callback and in a thread that your extension created. Open the view in a callback.

A change also needs a bound session. It can change only that session and the sessions that your extension created. A refused call raises GatewayError with the status 403:

Code Meaning
forbidden-route Extensions cannot use this call, or this thread cannot use it. Use a callback.
forbidden-session The session belongs to another extension or to a person.
session-not-bound The call changes something, but no session called your extension.

The model cannot use these calls from python_execution. They are for trusted extension code. A read shows no Activity. Each change shows its own Activity under the Activity of your tool.

Call other session tools

Use vis.tools to reuse a tool that the session already has, such as another extension's browser or GitHub tool. Do not implement it again. Name the tool the way the model calls it in python_execution:

import blockether.vis.extension as vis

runs = vis.tools.gh_runs(limit=5)                   # the model calls gh_runs(limit=5)
reservation = vis.tools.spel.reserve("x-login")     # an object method, spel.reserve(...)
reservation = vis.tools["spel.reserve"]("x-login")  # the same call, named by a string

The call runs in the session that called your extension, the same way the model's call would. It uses only tools that are active in that session and shows its own Activity under your tool's Activity.

Results arrive in the same shape the model sees:

  • A typed object from a Python extension becomes a frozen object with the same class name and public fields. Read a field as an attribute or with result["field"]. An unknown field raises AttributeError or KeyError listing the available fields. An object with a field-backed sequence iterates over that field and supports len(). Its property fields and its str text arrive as in Return typed objects.
  • Other results are JSON data. Objects become dicts that also allow attribute access, such as page.meta.total, and lists stay lists.

The object is a copy in your extension's process, not an instance of the other extension's class. So check its fields. Do not use isinstance with the other package's class. Results with the same class name and fields share one class, unless the object has its own str text.

Pass JSON-compatible arguments: strings, numbers, booleans, None, lists and dicts. A dataclass argument is sent as a dict of its public fields. An extension tool that annotates the parameter with a dataclass receives an instance of its own class, as described in Accept records as arguments.

A failed call raises VisToolError, a RuntimeError whose message carries the tool's own error, such as ValueError: no browser available. An unknown name raises the same error and lists similarly named active tools.

Vis refuses a call back into your own extension, or into another extension that waits on the current call. Such a call would never return. Call your own functions directly instead. Outside a Vis session, for example in plain unit tests, vis.tools raises an error. There, import and call the other package directly.

Attachments

Use vis.attach to store a file that your tool makes, such as a chart, a CSV table or a PDF report. The session keeps the file as a durable attachment of the python_execution block that called your tool. People see it in the transcript, and an image can also go to the model. The functions work like the attach functions in python_execution:

import blockether.vis.extension as vis

row = vis.attach("out/totals.png", label="Totals by month")  # a path, bytes, a figure or a Pillow image
vis.attach(csv_bytes, "totals.csv")                           # bytes need a filename
versions = vis.list_attachments("totals.csv")                 # every version, oldest first
latest = vis.get_attachment("totals.csv")                     # the descriptor, without bytes
data = vis.read_attachment(latest)                            # the bytes
vis.show_attachment(row)                                      # show the image to the model again

vis.attach returns a descriptor dict with id, filename, version, media_type, kind, size, audience and commentable. If you attach again under the same filename, Vis stores the next version of that file. Use a new filename only for a different file.

Use audience to set who sees the file: both (the default), user or model. A PDF or HTML document is for the user only. A CSV or TSV table shows as a table, and its rows do not go to the model. Set commentable=True to let a person comment on this version.

If you do not give media_type, Vis detects the type from the bytes, then from the filename. If both fail, Vis uses application/octet-stream.

The read functions take the same target: a filename, an id or a descriptor. A filename means the latest version, unless you give version. A negative version counts back from the latest version. read_attachment is the only function that returns the bytes. show_attachment shows a stored image to the model for the next request only.

If no python_execution block runs your code, for example in a slash command, these functions raise an error. Outside Vis, for example in plain unit tests, they keep the files in process memory. A test can then attach a file and read it back.

Filesystem and processes

Extension code runs in a trusted process, separate from the model's sandbox. A gateway-wide worker loads registrations. A session gets its own trusted extension worker on its first extension call. Session disposal stops that session's worker, not the gateway-wide registration worker. Interpreter memory and host-call identities are not shared across workers.

Model sandbox Extension context
Author the model you
Filesystem workspace roots when the jail is enabled your user's permissions
Network and processes gateway policy, no direct spawn unrestricted
Environment project values declared env plus project values
Native calls through ctypes refused under confinement supported
Lifetime session worker separate registration or session worker, reloaded by /reload

Tool results cross as data. Objects and dataclasses expose their public fields as frozen data records in the sandbox, including nested records. A dataclass also sends its public properties and its own str text, as described in Return typed objects. Methods, native pointers and object identity stay in the trusted process. Explicit field-backed sequences add only local collection operations. Expose other operations as declared tools rather than methods on returned objects.

subprocess, os.system and vis.shell({...}) run without the jail. Output not read by the extension is captured in its log. Child processes receive pipes rather than a terminal. Output is drained into an 8 MiB buffer per stream, including while the extension waits for the child to exit.

To confine a child, use vis.jailed_shell({...}). It applies the jail policy from the merged configuration on disk, re-read at every spawn, and works with or without a session. vis.shell({...}) applies no jail at all.

Both calls return the same live handle: sh.logs(), sh.wait(30), sh.type("y") and sh.stop() drive the process you started.

A jailed child sees the session's roots and the egress proxy, which is rarely everything a language runtime needs. Two options on vis.jailed_shell add exactly what you created for it, and nothing else:

Option Value Use it for
allow_read_write one path or a list of paths a FIFO or working file your extension made outside the session roots
unix_connect one socket path or a list of them a unix socket your extension is listening on
sh = vis.jailed_shell({
    "command": "java -cp … my.runtime",
    "cwd": project,
    "env": {"MY_RUNTIME_SOCKET": socket_path},
    "unix_connect": [socket_path],
})

Use them when the child must talk back to the extension. A shell child runs under a real terminal, so its merged output cannot carry a request/response protocol. Give the child a rendezvous endpoint instead. When the jail is disabled, Vis ignores both options.

Toolchain grants

A jailed child can read system files and the session roots. A toolchain often lives in another place: a JDK under ~/.sdkman, a package cache or a launcher script. Declare these paths once with vis.Grant in Extension.grants.

vis.register_extension(vis.Extension(
    name="java-tools",
    description="Java build tools.",
    grants=[
        vis.Grant(java_home, reason="Run the JDK."),
        vis.Grant("~/.m2/repository", "read_write", reason="Read and fill the Maven cache."),
    ],
))
Argument Default Meaning
path Required Absolute path, or a path that starts with ~
mode "read" "read" to read and run files. "read_write" for a cache that the tool also writes
reason Required Short text that tells people why the extension needs the path

Vis checks each grant when it loads the extension:

  • The path must be absolute after ~ expansion.
  • The grant must not cover the filesystem root, your home directory or the Vis home ~/.vis. A path inside them is allowed.
  • A refused grant makes the extension a load failure. vis-agent doctor shows the reason.

Vis adds the grants only to the vis.jailed_shell children of the extension that declared them. The model's shell and other extensions never receive them. The deny rules of the jail configuration still win. A grant can name a symbolic link, such as ~/.sdkman/candidates/java/current. The jail then also follows the link to its target.

Use allow_read_write only for a path that you create for one run, such as a FIFO. Declare a fixed toolchain path as a grant.

vis.fs provides filesystem operations with extension permissions: mkdir, write, read (bytes), read_text, copy, move, list, stat and remove. Ordinary open() uses the extension's permissions too, not the model's jail.

See also