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.
doc()shows a tool's types or defaults differently from what you declared. Check the tool contract rules.- You want users to configure your extension per project or session. Declare settings with their allowed scopes.
- Your extension needs a service from Vis, such as durable state, environment variables, session context, attachments, another session tool or other sessions.
- Your extension must start work in new sessions. Create and control sessions.
- Your own program must read or export Activity history. Read Sessions API.
- Your editor or type checker reports errors for code that runs in Vis. Install the SDK version that Vis bundles.
- A jailed tool cannot find its JDK, package cache or launcher. Declare toolchain grants.
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
@propertyorfunctools.cached_propertyof 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'sstr. Thenprint(result)shows your report, andrepr(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,frozensetanddictannotations, 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
isinstancechecks 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.
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:
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_testsresult. - 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:
outcomeis one line that tells what the work produced, such as41 passed. It shows after the headline whensummaryis empty.asideis 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, ...]hasvariadic: trueand one type inarguments. A fixed-length tuple keeps each item type and omitsvariadic.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=callbackgets eachvis.LiveEventof a panel view as an extension callback.view.values(),view.events(timeout=None)andview.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()andvis.jailed_shell()start a relative or emptycwdat the project root.- The env dict of activation, prompt and context callbacks has
workspace_rootandproject_root. - A slash command's
run(ctx)also receivesworkspace_rootandproject_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.sessionis theSessionof the bound session, the session that called your extension.vis.councilis its Council handle. Without a bound session, both raiseRuntimeError.vis.sessions.create(provider=..., model=..., **options)creates an independent session. Withoutroot, it starts in the root of the bound session. The options are those ofcreate_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 ofsearch.vis.sessions.export(session_id=None)returns a snapshot of the bound session or ofsession_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 ofAgent.run, alsoresponse_model. Withoutresponse_model, check thestatusof the turn.vis.speechspeaks text, transcribes audio and adds, changes or removes voices. It has the methods ofclient.speechin 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 raisesAttributeErrororKeyErrorlisting the available fields. An object with a field-backed sequence iterates over that field and supportslen(). Its property fields and itsstrtext 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 doctorshows 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 — choosing and documenting tool behavior.
- Installing and sharing extensions — installation and reload.
- Extension troubleshooting — loading and call errors.
- Sessions API — read the saved Activity of a session from a program.
