Skip to content

Python SDK reference

Stable root imports

from axm import ... supports the following contracts:

Export Purpose
AXMTool Structural tool protocol: name and execute
ToolResult Tool success, data, error, hint and text
ToolMetadata, tool_metadata Optional discovery settings and their defaults
tool_node, ToolNodeError Tool-to-node adapter and its contract errors
WitnessRule, WitnessResult, ValidationFeedback Validation contracts
__version__ Installed distribution version; "0.0.0" if metadata is missing
DeclarativeToolDefinition, DeclarativeToolManifest, load_declarative_manifest Versioned UI tool metadata, JSON Schema validation and immutable declarations
DeclarativeTool One shared adapter, explicitly unavailable without a host binding
tool_access_scope, ToolAccessDenied Positive, context-local permissions including nested resolution and cached nodes
tool_execution_guard Host-injected lease and argument-authority checks immediately before execution
validate_declarative_schema Detached object schemas checked offline under the supported 2020-12 dialect
tool_scope_sha256 SHA256 of compact UTF-8 JSON containing sorted logical target names
require_write_path Fail-closed write authority for a host-configured persisted target
require_read_path Canonical read authority for explicit and configured persisted targets

Definitions remain in their submodules. Prefer root imports for these contracts. load_tool and override_tools are additionally re-exported by axm.tools. CLI helpers and axm.tools.write_scope are implementation modules, not root SDK exports.

Tool results

ToolResult is a frozen dataclass, not a Pydantic model. It requires success; data defaults to a fresh empty dict, and error, hint, text default to None. Frozen fields do not make nested data immutable.

data is for machine consumers; text is an optional prepared rendering. Neither automatically derives from the other. hint is available to consumers but the generic CLI does not print it as a separate field. Use dataclasses.asdict when you explicitly need the whole dataclass; the CLI's shared --json-output emits only data.

ToolResult dataclass

Immutable result of a tool execution.

Attributes:

Name Type Description
success bool

Whether the tool execution succeeded.

data dict[str, Any]

Structured output data (backend-specific).

error str | None

Human-readable error message, if any.

hint str | None

Optional next-step suggestion for the agent.

text str | None

Optional pre-rendered text representation (e.g. Markdown).

Source code in packages/axm/src/axm/tools/base.py
Python
@dataclass(frozen=True)
class ToolResult:
    """Immutable result of a tool execution.

    Attributes:
        success: Whether the tool execution succeeded.
        data: Structured output data (backend-specific).
        error: Human-readable error message, if any.
        hint: Optional next-step suggestion for the agent.
        text: Optional pre-rendered text representation (e.g. Markdown).
    """

    success: bool
    data: dict[str, Any] = field(default_factory=dict)
    error: str | None = None
    hint: str | None = None
    text: str | None = None

AXMTool

Bases: Protocol

Structural protocol for AXM deterministic tools.

Implementors must provide: - name (property): unique tool identifier - execute(...) : deterministic execution with explicit params

Optionally provide: - agent_hint (class attribute): optional, free-form one-liner optimized for LLM consumption: what the tool does, key params, and what it replaces. Like the other discovery attributes below, it is not a protocol member and carries no guaranteed fallback — some discovery tooling reads it best-effort via getattr / :func:tool_metadata; absent, nothing is substituted. - expose_directly (class attribute, default False): when True, the MCP server registers this tool directly in tools/list (the hot path). When False (default), the tool is reachable only through the MCP facade (axm_search -> axm_describe -> axm_call), keeping the tools/list payload small. - domain (class attribute, default None): coarse capability group used by the facade for axm_capabilities and to scope axm_search (e.g. "ast", "git", "ticket"). - tags (class attribute, default frozenset()): free-form keywords feeding facade discovery (axm_search).

Uses structural typing (PEP 544) — no inheritance required. @runtime_checkable enables isinstance() checks. The discovery attributes (expose_directly / domain / tags) are not protocol members on purpose: adding data attributes to a runtime_checkable protocol would make them required for isinstance() and break the check for the many tools that satisfy AXMTool structurally without subclassing it. Read them through :func:tool_metadata (or getattr(tool, name, default)) instead, which works for subclasses and structural tools alike.

Example::

Text Only
class MyTool(AXMTool):
    agent_hint = "Frobnicate widgets — use width param."
    expose_directly = True          # hot path (read via tool_metadata)
    domain = "widget"
    tags = frozenset({"frobnicate"})

    @property
    def name(self) -> str:
        return "my-tool"

    def execute(self, *, value: int = 0) -> ToolResult:
        return ToolResult(success=True, data={"result": value})
Source code in packages/axm/src/axm/tools/base.py
Python
@runtime_checkable
class AXMTool(Protocol):
    """Structural protocol for AXM deterministic tools.

    Implementors must provide:
    - ``name`` (property): unique tool identifier
    - ``execute(...)`` : deterministic execution with explicit params

    Optionally provide:
    - ``agent_hint`` (class attribute): optional, free-form one-liner
      optimized for LLM consumption: what the tool does, key params, and
      what it replaces.  Like the other discovery attributes below, it is
      *not* a protocol member and carries no guaranteed fallback — some
      discovery tooling reads it best-effort via ``getattr`` /
      :func:`tool_metadata`; absent, nothing is substituted.
    - ``expose_directly`` (class attribute, default ``False``): when
      ``True``, the MCP server registers this tool directly in
      ``tools/list`` (the *hot path*).  When ``False`` (default), the
      tool is reachable only through the MCP facade
      (``axm_search`` -> ``axm_describe`` -> ``axm_call``), keeping the
      ``tools/list`` payload small.
    - ``domain`` (class attribute, default ``None``): coarse capability
      group used by the facade for ``axm_capabilities`` and to scope
      ``axm_search`` (e.g. ``"ast"``, ``"git"``, ``"ticket"``).
    - ``tags`` (class attribute, default ``frozenset()``): free-form
      keywords feeding facade discovery (``axm_search``).

    Uses structural typing (PEP 544) — no inheritance required.
    ``@runtime_checkable`` enables ``isinstance()`` checks.  The discovery
    attributes (``expose_directly`` / ``domain`` / ``tags``) are *not*
    protocol members on purpose: adding data attributes to a
    ``runtime_checkable`` protocol would make them required for
    ``isinstance()`` and break the check for the many tools that satisfy
    ``AXMTool`` structurally without subclassing it.  Read them through
    :func:`tool_metadata` (or ``getattr(tool, name, default)``) instead,
    which works for subclasses and structural tools alike.

    Example::

        class MyTool(AXMTool):
            agent_hint = "Frobnicate widgets — use width param."
            expose_directly = True          # hot path (read via tool_metadata)
            domain = "widget"
            tags = frozenset({"frobnicate"})

            @property
            def name(self) -> str:
                return "my-tool"

            def execute(self, *, value: int = 0) -> ToolResult:
                return ToolResult(success=True, data={"result": value})
    """

    @property
    def name(self) -> str:
        """Unique tool identifier (e.g., 'esbmc', 'dafny', 'pytest')."""
        ...

    def execute(self, **kwargs: Any) -> ToolResult:
        """Execute the tool with given arguments.

        Subclasses should override with explicit, typed parameters::

            def execute(self, *, title: str = "", body: str = "") -> ToolResult:
                ...

        The ``**kwargs`` signature here is the *structural minimum* —
        any callable accepting keyword arguments satisfies it.

        Returns:
            ToolResult with success status and structured data.
        """
        ...

name property

Unique tool identifier (e.g., 'esbmc', 'dafny', 'pytest').

execute(**kwargs)

Execute the tool with given arguments.

Subclasses should override with explicit, typed parameters::

Text Only
def execute(self, *, title: str = "", body: str = "") -> ToolResult:
    ...

The **kwargs signature here is the structural minimum — any callable accepting keyword arguments satisfies it.

Returns:

Type Description
ToolResult

ToolResult with success status and structured data.

Source code in packages/axm/src/axm/tools/base.py
Python
def execute(self, **kwargs: Any) -> ToolResult:
    """Execute the tool with given arguments.

    Subclasses should override with explicit, typed parameters::

        def execute(self, *, title: str = "", body: str = "") -> ToolResult:
            ...

    The ``**kwargs`` signature here is the *structural minimum* —
    any callable accepting keyword arguments satisfies it.

    Returns:
        ToolResult with success status and structured data.
    """
    ...

Discovery metadata

tool_metadata(tool) returns ToolMetadata with defaults expose_directly=False, domain=None, tags=frozenset(). It uses attributes rather than requiring inheritance. Tags are coerced to a frozenset; expose_directly is converted with bool.

agent_hint is a separate optional attribute, absent from ToolMetadata. The helper does not invent an agent hint or guarantee a docstring fallback.

ToolMetadata dataclass

Facade/CLI discovery metadata for a tool, with safe defaults.

Built by :func:tool_metadata from the optional expose_directly / domain / tags attributes a tool may declare. Centralising the defaults here keeps the MCP facade and the CLI reading the same contract.

Attributes:

Name Type Description
expose_directly bool

Whether the tool is on the MCP hot path (registered directly in tools/list). Default False.

domain str | None

Coarse capability group, or None if ungrouped.

tags frozenset[str]

Discovery keywords (possibly empty).

Source code in packages/axm/src/axm/tools/base.py
Python
@dataclass(frozen=True)
class ToolMetadata:
    """Facade/CLI discovery metadata for a tool, with safe defaults.

    Built by :func:`tool_metadata` from the optional ``expose_directly`` /
    ``domain`` / ``tags`` attributes a tool may declare.  Centralising the
    defaults here keeps the MCP facade and the CLI reading the same contract.

    Attributes:
        expose_directly: Whether the tool is on the MCP hot path
            (registered directly in ``tools/list``).  Default ``False``.
        domain: Coarse capability group, or ``None`` if ungrouped.
        tags: Discovery keywords (possibly empty).
    """

    expose_directly: bool = False
    domain: str | None = None
    tags: frozenset[str] = frozenset()

tool_metadata(tool)

Read a tool's optional discovery attributes into a :class:ToolMetadata.

Works for tools that subclass :class:AXMTool, tools that satisfy it structurally, and plain callables — anything missing an attribute falls back to the default. tags is coerced to a frozenset so callers can rely on set semantics regardless of how the tool declared it.

Parameters:

Name Type Description Default
tool object

The tool instance (or plain callable) to introspect.

required

Returns:

Name Type Description
A ToolMetadata

class:ToolMetadata with resolved values.

Source code in packages/axm/src/axm/tools/base.py
Python
def tool_metadata(tool: object) -> ToolMetadata:
    """Read a tool's optional discovery attributes into a :class:`ToolMetadata`.

    Works for tools that subclass :class:`AXMTool`, tools that satisfy it
    structurally, and plain callables — anything missing an attribute falls
    back to the default.  ``tags`` is coerced to a ``frozenset`` so callers
    can rely on set semantics regardless of how the tool declared it.

    Args:
        tool: The tool instance (or plain callable) to introspect.

    Returns:
        A :class:`ToolMetadata` with resolved values.
    """
    raw_tags = getattr(tool, "tags", None) or ()
    return ToolMetadata(
        expose_directly=bool(getattr(tool, "expose_directly", False)),
        domain=getattr(tool, "domain", None),
        tags=frozenset(raw_tags),
    )

Tool nodes

Read the composition guide before choosing mappings and failure handling. ToolNodeError is a RuntimeError subclass.

tool_node(name, *, args=None, returns=None, allow_failure_data=False)

Build a DAG python-node fn(payload) -> dict around an axm.tools tool.

Parameters:

Name Type Description Default
name str

The tool's axm.tools entry-point name (e.g. "ast_impact").

required
args Mapping[str, str] | None

Optional {param_name: mem_key} rename map for params whose mem key differs from the execute parameter name. Params absent from args are read from payload by their own name when present.

None
returns Mapping[str, str] | None

{write_key: source} where source is "text" (the ToolResult.text) or a key inside ToolResult.data. Defaults to {name: "text"} is not assumed — supply it so the node's writes are explicit (decision: writes nommés par clé).

Precedence / collision: the literal "text" is a sentinel that always routes to ToolResult.text, checked before any lookup in ToolResult.data. A tool whose data carries its own "text" key therefore cannot surface that field through this shortcut — source="text" yields ToolResult.text, never data["text"] (which is unreachable via returns). Route such a field under a different write source, or rename the data key upstream.

None
allow_failure_data bool

Opt-in for observation tools whose success=False is itself measured domain data (for example audit_test returning exact failed cases). When true, shape ToolResult.data through returns instead of raising solely on the success flag. Missing declared data still raises, so an empty/tool failure cannot become evidence. Defaults to the existing fail-fast behaviour.

False

Returns:

Type Description
Callable[[Mapping[str, object]], dict[str, object]]

A callable mapping the node's payload to a dict keyed by returns.

Raises:

Type Description
ToolNodeError

At call time, if the tool is unknown, returns success=False without the explicit observation opt-in, or omits one of the declared return fields.

Source code in packages/axm/src/axm/tools/node.py
Python
def tool_node(
    name: str,
    *,
    args: Mapping[str, str] | None = None,
    returns: Mapping[str, str] | None = None,
    allow_failure_data: bool = False,
) -> Callable[[Mapping[str, object]], dict[str, object]]:
    """Build a DAG python-node ``fn(payload) -> dict`` around an ``axm.tools`` tool.

    Args:
        name: The tool's ``axm.tools`` entry-point name (e.g. ``"ast_impact"``).
        args: Optional ``{param_name: mem_key}`` rename map for params whose mem
            key differs from the ``execute`` parameter name. Params absent from
            *args* are read from ``payload`` by their own name when present.
        returns: ``{write_key: source}`` where *source* is ``"text"`` (the
            ``ToolResult.text``) or a key inside ``ToolResult.data``. Defaults to
            ``{name: "text"}`` is **not** assumed — supply it so the node's writes
            are explicit (decision: writes nommés par clé).

            Precedence / collision: the literal ``"text"`` is a sentinel that
            **always** routes to ``ToolResult.text``, checked *before* any lookup
            in ``ToolResult.data``. A tool whose ``data`` carries its own
            ``"text"`` key therefore cannot surface that field through this
            shortcut — ``source="text"`` yields ``ToolResult.text``, never
            ``data["text"]`` (which is unreachable via *returns*). Route such a
            field under a different write source, or rename the data key upstream.
        allow_failure_data: Opt-in for observation tools whose ``success=False``
            is itself measured domain data (for example ``audit_test`` returning
            exact failed cases).  When true, shape ``ToolResult.data`` through
            *returns* instead of raising solely on the success flag. Missing
            declared data still raises, so an empty/tool failure cannot become
            evidence. Defaults to the existing fail-fast behaviour.

    Returns:
        A callable mapping the node's ``payload`` to a dict keyed by *returns*.

    Raises:
        ToolNodeError: At call time, if the tool is unknown, returns
            ``success=False`` without the explicit observation opt-in, or omits
            one of the declared return fields.
    """
    rename = dict(args or {})
    out_spec = dict(returns or {})
    cache: dict[str, AXMTool] = {}

    def _run(payload: Mapping[str, object]) -> dict[str, object]:
        require_tool_access(name)
        overrides = _OVERRIDES.get()
        if overrides and name in overrides:
            # A substitute shadows even a memoized real tool and never enters
            # the cache: it lives exactly as long as its block.
            tool: AXMTool = overrides[name]
        else:
            cached = cache.get(name)
            if cached is None:
                # Resolve entry points + instantiate once, lazily on first call
                # (late-binding): building the node scans nothing.
                cached = _load_tool(name)
                cache[name] = cached
            tool = cached
        kwargs = _kwargs_from_payload(payload, rename)
        try:
            result = guard_tool(name, tool).execute(**kwargs)
        except TypeError as exc:
            # A payload key with no matching ``execute`` parameter (strict
            # signature, no ``**kwargs``) must surface as the documented
            # ToolNodeError, not a raw TypeError.
            msg = f"tool {name!r}: bad payload for execute(): {exc}"
            raise ToolNodeError(msg) from exc
        if not result.success and not allow_failure_data:
            msg = f"tool {name!r} failed: {result.error or '<no error message>'}"
            raise ToolNodeError(msg)
        try:
            return _shape_output(name, result.data, result.text, out_spec)
        except ToolNodeError as exc:
            if not result.success and result.error:
                msg = f"tool {name!r} failed: {result.error}; {exc}"
                raise ToolNodeError(msg) from exc
            raise

    return _run

ToolNodeError

Bases: RuntimeError

A tool invoked as a DAG node failed (ToolResult.success was False).

Source code in packages/axm/src/axm/tools/node.py
Python
class ToolNodeError(RuntimeError):
    """A tool invoked as a DAG node failed (``ToolResult.success`` was ``False``)."""

load_tool(name)

Resolve and instantiate the AXM tool named name.

The name is resolved by :func:axm.tools.discovery.tool_entry_points (tribunal group first, legacy axm.tools second), the resolver the MCP catalog and the axm CLI share.

Raises:

Type Description
ToolNodeError

No tool is registered under name.

DuplicateToolError

One entry-point group publishes a name twice.

Source code in packages/axm/src/axm/tools/node.py
Python
def load_tool(name: str) -> AXMTool:
    """Resolve and instantiate the AXM tool named *name*.

    The name is resolved by :func:`axm.tools.discovery.tool_entry_points`
    (tribunal group first, legacy ``axm.tools`` second), the resolver the MCP
    catalog and the ``axm`` CLI share.

    Raises:
        ToolNodeError: No tool is registered under *name*.
        DuplicateToolError: One entry-point group publishes a name twice.
    """
    return guard_tool(name, _resolve_tool(name))

override_tools(tools)

Substitute tools ({entry_point_name: tool}) for the duration of a block.

Inside the block, a :func:tool_node built for one of the named tools calls the substitute instead of resolving the axm.tools entry point — whether the node was built before or after entering the block, and whether or not the real tool had already been resolved and memoized. Blocks nest: an inner block adds to (or shadows, per name) the enclosing one, and leaving it restores exactly what was active before.

The substitution applies to every resolution by name — :func:tool_node nodes and direct :func:_load_tool callers alike — so a graph node that resolves a tool itself (_load_tool("echo_check") in a python node) is covered too, not only tool_node wrappers.

The scope is a :mod:contextvars context, not a global: it propagates to asyncio tasks and :func:asyncio.to_thread calls started inside the block (how axm_dag executes python nodes) and is invisible to concurrent work started outside it. Substitutes are never memoized, so the real tool resolves again as soon as the block exits.

Parameters:

Name Type Description Default
tools Mapping[str, AXMTool]

Entry-point name → substitute implementing execute(**kwargs) -> ToolResult.

required
Source code in packages/axm/src/axm/tools/node.py
Python
@contextmanager
def override_tools(tools: Mapping[str, AXMTool]) -> Iterator[None]:
    """Substitute *tools* (``{entry_point_name: tool}``) for the duration of a block.

    Inside the block, a :func:`tool_node` built for one of the named tools calls
    the substitute instead of resolving the ``axm.tools`` entry point — whether
    the node was built before or after entering the block, and whether or not
    the real tool had already been resolved and memoized. Blocks nest: an inner
    block adds to (or shadows, per name) the enclosing one, and leaving it
    restores exactly what was active before.

    The substitution applies to every resolution *by name* — :func:`tool_node`
    nodes and direct :func:`_load_tool` callers alike — so a graph node that
    resolves a tool itself (``_load_tool("echo_check")`` in a python node) is
    covered too, not only ``tool_node`` wrappers.

    The scope is a :mod:`contextvars` context, not a global: it propagates to
    ``asyncio`` tasks and :func:`asyncio.to_thread` calls started inside the
    block (how ``axm_dag`` executes python nodes) and is invisible to concurrent
    work started outside it. Substitutes are never memoized, so the real tool
    resolves again as soon as the block exits.

    Args:
        tools: Entry-point name → substitute implementing ``execute(**kwargs)
            -> ToolResult``.
    """
    active = _OVERRIDES.get() or {}
    token = _OVERRIDES.set({**active, **tools})
    try:
        yield
    finally:
        _OVERRIDES.reset(token)

Process sandbox

axm.sandbox runs a subprocess that may write only under given directories, in a filtered environment. It is a submodule contract, not a root export.

Symbol Purpose
confinement(writable, protected=()) Probe the platform sandbox (sandbox-exec on macOS, bwrap on Linux) with the profile it will apply; Confinement(isolation="sandbox") or UNCONFINED ("detect-only")
run_confined(command, *, writable, cwd, env=None, protected=(), timeout=None, confined=None) Run one command under a confinement; returns ConfinedRun (isolation, return code, output)
inherited_environment(source=None) The allow-listed variables (INHERITED_ENV) only: no PYTHONPATH, FORCE_COLOR or credentials
private_environment(root) HOME, temporary and XDG variables pointed into fresh directories of root
darwin_profile, bubblewrap_argv The platform prefixes, for callers that launch the process themselves

A detect-only confinement does not confine anything: callers refuse it or report it, never treat it as a sandbox. A protected path inside a writable directory stays read-only. On macOS, sandbox-exec applies explicit write denials for protected paths after the writable allowances. On Linux, bubblewrap mounts the filesystem read-only, binds writable directories, then remounts every protected path read-only after those writable binds. Both platforms enforce the protection when confinement reports sandbox.

run_confined(command, *, writable, cwd, env=None, protected=(), timeout=None, confined=None)

Run command writing only under writable, in a filtered environment.

The environment is :func:inherited_environment updated with env; nothing else of the caller's environment is passed. confined is the confinement to apply, probed from writable and protected when omitted: the caller refuses a detect-only one beforehand if it must.

Raises:

Type Description
TimeoutExpired

The process outlived timeout.

OSError

The process could not start.

Source code in packages/axm/src/axm/sandbox.py
Python
def run_confined(  # noqa: PLR0913 - command, place, confinement and budget are distinct axes
    command: Sequence[str],
    *,
    writable: Sequence[Path],
    cwd: Path,
    env: Mapping[str, str] | None = None,
    protected: Sequence[Path] = (),
    timeout: float | None = None,
    confined: Confinement | None = None,
) -> ConfinedRun:
    """Run ``command`` writing only under ``writable``, in a filtered environment.

    The environment is :func:`inherited_environment` updated with ``env``;
    nothing else of the caller's environment is passed. ``confined`` is the
    confinement to apply, probed from ``writable`` and ``protected`` when
    omitted: the caller refuses a ``detect-only`` one beforehand if it must.

    Raises:
        subprocess.TimeoutExpired: The process outlived ``timeout``.
        OSError: The process could not start.
    """
    applied = confined or confinement(writable, protected)
    done = subprocess.run(  # noqa: S603 - caller's argv, no shell
        applied.wrap(command),
        cwd=cwd,
        env={**inherited_environment(), **(env or {})},
        capture_output=True,
        text=True,
        timeout=timeout,
        check=False,
    )
    return ConfinedRun(
        isolation=applied.isolation,
        returncode=done.returncode,
        stdout=done.stdout,
        stderr=done.stderr,
    )

confinement(writable, protected=())

The confinement writing only under writable, never under protected.

sandbox when the platform's sandbox applies here (probed from the first writable directory), else :data:UNCONFINED (detect-only).

Raises:

Type Description
ValueError

writable is empty.

Source code in packages/axm/src/axm/sandbox.py
Python
def confinement(
    writable: Sequence[Path], protected: Sequence[Path] = ()
) -> Confinement:
    """The confinement writing only under ``writable``, never under ``protected``.

    ``sandbox`` when the platform's sandbox applies here (probed from the first
    writable directory), else :data:`UNCONFINED` (``detect-only``).

    Raises:
        ValueError: ``writable`` is empty.
    """
    if not writable:
        raise ValueError("a confinement needs at least one writable directory")
    prefix = _candidate(writable, protected)
    if prefix is None or not _applies(prefix, writable[0]):
        return UNCONFINED
    return Confinement(isolation="sandbox", prefix=prefix)

Other contracts

Witnesses covers the remaining root protocols and result types. CLI reference documents launcher helpers.

The workspace build also generates module reference pages from docs/gen_ref_pages.py. This curated reference renders directly from the same source, so it works in both package-only and monorepo builds.

Declared instrument runtime

axm.DependencyEnvironment is the immutable runtime transported with a phase contract. axm.session_dependency_environment(project_path) returns the matching declared environment, or None only without a contract. A missing or ambiguous runtime, an unrelated readable root, or a missing interpreter fails explicitly. The interpreter retains its lexical virtualenv path. Resolving this runtime does not grant additional filesystem write authority.

Declarative UI tools

The frontend build publishes ui/tools.json in the installed resource package. One entry point identifies that package, not an executable per tool:

TOML
[project.entry-points."axm.tool_manifests"]
rugby = "axm_rugby_orison"

The entry-point name must equal addon_id. The common resolver used by MCP, CLI and load_tool reads this resource. It never compiles or scans Svelte. Python tribunal/legacy precedence is unchanged; UI/Python collisions and duplicate UI owners fail explicitly. A missing resource emits a provider-unavailable warning without disabling legacy tools. Invalid or incompatible manifests fail closed.

JSON
{
  "schema_version": 1,
  "addon_id": "rugby",
  "bundle_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "tools": [{
    "name": "rugby_ui_read_state",
    "handler": "rugby_ui_read_state",
    "description": "Read this view's presentation state",
    "inputSchema": {"type": "object", "additionalProperties": false},
    "outputSchema": {"type": "object"}
  }]
}

Schemas use JSON Schema 2020-12, object roots and local references only. The same validate_declarative_schema(schema) -> dict helper validates view snapshot schemas. It rejects remote references/IDs, non-2020-12 dialects and custom vocabularies. Legacy $recursiveRef/$recursiveAnchor are refused rather than silently ignored; use local $dynamicRef in the supported dialect. Schema annotations/examples and property names remain data, not reference keywords. Extra manifest fields are refused. Definitions detach their schema dictionaries; mutating an input dictionary does not change the published contract. The host separately verifies the bundle hash and owns target, revision, deduplication, permissions and lifecycle. Metadata does not grant execution authority.

Without an explicit binding, a discovered DeclarativeTool.execute returns success=False, status unavailable: no active-global-view fallback. A synchronous host can inject (name, arguments) -> ToolResult into DeclarativeTool; Orison's asynchronous host uses the scoped MCP factory. Successful ToolResult.data must satisfy outputSchema; invalid arguments never reach the invoker.

CLI tools accept one JSON --arguments value, for example axm rugby_ui_read_state --arguments '{}'; a headless call remains unavailable. The generic transport is not a second implementation of persistent business actions.

tool_access_scope(frozenset({...})) filters discovery and denies name-based loads, overrides and cached tool nodes outside the positive list. Nested scopes intersect instead of widening; outside a scope, legacy behavior is unchanged. This is an authorization contract for trusted tools, not a sandbox against arbitrary Python code holding direct function references.

tool_execution_guard(callback) installs callback(name, detached_kwargs, original_execute) -> None. A callback may normalize the detached keyword arguments and raises on a stale lease or denied effect. Scopes compose and restore using ContextVar; a nested scope cannot remove an outer guard. Resolved handles preserve the implementation's signature and metadata while retaining their creating guards. Tool-node caches retain only implementations, then apply the current request's guards at every execution. Legacy resolution without a scope/guard still returns the original implementation. Retained handles also capture bound filesystem authority. During the service body, the current caller and originating authority must both permit a read or write; reusing a readonly handle inside a writable conversation cannot widen it. The intersection remains active across nested dependency-read contexts. An explicit None on either side denies authority and cannot borrow an ambient grant.

This cooperative boundary checks named dispatch immediately before invoking a trusted tool. It cannot atomically revoke an effect already running or sandbox arbitrary Python references. Business services still enforce their own validations.

DeclarativeToolDefinition dataclass

Immutable tool metadata; schemas are detached on every access.

Source code in packages/axm/src/axm/tools/declarative.py
Python
@dataclass(frozen=True, init=False)
class DeclarativeToolDefinition:
    """Immutable tool metadata; schemas are detached on every access."""

    name: str
    description: str
    handler: str
    execution: str
    _input_json: str
    _output_json: str

    def __init__(
        self,
        name: str,
        description: str,
        input_schema: Mapping[str, object],
        output_schema: Mapping[str, object],
        handler: str,
    ) -> None:
        if not _NAME.fullmatch(name) or name in _RESERVED:
            raise ValueError(f"invalid or reserved declarative tool name: {name}")
        if not description.strip() or handler != name:
            raise ValueError("description is required and handler must equal tool name")
        for key, value in (
            ("name", name),
            ("description", description),
            ("handler", handler),
            ("execution", "ui"),
            ("_input_json", _schema_json(input_schema)),
            ("_output_json", _schema_json(output_schema)),
        ):
            object.__setattr__(self, key, value)

    @property
    def input_schema(self) -> dict[str, object]:
        """Detached JSON Schema for functional arguments, without routing fields."""
        return cast(dict[str, object], json.loads(self._input_json))

    @property
    def output_schema(self) -> dict[str, object]:
        """Detached JSON Schema for successful ToolResult.data."""
        return cast(dict[str, object], json.loads(self._output_json))

    def validate_input(self, arguments: Mapping[str, object]) -> None:
        """Reject malformed/non-JSON arguments before contacting any view."""
        detached = json.loads(json.dumps(dict(arguments), allow_nan=False))
        Draft202012Validator(self.input_schema).validate(detached)

    def validate_result(self, result: ToolResult) -> ToolResult:
        """Validate successful data; failures retain their structural signal."""
        if not isinstance(result.success, bool):
            raise ValueError("malformed ToolResult: success must be a boolean")
        if result.success:
            detached = json.loads(json.dumps(result.data, allow_nan=False))
            Draft202012Validator(self.output_schema).validate(detached)
        return result

input_schema property

Detached JSON Schema for functional arguments, without routing fields.

output_schema property

Detached JSON Schema for successful ToolResult.data.

validate_input(arguments)

Reject malformed/non-JSON arguments before contacting any view.

Source code in packages/axm/src/axm/tools/declarative.py
Python
def validate_input(self, arguments: Mapping[str, object]) -> None:
    """Reject malformed/non-JSON arguments before contacting any view."""
    detached = json.loads(json.dumps(dict(arguments), allow_nan=False))
    Draft202012Validator(self.input_schema).validate(detached)

validate_result(result)

Validate successful data; failures retain their structural signal.

Source code in packages/axm/src/axm/tools/declarative.py
Python
def validate_result(self, result: ToolResult) -> ToolResult:
    """Validate successful data; failures retain their structural signal."""
    if not isinstance(result.success, bool):
        raise ValueError("malformed ToolResult: success must be a boolean")
    if result.success:
        detached = json.loads(json.dumps(result.data, allow_nan=False))
        Draft202012Validator(self.output_schema).validate(detached)
    return result

DeclarativeToolManifest dataclass

One addon bundle's closed tool declarations.

Source code in packages/axm/src/axm/tools/declarative.py
Python
@dataclass(frozen=True)
class DeclarativeToolManifest:
    """One addon bundle's closed tool declarations."""

    schema_version: int
    addon_id: str
    bundle_sha256: str
    tools: tuple[DeclarativeToolDefinition, ...]

    def __post_init__(self) -> None:
        _validate_manifest(self)

load_declarative_manifest(raw)

Validate a JSON manifest from a trusted installed package.

Extra fields, remote schema references, versions and collisions are refused. This validates metadata only: the host verifies the mounted bundle.

Source code in packages/axm/src/axm/tools/declarative.py
Python
def load_declarative_manifest(raw: Mapping[str, object]) -> DeclarativeToolManifest:
    """Validate a JSON manifest from a trusted installed package.

    Extra fields, remote schema references, versions and collisions are refused.
    This validates metadata only: the host verifies the mounted bundle.
    """
    data = _object(dict(raw), {"schema_version", "addon_id", "bundle_sha256", "tools"})
    if not isinstance(data["tools"], list):
        raise ValueError("manifest tools must be a list")
    version = data["schema_version"]
    if type(version) is not int:
        raise ValueError("schema_version must be an integer")
    return DeclarativeToolManifest(
        schema_version=version,
        addon_id=_string(data["addon_id"]),
        bundle_sha256=_string(data["bundle_sha256"]),
        tools=tuple(_definition(tool) for tool in data["tools"]),
    )

DeclarativeTool

Common AXMTool adapter, unavailable without an explicit host binding.

Source code in packages/axm/src/axm/tools/declarative.py
Python
class DeclarativeTool:
    """Common AXMTool adapter, unavailable without an explicit host binding."""

    expose_directly = True
    tags = frozenset({"ui", "declarative"})

    def __init__(
        self,
        definition: DeclarativeToolDefinition,
        invoke: Callable[[str, dict[str, object]], ToolResult] | None = None,
    ) -> None:
        self.definition = definition
        self._invoke = invoke
        self.__doc__ = definition.description

    @property
    def name(self) -> str:
        """Canonical name shared by CLI, nodes and MCP."""
        return self.definition.name

    def execute(self, **kwargs: object) -> ToolResult:
        """Validate and invoke the host binding, never an ambient view."""
        try:
            self.definition.validate_input(kwargs)
            if self._invoke is None:
                return ToolResult(
                    success=False,
                    data={"status": "unavailable"},
                    error="UI tool unavailable: no host view binding",
                )
            return self.definition.validate_result(self._invoke(self.name, kwargs))
        except (ValidationError, ValueError, TypeError) as exc:
            return ToolResult(success=False, error=f"invalid UI tool contract: {exc}")

name property

Canonical name shared by CLI, nodes and MCP.

execute(**kwargs)

Validate and invoke the host binding, never an ambient view.

Source code in packages/axm/src/axm/tools/declarative.py
Python
def execute(self, **kwargs: object) -> ToolResult:
    """Validate and invoke the host binding, never an ambient view."""
    try:
        self.definition.validate_input(kwargs)
        if self._invoke is None:
            return ToolResult(
                success=False,
                data={"status": "unavailable"},
                error="UI tool unavailable: no host view binding",
            )
        return self.definition.validate_result(self._invoke(self.name, kwargs))
    except (ValidationError, ValueError, TypeError) as exc:
        return ToolResult(success=False, error=f"invalid UI tool contract: {exc}")

tool_access_scope(names)

Apply an immutable allowlist; nested scopes can only narrow it.

Source code in packages/axm/src/axm/tools/access.py
Python
@contextmanager
def tool_access_scope(names: frozenset[str]) -> Iterator[None]:
    """Apply an immutable allowlist; nested scopes can only narrow it."""
    if not isinstance(names, frozenset):
        raise ValueError("tool scope must be an immutable frozenset")
    outer = _ALLOWED.get()
    token = _ALLOWED.set(names if outer is None else outer & names)
    try:
        yield
    finally:
        _ALLOWED.reset(token)

tool_execution_guard(guard)

Install a neutral execution guard; inner scopes cannot remove outer guards.

Guards validate the current lease and may normalize detached keyword args. They run immediately before execution, including cached and injected tools. A transport owns its callback: the SDK does not import the transport.

Source code in packages/axm/src/axm/tools/access.py
Python
@contextmanager
def tool_execution_guard(guard: ExecutionGuard) -> Iterator[None]:
    """Install a neutral execution guard; inner scopes cannot remove outer guards.

    Guards validate the current lease and may normalize detached keyword args.
    They run immediately before execution, including cached and injected tools.
    A transport owns its callback: the SDK does not import the transport.
    """
    token = _GUARDS.set((*_GUARDS.get(), guard))
    try:
        yield
    finally:
        _GUARDS.reset(token)

validate_declarative_schema(schema)

Return a detached object schema validated offline for tools or snapshots.

Only the supported 2020-12 dialect and local references are accepted. Remote IDs/vocabularies and legacy recursive dialect keywords cannot weaken it.

Source code in packages/axm/src/axm/tools/declarative.py
Python
def validate_declarative_schema(schema: Mapping[str, object]) -> dict[str, object]:
    """Return a detached object schema validated offline for tools or snapshots.

    Only the supported 2020-12 dialect and local references are accepted. Remote
    IDs/vocabularies and legacy recursive dialect keywords cannot weaken it.
    """
    encoded = json.dumps(dict(schema), allow_nan=False)
    detached = json.loads(encoded)
    if detached.get("type") != "object":
        raise ValueError("tool schema root must be an object")
    _local_references(detached)
    try:
        Draft202012Validator.check_schema(detached)
    except SchemaError as exc:
        raise ValueError(f"invalid tool schema: {exc.message}") from exc
    return cast(dict[str, object], detached)

ToolAccessDenied

Bases: PermissionError

A named dispatch attempted to escape its positive tool scope.

Source code in packages/axm/src/axm/tools/access.py
Python
class ToolAccessDenied(PermissionError):  # noqa: N818
    """A named dispatch attempted to escape its positive tool scope."""

tool_scope_sha256(names)

Fingerprint a positive tool list; no provider-specific spelling.

Source code in packages/axm/src/axm/tools/declarative.py
Python
def tool_scope_sha256(names: frozenset[str]) -> str:
    """Fingerprint a positive tool list; no provider-specific spelling."""
    wire = json.dumps(sorted(names), ensure_ascii=False, separators=(",", ":"))
    return hashlib.sha256(wire.encode()).hexdigest()

require_write_path(path) is a separate service-side persistence guard. It raises PermissionError without an active contract or outside its allowed write prefixes; readable roots and dependency aliases do not grant writes. Relative paths resolve against the contract execution root, never the ambient working directory, and symlinks are canonicalized. Check the host-configured store/file before the canonical mutation. Metadata tags alone do not classify arbitrary domain tools as writes. MCP wrappers supply the bound contract; a local CLI can use an explicit AXM_WRITE_CONTRACT. A trusted UI route retains its own host authorization and shares the same business service rather than imitating an MCP click.

require_write_path(path)

Require active write authority for a persisted target, or raise PermissionError.

Use this before the canonical business mutation, with the host-configured path, not a path chosen by the model. Only allowed write prefixes count: readable roots/dependency aliases never grant writes. Resolution is relative to the contract execution root and follows symlinks through its canonical resolver. Missing authority is refused, including headless calls without an explicit contract. Local CLI callers may use their explicit AXM_WRITE_CONTRACT.

Source code in packages/axm/src/axm/tools/write_authority.py
Python
def require_write_path(path: str | Path) -> None:
    """Require active write authority for a persisted target, or raise PermissionError.

    Use this before the canonical business mutation, with the host-configured
    path, not a path chosen by the model. Only allowed write prefixes count:
    readable roots/dependency aliases never grant writes. Resolution is relative
    to the contract execution root and follows symlinks through its canonical
    resolver. Missing authority is refused, including headless calls without an
    explicit contract. Local CLI callers may use their explicit AXM_WRITE_CONTRACT.
    """
    contract = current_read_contract()
    if contract is None:
        raise PermissionError("write authority missing for persisted target")
    resolved = contract.resolve(contract.execution_root, str(path))
    if not contract.permits(resolved):
        raise PermissionError(f"persisted target outside write authority: {resolved}")

require_read_path(path) must run after a business service resolves either a caller-supplied store path or its configured default, immediately before opening that target. A bound contract uses canonical readable authority (read roots and declared dependency sources); relative paths use the execution root and symlinks cannot escape. An explicitly transport-bound None refuses reads and suppresses ambient contracts. Only legacy local callers with neither bound authority nor ambient contract remain unrestricted. Read authorization grants no writes.

require_read_path(path)

Require read authority before opening a service's persisted target.

Both explicit caller targets and implicit configured targets must pass here after canonical service resolution. Relative paths use the active contract's execution root, and symlinks use its canonical readable decision. Transport None explicitly denies reads and suppresses the ambient environment. Only legacy local callers with neither transport binding nor contract stay unrestricted; this helper never grants write authority.

Source code in packages/axm/src/axm/tools/read_authority.py
Python
def require_read_path(path: str | Path) -> None:
    """Require read authority before opening a service's persisted target.

    Both explicit caller targets and implicit configured targets must pass here
    after canonical service resolution. Relative paths use the active contract's
    execution root, and symlinks use its canonical readable decision. Transport
    None explicitly denies reads and suppresses the ambient environment. Only
    legacy local callers with neither transport binding nor contract stay
    unrestricted; this helper never grants write authority.
    """
    contract = current_read_contract()
    if contract is None:
        if read_context_bound():
            raise PermissionError("read authority missing for persisted target")
        return
    resolved = contract.resolve(contract.execution_root, str(path))
    if not contract.readable(resolved):
        raise PermissionError(f"persisted target outside read authority: {resolved}")