harness
v1.8.50Thin durable turn loop that wires session-manager, context-manager, and llm-router into an agent loop; spawns sub-agents as child sessions.
Install
iii trigger compose::add worker=harness@1.8.50Run in your project with the iii engine running. Exact versions are immutable and digest-pinned.
skill doc
harness
The harness is the durable turn loop that wires session-manager, llm-router,
and context-manager into an agent. A consumer stays thin: it kicks off a turn,
renders the conversation from the session transcript, and reacts to turn
boundaries and human-gated calls. The harness streams the assistant message into
the session as it generates, so you watch the session, not the harness — there
is no agent::events stream, and harness::status is a point-in-time recovery
read, not a render feed.
Every invocation is a trigger (iii.trigger({ function_id, payload })); there is
no separate "call" verb. Tool dispatch is deny-by-default: a send with no
options.functions.allow is a plain chat loop and every model-requested call is
refused until you allow globs per send. Sessions are minted by the harness
(s_) or supplied by you; a send into a running turn folds in as steering
(merged: true) instead of erroring, and a repeated idempotency_key returns
the original turn without appending.
A session-creating send names a model, or only a provider: the harness
then starts on that provider's declared default model (default_model in
router::provider::list, checked against its live catalog; a provider that
reports none fails the send as before). A new session that names neither
thinking_level nor provider_options also starts on the provider's
default_thinking_level; an explicit level wins, and later sends into the
session inherit the prior turn's as usual. thinking_level is off,
minimal, low, medium, high or xhigh; off is honoured only on
models whose catalog entry says supports_thinking_off: true; elsewhere the
fallback is the provider's own (most send their lowest effort and warn, Kimi
ignores the level).
Prerequisites: session-manager (required — transcript store and change feed)
and llm-router (required — generation and the model catalog) must be present.
context-manager (token budgeting and compaction) is a soft dependency — absent
it, the harness sends raw history. approval-gate (the human-in-the-loop gate)
is optional; without it no call is held and every allowed call runs un-gated.
When to Use
- Start or steer an agent turn and return immediately (
harness::send). - Cancel an in-flight turn (
harness::stop) or read coarse turn state for recovery and guards (harness::status). - Chain turns or react to outcomes by binding
harness::turn-completed. - Drive a turn from an arbitrary inbound event (cron tick, webhook, sensor) by
translating it into a
harness::send.
Boundaries
- Not a transcript feed. Render from
session-manager'ssession::message-added/message-updated/status-changed(reconcile byrevision); do not pollharness::statusfor content. - Not the approvals engine. The harness ships only the gate mechanics; the
policy, decision RPCs (
approval::resolve), inbox (approval::list-pending), and prompt triggers live inapproval-gate. - Not a chain guard.
options.max_turnsbounds a single turn, not a send-completed-send loop; carry your own stop condition. - Do not trigger the internal functions (below) — they forge call ids and turn progress, so calling them out of band corrupts the turn record.
- An in-run agent cannot start turns:
send/run/turn/stopare denied to the model by policy.harness::spawnis the only turn-starter an agent calls directly, and it self-enforces depth, fan-out, and policy subsetting. The other path is event-driven: an agent bindsengine::register_triggerstraight toharness::spawn(see Reactive triggers), with the spawn spec in the registrationmetadata, and the engine spawns the sub-agent when the event fires.
Functions
Consumer-facing:
harness::send— ensure the session, persist the incoming message, and kick off a turn; returns fast or merges into a running turn (steering).harness::stop— request cancellation of an in-flight turn; cascades to spawned children.harness::status— read the current turn state for a session;nullwhen no turn ever ran. For recovery and guards, not rendering.harness::spawn— spawn a sub-agent in a child session. Model-facing (invoked throughagent_trigger), not a consumer entry point.harness::ask— put a decision with discrete options to the user as a clickable card (1–4 questions, 2–4 options each). Model-facing (invoked throughagent_trigger) and answered by the turn loop itself: the turn ends on the question and the answer arrives as the user's next message. Refused in sub-agents, structured-output turns, and for a second ask in one step.
Internal — the harness drives these; never trigger them directly:
harness::turn (the durable loop step), harness::function::trigger /
harness::function::resolve (dispatch and parked-call settle),
harness::sweep-pending (cron expiry), and harness::on-config-change
(hot-reload).
Filesystem scope
options.metadata.fs_scope.root on a send is the session's working
directory; a later send that omits fs_scope keeps it. The harness stamps a
trusted fs_scope { root, grants, boundary } onto every shell::* /
coder::* call and strips any the model supplies. boundary decides what
root means to the ide worker:
workspace—coder::*,shell::fs::*and an execcwdstay insiderootplus the session's grants (harness::filesystem::grant).configured_roots—rootonly anchors relative paths; the worker's own roots apply. The model's prompt says so ("default directory, not an access boundary").
The filesystem_boundary config picks it: auto (the default) is
workspace only while approval-gate's access watch is bound; workspace or
configured_roots pins it. harness::filesystem::info reports the boundary
in effect. A sub-agent spawned in a turn into a new session starts with a
copy of its parent's grants; later grants to the parent do not reach it.
Under workspace, an in-turn spawn's options.filesystem_root must lie
inside the parent's root or grants. What an exec'd process itself writes is
the ide worker's fs.exec_confinement switch.
Reactive triggers
The harness emits two async turn-boundary trigger types so consumers and siblings
react without polling harness::status:
harness::turn-started— a turn began executing (first loop step).harness::turn-completed— a turn reached a terminal status (completed/cancelled/failed), carrying the result or error for chaining, failure toasts, auto-titling, and result delivery.
Bind turn-completed for outcomes and to chain the next hop; bind turn-started
only for observability. Delivery is fire-and-forget, at-least-once, and unordered
— treat each event as an edge. Nothing replays on reconnect: re-seed with
harness::status and approval::list-pending, then rebind. Do not bind these for
live transcript rendering — that is session-manager's job.
Binding config filters delivery by session_id, or by parent_session_id to
watch the children a turn spawns (in-turn spawns only — a direct
harness::spawn call creates no parent link, so filter those by session_id).
Bind an event to a sub-agent with one call — the spawn spec goes in the
registration metadata; the fired event is appended to the task:
engine::register_trigger {
"trigger_type": "harness::turn-completed",
"config": { "session_id": "<child>" },
"function_id": "harness::spawn",
"metadata": { "task": "Summarize the completed run.", "once": true }
}model defaults to the registering turn's model. A failed upstream turn
does not fire the reaction unless continue_on_error: true. Reactive
chains stop at depth 8. There is no fan-in join primitive — use the
workflow worker to join parallel work.
An in-session registration that omits model also inherits its provider,
when the spec pins none; raw engine-side registrations have no turn to
inherit from and must pass one, and any supplied model must be a live id
from router::models::list (validated at registration and again at fire
time). Omit parent_session_id and the child nests under the registering
session's root automatically; pin it only to choose a different REAL
session (an invented id shows the children as disconnected roots). A
trigger-fired spawn has no parent policy to inherit — it gets the harness's
read-only default_functions baseline unless options.functions grants
more (narrowing only, same as a direct harness::spawn call). Filter
turn-completed only by session ids you know exist; registration returns a
warning note when the filtered session doesn't. Self-edge drop and the
depth cap are the ONLY loop breakers — still design filters so a reaction is
not matched by its own subscription. A cycle routed through a state write (or
any other hop that starts a fresh turn) re-enters at depth 0 and is NOT
throttled: every lap around the cycle spawns another paid turn indefinitely.
Design reaction graphs acyclically. This is the in-run agent's chaining path;
the registerFunction recipe below is for workers.
How to bind
- Register a handler:
registerFunction('myapp::on-turn-done', handler). - Register the trigger:
iii.registerTrigger({
type: 'harness::turn-completed',
function_id: 'myapp::on-turn-done',
config: { session_id: sessionId },
})For the event payload shape, call get function info on the trigger type.
Hooks (policy siblings only)
The harness also registers five synchronous, in-path hook trigger types:
harness::hook::pre-turn, harness::hook::pre-generate,
harness::hook::post-generate, harness::hook::pre-trigger, and
harness::hook::post-trigger. A bound hook runs in the turn's critical path and
the harness acts on its return value (veto / hold / mutate) under a per-binding
timeout_ms and on_error policy; pre-trigger / post-trigger bindings take a
functions glob list to scope which calls they gate. These are for
operator-trusted policy siblings (approval-gate binds pre-trigger); ordinary
consumers do not bind hooks.