skip to content
$worker

harness

v1.8.23

Thin durable turn loop that wires session-manager, context-manager, and llm-router into an agent loop; spawns sub-agents as child sessions.

iiiverified
1,703 installs0 in 7d0 today
install
$iii trigger compose::add worker=harness@1.8.23
binarylicense: Apache-2.0agentautonomousharnessloop
  • macOS: arm64
  • Linux: arm64 · armv7 · x64
  • Windows: arm64 · x64

exact versions are immutable; binary and bundle artifacts are digest-pinned.

README.md

harness

A thin, durable turn loop that turns a model plus a few iii workers into an agent.

Install: iii trigger compose::add worker=harness License: Apache 2.0 Built with Rust harness on the workers registry ade on the workers registry

harness is the thin, durable turn loop that turns a model plus a few iii workers into an agent. It takes an incoming message, persists it, assembles a context, streams a completion, runs any function calls the model requests, and repeats until the turn stops — all as durable, resumable steps so a crash or restart picks up mid-turn. It wires session-manager (transcript), context-manager (token budgeting, soft dependency), and llm-router (generation); install those alongside it for the full loop.

Quickstart

Install the engine, export the Anthropic credential in the terminal that will run the engine, initialize a project, and start it:

curl -fsSL https://install.iii.dev/iii/main/install.sh | sh
export ANTHROPIC_API_KEY='<your-anthropic-api-key>'
export OPENAI_API_KEY='<your-openai-api-key>'
iii project init iii-app && cd iii-app
iii compose --up
# New terminal, same folder. `compose::add` updates this Compose project.
cd iii-app
iii trigger compose::add worker=harness worker=ade
open http://localhost:3113

Create a session, select Anthropic → Claude Sonnet 5, and send your first message. Then select OpenAI → GPT-5.6 Luna in the same chat and send another message. Create a new chat and send one more message with GPT-5.6 Luna. The providers read ANTHROPIC_API_KEY and OPENAI_API_KEY from the engine environment, so credentials do not need to be pasted into or stored by the Console.

iii trigger compose::add worker=harness installs every worker the loop needs (see the badges above); you do not add them one by one. During bootstrap, the harness asks the queue worker to define a dedicated harness-turn queue before it reports ready. That queue is FIFO within each session_id and processes separate sessions concurrently; startup fails if the queue cannot be ensured.

Every turn, sub-agent spawn, and provider call is one correlated trace: the harness turn waterfall in the console. Failed descendants stamp the whole trace as failed and carry standard error attributes. The session transcript keeps the same recovery, partial-output, and blocked-reaction explanation after refresh. Automatic generation recovery requires partial output from the failed response. A startup failure with no output ends the turn with a durable failure notice; it does not consume the partial-response recovery budget.

Harness turn waterfall in the iii console

The agent-facing function surface is deny-by-default: with no functions.allow globs, every model-requested call is refused and the harness is a plain chat loop. Allow functions in per-send (options.functions.allow) and gate them with the optional approval-gate sibling.

The full function reference (every harness::* id and its request/response schema) lives in the code and iii worker info harness.

Building a consumer — a chat UI, a Telegram/WhatsApp bridge, a cron worker, or any event-driven loop on top of the harness? Start with the integration contract in architecture/integration.md: the functions to trigger, the triggers to bind, and the canonical consumer patterns.

Local development

See DEVELOPMENT.md to run the Harness and its required workers from the local source tree with iii compose.

Working with iii

iii is a language agnostic runtime where services, agents, and tools are composed of the same things: workers, triggers, and functions. One engine holds a live registry of every connected worker, their functions, and the triggers bound to them. Calls route worker to engine to worker, so the language, runtime, and location of a worker are invisible; the function id is the only contract.

1. Discover what is already there (the engine is the source of truth)

  • engine::functions::list — every function across all workers (filter with prefix / search / worker)
  • engine::functions::info { function_id } — the request/response schema for ONE function (this is your API reference)
  • engine::workers::list / engine::workers::info { name } — connected workers and their surface
  • engine::triggers::list / engine::triggers::info { id } — legal trigger types and their config schemas
  • engine::registered-triggers::list — every trigger instance already bound

2. Call a function. Use agent_trigger with { function: "::", description: "", payload: { ... } }. The description is shown as the agent's activity in chat; keep it concise and in the user's language. The payload is a JSON object (never a stringified one), and you fetch the contract via engine::functions::info before the first call.

3. Need a capability that is not registered?

  • directory::registry::workers::list { search: "" }
  • directory::registry::workers::info { name } to judge fit
  • choose an operation id and register a one-shot compose-operation wake with { "operation_id": "", "terminal_only": true }
  • call compose::add { worker: "", operation_id: "" } with the same id
  • read compose::operation once for race recovery, then confirm the functions after the terminal event

4. Worker lifecycle. compose::status, compose::add, compose::up, compose::down, compose::restart, compose::update, and compose::remove. add, update, and remove use the same compose-operation wake flow above; the other operations return their final result directly. add, update, and remove accept a workers list for one batch, while their singular worker field remains compatible. Fetch their contracts with compose::schema { function_id: "compose::" }. The harness routes compose::* to its supervising daemon and scopes each call to its own compose file.

5. Triggers, not polling. To react to events (HTTP, schedule, webhook, file change), bind a trigger instead of polling. Discover the type with engine::triggers::list, copy config from its schema, and confirm the binding fires with a real call (e.g. web::fetch to its local URL).

6. Handy workers.

  • web::fetch — all HTTP(S); pass format: "markdown" to read docs without flooding context
  • coder::* — file ops for any code task (read/search/create/update/move/delete)
  • slack::* — post to Slack

7. Authoring a worker. Read the SDK reference for your language first (Node / Python / Rust / Browser / Engine WS) at https://iii.dev/docs/reference/. Use the SDK's registerWorker(...) and call iii.registerFunction / iii.registerTrigger / iii.trigger on the returned value; they are methods, not top-level exports. Always declare description, request_format, and response_format so the next caller gets a real contract.

TL;DR: list, info, call. The engine tells you the truth; trust it over memory.

Adding workers with container settings

Fetch compose::schema { function_id: "compose::add" } before choosing the payload. Use container objects only if the running daemon's schema supports them. The singular worker field remains a string shorthand. For settings, use workers, which can mix strings and objects. Put settings inside each object, not at the request's top level. Only add accepts objects; update and remove take worker names.

After registering the one-shot wake described above, call compose::add with a payload like this (the local worker directory and env file must already exist):

{
  "operation_id": "<operation-id>",
  "workers": [
    "state",
    {
      "worker": "./workers/api",
      "start_after": ["state"],
      "scripts": {
        "pre_run": "pnpm build",
        "pre_run_timeout": "60s",
        "run": "pnpm start",
        "post_run": "echo stopped"
      },
      "config_name": "api",
      "config_override": { "port": 3000 },
      "working_dir": "./workers/api",
      "environment": { "NODE_ENV": "development" },
      "env_file": ["./api.env"],
      "startup_timeout": "30s"
    }
  ]
}

worker accepts a package name, name@version, registry reference, or local directory, including package:// and path:// sources. Objects also accept version for packages; it must agree with any version in worker. Omission resolves the latest matching package version, so keep an explicit version when it must stay pinned.

Use scripts (plural); scripts.run is valid only for local workers. start_after contains container keys, derived from the last part of each worker name or directory; required package dependencies are also included. config_name selects the base configuration, and config_override supplies values over it. environment values must be strings. startup_timeout limits the wait for registration.

Relative worker, working_dir, and env_file paths start at the compose file's directory. working_dir sets the process and hook directory; when omitted, local workers use their own directory and packages use the compose directory.

For an existing container, omitted settings stay in place. A supplied field replaces that whole field, including scripts, environment, and config_override; these are not partial map edits. Use {} or [] to clear maps or lists. A supplied start_after replaces the list while retaining required package dependencies. Changes can restart running workers. The acceptance response does not mean ready: finish the same compose-operation workflow before using the worker.

Configuration

The harness configuration entry is owned by the configuration worker; every field hot-reloads (no restart). The fields a deployment is most likely to tune:

default_max_turns: 16            # per-turn generate-step cap when a send omits it
default_pending_timeout_ms: 1800000  # legacy parked-call (hold / pre-deploy child) wait guard
max_depth: 3                     # sub-agent depth budget
max_children: 8                  # sub-agent spawns-per-turn budget
max_transient_resumes: 1         # recovery generations after a partial stream failure
projects_file_path: ~/.iii/data/harness/projects.json  # durable operator project catalog (default: data/harness-projects.json under III_COMPOSE_DIR / cwd)
sweep_expression: "0 * * * * *"  # cron for the pending-call expiry sweep

Other keys (RPC timeouts, stream coalescing, idempotency TTL, validation retries) and their defaults live in src/config.rs.

System prompt

The identity prompt is assembled once at send/spawn time. EVERY agent — top-level turns (harness::send) and spawned children alike — is seeded with the same single identity (prompts/default.txt): a deliberately minimal prompt carrying only the basic engine functions and the discovery loop (list, info, call). A default entry in the directory's system-prompt store (/system-prompts/default.md, served by directory::system-prompts::get) overrides the embedded prompt for every new composition — write it with directory::system-prompts::create { name: "default" } the first time and ::update after (or edit the file directly; no console surface authors it) and the next send picks that up, no restart; any store failure (directory absent, entry missing, blank body) falls back to the embedded prompt. What makes a child a leaf is its POLICY, not its prompt: children are capability-walled out of the orchestration surface (harness::spawn, harness::send, trigger registration) unless spawned with options: { orchestrator: true }; spawn options.system_prompt remains the identity escape hatch.

A spawn may also give its child a display-only identity with display: { name, icon?, color? }. name is trimmed, limited to 48 characters, and becomes the title of a newly created child session; icon and color are closed semantic tokens recorded with the child linkage in metadata.subagent_display. These fields never affect routing or execution, and reusing an existing session_id retains that session's original title and metadata. Icons are agent, code, search, terminal, database, test, review, docs, or design; colors are neutral, blue, purple, teal, green, amber, or rose.

No prompt prescribes an orchestration process — identity prompts carry tool guidance only, enforced repo-wide by tests/prompts.rs. The opt-in fan-out playbook (parent-owned control plane: pick a medium, arm notifications, spawn leaves directly, define completion per medium) lives in skills/orchestration.md — paste it into a task prompt or pass it via options.system_prompt.

A non-empty options.system_prompt is combined with the built-in prompt per options.system_prompt_strategy: enrich (default) appends it to the built-in prompt, while override uses it verbatim. Assembly is tested in src/prompt/tests.rs.

The resolved prompt is STICKY per session, like model/provider and the dispatch policy: a send to an existing session that names neither system_prompt nor system_prompt_strategy inherits the prior turn's resolved prompt verbatim (a prior disabled turn's absent prompt inherits too). Naming either field resolves fresh — an explicit bare system_prompt_strategy (e.g. "enrich") is the reset-to-default escape hatch. The inherited string is frozen at its original resolution — resend the prompt fields to re-resolve.

Agent profiles

options.agent on a session-creating harness::send names a directory agent profile (directory::agents::*, one markdown file per profile). The harness resolves it ONCE via directory::agents::get and freezes the result onto the turn: the profile's RESOLVED system prompt — the directory composes extends chains root-first, so tech-lead extending the bundled iii base arrives as the full iii doctrine followed by the tech-lead body — IS the session identity. Nothing built-in sits underneath it and no prefix is added; the usual per-step runtime context (session id, working directory, policy aid, skills index, hook injections) follows it. A profile whose extends chain does not resolve is refused as an invalid request with the directory's D415 text. The profile's skills are PRELOADED: each id's body is fetched once from directory::skills::get and frozen into a block of sections appended to the prompt (ids the directory cannot serve are named as unavailable), so the skill is in context on the first step; the session's skills index is never narrowed by a profile — only an explicit options.skills does that. Its model and optional provider-native reasoning_effort are authoritative for the session, and — when the send also omits options.functions — the dispatch policy defaults to the configured default_functions baseline instead of deny-all (an identity picked to DO something must be able to dispatch). When the profile declares (or inherits) functions — its PRELOADED functions, engine function ids it uses routinely — the harness renders each one's current description and compacted request schema into a block appended to the frozen prompt (contracts come from the cached registry snapshot, with one engine::functions::info batch for ids the snapshot cannot vouch for; ids the engine does not know are named as unavailable), so the model calls them on the first step instead of spending a search and a contract lookup per session. The block comes first, after it. The frozen name/icon/color/model/effort/skills/functions snapshot is also written to session metadata for clients that render established sessions. The frozen identity travels with the prompt-stickiness rule: bare later sends inherit it, an explicit prompt field sheds it. Refused on an existing session or combined with either prompt field. Directory edits after resolution never reach a live session — start a new one to pick them up.

harness::spawn takes the same id as a top-level agent field: the profile's resolved prompt (preloaded functions and skills included) is the child's whole identity, its model/effort slot in the same way (model precedence profile → explicit model → parent, without dragging the parent's provider onto a foreign model), and its name and icon become the display defaults. A spawn that names NO profile continues the parent turn's: an agent running under a profile fans work out to itself, not to a stranger wearing the built-in identity, and the child re-resolves that same id so its own preloaded functions and skills arrive with it. options.system_prompt — the escape hatch for a child that genuinely needs a different identity — sheds the inherited profile instead of colliding with it, and a parentless spawn (console, workflow, CLI) has no parent turn to inherit from. Which agent profile a spawn names is the prompt's decision — the profile body steers it, nothing gates it. Spawning with agent into an already RUNNING session of the caller's own tree merges the task like any reuse and does not re-apply the profile.

The harness ships one profile of its own, worker-builder (agents/worker-builder.md): an identity that extends the bundled iii base and takes a new worker for this repository from scope to scaffold, CI gates, live verification on the bus, pull request, and an experimental registry release, asking before every irreversible step. It is published in the harness skills payload as agents/worker-builder.md, which directory::skills::download { worker: "harness" } routes into the directory's agents_folder; copying the file there by hand works the same. Its skills preload the six knowledge skills from iii-hq/iii/skills (npx skills add iii-hq/iii/skills) into every session's prompt; missing ones are named as unavailable, not failures. Then harness::send { options: { agent: "worker-builder" } } (or the console's agent picker) runs it.

New sessions also freeze a names-and-descriptions-only skill index into the system-prompt prefix. options.skills on harness::send or harness::spawn accepts exact skill ids. For a fresh session, omitted or empty means all model-invocable skills. On an existing new-format session, omission inherits the previous filter while an explicit empty list resets to all; explicit changes are rejected while its turn is active. This is curation, not authorization: the turn's function policy must still allow directory::skills::get, and the function must exist in the live registry. Skill bodies enter context when the model calls that function — or up front, as sections, when an agent profile preloads them. Catalog changes are appended as durable user-role corrections, leaving the frozen prefix unchanged. Legacy sessions keep their already-frozen prompt; start a new session to apply an id filter to one.

Trusted console surfaces can preview the built-in, selected, frozen skill, runtime-context, registry-notice, and declarative worker-injection layers with harness::system-prompt::get, without making a model request. When the caller passes no selected_prompt and the session has a turn record, the preview reports the record's RESOLVED prompt (labeled session (frozen at send)) — the truth for what ran and what the next send inherits — instead of rebuilding the built-in. Set default_only: true to read the exact embedded Harness default without consulting session or runtime state. Static pre_generate hooks publish their exact contribution as trigger metadata inject_prompt; request-dependent hook functions and compaction are not run by the read-only preview and may change content when the prompt is sent.

Custom trigger types

The harness emits two async orchestration trigger types siblings and consumers bind to, and registers five synchronous hook points operator-trusted siblings plug into in-path. Bind with the standard two-step pattern.

Trigger type Kind Fires / runs
harness::turn-started async event A turn began executing (first loop step). Payload: session_id, turn_id, timestamp, depth (0 = top level), message_preview (first characters of the message that started the turn, when there is one), and parent / parent_session_id for sub-agents. Worker-bindable via direct engine registration only — the agent path (engine::register_trigger) refuses harness-internal types in every shape.
harness::turn-completed async event A turn reached a terminal status (completed / cancelled / failed), carrying the result and terminal: boolfalse while the session still owns an armed wake (a one-shot notify), meaning a later turn carries the run's real outcome; consumers finalize a logical exchange only on terminal: true. Worker-bindable only, same as above.
harness::hook::pre-turn sync hook First step of a turn, before any model spend. May veto.
harness::hook::pre-generate sync hook After context assembly, before generation. May extend the system prompt, append messages, or veto. A static-only hook may publish its exact contribution as trigger metadata inject_prompt; the harness appends it directly and skips the compatibility handler.
harness::hook::post-generate sync hook After the final assistant message. Observe only.
harness::hook::pre-trigger sync hook After the allow/deny policy passes, before the target runs. May deny, hold, or rewrite arguments.
harness::hook::post-trigger sync hook After the target returns, before the result is persisted. May rewrite the result.

Event configs accept { session_id?, parent_session_id? }; hook configs accept { functions?, priority?, timeout_ms?, on_error? }. See the spec at tech-specs/2026-06-agentic/harness.md for the hook contract and chain semantics.