# Extension API

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

## When to use

- **You need the exact declaration, argument or return value for an extension
  feature.** Start from [Find an API](#find-an-api).
- **`doc()` shows a tool's types or defaults differently from what you declared.**
  Check the [tool contract rules](#tool-contracts).
- **You want users to configure your extension per project or session.** Declare
  [settings](#settings) with their allowed scopes.
- **Your extension needs a service from Vis**, such as [durable
  state](#durable-state), [environment variables](#environment), [session
  context](#session-context), [attachments](#attachments), [another session
  tool](#call-other-session-tools) or [other sessions](#find-and-read-sessions).
- **Your extension must start work in new sessions.** [Create and control
  sessions](#create-and-control-sessions).
- **Your own program must read or export Activity history.** Read [Sessions
  API](sessions-api.md#read-activity-history).
- **Your editor or type checker reports errors for code that runs in Vis.**
  Install the [SDK version that Vis bundles](#editor-types).
- **A jailed tool cannot find its JDK, package cache or launcher.** Declare
  [toolchain grants](#toolchain-grants).

Start with the [tutorial](extending.md) for a complete entry file or [Extension
design](extension-design.md) for authoring and test guidance.

## Find an API

| Task | Section |
| --- | --- |
| Declare and register an extension | [Registration](#registration) |
| Export a callable or object namespace | [Tools](#tools) |
| Explain when the agent should use it | [Prompts and discovery](#prompts-and-discovery) |
| Read types, defaults and introspection limits | [Tool contracts](#tool-contracts) |
| Add a user command or guard | [Slash commands](#slash-commands) · [Op hooks](#op-hooks) |
| Add configurable behavior | [Settings](#settings) |
| Persist data or report status | [Durable state](#durable-state) · [Logging and notifications](#logging-and-notifications) |
| Store or read files that a tool makes | [Attachments](#attachments) |
| Read environment or add session context | [Environment](#environment) · [Session context](#session-context) |
| Access files or start a process | [Filesystem and processes](#filesystem-and-processes) |
| Resolve the active working copy | [Workspace root](#workspace-root) · [Python environment](#python-environment) |
| Show forms, live work or register a provider | [Forms](human-input.md) · [Live views](live-views.md) · [Providers](provider-extensions.md) |

## 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`:

```bash
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](python-sandbox.md#experiment-with-extension-declarations)
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](jail.md#project-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](#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](#project-root). Keep `prompt`,
`activation` and `ctx` short-running. Calls into one extension instance are serialized.
Dependency and skill metadata belong to the [package manifest](extension-packages.md#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.

```python
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.

```python
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.

```python
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.

```python
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](configuration.md#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](configuration.md#project-group-and-session-settings) for
inheritance, submission timing, engine activation and access limits.

## Tools

```python
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](#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](#filesystem-and-processes) 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](#field-backed-sequences). 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](extension-design.md#typed-catalog-and-generated-help).

### 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](extension-design.md#describe-structure-once) 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.

```python
@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:

```python
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](#supported-types-and-unresolved-annotations). 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`:

```python
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](extension-design.md#keep-the-entrypoint-small). 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:

```python
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.

### Link receipts for one operation

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:

```python
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`:

```python
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](extension-design.md#keep-the-entrypoint-small) shows the callback beside
registration. The [Activity
schema](https://github.com/Blockether/vis/blob/main/packages/vis-contract/resources/vis-contract/schema/activity.json)
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](sessions-api.md#read-activity-history).

### 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:

```python
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.

```python
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:

```text
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](skills.md). 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](https://github.com/Blockether/vis/blob/main/packages/vis-contract/resources/vis-contract/schema/symbol.json).

### 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](extension-design.md#document-default-behavior).

| 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](#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:

```python
# decorators.py
from functools import wraps


def traced(fn):
    @wraps(fn)
    def call(*args, **kwargs):
        return fn(*args, **kwargs)
    return call
```

```python
# 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](extension-design.md#keep-the-entrypoint-small).
This immediate local operation can use `show_start=False`.

## Slash commands

```python
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()`](#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

```python
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](extension-design.md#check-code-complexity-after-edits).

`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](drafts.md).

`"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.

```python
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

```python
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](logging.md) 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](components.md). Both are documented on
their own pages: [Forms and user input](human-input.md) and [Live views](live-views.md). For
concurrent read-only polling with fail-fast, Stop and owned cleanup, use the
[fixed-build monitoring recipe](live-views.md#monitor-a-fixed-build-set).

- `vis.live(..., placement="panel", key=...)` opens a view in the side panel of a session. [Choose
  a placement](live-views.md#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](live-views.md#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.

```python
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](configuration.md#environment)). 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:

```python
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.

```python
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()`.

```python
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](extension-packages.md#load-extensions-from-nested-projects)
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.

```python
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](configuration.md#python-extension-environment).

```python
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.

```python
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](sessions-api.md).

```python
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](council-api.md) 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](sessions-api.md#save-and-restore-a-session-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](speech-api.md#use-speech-in-an-extension).

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](sessions-api.md#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`:

```python
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](#field-backed-sequences) iterates over that field and
  supports `len()`. Its property fields and its `str` text arrive as in
  [Return typed objects](#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](#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`:

```python
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](#return-typed-objects).
Methods, native pointers and object identity stay in the trusted process. Explicit
[field-backed sequences](#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 |

```python
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`.

```python
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

- [Extension design](extension-design.md) — choosing and documenting tool behavior.
- [Installing and sharing extensions](extension-packages.md) — installation and reload.
- [Extension troubleshooting](extension-troubleshooting.md) — loading and call errors.
- [Sessions API](sessions-api.md#read-activity-history) — read the saved Activity of a session from
  a program.
