Skip to main content

Guides · 5 of 6

Structured runs, delegates and hooks

Each of these features changes how a run can finish or what the model can call, so each is opt-in, and none widens the tool catalogue or passes the approval gate.

crates/harness-loop/src/answer.rs; crates/harness-loop/src/skill.rs; crates/harness-loop/src/memory.rs; crates/harness-loop/src/delegate.rs; crates/harness-cli/src/agents.rs; crates/harness-cli/src/hooks.rs; b10x-harness run --help

Structured runs, delegates and hooks

These features are opt-in because each changes how the loop can finish or what the model can call. They share one rule: they do not widen the workspace tool catalogue or bypass its approval gate.

Structured output​

--output-schema FILE publishes a loop-owned tool named answer. The schema must describe a JSON object. The model finishes by calling that tool, and its arguments become the answer.

Create answer.schema.json:

{
"type": "object",
"additionalProperties": false,
"properties": {
"summary": {"type": "string"},
"files": {
"type": "array",
"items": {"type": "string"}
}
},
"required": ["summary", "files"]
}

Then run:

b10x-harness run \
--output-schema answer.schema.json \
--base-url https://gateway.example/v1 \
--model model-alias \
--api-key-env MY_MODEL_TOKEN \
--workspace . \
--input "Map the repository" | jq .

Without --json, stdout contains exactly one compact JSON value when the run completes. Model prose and progress go to stderr. If the model ends in prose, Harness nudges it once to call answer — and holds the turn that nudge opens to that tool at the provider, so the model cannot answer in prose twice by choice. A held turn that still ends in prose is refused on the wire with Protocol: model terminal reason contradicts its tool obligations on stderr, and the run exits 1 with nothing on stdout. A run never exits 0 over prose.

The constraint is sent on that one turn and no other. Holding every turn to answer would be a run that answers before it does any work, and on one route the field sits outside the prompt cache — so one turn per run, on a run that was otherwise about to report nothing.

The provider receives the schema as the tool's argument schema, and Harness independently validates the proposed answer against the same schema. Invalid arguments become a failed tool outcome and the run continues; an invalid schema refuses the run before the first provider request.

Offer skills on demand​

--skills-dir DIR offers every DIR/<name>/SKILL.md — YAML frontmatter with name and description, the document after — as a skill. Only the descriptions reach the model, one line each in the standing instruction; the body arrives as the result of a skill call the model makes by name. This loop replays the whole conversation every turn, so a body placed in the instruction is billed on every turn of every run: --context is for the files a run needs throughout, and a skill library is the other case. The skill tool's input is a schema enum of the offered names, so a name this run does not have is refused by the provider before it is sent.

--plugin-dir DIR reads DIR/skills/ and DIR/agents/ the same way and qualifies each name <plugin>:<name> from DIR/.claude-plugin/plugin.json, so two plugins that both ship a planning stay distinguishable. The started event and b10x-harness tools list the offered names — always, empty included — because a run given skills and a run given none are different records.

--plugin-dir contributes no memories. It is a skills-and-agents flag and nothing else.

Offer memories on demand​

--memory-dir DIR, repeatable, offers every DIR/<id>.md as a memory — the id is the file's own name, the frontmatter is kind, summary, trust, status and an optional supersedes, and the body follows. It is the exact shape --skills-dir has, for the same reason: only the summary reaches the model, one line each in the standing instruction, and the body arrives as the result of a recall call the model makes by id. recall's id argument is a schema enum over the active records, so an id this run does not have is refused by the provider before it is sent.

b10x-harness run --memory-dir ./vault --input "what did we decide about retries?" ...

Memories is a value the caller constructs and hands in. The loop walks no directory and reads no file, before the run or during it, and the whole value is cloned into a delegate, so a child observes exactly its parent's vault entry for entry. Without the flag a run has no memories and publishes no recall tool, and its bytes are identical to a run built before memories existed.

A record is closed and refuses rather than guessing. kind is one of decision, fact, handoff, lesson, preference, runbook, with no catch-all. trust has exactly one legal value, unreviewed. status is active, rejected or superseded — nothing is deleted, and a correction is a new record naming the old one in supersedes. Only active records are offered; the rest are held, reported, and answered by name if the model asks. An unknown frontmatter key, an unreadable record, an empty summary, two records of one id, a dangling supersedes, or a bidirectional override or control codepoint in any field refuses the whole vault by name — never a smaller one, because a vault silently missing the record that mattered reads to the model exactly like a complete vault. Memories::new is fallible where Skills::new is not, and that is the difference it buys.

Like skills, the ids are listed by the started event and by b10x-harness tools — always, empty included, and whatever a record's status, because a run handed a superseded record it did not offer is not a run that was handed nothing.

There is no memory-writing tool and no writing flag. The shipped toolset is read-only; every memory a run can see was handed to it before it started. A writer is its own change with its own gate, and never a flag on the reader.

Delegate one task​

--delegate publishes another loop-owned tool. The model can hand one self-contained task to a fresh conversation:

b10x-harness run \
--delegate \
--delegate-turns 12 \
...

The delegate receives:

  • the same standing instruction;
  • the same tool port, approver, hooks, cancellation token, and machine boundaries;
  • the parent's remaining token, time, and cost budget;
  • only the delegated task, not the parent's conversation.

It reports {stop, turns, text} once to the parent. Delegation is depth one: a delegate cannot delegate again. Child events are wrapped as delegated so a consumer cannot confuse child text with the parent answer.

Neighbouring delegate calls may run side by side up to --delegate-parallel N (default 4) only when every reachable tool is non-mutating and needs no approval and no hook is attached. A mutating surface, approvals, hooks, a port that cannot fork, or a budget that cannot divide takes the same delegates through the sequential path in model order.

Name an agent​

--agents-dir DIR, or the agents/ half of --plugin-dir DIR, describes delegates in advance in the on-disk shape Claude Code reads — DIR/<name>.md:

---
name: reviewer
description: Reads a diff and reports what would break, without editing anything.
tools: Read, Grep, Glob
---
You are reviewing, not fixing. Report findings as file:line and stop.

The body is that agent's standing instruction. The model calls delegate(task, agent); the agent argument is a schema enum of the offered names, so a name this run does not have is refused before it is sent, and a run with no agents publishes no agent key at all.

An agent narrows and never widens. Its tools: is mapped to Harness names and intersected with what the parent was admitted, so a child of an already narrowed run cannot climb back out by naming an agent, and a file arriving from an unaudited place — a plugin, a checked-out dependency — cannot grant run to a run whose catalogue never published it. What the agent asked for and did not get is a withheld record in the child's own session. The narrowing filters what is published and refuses the call, so a hidden tool is not reachable by name. A file with no tools: key is unrestricted, not disarmed; tools: [] is refused.

Run operator hooks​

--hooks FILE loads a versioned JSON document. Hooks run operator-owned programs at three points in a run:

PointEffect
before-callRuns after approval; exit 2 blocks the call
after-callAdds a note beside the tool outcome for the model
stopExit 2 asks the loop to continue, at most three times

Example hooks.json:

{
"version": 1,
"hooks": [
{
"on": "before-call",
"tools": ["file_write", "file_edit"],
"command": ["/opt/policy/check-write"]
},
{
"on": "stop",
"command": ["/opt/policy/check-answer"]
}
]
}

Commands receive one JSON document on stdin and return a decision through exit status and optional JSON on stdout. They are started directly as an argv, never through a shell.

  • exit 0 proceeds; an after-call hook may return {"note":"..."};
  • exit 2 blocks or continues, using {"reason":"..."} as the explanation;
  • another status, startup failure, timeout, or oversized output is recorded as a failed hook.

A before-call failure fails closed. An after-call failure is reported as a note because the tool already ran. A stop failure fails open because it cannot retroactively erase an otherwise complete answer.

A hooks file naming any other point is refused by name before the run starts.

Hooks are not confined

Hook programs run on the operator's host, outside substrate confinement. They are named explicitly and never discovered from the workspace. See Security boundary.