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.
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:
| Point | Effect |
|---|---|
before-call | Runs after approval; exit 2 blocks the call |
after-call | Adds a note beside the tool outcome for the model |
stop | Exit 2 asks the loop to continue, at most three times |
More columns: swipe horizontally, or focus the table and use the arrow keys.
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
0proceeds; anafter-callhook may return{"note":"..."}; - exit
2blocks 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.
Hook programs run on the operator's host, outside substrate confinement. They are named explicitly and never discovered from the workspace. See Security boundary.