orchestrate is the autonomy primitive: the model writes one script that chains
capability-scoped typed tools inside a jailed child, and only what the script console.logs
(its stdout) re-enters the conversation. A search → fetch → synthesize chain that would take
many back-and-forth tool calls runs in one inference turn — and the intermediate, often
high-volume (and untrusted) results never enter the transcript.
It is assembled for an autonomy-bearing agent (the standard profile and
up — orch:read and orch:web are on by default) on a host where the bwrap jail is buildable.
A non-autonomy agent, or a host with no sandbox provider, does not get the tool.
Parameters
The typed SDK (comis_tools)
The script imports a generated, typed SDK — one async method per capability-mapped tool.
The surface uses progressive disclosure: comis_tools.describe() lists every available tool
with its capability and a one-line summary (cheap to enumerate), and the full per-tool types
load on demand. The SDK is emitted from the same tool→capability map as the dispatch gate,
so the SDK and the gate can never drift (an architecture test asserts byte-identity).
tool.invoke
surface over the lease-authenticated capability socket. The full curated list of tools and the
capability each requires is the tool→capability map.
ResultRef: high-volume returns
A tool return over a per-tool size threshold is not inlined into the script’s value — it is written to the jailed workspace’sresults/ directory and the call returns a small ResultRef
handle instead. Only the handle (with a tiny preview) would re-enter context; the big payload
stays on disk as data. The script slices it in-jail — the same turn — so just the
relevant rows/lines come back:
ResultRef carries { ref, kind, bytes, rows?, schema?, preview, expiresAt } plus the
in-jail .read(offset?, limit?) / .grep(pattern) / .jq(expr) / .sql(query) /
.jsonpath(expr) extraction methods. The results/ directory is bounded (a per-file cap, a
per-run aggregate cap, and a TTL) and auto-cleaned when the run ends.
The
.sql() and .jsonpath() helpers run the DuckDB CLI daemon-side (the jail stays
--unshare-net), hardened to a read-only slicer confined to the run’s workspace. The
daemon-side DuckDB is spawned with the workspace as its only readable directory:
SET allowed_directories=['<workspace>'], then SET enable_external_access=false and
SET lock_configuration=true (set before the model query so it cannot widen them). That
master switch blocks every local-file table function (read_text/read_blob/glob/…),
ATTACH/COPY, and remote readers wholesale — except reads under the workspace — so a
query like read_text('/etc/passwd') or read_blob('~/.comis/config.yaml') is refused, not
served. As defense in depth, --readonly :memory:, extension auto-install/auto-load disabled,
the INSTALL/LOAD/ATTACH/COPY/EXPORT/PRAGMA verbs, the pure-exfil readers
(read_text/read_blob/glob/getenv/parquet_metadata), and remote (http(s):///s3://)
readers are all refused before DuckDB is ever spawned. The legitimate tabular readers the
contract uses — read_json_auto/read_csv/read_parquet over the results/ ResultRef — keep
working (the workspace is the allow-root). DuckDB is a single static binary the daemon host must
provide on PATH (the official Docker image installs it; it is not in the Debian apt repos). If
duckdb is absent, .sql()/.jsonpath() honest-degrade to an { error } — .jq()/.grep()/
.read() (which use the always-present jq / built-in readers) keep working.Worked example — research in one turn
console.log.
Connected MCP tools (comis_tools.mcp)
When the operator connects one or more MCP servers and explicitly allowlists their tools for
an agent (see Autonomy → Connected MCP tools), those tools
surface in the jailed SDK under a dynamic namespace:
comis_tools.mcp is a runtime
namespace, not a fixed list — comis_tools.mcp.<server>.<tool>(args) resolves each <server>
and <tool> on the fly to a single cap-socket call. It runs under the orch:mcp capability, which
is a floor cap on standard/unattended/max (held by default, like write/message). Holding the
cap opens no server, though — reachability is gated by the per-server autonomy.mcp.allow
allowlist (empty by default ⇒ deny by absence), exactly like the write surface’s separate opt-in
(see below).
The call runs exactly like web_fetch: daemon-side, on the daemon’s network, so the jail
stays --unshare-net. The jailed script’s call crosses the 0600 capability socket to the
daemon, which invokes the connected MCP client and returns the tool’s result as data:
- Wrapped, never control. The return is sanitized and wrapped as untrusted external content
(the same treatment as
web_fetch) — the script reads it as data; it can never be interpreted as instructions to the model. - High-volume returns offload to a ResultRef. A large MCP result is materialized to the
workspace’s
results/directory and returns aResultRefhandle, sliced in-jail with.jq/.grep/.read/.sql— identical toweb_fetch. Only the slice you log re-enters context. - Allowlisted or denied — nothing in between. A
{server, tool}pair the operator has not allowlisted is denied at the socket (a content-free capability denial); the jailed call surfaces the deny as a thrown error in the same turn.
mcp_manage/mcp_login) — is never on this surface. The proxy can only ever name a connected
server’s tool; it has no path to a management method.
Python is identical, in the synchronous idiom:
The typed mutation surface (write and message)
Beyond reading and fetching, an autonomy-bearing agent can write to its workspace and send
outward from inside the same jailed turn — so a fetch → transform → send chain that would
otherwise take several round-trips runs in one script:
message_send, message_reply, and message_react — the
genuinely-outward send subset (dispatching message.send/reply/react).
write is run-scoped, run-ephemeral, and isolated from the discovery/config subtrees: every
path resolves under a per-run results/writes area inside the agent’s jailed workspace (a ../,
absolute, or ../…/skills/… path is refused), and that area is reaped when the run ends — a
file written this run is gone the next run. It is not a persistent store, and it cannot reach
the workspace-root skill-discovery or config subtrees (skills/, .learned-skills/, memory,
config), so a jailed turn can never plant a cross-run skill. Enable the surface per agent with
autonomy.write: true. message_send/reply/react target the agent’s own origin channel by
default — a send to a new channel escalates for approval (see Autonomy)
— and the message.* edit/delete/fetch subset stays admin-only,
not part of orch:message.
Worked example — fetch, transform, send in one turn
Python (language: "py")
Set language: "py" to write the script in Python instead of TypeScript/JavaScript. It runs in
the identical jail — --unshare-net, workspace-confined, reachable only through the same
0600 capability socket — and reaches tools through a generated, self-contained comis_tools
module that speaks the same { bearer, method, params } cap-socket wire as the TypeScript
SDK (no pip install: the jail has no network). Only the language idiom differs — import the
module rather than a named export, and the calls are synchronous (no await):
ResultRef here too, carrying the same in-jail slicing methods —
.read(offset, limit) / .grep(pattern) / .jq(expr) / .sql(query) / .jsonpath(expr) — so
only the rows/lines you extract re-enter context, never the full payload. As with the TypeScript
path, the jail stays --unshare-net and only what the script prints to stdout re-enters the
conversation.
The interpreter is the host’s own python3, available read-only inside the jail. A host with no
python3 refuses the run with a clear error — never a silent, unjailed fallback.
The containment contract
The jailed child is deliberately constrained — this is what makes letting the model write and run arbitrary code safe:- Workspace-confined. It can read and write only the agent’s jailed workspace; it cannot read
~/.comis, the secret store, other agents’ workspaces, or the host filesystem. --unshare-net. The jail has no network. The only egress is the0600capability socket — so the only outbound calls aretool.invoketo capabilities the agent holds. Theweb_search/web_fetchtools run daemon-side (on the daemon’s network, DNS-pinned) and return their results as data, never as control.- Cannot mint tokens or change config. The administrative control plane (
gateway, the*_managefamily,mcp_manage/mcp_login) is unreachable through the cap surface, and deny-by-origin rejects any agent-origin call to it regardless of capability. - Secret-scrubbed env. The inherited environment is filtered — any
*KEY*/*TOKEN*/*SECRET*variable is dropped before the child starts. The only injected runtime vars are the short-lived lease bearer (COMIS_CAP_LEASE) and the socket path (COMIS_ORCH_SOCKET), which the SDK authenticates with; the lease is registered with the OutputGuard at mint so it is never logged.
Pre-flight cap check
Before the jailed child is spawned, the runner statically scans the script for the set of capabilities itscomis_tools calls require. If the script calls a tool needing a capability
the agent does not hold, the run is rejected pre-spawn with a capability-named error
(for example, naming the missing orch:web) — so a model that reaches for a capability it
lacks gets a precise, actionable message in the same turn rather than a mid-run failure after
a jail has already been built. When the approval workflow
is enabled, the same pre-flight fires one approval on the script’s whole capability
footprint before the spawn.
This pre-flight is advisory — a fail-fast and approval-UX aid, not the containment
boundary. The
--unshare-net jail and its capability socket remain the sole authoritative
gate: a script that dodges the static scan (say, via a dynamically-computed method name)
proceeds past the pre-flight and is still denied at the socket by default-deny-by-absence.One-shot repair
When a script exits non-zero on a recoverable error — a bad import, a misusedcomis_tools call, or a TypeError — the runner does one utility-model re-prompt
that regenerates the script and re-runs it exactly once. A small model’s near-miss
becomes a working run in the same turn, without bouncing the failure back to the model
that wrote it.
It is class-gated off the agent’s model capability class — on for small/nano
models (which benefit most), off for mid/frontier — with no config toggle
(the model tier is the sole control). See
Autonomy → One-shot auto-repair for the class
table and the no-toggle rationale.
The repair is bounded to a single attempt: a repaired-then-failed run surfaces the
original error, never a loop. The regenerated script re-runs in the identical
jail, capability, and lease envelope as the original — the same --unshare-net jail and
the same per-run lease, so a repaired script has the same blast radius and gains no new
privilege. The repair completion rides normal spend/budget accounting.
Per-run observability
Every run is minted its own per-run capability lease — an attenuation of the agent’s assembly lease, scoped to that single script execution. Because the already-emittedcapability:audited audit stream is keyed on that per-run leaseId, a run’s in-jail
tool.invoke calls (and any deny) attribute to the run — not to the shared assembly
lease that spans many runs. A denied orch:web groups under the run that attempted it, so a
post-mortem can pin a denial to one script.
comis explain <sessionKey> (the obs.explain report)
renders a bounded, content-free orchestrate section — one entry per run in the session:
outcome derives from exitCode (0 → success). failureClass is present only on a failed
run and is a closed enum: timeout (the wall-clock ceiling fired), stdout_cap (the stdout
hard cap tripped), nonzero_exit (the script exited non-zero), spawn_fail (the jail never
started), or lease_absent (the run reached the jail with no capability lease). toolCalls is
the per-run tally attributed by the run’s leaseId, resultRefs the materialized { count, bytes },
and savings the measured token-savings estimate (below), carried only when the run
materialized ResultRefs.
A run that exited non-zero — or that recorded a decision: "deny" on any tool call —
root-causes to a deterministic orchestrate_failed likelyRootCause: the same signals
always produce the same verdict, so a failed run is a one-call comis explain diagnosis rather
than a log grep. Across the whole daemon, comis fleet
rolls the per-run savings into an orchestrate_efficiency finding (run count + summed
estimated tokens saved — counts only, safe to paste into a review).
What the audit stream does and does not cover.
capability:audited records the
authorization decision for each tool.invoke — whether the run’s lease allowed the call.
It does not record whether an allowed call’s tool result then failed. That half is
covered at the run level instead: a script that reacts to a failed-but-allowed result by exiting
non-zero surfaces as the run’s failureClass + exitCode, and the failing tool’s own bounded
stderr tail rides the tool-error result — never the content-free audit fold.Proven procedures become advisory docs
When a script solves a task and that approach proves out across verified successes, the learning engine distils it into an advisory procedure doc — a learnedkind: skill doc that additionally records the run’s
deterministic tool footprint (the audited tool names). It surfaces into the agent’s
<available_skills> listing like any other learned skill.
The doc is advisory guidance, not a stored or replayed template. On a matching later task the
model re-authors a fresh script from the guidance and runs it under the same jail and
per-run capability lease as any other orchestrate call — there is no new execution path, no
saved script, and no capability the agent did not already hold. The tool footprint is metadata
that helps the model reconstruct the approach; it never executes on its own.
To keep a burst of procedure docs from bloating every prompt, the surfaced procedure-doc subset is
capped per agent by
learning.reflect.maxProcedureDocsSurfaced
(default 10): when the cap is exceeded the most-corroborated procedure docs (ranked by
proof count) keep their slot, surfaced in a stable listing order. User-intent
skill docs and topic docs keep a separate, uncapped path. A procedure doc is outcome-gated and
demotes on sustained failing reuse like every learned doc.
Related
Capability model
The closed capability union, the curated tool→capability map, and deny-by-origin.
Autonomy
Named profiles, the zero-config default, and the legible degrade that grant and bound capabilities.
tool.invoke
The lease-authenticated cap-socket dispatch the SDK speaks.
Environment variables
The lease + socket vars the daemon injects into the jail.
