Build

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. runbookAsTool is 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 your rules declaration, or the honest 'undeclared';
    • result.walk — the recorded walk's descriptor. The walk itself ships as an artifact ticket (kind recording/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, and verdict_meanings generated from the decider's declared branches, the rule labels this run's decide() 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_meanings simply 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 meaninglabel: '' 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-circuitsabsent() 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 needs resolves 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.
  • composedOf names the ingredients — verified at agent.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 all
  • verdicts, rows_shown, rows_total, rows_complete and verdict_meanings are identical across the two modes for the same run — the dial names who renders the rows, never which rows there are.
  • render_note is present in both, because a rowset always ships with a rule about its surface. It is VERDICT_RENDER_NOTE under 'prose' and PANEL_RENDER_NOTE under 'panel'; both are exported, so a consumer or a test asserts on the constant rather than on a string literal.
  • table is 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's report cannot 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 keyThe bridge reads it as
verdictsthe rowset (verdict projection only): an array of row bags, each with a string verdict
coveragechart-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
reportthe 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, every condition entry with its evidence), and projection: 'control-flow' says which one you are holding.
  • No store, or a failed mint: the descriptor still ships with its counters, and its note names why there is no ticket. A failed mint costs the ticket, never the answer.
  • walk_segment is '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: checkIn and wants tools refuse by name; composition depth stops at one (an inner tool does not get ctx.tools).

The API surface

Everything ships from the main barrel:

  • runbookAsTool(options) — the bridge. RunbookAsToolOptions is the full options bag; RunbookProcedure is the factory type ((tools: ToolDispatch) => FlowChart); RunbookRules is the { name, version } provenance stamp; RunbookVerdictsOptions ({ decider, maxRows }, default cap DEFAULT_MAX_ROWS = 50), presentation ('prose' | 'panel', default 'prose') and RunbookWalkOptions ({ cap, recording }, default DEFAULT_WALK_CAP = 500) are the projection dials.
  • The chart recordingRunbookRecordingOptions is the object form of walk: { recording } ({ label, maxBytes }), with DEFAULT_RECORDING_MAX_BYTES (5,000,000) as the ceiling a bundle is refused over. It files under the existing RECORDING_ARTIFACT_KIND ('recording/run') via recordingPutInput, which now also accepts toolCallId so a tool-minted recording carries the same origin join key as the walk beside it.
  • RunbookEnvelope — the returned envelope type (spine + optional projection); VerdictRow is one rowset row (anything with a string verdict); WalkDescriptor is the spine's walk entry; DECLINED_VERDICT ('declined') is the reserved no-classification verdict word.
  • The two render lawsRunbookPresentation ('prose' | 'panel') is the presentation dial's type; VERDICT_RENDER_NOTE is the law stated beside a pre-rendered table under 'prose', and PANEL_RENDER_NOTE is the one stated instead of a table under 'panel'.
  • The dispatchToolDispatch (has/call) and ToolDispatchCallOptions ({ signal, allowAbsent }) are the ctx.tools contract every tool sees. The bridge's own wrapper is exported for direct callers and tests: recordingDispatch(delivered, name) returns a RecordedDispatch ({ tools, records }, each an InnerCallRecord); probeDispatch(name) is the definition-time probe; RunbookAbsenceSignal is the control signal an un-survivable inner absence throws, recognized by absenceSignalOf(err) anywhere down a cause chain.
  • The walkprojectWalk(entries, cap) is the pure cap law, returning a ProjectedWalk of WalkRows; the artifact kind constant is CHART_WALK_ARTIFACT_KIND ('recording/chart-walk') with chartWalkPutInput(rows, facts) beside recordingPutInput.
  • The projection helpersverdictRowsOf(state) reads the rowset off a final state; renderVerdictTable(rows) renders the one-cap markdown table.
  • The declarationsTool.composedOf and Tool.gates (see above), with assertComposedOf and assertGates exported beside the other definition-time asserts for consumers assembling Tool objects by hand.

Next steps

  • Flowchart as tool — the thin wrapper this bridge supersedes for new procedures
  • Tools guidedefineTool, the coverage primitives (coverage() / absent())
  • Artifacts — the claim-check store the walk ticket rides
  • footprintjs — the chart substrate: deciders, decide() evidence, fan-out

On this page