Extension API
Reference for Python extension declarations, tool contracts, callbacks and host operations. 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 |
| Persist data or report status | Durable state · Logging and notifications |
| Read environment or add session context | Environment · Session context |
| Access files or start a process | Filesystem and processes |
| Show forms, live work or register a provider | Forms · Live views · Providers |
Registration
Examples on this page use import blockether.vis.extension as vis inside a trusted
extension, not in python_execution. Call vis.register(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; 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 |
kind, version |
None |
Display metadata; an installed package supplies its manifest metadata |
The callback env dict contains cwd, session_id and channel. 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.
Tools
vis.Symbol(fn_or_object, name=None, tag="observation", is_hidden=False, activity=None)
name=None uses a function's name; set name explicitly for an object namespace.
tag is observation or mutation. is_hidden=True removes a callable from
model-facing discovery but does not make it inaccessible or authorize its use.
Every exported callable needs a nonblank docstring. activity=None uses the
default presentation.
Source functions can be ordinary synchronous Python. The model calls their proxies
with await in python_execution; arguments and results remain Python values.
Exceptions are ordinary tool failures. The execution boundary
determines which values can cross to the sandbox.
Return typed objects
A dict is sufficient 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.
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.
Activity presentation
activity=vis.Activity(presenter="tests", label="Run checks") describes how a
running tool is shown in the TUI and the Companion app. Add render=callback
to customize the display. The callback receives phase (start, success
or failure), args, kwargs, result and error and returns a
vis.ActivityPresentation(headline, summary, blocks) or None. Blocks are
heading, text, markdown, code, diff, table, progress, image,
video, audio and file. vis.publish_activity(presentation) replaces the
presentation while the tool runs. Presentation errors never change a tool's
result. Supported block types are defined in the
Activity contract.
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. doc(name)
or doc(hit) reads the complete matching document. Put parameter details in the
tool's docstring and annotations, not the prompt. 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, and type description |
doc() renders this same contract as the signature, prose, argument types and
result fields. 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.
Defaults and introspection
Non-None runtime default values are withheld, and their repr() is never called.
This avoids exposing private host objects or credentials, including values whose
Python type looks ordinary. Public default behavior still belongs in the tool's
documentation; see documenting defaults.
| Python declaration | has_default |
default_is_none |
Rendered default |
|---|---|---|---|
| Required argument | False |
False |
No default |
Argument with = None |
True |
True |
None |
Argument with any other default, including False or 30 |
True |
False |
... |
*args and **kwargs are not required even though they have no default.
The original defaults apply when arguments are omitted. ... is a display marker,
not an instruction to pass Ellipsis. Dataclass-field defaults and factories are
also described without exporting values or running factories.
| 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 and parameter kinds; None or Ellipsis defaults; no type annotations |
tool.__annotations__, typing.get_type_hints(tool) |
Empty dictionaries, not a supported type-discovery API |
tool.__signature__ |
Not supplied |
The original host classes and their identity do not cross the sandbox boundary.
Use .contract for type discovery instead of trying 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 retains 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 just top-level returns.
Use from __future__ import annotations and module-level result classes. Python
3.14's deferred annotation functions can execute code even when asked for strings;
without that import they are reported as unresolved instead of evaluated. Local
forward references absent from the defining module remain unresolved.
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". The SDK tests
execute these snippets, and host-to-sandbox tests cover invocation, nested records,
redacted defaults, introspection and refreshed metadata after reload.
Slash commands
vis.SlashCommand(name, run, doc=None, usage=None)
run(ctx) receives {"channel", "args", "raw", "session_id"} 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.
Op hooks
vis.OpHook(ops, fn, phase="before")
ops names sandbox tools such as "patch", "shell" or "python_execution".
With phase="before", fn(call) receives {"op", "args"} and returns
vis.block(reason) to refuse the call or None to allow it; the model sees the
reason as a tool failure. With phase="after", fn receives {"op", "args", "result"} and its return value is ignored. An error inside a tool hook allows
the call.
ops also names the draft lifecycle: "draft/create", "draft/approve" and
"draft/discard" run for the sandbox's draft_create(), 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 and is keyed by extension name, so a project override
shares state with the global extension it replaces and two different extensions
never share.
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 under ~/.vis/logs/. vis.notify shows a
toast in the active channel.
Asking the human and showing live work
vis.ask(title, fields) 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 are documented on their own pages:
Asking the human and Live views.
Environment
An extension does not automatically receive the full host environment.
Declare the variables it needs in env. vis.register() resolves them and
adds them to the extension's os.environ; read them after registration.
import os
vis.register(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(vis.Extension(name="todo", description="Todo list.", ctx=_ctx))
Return a string-keyed dict under a key unique to your extension. Results from all extensions are deep-merged. A non-dict return or exception adds no context and does not block the turn.
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. The original class, methods, native pointers and object identity stay in the trusted process. Expose 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 a jailed shell:
| Call | Policy | Needs a session |
|---|---|---|
vis.shell({...}) |
none | no |
vis.jailed_shell({...}) |
merged configuration on disk, read at each spawn | no |
vis.jailed_shell_session({...}) |
the invoking session's policy snapshot | yes |
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.
