Runbook as tool
Turn a written operational procedure into one agent tool whose every answer is evidence — coverage, provenance, rule version, and the recorded walk — instead of a pile of logs.
The most-used agent job in a business is triage: run the standing procedure, come back with a verdict somebody can act on. Teams that build this by hand end up rebuilding the same envelope every time — a coverage ledger, a rule-version stamp, capped verdict rows with a rendered table, and the recorded walk that lets a reader check the verdict instead of trusting it.
runbookAsToolis that envelope as one declaration bag. The first production triage tool of this shape was ~800 hand-written lines; through the bridge, the next runbook is a page of declarations.
Why not just flowchartAsTool
flowchartAsTool is a thin wrapper: it runs the chart and returns JSON.stringify(values) (or your resultMapper's string). Nothing about the answer says what was checked, what was not, which rule version judged it, or where the walk is. runbookAsTool is the standard bridge — the answer is the evidence:
- The mandatory honesty spine, on every answer whatever the runbook's shape:
af_coverage— the three-list ledger (the coverage primitive), with every inner tool's own ledger folded upward, plus a sentence naming the rule set and version;result.af_provenance— re-emitted first, carrying any provenance stamp an inner source declared (a seeded source's confession survives composition) plus this call's own{tool, toolCallId};result.rule_version— from yourrulesdeclaration, or the honest'undeclared';result.walk— the recorded walk's descriptor. The walk itself ships as an artifact ticket (kindrecording/chart-walk), never as bytes in the payload.
- An optional verdict projection, selected by
resultKind: 'verdict/*'— verdict rows, truthful counters, one cap for the structured list and the pre-rendered table, andverdict_meaningsgenerated from the decider's declared branches, the rule labels this run'sdecide()evidence carried, and the default branch's own label (why the default is special). Never hand-restated, so a rule change and its meaning change on the same day. - Three outcomes, honestly: a clean envelope; an inner absence passed through verbatim (the framework still reads it as an absence — a verdict over a source that answered "nothing here" would be a confident partial answer); and declined rows counted into the ledger as not-checked ground.
flowchartAsTool stays for compatibility — resultMapper users stay put. New procedures start here.
The worked example
One inner tool, one bounded fan-out, one decider per subject — the triage shape end to end:
import { decide, flowChart, type DecideRule } from 'footprintjs';
import { Agent, coverage, defineTool, inMemoryArtifacts, runbookAsTool } from 'agentfootprint';
// THE RULES — one home, versioned. Filter rules on purpose: the evidence
// records {key, op, threshold, actual}; a function rule would leave no 7 behind.
const STALE_AFTER_DAYS = 7;
const POSTURE_RULES: DecideRule<Record<string, unknown>>[] = [
{ when: { age_known: { eq: false } }, then: 'declined',
label: 'the age signal is unreadable — no classification' },
{ when: { age_days: { gt: STALE_AFTER_DAYS } }, then: 'unprotected',
label: `last backup older than the ${STALE_AFTER_DAYS}-day threshold` },
];
// THE DEFAULT — chosen by NO rule, so no rule label can name it. Declared
// beside the rules, its meaning rides the same decide() evidence.
// Each subject's chart decides with: decide(scope, POSTURE_RULES, POSTURE_DEFAULT)
const POSTURE_DEFAULT = {
branch: 'protected',
label: `no rule fired — last backup within the ${STALE_AFTER_DAYS}-day threshold`,
};
// An INNER tool — registered like any other; its coverage ledger folds upward.
const inventory = defineTool({
name: 'backup_inventory',
description: 'List backup subjects with their last-backup age.',
execute: () => coverage(
{ rows: fetchSubjects() },
{ checked: ['the backup inventory, one row per job'],
cannotCover: [{ what: 'WHY a protection was paused',
why: 'no change record is collected here' }] },
),
});
const triage = runbookAsTool({
name: 'backup_triage',
description: 'Assess backup protection posture for every subject.',
resultKind: 'verdict/backup-posture', // selects the verdict projection
rules: { name: 'health-signal', version: 'v1' }, // provenance → envelope + sentence
verdicts: { decider: 'Protection posture' }, // whose branches name the verdicts
composedOf: ['backup_inventory'], // drift-checked at agent build
// The procedure: a factory invoked PER CALL with the run's own dispatch —
// fresh chart every run, stages close over `tools`.
procedure: (tools) =>
flowChart<Record<string, unknown>>('backup-protection-triage', async (scope) => {
const inner = await tools.call('backup_inventory', {}) as { result: { rows: Subject[] } };
scope.subjects = inner.result.rows;
}, 'inventory')
.addParallelForEach<Subject>('Assess each subject', 'per-subject', {
items: (scope) => scope.subjects ?? [],
branch: (item, i) => subjectChart(item, i), // decider per subject, isolated branch
maxBranches: 50,
into: 'subject_results',
})
.addFunction('Collect', (scope) => {
scope.verdicts = rowsFrom(scope.subject_results); // ← the rowset the bridge reads
scope.report = { stale_after_days: STALE_AFTER_DAYS }; // ← app fields, spread into result
}, 'collect')
.build(),
});
const agent = Agent.create({ provider, model, artifacts: inMemoryArtifacts() })
.tool(inventory)
.tool(triage) // composedOf verified HERE — the catalog is complete
.build();What one run returns (abridged — a real envelope, from a real run):
{
"af_coverage": {
"checked": [
{ "what": "the backup inventory, one row per job" },
{ "what": "the declared procedure 'backup-protection-triage' — 15 step(s) recorded under health-signal v1" }
],
"not_checked": [
{ "what": "1 row(s) that reached NO classification (verdict 'declined')", "why": "…" }
],
"cannot_cover": [{ "what": "WHY a protection was paused", "why": "no change record is collected here" }],
"note": "This result covers only what `checked` lists. …",
"sentence": "Ran 'backup-protection-triage' under health-signal v1 — 15 step(s) recorded; 3 row(s) assessed, 1 declined (no classification reached); 1 not-checked item(s) declared."
},
"result": {
"af_provenance": { "tool": "backup_triage", "toolCallId": "c1" },
"rule_version": "v1",
"stale_after_days": 7,
"verdicts": [
{ "subject": "cluster-a", "verdict": "protected", "age": 2 },
{ "subject": "cluster-b", "verdict": "unprotected", "age": 30 },
{ "subject": "cluster-c", "verdict": "declined", "age": null }
],
"rows_shown": 3, "rows_total": 3, "rows_complete": true,
"table": "| subject | verdict | age |\n|---|---|---|\n| cluster-a | protected | 2 | …",
"render_note": "table is PRE-RENDERED over the same rows as `verdicts` — output it VERBATIM. …",
"verdict_meanings": {
"protected": "no rule fired — last backup within the 7-day threshold",
"declined": "the age signal is unreadable — no classification",
"unprotected": "last backup older than the 7-day threshold"
},
"walk": {
"ref": "art_2tPdO5Qlnd502pwXDyPNwU",
"kind": "recording/chart-walk",
"rows": 46, "steps_executed": 15,
"projection": "full", "shown": 46, "total": 46, "complete": true,
"walk_segment": "full",
"note": "The chart's own walk, one row per execution step. The `condition` rows carry the rule that matched, the values it compared and the branch it chose — …"
}
}
}The runnable version of this example is examples/features/68-runbook-as-tool.ts.
Naming the default branch
verdict_meanings is generated, never restated by the caller: each meaning comes from something the run itself said. There are two speakers, and for one branch both are silent.
Every branch a rule can reach is named by that rule's label, which travels out on the run's decide() evidence. Branches declared statically in the chart are also named by their own descriptions. The default branch has neither. It is the branch chosen by no rule — it fires exactly when every rule failed, so no label describes it; and when the decider lives inside a dynamically generated fan-out branch (one decider pass per subject, as above), the branch chart does not exist at build time either, so there is no declared description to fall back on. It was the one verdict the rowset could show and the meanings map could not explain — visible in a "verdict": "protected" row sitting beside a map with no protected key.
Name it where the rules are named — one line at the decide() call:
// before — the default is a branch id and nothing else
decide(scope, POSTURE_RULES, 'protected');
// after — the branch id, plus what falling back to it MEANS
decide(scope, POSTURE_RULES, {
branch: 'protected',
label: 'no rule fired — last backup within the 7-day threshold',
});The label lands on the decision evidence (DecisionEvidence.defaultLabel, footprintjs ≥ 9.16.1) and is harvested here exactly like a rule label. Consequences worth knowing:
- It is recorded on every decision, not only the runs that fell through. A default's meaning belongs to the decider, not to the day's data — otherwise a published meanings map would gain and lose a key depending on which way the rows went.
- Declare nothing and the map stays honestly silent. The bare-string default still works unchanged;
verdict_meaningssimply has no entry for that branch. The bridge never invents a sentence from a branch id — an unexplained verdict is better than an explanation nobody wrote. - There is deliberately no meanings map at the tool boundary. A map a caller can hand in is a map that can describe rules that never ran, and would be indistinguishable in the answer from meanings the run actually produced. The label is declared beside the rules it competes with, so a rule change and its meaning change in the same edit.
- A blank label is not a meaning —
label: ''is recorded as no entry, for a rule or for the default.
Stages call registered tools: ctx.tools
The dispatch belongs to the agent, and it only exists at execute time — so the bridge invokes your procedure factory per call with the run's own dispatch, and stage functions close over it. tools.call(name, args) executes the registered tool (static registrations plus skill-carried tools; ToolProvider-delivered tools are not visible — there is no build-time list of them) and returns its result exactly as returned.
Discipline the dispatch holds for you:
- Inner tools never mint competing tickets — inner calls run with
hasArtifacts: false, so one answer carries one walk ticket, not three chips for data nobody asked about. - An inner absence short-circuits —
absent()from a source becomes the runbook's own answer, verbatim. A call site that can carry on without the source declares it:tools.call(name, args, { allowAbsent: true })— and owns stating the gap. - A declared
needsresolves on the non-interactive path (fail-closed); a tool that requires a human —checkIn, or a credential needing consent — refuses by name, because an inner call cannot pause. composedOfnames the ingredients — verified atagent.build(), when the catalog is complete. A renamed ingredient fails the build, not the first 3 a.m. run.
Who renders the rowset: presentation
A rowset can have a surface other than the model's prose — and which one it has is the single thing about its client a runbook cannot work out for itself.
In a chat client it cannot: the model's words are the only place the rows can appear. So the envelope ships table pre-rendered and tells the model to output it verbatim, because the alternative is retyping, and a retyped identifier that looks right and matches nothing is the failure that note exists to stop.
In a client that draws the rowset itself — a data panel, a grid, a report page — the reader is already looking at the rows. Asking the model to reproduce them in prose runs exactly the same transcription risk, for no gain, and puts a second, subtly-different copy of the table beside the real one.
So the caller declares it:
// DEFAULT — 'prose'. The model's words are the rowset's only surface.
const triage = runbookAsTool({ /* … */ resultKind: 'verdict/backup-posture' });
// result: { verdicts, rows_shown, rows_total, rows_complete,
// table: "| subject | verdict | age |\n…",
// render_note: "table is PRE-RENDERED … output it VERBATIM …", … }
// 'panel' — this host tickets the rowset and draws it beside the answer.
const triage = runbookAsTool({ /* … */ resultKind: 'verdict/backup-posture',
presentation: 'panel' });
// result: { verdicts, rows_shown, rows_total, rows_complete,
// render_note: "the rows in `verdicts` are ALREADY on the reader's screen …
// Do NOT reproduce those rows in prose in any form …", … }
// ↑ no `table` key at allverdicts,rows_shown,rows_total,rows_completeandverdict_meaningsare identical across the two modes for the same run — the dial names who renders the rows, never which rows there are.render_noteis present in both, because a rowset always ships with a rule about its surface. It isVERDICT_RENDER_NOTEunder'prose'andPANEL_RENDER_NOTEunder'panel'; both are exported, so a consumer or a test asserts on the constant rather than on a string literal.tableis the only key that moves, and it is absent under'panel'— the key itself, not an empty string. Its name stays reserved in both modes, so a chart'sreportcannot put a table back into a panel answer.- An unknown value throws at definition, never falls back to
'prose': a mis-spelled dial that silently keeps working is a dial you cannot trust to have been set.
PANEL_RENDER_NOTE states four things: the rows are already on the reader's screen; do not reproduce them in prose in any form (table, bullets, or one sentence per row); quote the evidence sentence a row carries verbatim when the answer names that row; cite only the values the finding rests on, copied byte-for-byte.
presentation is about the rowset's surface only. A host that draws the rows usually wants to draw the walk too — that is a different dial, walk: { recording } (see "Drawing the walk" below), and the two are independent: either can be on without the other.
What the chart declares back (reserved state keys)
| State key | The bridge reads it as |
|---|---|
verdicts | the rowset (verdict projection only): an array of row bags, each with a string verdict |
coverage | chart-declared coverage entries: { checked?, not_checked?, cannot_cover? } of {what, why?} items — the app's own counters translated into the ledger's vocabulary by the stage that knows them |
report | the app's own result fields, spread into result verbatim beside the spine (spine keys win) |
The reserved verdict word declined means "no classification was reached" — those rows are counted into the ledger as not-checked ground, never dressed up as findings.
Spine keys win, and a collision is spoken. The envelope's own names — af_coverage, af_provenance, rule_version, walk, report_note, and the projection keys a verdict-shaped run assembled (verdicts, rows_shown, rows_total, rows_complete, table, render_note, verdict_meanings) — are reserved. A report field spelling one of them is discarded, and result.report_note names every field that was: a boundary a chart can overwrite is a boundary that reports whatever the chart says about itself. report_note is present only when something was actually refused, so the normal path never sees it.
The walk, honestly
- The walk is minted through the run's artifact store, kind
recording/chart-walk; the envelope carries the descriptor (ref + truthful counters), never the bytes. - When it does not fit the cap (default 500,
walk: { cap }): a head slice would keep four hundred writes and drop every decision — so the control flow survives (stages, forks, subflows, everyconditionentry with its evidence), andprojection: 'control-flow'says which one you are holding. - No store, or a failed mint: the descriptor still ships with its counters, and its
notenames why there is no ticket. A failed mint costs the ticket, never the answer. walk_segmentis'full'today. When approval gates land, a resumed run's walk will honestly say'post-resume'— the discriminant ships now so the wire does not break then.
Drawing the walk: walk: { recording }
The walk is a row projection — eight declared columns per execution step, values off by construction. That is the right thing to hand a model and the wrong thing to hand a renderer: a step graph cannot be inferred from sentences about steps, and a consumer handed 129 rows can only correctly refuse to guess at the edges. The one piece that makes a walk drawable is structure, the chart's build-time graph — which a finished run does not leave behind and no snapshot carries.
So opt in, and the bridge files the inner chart's own recording beside the walk — { snapshot, events, structure }, the same shape recordRun produces and every flow viewer mounts — under kind recording/run, with its ref on the same descriptor:
const triage = runbookAsTool({
name: 'backup_triage',
description: 'Assess backup protection posture for every subject.',
resultKind: 'verdict/backup-posture',
rules: { name: 'health-signal', version: 'v1' },
redact: { keys: ['apiKey'] }, // ← scrubs the walk AND the recording, one policy
// `true` for the defaults, or the bag to set them yourself.
walk: { cap: 500, recording: { label: 'nightly posture sweep', maxBytes: 5_000_000 } },
procedure: (tools) => /* … as above … */,
});…and the spine's walk descriptor grows four fields:
"walk": {
"ref": "art_2tPdO5Qlnd502pwXDyPNwU",
"kind": "recording/chart-walk",
"rows": 46, "steps_executed": 15,
"projection": "full", "shown": 46, "total": 46, "complete": true,
"walk_segment": "full",
"note": "The chart's own walk, one row per execution step. …",
"recording_ref": "art_GOlowossXqVvjvEveeCXWh",
"recording_kind": "recording/run",
"recording_bytes": 35371,
"recording_note": "The inner chart's own recording — `{ snapshot, events, structure }`, the shape a viewer mounts — so this walk can be drawn as the flowchart it actually ran. …"
}The consumer redeems it with the artifact op it already has — { op: 'artifact-get', ref: walk.recording_ref } — parses the JSON, and hands the three keys straight to the viewer. No new operation, no new adapter.
Why it is off by default. A walk carries sentences about what happened and no payload from it. A recording is the run: the chart's shared state, its whole commit log, and every attached recorder's data — whatever the chart wrote. That is a materially bigger promise, so it is a thing you declare, never a thing the library starts doing to you. Unset, nothing extra runs: no second snapshot, no bytes measured, no store call, and the envelope is byte-identical to before the option existed.
Redaction means the same for both. The recording's snapshot is read from the redacted mirror, not raw working memory — so the redact policy that scrubs the walk scrubs the recording by the same rule at the same moment. A redacted key is still present in the recording, carrying REDACTED: a reader sees that a value existed and was scrubbed, rather than that nothing was written.
Size has a declared failure mode. A recording over walk.recording.maxBytes (default DEFAULT_RECORDING_MAX_BYTES = 5,000,000) is refused, not truncated, and recording_note names what it measured, the ceiling it broke, and the option that raises it. The asymmetry with the walk's cap is deliberate: walk rows are independently meaningful, so a projection of them is still true — but { snapshot, events, structure } is one bundle, and half a commit log under a whole chart draws a picture nobody can check.
The absence is always stated. No store, an over-size refusal, an unserializable snapshot, a store that threw — each costs the ref and lands a reason in recording_note. A missing ref with no sentence would leave a reader guessing, which is the one thing the spine exists to prevent. And with the option unset the descriptor says nothing at all about a recording — a reader who never asked for one should not have to read a sentence explaining its absence.
events is empty, on purpose. It is the typed agentfootprint stream, fired by an agent turn; what ran here is a footprintjs chart on its own executor, which fires none. All three keys are present (that is what a viewer reads), the empty array is the honest count, and recording_note says so out loud so nobody reads it as a dropped stream. The walk's own story rides snapshot, where the narrative recorder's data already lives.
origin.toolCallId is the outer call on both parcels — the walk and the recording are two views of one tool call, so they carry the same join key and a consumer can pair them (and join either back to the call the model made).
What is not here yet
- Pause: a procedure that pauses throws with the checkpoint attached (exactly like
flowchartAsTool). The approval-gate phase brings typed resume through the agent rail. - Inner dispatch ceilings:
checkInandwantstools refuse by name; composition depth stops at one (an inner tool does not getctx.tools).
The API surface
Everything ships from the main barrel:
runbookAsTool(options)— the bridge.RunbookAsToolOptionsis the full options bag;RunbookProcedureis the factory type ((tools: ToolDispatch) => FlowChart);RunbookRulesis the{ name, version }provenance stamp;RunbookVerdictsOptions({ decider, maxRows }, default capDEFAULT_MAX_ROWS= 50),presentation('prose' | 'panel', default'prose') andRunbookWalkOptions({ cap, recording }, defaultDEFAULT_WALK_CAP= 500) are the projection dials.- The chart recording —
RunbookRecordingOptionsis the object form ofwalk: { recording }({ label, maxBytes }), withDEFAULT_RECORDING_MAX_BYTES(5,000,000) as the ceiling a bundle is refused over. It files under the existingRECORDING_ARTIFACT_KIND('recording/run') viarecordingPutInput, which now also acceptstoolCallIdso a tool-minted recording carries the sameoriginjoin key as the walk beside it. RunbookEnvelope— the returned envelope type (spine + optional projection);VerdictRowis one rowset row (anything with a stringverdict);WalkDescriptoris the spine's walk entry;DECLINED_VERDICT('declined') is the reserved no-classification verdict word.- The two render laws —
RunbookPresentation('prose' | 'panel') is thepresentationdial's type;VERDICT_RENDER_NOTEis the law stated beside a pre-rendered table under'prose', andPANEL_RENDER_NOTEis the one stated instead of a table under'panel'. - The dispatch —
ToolDispatch(has/call) andToolDispatchCallOptions({ signal, allowAbsent }) are thectx.toolscontract every tool sees. The bridge's own wrapper is exported for direct callers and tests:recordingDispatch(delivered, name)returns aRecordedDispatch({ tools, records }, each anInnerCallRecord);probeDispatch(name)is the definition-time probe;RunbookAbsenceSignalis the control signal an un-survivable inner absence throws, recognized byabsenceSignalOf(err)anywhere down acausechain. - The walk —
projectWalk(entries, cap)is the pure cap law, returning aProjectedWalkofWalkRows; the artifact kind constant isCHART_WALK_ARTIFACT_KIND('recording/chart-walk') withchartWalkPutInput(rows, facts)besiderecordingPutInput. - The projection helpers —
verdictRowsOf(state)reads the rowset off a final state;renderVerdictTable(rows)renders the one-cap markdown table. - The declarations —
Tool.composedOfandTool.gates(see above), withassertComposedOfandassertGatesexported beside the other definition-time asserts for consumers assemblingToolobjects by hand.
Next steps
- Flowchart as tool — the thin wrapper this bridge supersedes for new procedures
- Tools guide —
defineTool, the coverage primitives (coverage()/absent()) - Artifacts — the claim-check store the walk ticket rides
- footprintjs — the chart substrate: deciders,
decide()evidence, fan-out
Middleware
A typed chain around every tool dispatch and around the message boundary. Three verbs — allow, deny, ask — and no way to return a result, so the answer the model reads is always the real tool's or a refusal.
Runbooks inside a fan-out
Six engine behaviours a decision chart meets the first time it runs one branch per subject. A branch is seeded with only item and index, a decider reports itself path-prefixed by name, and four of the six fail without an error — the run finishes, the rowset looks complete, and the answer is wrong.
