Tools Spec¶
Tool interaction is modeled as two strict types:
ToolCall— what the assistant asks to runToolResult— what the runtime reports back
Plus one orthogonal classifier — ToolMode — that the harness uses to
decide whether a call needs explicit user consent.
ToolCall¶
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string |
yes | Unique within a chat (use cuid2 or similar) |
name |
string |
yes | Tool name registered with the runtime |
arguments |
Record<string, unknown> |
yes | LLM-provided JSON args (validated by tool's schema) |
additionalProperties is disabled so tool envelopes stay
deterministic across versions. New per-call metadata should go through
the harness's TraceSpan.attrs, not into ToolCall.
ToolResult¶
| Field | Type | Required | Notes |
|---|---|---|---|
success |
boolean |
yes | Hard distinction — false flips status to error |
terminal |
boolean |
no | Explicitly mark the result as terminal |
needsFollowup |
boolean |
no | Even on success: false, re-prompt the LLM |
nextAction |
string |
no | Machine-readable hint for the next operation |
message |
string |
no | User-facing text (rendered in the bubble) |
error |
string |
no | Debug-friendly error string (logged + shown) |
data |
Record<string, unknown> |
no | Arbitrary structured payload |
additionalProperties is enabled for forward compatibility.
ToolMode (harness classifier)¶
The harness's decide_tool_mode(name) returns
one of:
| Mode | Meaning | Default UI treatment |
|---|---|---|
read |
Pure inspection (no side effects) | Auto-run, no consent |
safe_write |
Bounded mutation (e.g. update_event) | Auto-run with diff preview |
destructive |
Irreversible (delete_, drop_, …) | Auto-run, undo affordance |
local |
Touches the user's machine | Requires consent |
external |
Calls outside services | Auto-run, log |
Pattern rules (TypeScript regex equivalents in
@steerable/agent-ui/useToolCallStatus):
^get_ | ^list_ | ^read_ | ^search_ → read
^create_ | ^update_ | ^add_ | ^set_ → safe_write
^delete_ | ^remove_ | ^archive_ | ^drop_ → destructive
^local_ | ^shell_ | ^exec_ → local
You can override the inferred mode at registration time via the @tool
decorator's mode= kwarg.
Exposure tiers¶
Every registered tool carries a ToolExposure tier
(steerable_agent_runtime/tools.py):
| Tier | Offered list (describe_model()) |
Dispatchable | tool_search-able |
|---|---|---|---|
direct |
yes | yes | n/a |
deferred |
no | yes | yes |
hidden |
no | yes | no — also excluded from unknown-tool suggestions |
Dispatch never gates on exposure: a tool the model discovered (or a host
invoked directly) runs by name without being re-listed. describe() keeps
the full inventory for host introspection.
tool_search (tool_search.py) is the deferred tier's discovery seam: one
direct-tier tool that BM25-ranks the deferred inventory over name +
description (name tokens weigh double) and returns full schemas so a match
is callable the next round. Results default to 8 with a per-call ceiling of
20 — every match carries a schema, so the payload stays bounded. Ranking
has a relevance floor: a document containing no query term scores zero and
is dropped, so an off-vocabulary query returns an empty result rather than
irrelevant tools.
Third-party tools (plugin runtime)¶
An installed Python package adds tools without any host code change by
declaring the steerable.tools entry-point group in its own packaging
metadata:
# pyproject.toml of the extension package
[project.entry-points."steerable.tools"]
my_tools = "my_package.tools:register"
# my_package/tools.py
from steerable_agent_runtime import tool
def register(router):
@tool(router=router, description="Greet by name")
async def greet(name: str) -> str:
return f"hello {name}"
The sidecar boots a PluginRegistry over the tool router and loads every
configured PluginSource: EntryPointSource (installed packages, as
above) plus DirectorySource on the directory named by
STEERABLE_PLUGIN_DIR (local development — each non-_-prefixed .py
file is one plugin with a top-level register(router)). The registry
tracks which tool names each plugin registered (via a recording proxy over
the router), so plugins have a lifecycle after boot:
enable/disable/unload/reload. Disable removes the plugin's tools
from the router; enable re-runs the register callable; reload re-imports
the plugin's module (importlib.reload) and swaps its registrations —
with the usual reload boundaries (references already imported from the
module elsewhere keep the old objects). Remote sources (a market) plug
into the PluginSource protocol; only discovery is reserved, no download
mechanism.
Failures fail loud and name the offender: a missing source directory, an
import failure, a missing/non-callable register, and tool-name conflicts
(first registrant wins; the later plugin's load raises PluginLoadError)
all abort the offending plugin — and, at boot, the sidecar — rather than
silently dropping an installed tool. A registration that fails mid-way
rolls back the tools already registered by that plugin. Lifecycle calls on
unknown or wrong-state plugins raise PluginStateError.
ask_user (structured user questions)¶
Opt-in per request (askUser: true on agent.chat.stream). The model calls
ask_user with {intro, questions[], outro?} — field names mirror the
protocol's AskUserQuestionsPayload, so the desktop renders the arguments as
the question card unchanged. Each question is select / text / password
with optional options and multiSelect.
Models do not always follow the schema literally (observed: gpt-oss via
Ollama emits Inquirer-style name / message / choices). The tool
normalizes those aliases onto the canonical fields at the model-JSON
boundary, so hosts only ever render the canonical payload; a question still
missing a non-empty id or text after normalization fails the tool call
with an error naming the fix, and the model retries within the same turn.
The tool blocks: dispatch awaits the product-injected handler, and the
answers ({questionId: value}) return as the tool result, landing in the
durable record and the model's next context. On the desktop path the sidecar
routes it over the reverse channel (ask_user.request) to the host UI; an
unreachable host or a user cancel records an empty answers mapping rather
than hanging the turn. Under toolsViaHost the sidecar answers ask_user
locally from its own router (same interception as run_code) — the host's
tool.invoke surface has no ask_user, so forwarding it would fail with
Unknown tool. The framework seam is make_ask_user_tool(handler) in
steerable_agent_runtime.ask_user — a CLI or ACP embedder injects its own
handler (an ACP elicitation, a terminal prompt) instead of the host-routed
one.
run_code (programmatic tool calls)¶
Opt-in (STEERABLE_RUN_CODE=1). The model still sees native tools; run_code
is an extra tool whose arguments are {code, description}. code is the
body of a Python function. The program runs in a child interpreter
under the same layer-2 backend as bash (Seatbelt / bwrap / Landlock). The
sidecar process that holds the API key does not exec model Python.
The child talks JSON-over-stdio (tools.call(name, **kwargs) /
tools.<name>(...)). Nested calls go through the live executor (approval,
sandbox rewrite, host tool.invoke). Nested run_code is refused.
import os / subprocess / socket fail. No backend →
error: sandbox_unavailable. Default off; Harbor does not force it off
the way --no-web-tools omits fetch — leave the env unset unless the trial
wants it.
Child environment. The child inherits only an allowlist (PATH, HOME,
TMPDIR/TEMP/TMP, LANG, LC_*, PYTHONPATH, Windows SYSTEMROOT/
SYSTEMDRIVE) plus PYTHONDONTWRITEBYTECODE=1. Everything else — including
STEERABLE_API_KEY and any *_API_KEY / *_TOKEN the sidecar holds — is
scrubbed, because the import guard blocks import os but not the
__subclasses__ route to os.environ.
Inheriting layer-1. When the host already runs the sidecar under an OS
sandbox it sets STEERABLE_SIDECAR_CONFINED=1; a confined sidecar cannot
apply a second sandbox to its own child (macOS denies a nested
sandbox_apply once the outer profile allows outbound network). In that
posture run_code skips the layer-2 wrap and lets the child inherit the
layer-1 boundary; the result's data._sandbox reads
{backend: "inherited", enforcement: "partial", via: "layer1"} instead of
naming a dedicated backend.
The progressive harness strategy (harness.py) builds on the tiers: the
offered list is the direct tier plus the tool_search descriptor. It needs
the run's ToolRouter — the entrypoint calls
AssembledHarness.wire_tools(router) before selection, which registers the
discovery tool. Selecting progressive without wiring raises: the model is
never offered a tool that cannot dispatch. Paths whose tools arrive over
the wire (the sidecar's host-tools chat path) have no router to bind and
must use full or minimal.
File tools: read-before-write state¶
read_file returns a version (SHA-256 of the full content) alongside the
(clipped) preview; write_file / edit_file accept an optional
expectedVersion that rejects the write when the file changed since. On top
of that explicit token, the workspace keeps a session-scoped
readFileState (path → version) that the tools maintain themselves:
read_file records it, and every successful write_file / edit_file /
apply_patch refreshes it to the post-write version (without the refresh, a
second write would reject against the pre-write version — iterative editing
is the norm). When the model passes no expectedVersion, writes and edits
automatically CAS against the tracked state, so a file modified outside the
session (another tool, a human, a crashed write's partial state) is rejected
with a conflict instead of silently overwritten. A file that vanished since
the read is a plain create, not a conflict; brand-new files are never gated.
The state lives in the tool-owning process (the workspace_tools_for_cwd
caller's dict, the desktop's LocalExecutor), not in the transcript — context
compaction cannot fold it away. The loss point is process restart + session
resume, so resume re-seeds it from the durable record, whose tool messages
carry the result JSON with data.path / data.version (apply_patch
carries data.versions): the sidecar pushes the rebuilt mapping to the host
over read_state.seed on a toolsViaHost resume, and the ACP adapter seeds
its session on hydration (read_file_state_from_messages). Unparseable
entries (spilled/folded bodies) are skipped — a missing entry means one fewer
CAS check, never a wrong write.
The hard gate is ON by default (CC read-before-write parity): overwriting
an existing file the session never read is rejected outright with an error
naming the fix ("read it first, then write with the read version"), and the
model recovers within the same turn. Creating a new file is never gated.
STEERABLE_REQUIRE_READ_BEFORE_WRITE=0 opts out.
Two deliberate refinements over a plain mtime check:
- The CAS token is a content hash, not an mtime — a touch that leaves content identical does not false-positive, and a same-mtime content change does not false-negative.
- A read whose display was clipped at
_MAX_OUTPUTis a partial view (CCisPartialViewparity): the result carriespartial: true, and a blind full-filewrite_fileoverwrite is rejected because the model never saw the tail it would destroy. A CAS-checked targetededit_filestays allowed; a full (unclipped) read or an own write clears the flag. The desktop'slocal_read_filerejects oversized files instead of truncating, so partial views only arise from the sidecar's display clip.
Web tools (sidecar)¶
web_search and web_fetch (steerable_sidecar/web_tools.py) are the
network-read pair. One implementation serves every entry point:
headless/ACP get them through workspace_tools_for_cwd; the
desktop-spawned sidecar registers them on the RPC router at boot and the
host delegates execution over tool.invoke (the host router carries
schemas only, gated by a tool.list handshake so an unconfigured
deployment never advertises a broken tool). Single implementation → they
are deliberately not in tool_contract.json, which exists to keep
independently implemented capabilities from diverging: the
bash/read_file/write_file/edit_file pairs, and tool_search's
ranking (toolSearch, scored against a fixed inventory — the desktop ports
BM25 in tool-search-rank.ts rather than delegating, since deferred tools
are registered host-side).
A caller whose task contract is offline declares that:
workspace_tools_for_cwd(..., web_tools=False), surfaced as headless's
--no-web-tools. The Harbor eval runner passes it on every trial — TB 2.1
tasks are solved from the container, and the container has egress for the
LLM gateway, so an offered web_fetch would both let a trial answer from
outside the environment under test and confound a harness comparison with a
capability change. Every other surface keeps the pair.
Both register at the direct exposure tier in read mode: primary
capabilities, side-effect-free network reads. Approval gating is the
executor wrapper's job on interactive paths, not the registry's — the
harness classifier names web_search / web_fetch explicitly (exact
names, not a web_ prefix, so a future write-flavored web_* tool does
not inherit the read posture).
Provider seam¶
web_search goes through the WebSearchProvider protocol — the same grain
as LLMProvider: a protocol, a default factory
(default_web_search_provider), and explicit injection at registration, so
the backend changes without touching the tool. The shipped in-process
backend is Tavily (POST {base_url}/search, bearer key from
STEERABLE_WEB_SEARCH_API_KEY or TAVILY_API_KEY — never the brokered LLM
key: under credential-broker mode the sidecar must not hold the real chat
key, so search carries its own credential). The desktop settings page
persists that key in userData and injects it at sidecar spawn; an empty
key still leaves web_search unregistered.
STEERABLE_WEB_SEARCH_PROVIDER=host registers without a sidecar key so the
Electron host can execute hosted search with the existing chat credential
(OpenAI api.openai.com only). GLM, OpenRouter, and DeepSeek have no hosted
search here — they need the Tavily settings key, or the explicit free
backend STEERABLE_WEB_SEARCH_PROVIDER=ddg (DuckDuckGo lite HTML; not a
silent fallback when the Tavily key is empty). Harbor keeps --no-web-tools.
An unknown provider name raises at resolve time.
Bounds¶
Every bound is a validated WebToolsConfig field resolved from
STEERABLE_WEB_* env vars; invalid values raise at resolve time (headless
fails at load; the desktop sidecar logs the misconfiguration and serves
without the web pair, so a typo'd optional-feature var cannot brick chat).
| Field | Env var | Default | Ceiling |
|---|---|---|---|
fetch_timeout_ms |
STEERABLE_WEB_FETCH_TIMEOUT_MS |
30 000 | 600 000 |
fetch_max_bytes |
STEERABLE_WEB_FETCH_MAX_BYTES |
1 000 000 | 100 000 000 |
fetch_max_redirects |
STEERABLE_WEB_FETCH_MAX_REDIRECTS |
5 | 20 |
search_timeout_ms |
STEERABLE_WEB_SEARCH_TIMEOUT_MS |
30 000 | 600 000 |
search_max_results |
STEERABLE_WEB_SEARCH_MAX_RESULTS |
8 | 20 |
session_search_cap |
STEERABLE_WEB_SESSION_SEARCH_CAP |
200 | 1 000 000 |
session_fetch_cap |
STEERABLE_WEB_SESSION_FETCH_CAP |
0 (off) | 1 000 000 |
The byte cap bounds what a page can push into the process; the
transcript-side bound is the existing spill hook (SpillHooks
externalizes oversized data), not a second truncation path. Redirects are
followed same-origin only and re-validated per hop; a cross-origin
redirect is reported (redirect_to in data), not followed, so the model
re-issues the call against the new origin and the approval prompt names
it. Non-text content types are refused with a pointer at bash + curl.
The session caps are per-sidecar-process counters (one sidecar serves one session); the search default of 200 mirrors Claude Code's per-session WebSearch limit, and 0 disables a cap. Exceeding one fails the call with a followup-able error naming the limit and its env var.
Domain policy¶
allowed_domains / blocked_domains (comma-separated
STEERABLE_WEB_ALLOWED_DOMAINS / STEERABLE_WEB_BLOCKED_DOMAINS) are the
Claude Code WebSearch allowed_domains/blocked_domains parity knobs. An
entry matches its exact host and every subdomain (example.com covers
docs.example.com); blocked wins over allowed on a tie; an empty
allow-list allows every public host. web_fetch refuses a disallowed
target before any DNS or network work (redirects are same-origin, so the
initial check covers the chain). web_search both passes the lists to
providers with native support (Tavily's include_domains /
exclude_domains, so ranking happens inside the policy) and filters
returned hits post-hoc, so the policy holds for every provider.
SSRF policy¶
web_fetch takes a model-supplied URL — untrusted input crossing into the
host's network position. Every hop (initial URL and each redirect target)
is validated: http(s) only, no credentials-in-URL, URL length ≤ 2048, and
the host's DNS answers must ALL be globally reachable
(ipaddress.is_global), with IPv4-mapped and NAT64 (64:ff9b::/96) forms
unwrapped before the check — so loopback, private, link-local (including
169.254.169.254-style metadata endpoints), and reserved ranges are
refused. Residual gap, documented honestly: the policy check and httpx's
own connect resolve DNS twice, so a hostile authoritative server could
rotate answers between them (classic TOCTOU). httpx exposes no lookup hook
to pin the connection to the validated address, so per-hop re-validation
plus the short window is the mitigation.
Egress-proxy interaction¶
When the desktop runs the per-host egress proxy (STEERABLE_EGRESS_PROXY,
default-on in the desktop since 2026-09-08; see safety.md), the sidecar's
outbound is confined to the proxy. The proxy's CONNECT allow-list covers the
configured LLM provider endpoint, the deployment's web domain list, and a
configured in-sidecar search backend's fixed API endpoint — one source
(STEERABLE_WEB_ALLOWED_DOMAINS) feeds both this module's application-layer
policy and the proxy's network-layer list. The desktop marks that posture
with STEERABLE_EGRESS_CONFINED=1 in the sidecar env — set only on the
proxy-started path, never on the startup-failure fallback, so the sidecar
cannot believe it is confined when it is not — and points HTTPS_PROXY at
the proxy. Both tools then run through the proxy (httpx trust_env): the
domain policy and the SSRF pre-check are unchanged, and a target outside the
proxy's allow-list fails with an error naming the list. Two honest edges:
the proxy matches exact hosts, so a subdomain of an allowed domain passes
the app layer but is denied by the proxy unless listed separately; and an
empty STEERABLE_WEB_ALLOWED_DOMAINS (app-layer "any public domain") cannot
be expressed in a closed proxy list, so arbitrary fetches then fail at the
proxy. The marker without any proxy env is a misconfiguration and fails loud
with an actionable error instead of hanging behind an absent proxy.
Completion semantics¶
isTerminalResult(result) (TS) /
is_terminal_result(result.model_dump()) (Py) treats a result as
terminal when:
terminal == true, orsuccess == falseandneedsFollowup != true
Use needsFollowup=True on a failure to ask the LLM to self-heal (write
a different argument, try a different tool, etc.). Without it, a failed
call ends the run.
Example pair¶
// ToolCall
{"id":"c_42","name":"create_event","arguments":{"title":"Lunch","start":"2026-05-15T12:00:00Z"}}
// ToolResult (success)
{"success":true,"message":"Event created.","data":{"eventId":"e_777"}}
// ToolResult (recoverable failure)
{"success":false,"needsFollowup":true,"error":"Invalid date format","message":"Please retry with ISO-8601."}
// ToolResult (terminal failure)
{"success":false,"terminal":true,"error":"Calendar service unavailable"}