Extension design
Design tools around tasks the agent needs to complete, then test their Python behavior and their Vis integration. Start with the one-file tutorial; use the tested greeter package when the implementation grows beyond a small entry file.
Choose a useful tool boundary
Prefer one operation with a clear result over a sequence the agent must assemble for every call. Return Python values, not printed CLI output or JSON text that callers must parse. Use a scalar for a scalar result; use a frozen dataclass when several fields have distinct meanings. Do not introduce a class just to wrap one string.
Keep reads separate from mutations. Mark state-changing tools with
tag="mutation", or @vis.method(tag="mutation") on a namespace method. The tag
describes the operation; it does not grant permission or enforce a policy.
Validate domain constraints in the implementation and raise a useful exception
when they fail. Type annotations describe the API; they do not validate calls.
Describe structure once
Use the signature for parameter names and kinds, annotations for types, and prose
for meaning. A callable's nonblank docstring becomes its doc() page. Its first
line supplies the short apropos() preview, so begin with what the tool does.
Search matches tool names, not that preview: choose names the agent can predict.
A useful tool description answers:
- When should I call this, and what must already be true?
- What does each input mean, including units, limits and omitted values?
- What does the result contain, and what do empty or missing values mean?
- Does the call change anything, ask the human, or access an external service?
- What failures should the caller handle?
Use Annotated[T, "meaning"] for parameter and result-field descriptions. Keep
preconditions, side effects and failure conditions in the docstring. Do not copy
the signature into prose or maintain a second schema. Use
from __future__ import annotations and module-level result classes for safely
inspectable annotations on supported Python versions.
The packaged example's src/vis_greeter/__init__.py is ordinary Python with no Vis dependency:
"""Ordinary Python code, usable and testable without a Vis session."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Annotated
@dataclass(frozen=True)
class Greeting:
"""The generated greeting, without any external side effects."""
text: Annotated[str, "Greeting text, ready to display."]
characters: Annotated[int, "Number of Unicode code points in text, not bytes."]
class Greeter:
def hello(
self,
name: Annotated[str, "Nonblank name of the recipient."],
*,
uppercase: bool = False,
) -> Greeting:
"""Greet one person. Requires a nonblank name; raises ValueError otherwise.
uppercase defaults to False, preserving the recipient's capitalization.
Does not send a message or modify stored state.
"""
if not name.strip():
raise ValueError("name must not be blank")
text = f"Hello, {name.strip()}!"
if uppercase:
text = text.upper()
return Greeting(text, len(text))
Document default behavior
Explain what happens when an argument is omitted. A caller needs that behavior,
not merely a statement that the parameter is optional. Public constants belong
in the documentation: uppercase defaults to False in the example. Test the
omitted-argument call as well as an explicit override so the prose stays accurate.
For contextual defaults, name the resolution rule: for example, “Omitting repo
uses the current project's repository.” For None, say whether it means automatic
selection, no limit, or absence. Avoid vague phrases such as “uses the default.”
Vis does not automatically export non-None default values or call their repr():
a host default can be a credential, client or other private object. This restriction
is not a ban on documenting known public defaults. Write their meaning in the
docstring or Annotated description; never copy a resolved credential or environment
value there. The contract reference
explains ..., has_default and sandbox introspection.
Keep the entrypoint small
For reusable code, keep business logic in an importable package and registration
in extension.py. A CLI, if useful, is another caller of the same functions.
Do not import the entrypoint from domain code, change sys.path, or start login,
network requests or background work during registration.
The packaged example connects the implementation to Vis with this entire entrypoint:
"""Vis entrypoint; business logic lives in vis_greeter, not this file."""
import blockether.vis.extension as vis
from vis_greeter import Greeter
vis.register(
vis.Extension(
name="vis-greeter",
description="Typed greeting tools and an optional greeting procedure.",
alias="greet",
symbols=[vis.Symbol(Greeter(), name="greet")],
)
)
Symbol(Greeter(), name="greet") exports greet.hello(...). The public namespace
comes from Symbol.name, not Extension.alias. Declare import roots in the
package manifest, or use an
editable project; do not combine both import strategies
for the same source.
Give each kind of instruction one owner
| Information | Owner |
|---|---|
| A tool's inputs, defaults, result and failure conditions | Its annotations and docstring |
| When to use this extension and where to discover its tools | A short extension prompt |
| A multi-step procedure spanning tools | A skill with a clear trigger in its description |
| Project-wide rules | Project instructions, not every tool's prompt |
Link from a skill to doc("greet.hello") instead of copying the API reference.
Keep references and templates beside SKILL.md and read them when needed.
Installing or reading a skill never executes its procedure or supplies authorization.
See prompts and discovery and
bundled skills.
Test both boundaries
Test the Python implementation
From a development environment containing the SDK and pytest, run the example's
tests from the copied greeter/ directory:
python -m pip install vis-agent pytest
python -m pytest tests
Cover the normal result, omitted arguments, explicit overrides, invalid input and side effects. The example's tests run without a gateway. Use your project's own test environment; installing the SDK there does not install an extension into Vis.
Outside the engine, vis.state, vis.log and vis.shell use a local host, and
vis.ask prompts in the terminal. Supply answers with
vis.outside.answer_with({...}) or VIS_OUTSIDE_ANSWERS JSON. Set
VIS_OUTSIDE_NONINTERACTIVE=1 to make input requests undeliverable. Session-only
operations cannot be proved by an outside test. For views, use the existing
LiveRecorder.
Test the registered tool
- Install the local package, then start
Vis in the target project or run
/reload. - On the next turn, inspect
apropos(r"^greet\."),doc("greet.hello")andgreet.hello.contract. Confirm the inputs, public default behavior and result fields. - Call
await greet.hello("Ada")andawait greet.hello("Ada", uppercase=True). Check the returned.text, not only registration or the extension list. - After an edit, reload and repeat a call. If the last working version was retained as stale, resolve the load failure before claiming that the edit works.
The repository's regression suite executes the documented package and loads it
through the real host, including discovery, sandbox results, skill reload and
removal. Your integration also needs a representative call against its own
required dependencies. For tests in Vis's shared environment, use
vis-agent python -m pytest, not vis-agent python pytest.
See also
- Extension API — exact declarations, contracts and callback rules.
- Using an existing Python project — editable imports and dependency preparation.
- Installing and sharing extensions — the distributable package layout.
