Tracker pipelines pass data between nodes through a shared key-value store called the pipeline context, not through LLM chat history. Agent nodes start a fresh LLM session each time they run — prior conversation turns are not replayed. Tool, human, parallel, and fan-in nodes produce outputs by writing specific keys into the context. Downstream nodes read those keys to see what happened upstream.
- Fresh LLM sessions: each agent node runs a brand-new conversation with its own system prompt. No chat history leaks between agent nodes.
- Shared context store: all nodes read from and write to a single
PipelineContext(a string-keyed map) for the whole run. - Per-node scoping: after each node completes, every key it wrote is also copied into
node.<nodeID>.<key>so later nodes can reference a specific upstream node's output by name. - Fidelity levels control how much context an agent node sees in its prompt to prevent context-window bloat.
Each node solves a single well-scoped problem. Carrying a full conversation history across nodes would push the LLM into a context window built for a different task, wasting tokens and confusing attention. By resetting the session every node, tracker matches how the best Claude Code workflows operate — clear the context before big work, pass only the inputs that matter.
| Key | Written by | Purpose |
|---|---|---|
last_response |
agent (codergen) handler | The most recent agent node's final message content |
response.<nodeID> |
agent and human handlers | Per-node response snapshot addressable by node ID |
episode_summary |
agent (codergen) handler | Summary of tool attempts/outcomes from the most recent agent session |
episode_summaries |
agent (codergen) handler | JSON array of accumulated prior episode_summary values for retries/resumes |
outcome |
every handler | success / fail / retry for the node that just ran (engine-level runs can also terminate with budget_exceeded). "Escalation" is not an outcome — it's a routing convention on top of fail; see engine.md#escalate. |
preferred_label |
human handler | Label the user selected on a labeled gate |
human_response |
human handler | Freeform text the user typed at a gate |
tool_stdout / tool_stderr |
tool handler | Subprocess output captured from tool_command |
graph.goal |
engine (from workflow.Goal) |
The workflow's stated objective, always available |
parallel.results |
parallel handler | JSON array with one entry per branch: {node_id, status, context_updates, stats} |
Handlers do NOT let user content set arbitrary context keys. The table above lists every key that each handler writes. Agent, tool, and human nodes each populate a fixed set — agents write last_response and response.<nodeID>, tools write tool_stdout/tool_stderr, humans write human_response and preferred_label. If you need to propagate a custom value from one node to the next, see Returning custom data from a node below.
Inside an agent prompt:, a tool command:, or an edge when: condition, refer to a context key with ${ctx.<key>}:
agent Step2
prompt: |
The planner said: ${ctx.last_response}
Goal: ${ctx.graph.goal}
For per-node scoped reads (addressing a specific earlier node):
agent Synthesize
prompt: |
Design from Architect: ${ctx.node.Architect.last_response}
Review from Critic: ${ctx.node.Critic.last_response}
Note that ctx. is the user-facing prefix; internally the engine stores bare keys (last_response) and scoped aliases (node.Architect.last_response). Conditions strip the prefix before lookup.
Workflow-level params are also available via ${params.<key>}. Define defaults in the top-level vars block and optionally override them at run-time with repeatable CLI flags: tracker workflow.dip --param key=value.
Declared inputs — the caller-supplied run signature from the workflow's inputs block (dippin #190, requires dippin ≥ v0.51) — are available via ${inputs.<name>}. They are bound and validated once at run start (tracker.Config.Inputs; see embedding.md), before any node runs. A text/number/bool/enum input expands to its coerced string; a file input expands to the workdir-relative path of its staged copy (.tracker/inputs/<name>). Inputs are untrusted by construction — the entire inputs.* namespace is blocked from tool-command interpolation (see the safe-key section below); to use a file input in a shell command, read the staged path directly (e.g. cat .tracker/inputs/spec), never ${inputs.spec}.
Agent prompts and conditions can interpolate any context key. Tool commands have a restricted safe-key allowlist to prevent LLM-origin values from leaking into shell commands and causing injection attacks.
Safe keys in tool commands:
outcome,preferred_label,human_response,interview_answers(handler-written, user-controlled)- All
graph.*keys (author-defined in the workflow) - All
params.*keys (declared in workflowvars, optionally overridden via--param)
Blocked keys in tool commands:
last_response— LLM-generated, unsaferesponse.<nodeID>— LLM-generated, unsafetool_stdout,tool_stderr— subprocess output, unsafeparallel.results— LLM-generated, unsafeinputs.*— caller-supplied, untrusted by construction (the whole namespace is blocked; #553). dippin lints a${inputs.*}reference inside acommand:as DIP157.- Any other LLM-origin context keys
If a tool command tries to interpolate a blocked key, the placeholder is replaced with an empty string. To safely use LLM output in a tool command, follow the Returning custom data from a node pattern: have the agent write output to a file, then read the file in the tool command. See CLAUDE.md's "Tool node safety" section for detailed patterns and safety rationales.
tracker validate / simulate / doctor and the library's ValidateSource walk the graph and check that every ctx.<key> reference — edge when predicates, ${ctx.*} interpolations in node attributes, and declared reads: entries — can actually be produced by some node. Findings name the offending (node, key) pair. Implementation: pipeline.ValidateVariableAvailability (pipeline/validate_vars.go).
- Reachability rule: plain directed-graph reachability (transitive closure over edges), not topological order. A producer reaches a consumer when any path exists, and a node reaches itself — so fix-loops and
manager_loopcycles are legal producers. - Fail-open: only a key with no producer anywhere in the graph, referenced unambiguously (an explicit
ctx./context.-prefixed condition operand, or areads:entry), is an error. A producer that exists but cannot reach the reference, a bare condition operand, a${ctx.*}interpolation, and an unknown node id innode.<id>.<key>are warnings. - Never flagged: the built-in keys in the table above, and every dynamic namespace (
graph.,params.,inputs.,response.,node.,steer.,stack.,summary.,parallel.,fan_in.,internal.).inputs.members are produced at run start from the declaredinputsblock, so a${inputs.x}reference or awhen inputs.xoperand is ambient, not an undefined-variable finding. References reachable from an opaquesubgraph/stack.manager_loopnode are skipped entirely, since those handlers merge back keys the enclosing graph cannot enumerate. - Limitation: keys injected from outside the graph (the library's
Config.Context, or a parent's context snapshot handed to a subgraph child) are invisible to the walk. Declare them in the producing node'swrites:so the contract lives in the graph that consumes it.
After a node finishes, the engine calls PipelineContext.ScopeToNode(nodeID), which copies the keys marked dirty by Set or Merge since the previous scope into node.<nodeID>.<key> aliases. Bootstrap writes that happen before execution begins (via NewPipelineContextFrom and the ClearDirty call in initRunState) are excluded — only keys dirtied during the node's actual execution are scoped. Keys that already start with node. are skipped to avoid creating doubly-nested aliases. Earlier scoped aliases are preserved — only the bare keys get last-writer-wins semantics.
Sequential pipelines: ${ctx.node.<id>.last_response} is the reliable way to reference a specific agent's output later in the run, instead of relying on last_response (which changes every node).
Parallel branches: parallel branch handlers run in their own isolated context snapshots. After all branches complete, the parallel node writes a single parallel.results JSON array that surfaces each branch's status and context updates. The parent context does NOT gain per-branch scoped keys — there is no ${ctx.node.<BranchID>.*} namespace populated by parallel execution. If you need per-branch outputs after a fan-in, the robust patterns today are:
- Filesystem hand-off (used by
examples/ask_and_execute.dip): each branch writes files under.ai/and the fan-in node reads them. - Parse
parallel.results: the parallel handler writes a JSON array to${ctx.parallel.results}containing each branch's status and anyContextUpdatesit produced. - Explicit per-branch keys: a branch's agent prompt can instruct the model to write a specific key (e.g.
branch_a_summary), and the fan-in node reads${ctx.branch_a_summary}.
Reading ${ctx.node.<BranchID>.last_response} after a parallel block will NOT automatically contain each branch's final output — use one of the patterns above instead.
Use declarative writes: to map structured output into first-class context keys. This works on agent and tool nodes, and on human nodes when mode: interview.
agent Planner
response_format: json_object
writes:
- milestone_id
- files
tool ExtractMeta
command: |
jq -n '{commit_sha:"abc", branch:"main"}'
writes:
- commit_sha
- branch
Runtime contract:
- If
writes:is declared, the runner runs an extraction cascade against the node's output:- Direct JSON parse — the response IS a top-level JSON object. The fast path.
- Fenced-block extraction — the response contains one or more
...blocks; the runner walks every fence pair and accepts the first whose content parses as a JSON object. A non-JSON preamble fence (text,bash, etc.) does not block discovery of a laterjsonfence. - Balanced-brace scan — no fences, but a top-level
{…}span exists in mixed prose. The scanner finds the first balanced span (skipping{inside[…]arrays and inside JSON string values) that parses as a JSON object. - Single-key prose fallback — only when no JSON object was found by any prior step AND the node declares exactly one writes key, the raw response is stored under that key with a
writes_warningset in the context. The fallback value is capped at 8 KiB. Multi-key writes do not fall back — prose can't be distributed across multiple keys.
- Every declared key must be present in the extracted JSON. A model that returns valid JSON missing the declared key hard-fails the node with a specific
writes_error("contained extractable JSON but failed the writes contract") — the prose fallback does NOT fire in this case, since the model intentionally produced JSON. writes:keys cannot collide with thetool_commandsafe-key allowlist (outcome,preferred_label,human_response,interview_answers). The runner rejects such declarations at runtime to prevent LLM output from landing in reserved names that bypass shell-input sanitization.- String fields are stored as plain strings; non-strings are stored as compact JSON text.
- Extra produced fields (in the JSON but not declared) are allowed and surfaced via
writes_warning. - Two reserved context keys carry the cascade signal:
writes_error(hard failure — the node returnedOutcomeFail) andwrites_warning(cascade healed but with caveats — extras present, fallback used). Both are plain context keys; downstream nodes can branch on them via${ctx.writes_error}/${ctx.writes_warning}. - Backward-compatible built-ins (
last_response,tool_stdout,interview_answers, etc.) are still written regardless of the cascade outcome.
For human interview mode, writes: extraction uses the collected interview answers object (question IDs and normalized question-text keys mapped to answers).
For other human modes (freeform, choice, yes_no), writes: extraction is not applied automatically; rely on built-in human response keys unless handler behavior is extended.
Tool-side complement: locally-executed tool subprocesses (the default LocalEnvironment exec path) also receive TRACKER_RUN_ID, TRACKER_RUN_DIR, and TRACKER_WORKDIR in their environment (#323), so a tool node can read an upstream node's artifact directly — cat "$TRACKER_RUN_DIR/<NodeID>/response.md" — even when that agent has tool_access: none and cannot write files itself. These are env vars only, not ${ctx.*} expansion keys; see artifacts.md §Run identity env vars.
An agent node receives a compacted view of the pipeline context as part of its prompt construction. The amount of context injected is controlled by the fidelity attribute.
full— every key in the pipeline context is passed through. Expensive; use for nodes that genuinely need the whole picture.summary:high— all keys plus asummary.<nodeID>entry per completed node, containing the first 2000 characters of each node's artifactresponse.md.summary:medium— only these keys:outcome,last_response,human_response,preferred_label,tool_stdout,tool_stderr,graph.goalplus any keys declared in the current node'sreads:.summary:low—graph.goalplus a singlecompleted_summarykey listing which nodes have finished.compact—graph.goalandoutcomeonly.truncate— same keys assummary:medium, but each value is word-boundary-truncated to 500 characters and"..."is appended when truncation occurs.
On resume from a checkpoint, the engine applies DegradeFidelity() to drop one level automatically so the replayed context doesn't blow out the window.
Set it as a node attribute (highest precedence):
agent Planner
fidelity: summary:high
prompt: ...
Or set a graph-level default that every node inherits unless it overrides:
workflow build_product
defaults:
fidelity: summary:medium
Lookup order: node fidelity attribute → graph default_fidelity attribute → hardcoded default (full).
agent Analyze
prompt: |
Analyze this spec: ${ctx.human_response}
agent Build
prompt: |
Based on: ${ctx.last_response}
Build the described feature.
agent Review
prompt: |
Plan from Architect: ${ctx.node.Architect.last_response}
Code from Builder: ${ctx.node.Builder.last_response}
Review both and report.
edges
Build -> Test when ctx.outcome = success
Build -> FixFailure when ctx.outcome = fail
| Mistake | Fix |
|---|---|
| Expecting the chat history from one agent node to carry into the next. | Reset your mental model: each agent node sees only its own prompt. Pass data explicitly via ${ctx.<key>}. |
Reading ${ctx.last_response} after a parallel fan-in and expecting the results of all branches. |
Use parallel.results, filesystem hand-off, or explicit per-branch keys. |
| Setting huge prompt content that blows past the context window. | Choose a lower fidelity, or use truncate / summary:low on downstream nodes. |
| Relying on a custom key written by one node without declaring it in the workflow. | Pick a key name, write it in the upstream node's prompt/command, and read it downstream with ${ctx.<key>}. |
CLAUDE.md— "Token usage flows through three layers" explains how token accounting aggregates across nodespipeline/context.go—PipelineContext,ScopeToNode,GetScopedpipeline/fidelity.go— fidelity level implementation and compactionexamples/ask_and_execute.dip— real-world parallel + fan-in pattern using filesystem hand-off