<div align="center">

# harness

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

<p>
  <a href="#install"><img alt="Install: iii trigger compose::add worker=harness" src="https://img.shields.io/badge/install-iii%20trigger%20compose%3A%3Aadd%20worker%3Dharness-0a84ff?style=flat-square"></a>
  <a href="../LICENSE"><img alt="License: Apache 2.0" src="https://img.shields.io/badge/license-Apache%202.0-blue.svg?style=flat-square"></a>
  <a href="https://www.rust-lang.org"><img alt="Built with Rust" src="https://img.shields.io/badge/built%20with-rust-orange?style=flat-square&logo=rust&logoColor=white"></a>
  <a href="https://workers.iii.dev/workers/harness"><img alt="harness on the workers registry" src="https://workers.iii.dev/workers/harness/badge.svg"></a>
  <a href="https://workers.iii.dev/workers/ade"><img alt="ade on the workers registry" src="https://workers.iii.dev/workers/ade/badge.svg"></a>
</p>

</div>

`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`](https://github.com/iii-hq/workers/tree/main/session-manager)
(transcript), [`context-manager`](https://github.com/iii-hq/workers/tree/main/context-manager) (token budgeting, soft
dependency), and [`llm-router`](https://github.com/iii-hq/workers/tree/main/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:

```bash
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
```

```bash
# New terminal, same folder. `compose::add` updates this Compose project.
cd iii-app
iii trigger compose::add worker=harness worker=ade
```

```bash
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.

<p align="center">
  <img src="https://raw.githubusercontent.com/iii-hq/workers/main/harness/docs/images/console-traces.webp" alt="Harness turn waterfall in the iii console" width="100%">
</p>

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`](https://github.com/iii-hq/workers/tree/main/approval-gate) sibling.
Before dispatch, arguments that fail the target's schema are reconciled: stringified JSON the
schema rejects is parsed. When the optional
[`judge`](https://github.com/iii-hq/workers/tree/main/judge) worker is deployed, it also settles
misnamed keys, off-enum values and unknown arguments; it sees the arguments with secret-keyed
values (`password`, `token`, `api_key`, `authorization`, …) masked and long strings cut. Every
repair is noted in the call's result.

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`](architecture/integration.md): the functions to
trigger, the triggers to bind, and the canonical consumer patterns.

## Local development

See [`DEVELOPMENT.md`](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: "<worker>::<fn>", description: "<short user-facing action>", payload: { ... } }`. The description is shown as the agent's activity in chat; keep it concise and in the session's response language (named in the runtime context, detected from the user's first message). 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: "<capability>" }`
- `directory::registry::workers::info { name }` to judge fit
- choose an operation id and register a one-shot `compose-operation` wake with
  `{ "operation_id": "<operation-id>", "terminal_only": true }`
- call `compose::add { worker: "<name>", operation_id: "<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::<operation>" }`. 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):

```json
{
  "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:

```yaml
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
max_result_bytes: 262144         # function-result byte cap at capture; oversized results become an elision marker (0 = off)
prompt_cache_sections: true      # send the frozen profile prefix as its own cacheable section (+ digest) on router::chat
call_reconciliation: judge       # repair malformed call arguments before dispatch: off | coerce (lossless parses) | judge (+ judge::evaluate when deployed)
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`](src/config.rs).

## Anonymous usage reporting

The harness announces its own usage on the durable `harness:usage` topic. The
engine subscribes to that topic and sends one anonymous product event for each
message. Two moments report:

- `harness_session_progress`, at root turn 1, 2, 5, 10, 25 and 50. Each report
  carries the cumulative totals that `harness::metrics` aggregates over the
  session tree: turns, function calls, tokens and cost. A later report
  supersedes the one before it.
- `harness_turn_failed`, once per session for each of the `failed`, `cancelled`
  and `max_turns` outcomes. This keeps the reason a run stopped exact when it
  stopped between two milestones.

One session reports at most 9 messages, however many turns it runs, and
sub-agent turns report nothing of their own. A message carries the model name,
the provider name, the counters above and a fixed failure class. It carries no
message text, no prompt, no file path and no function id.

The `harness_usage/<session_id>` state row records the milestones and outcomes
a session announced, so a restart does not announce one again. Each step is best
effort: a failed read or publish is logged at debug level and the turn
continues.

To stop the reports, set `III_TELEMETRY_ENABLED=false` on the engine. The engine
then takes the messages from the topic and discards them, so an opted-out
deployment stores nothing.

## 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`](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
(`<skills_folder>/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`](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`](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`](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.

Reasoning is sticky the same way: a send that names neither
`thinking_level` nor `provider_options` keeps the prior turn's pair, so an
omitted field never silently resets the effort (which would also bust the
provider's messages cache). Naming either field replaces the pair —
`provider_options: {}` resets to the provider default.

`options.reasoning: "lowest"` asks the harness to choose the effort instead:
the first entry other than `none` in the model's `reasoning_efforts`, else
`minimal` for a model that only reports `supports_thinking`, else no effort
(the provider decides). It cannot be combined with `thinking_level` or
`provider_options`, fails the send when the router has no catalog row for
the model, and the send response reports the choice as `reasoning`
(`thinking_level` and the native `reasoning_effort`). Later sends inherit it
like any explicit effort.

The prompt reaches `router::chat` in two forms: the flat `system_prompt`,
and `system_sections` — the STABLE prefix (the frozen profile or identity
prompt plus the frozen skills index, `cache_boundary: true`) followed by the
per-session tail (session id, working directory, policy aid, and whatever
context assembly and hooks append). Two sessions on the same profile send
the same stable bytes, and `cache_intent.surface_digest` (`sha256:` of that
section) names them, so cache-aware providers can keep one prefix entry for
all of them — when the provider supports it and the prefix meets its minimum
cacheable size: Anthropic puts its cache marker on the boundary block, OpenAI
and Codex derive `prompt_cache_key` from the digest. The digest is a local
identity, never evidence of a hit — `usage.cache_read` is. `harness::status`
reports it as `context.prompt_surface_digest`, or
`context.prompt_sections_fallback` when no sections went out: `disabled`
(`prompt_cache_sections: false`), `no_stable_prefix`, or `prefix_rewritten`
(a `pre_generate` hook replaced the prompt head — the hook's prompt still
wins, it just shares nothing).

### 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, response language, 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 `<preloaded_skills>` block of `<skill id="…">` 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 `<preloaded_functions>` 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 `<preloaded_functions>` block comes first, `<preloaded_skills>`
after it. The frozen block is never rewritten (it is the shared cache
prefix); when a preloaded contract later changes, disappears, or an
unavailable one appears, every step carries a tail notice naming exactly
those ids until a new session resolves the profile afresh. 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.

A spawned child also starts with the contracts it would otherwise look up
first, in a `<preloaded_functions>` block of its own (MOT-4851). The block
holds, capped at 30 contracts:
- its whole allow-list, when that is a short (≤ 30), glob-free list of
  explicit ids;
- then every function id its task names verbatim (for example
  `` `coder::read-file` ``).

Only ids the child may dispatch are seeded, and only when the registry lists
them. Discovery grants and ids its profile already preloads are skipped, and
`expose: native` seeds nothing, because the tools already carry the schemas.
The block is frozen at spawn and rides after the cache seam (the runtime aid),
so default-identity sessions keep sharing their stable prefix. Its digests join
the preloaded-contract stale notice.

The harness ships one profile of its own, `worker-builder`
([`agents/worker-builder.md`](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`](https://github.com/iii-hq/iii/tree/main/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 `<skill id="…">` 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.

The read-only `harness::context-policy` surface returns whether the destination model permits aged function-result pruning. Console context previews must use this response rather than maintaining a model list locally; failures or malformed responses are not a permission to infer a policy.

## Delete a session subtree

Console/operator callers use `harness::delete-session-tree { session_id }`, not
raw session deletion. The selected session is the root; parents and siblings
remain. The command returns `{ operation_id, attempt, session_id, status,
deleted_session_ids, error? }` quickly. Subscribe to
`harness::session-tree-deletion { session_id?, operation_id? }` first, read
`harness::delete-session-tree-status { operation_id }` once for race recovery,
then wait for `completed` or `failed`. Pending/completed requests are idempotent;
failed requests retry under the same operation id with an incremented required
`attempt` (starting at 1). Correlate terminal events by `operation_id` **and**
`attempt`, ignoring terminal events from older attempts. Pending/completed
repeats and recovery retain the attempt.

The durable queue operation closes admission, cancels every durable descendant,
waits for terminal turns/in-flight tools, delivers one consolidated message to
the surviving direct parent, and deletes child-first. Unconfirmed work fails
closed with data retained. See [recovery, safety boundaries and tests](architecture/session-tree-deletion.md).

## Asking the user (`harness::ask`)

`harness::ask` lets the model put a decision with discrete options to the user
as a clickable card instead of a question written in markdown. It is
registered in the catalog like `harness::spawn`, but it is a harness control
like `submit_result`: the turn loop answers it in-process, never dispatches it
to a target, and never parks the turn. A direct call outside a turn fails with
`harness/invalid_request: harness::ask only works inside an agent turn: call it
from a model turn, not directly`.

```json
{ "questions": [ {
    "header": "Approach",
    "question": "When the agent asks, does the turn pause or end?",
    "multi_select": false,
    "options": [
      { "label": "Pause the turn", "description": "Like AskUserQuestion" },
      { "label": "End the turn", "description": "The answer becomes the next message" }
    ] } ] }
```

- 1–4 questions per call and 2–4 options per question.
- `header` is 1–16 characters and `question` must not be empty.
- `label` is 1–80 characters and unique within its question.
- `header` and `label` must not contain line breaks (the answer is one line
  per question).
- Limits count characters, not bytes. A value that is only whitespace counts
  as empty, and labels are compared after trimming.
- `multi_select` defaults to `false`; `description` is optional.
- The UI adds a free-text "Other" choice, so the model must not include one.

An accepted ask is answered at once with a function result whose `details` is
the card:

```json
{ "status": "awaiting_answer", "question_id": "<function call id>",
  "session_id": "…", "turn_id": "…", "questions": [ … ] }
```

In `questions`, `multi_select` is always present and `description` is omitted
when absent. The model reads one text block telling it the questions are shown,
the answer arrives as the user's next message, and it must not repeat them in
text.

**The turn ends on the question.** A step that accepted an ask finalizes the
turn without another model call, through the same path as a text-only step:
`completed`, with the step's assistant text as the result. Three things change
that:

- A `submit_result` in the same step is handled first.
- A user message that arrived during the step (steering or a queued message)
  advances the turn as usual.
- A step whose other calls parked waits for them; the resumed step calls the
  model again.

The accepted step is recorded on the turn record (`ask_step`), so a
redelivered step still ends on the question and still refuses a second ask.

Refusals come back as an `is_error` result, and the turn continues so the model
can react:

| Case | Result text |
|---|---|
| A sub-agent (depth > 0) or a turn with a JSON output contract | `harness::ask needs a human to answer and none is attached to this turn (sub-agent or structured-output turn); report blocked with your question instead` |
| A second ask in the same step | `only one harness::ask per step; put all your questions (up to 4) in one call` |
| Arguments that do not parse | `invalid harness::ask arguments: <serde error>` |
| A broken limit | the field path and the limit, e.g. `questions[1].header must be at most 16 characters (got 17)` |

**Policy and hooks.** The session's allow/deny policy still applies: a denied
ask gets the usual `policy_denied` result. The repeated-failure breaker and
argument reconciliation still apply too. `pre_trigger` and `post_trigger` hooks
are **not** consulted. An approval gate could only hold the ask, and a held
call's release goes to the registered handler, which cannot show the card.

**Answering.** The answer is simply the user's next message, so any client
that can send one can answer. The console renders the call as a card: radio
buttons or checkboxes, each option's description, an "Other" field and Send.
Send submits the answer through the composer, one line per question:

- a single choice sends `Approach: End the turn`;
- a multi-select sends `Channels: Console, Slack`;
- "Other" sends the typed text.

The card stays open while `harness::status` reports the same `turn_id`. Once
the session has moved to a later turn, it becomes read-only and is marked
answered. Typing a reply in the composer instead works the same way.

## Custom trigger types

The harness emits 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::session-tree-deletion` | async event | A subtree deletion snapshot; config `{ session_id?, operation_id? }`. Terminal statuses are `completed` and `failed`. |
| `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: bool` — `false` 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. Never consulted for `harness::ask`, which the turn loop answers itself. |
| `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`](https://github.com/iii-hq/workers/blob/main/tech-specs/2026-06-agentic/harness.md)
for the hook contract and chain semantics.
