Saga manifests
A saga manifest is your workflow blueprint. Warden tracks each one by namespace, name, and version — see Component identity. It composes catalog step manifests into an ordered run, binds input ports, and gates steps with when. YAML examples below follow the GitHub MCP demo shape unless noted otherwise.
Capability fields (worker pin, prompt, tools, policy, hitl, facts, budgets, and so on) live on step manifests. This page covers saga composition and the runtime behavior of hydrated reason/commit steps.
Composing catalog steps
Authoring YAML uses use: + version to pin a step definition in the saga's namespace. Bind ports with with, skip with when, and optionally apply tighten-only overrides (timeout_seconds, max_turns, HITL fields, token caps) that may only narrow catalog guardrails — never widen them.
At deploy, the engine link-checks every use: against the catalog (ports, tighten-only overrides, workers, artifacts) and stores the authoring graph on the saga definition. Running instances hydrate that graph into frozen_steps at start; they do not re-resolve the catalog at schedule time.
# config/saga.github-demo.yaml
kind: saga
name: github-demo
namespace: default
version: "0.1.0"
description: Triage open issues; post a governed comment when there is work.
steps:
- id: triage
use: github-triage
version: "0.1.0"
with:
owner:
from: $.input.owner
repo:
from: $.input.repo
focus_issue_number:
from: $.input.issue_number
- id: post-comment
use: github-post-comment
version: "0.1.0"
when:
cel: "has(steps.triage.facts.triage_metrics) && steps.triage.facts.triage_metrics.total_count > 0"
with:
owner:
from: $.input.owner
repo:
from: $.input.repo
issue_number:
from: $.steps.triage.output.data.recommended_issue_number
body:
from: $.steps.triage.output.data.comment_body
| Saga-local field | Role |
|---|---|
id | Stable id in saga context, CEL, and bindings (steps.triage, …) |
use / version | Pin a step definition |
with | Bind values into the step's declared inputs |
when | Optional CEL skip — see Conditional branching |
| Tighten-only overrides | Narrow catalog timeout_seconds, max_turns, HITL, token caps |
Deploy workers → steps → sagas. See Step manifests for capability authoring and Manifests and artifacts → Deploy order.
Even though individual steps can be skipped, paused, or failed by human reviews and conditional logic, the hydrated structure of your workflow stays stable: forward order, which worker handles which step, and the rule that commit steps handle exactly one tool call. The sections below document that runtime shape.
Step kinds
Every hydrated step is either reason or commit (from the catalog step_kind). Each needs a unique id and a name (from the saga ref or the step definition title):
| Field | Role |
|---|---|
id | Stable identifier in saga context, CEL, and with bindings (steps.triage, $.steps.triage.output…) |
name | Human-readable label for operators and logs |
A reason step sends work to an LLM-backed worker to produce structured JSON output. By default, this uses agent-adapter: react, which lets the agent loop through multiple tool calls until it achieves its goal and invokes the built-in _submit tool. If you don't need a tool loop and just want a single, direct response from the model, set agent-adapter: simple instead on the step manifest. See Reason step execution (agent-adapter). Requires worker, worker_version, and prompt on the catalog step.
A commit step makes one deterministic MCP call with no LLM loop. Use it for side effects — posting a comment, triggering a webhook, writing a record. It requires worker and worker_version only (no prompt) on the catalog step.
After start hydrate, the instance frozen_steps look like inline reason/commit fields (illustrative — do not author this as saga YAML):
# Instance frozen_steps after start hydrate — not saga definition YAML
steps:
- id: triage
name: Triage open issues
kind: reason
worker: github-demo-worker
worker_version: "0.1.0"
prompt: github-triage.j2
- id: post-comment
name: Post triage comment
kind: commit
worker: github-demo-worker
worker_version: "0.1.0"
Reason step execution (agent-adapter)
Reason steps choose how the worker completes the step — separate from the worker manifest's adapter field (usually langchain).
| YAML field | Where | Meaning |
|---|---|---|
adapter: langchain | Worker manifest | Which agent runtime implementation the worker loads |
agent-adapter: react | simple | Reason step in step manifest | Execution strategy inside that port |
Decision matrix
| Use case | Step kind | agent-adapter | tools.allow | How output is produced |
|---|---|---|---|---|
| Tool-heavy agents (GitHub demo) | reason | react (default) | MCP tool ids | ReAct loop → virtual _submit |
| Connectivity / single-turn transforms (Quickstart) | reason | simple | must be [] | Single structured LLM turn → JSON |
| Deterministic side effect | commit | — | exactly one tool | MCP only, no LLM |
react (default)
Multi-turn ReAct loop. The worker binds MCP tools from tools.allow plus a virtual _submit tool (never list _submit in the allowlist). The model calls tools until it invokes _submit with a non-empty JSON object. Optional output_schema validates that payload; without it, any non-empty JSON shape is accepted.
_submit must be alone in its turn. If the model batches _submit with other tools, Warden runs the non-submit tools, returns a tool-role error for _submit (must be the only tool call), and continues the loop so the model can observe those results before submitting.
Warden admits sloppy LLM JSON against MCP tool inputSchema values and against _submit output_schema (stringified arrays/objects, scalar strings to numbers/booleans) before strict validation. See Configuration → LLM JSON admission.
simple
Single structured LLM completion — no ReAct loop, no virtual _submit, no MCP tools. Warden rejects the deploy if you set agent-adapter: simple with non-empty tools.allow, resources.allow, or facts:.
When output_schema is omitted, the worker applies a built-in fallback schema requiring a summary string (steps.<id>.output.data.summary). Set output_schema when downstream bindings need stable field names. Structured payloads also go through LLM JSON admission before validation.
# config/step.minimal-step1.yaml — live inference smoke test (catalog)
kind: step
name: minimal-step1
version: "0.0.1"
step_kind: reason
agent-adapter: simple
worker: minimal-worker
worker_version: "1.0.0"
prompt: noop.j2
tools:
allow: []
Contrast with the GitHub triage step (default react, tools + _submit):
# config/step.github-triage.yaml (excerpt)
kind: step
name: github-triage
step_kind: reason
# agent-adapter: react # default — omit in YAML
worker: github-demo-worker
worker_version: "0.1.0"
prompt: github-triage.j2
output_schema: github-triage-output.json
tools:
allow:
- name: list_issues
- name: issue_read
Failure codes by strategy
agent-adapter | Typical error_details | Meaning |
|---|---|---|
react | no_submit_call | Model finished with text only — no _submit (after optional soft text-exit retry; see WARDEN_REACT_SUBMIT_TEXT_RETRIES) |
react | empty_submit_result | _submit called with {} |
simple | structured_output_failed | Model response was not parseable JSON (common on weak local models) |
simple | empty_structured_result | Parsed JSON object was empty |
| either | STEP_TOKEN_LIMIT_EXCEEDED | Accumulated provider total_tokens exceeded max_step_tokens (or WARDEN_MAX_STEP_TOKENS) |
| either | validation: output_schema / OUTPUT_SCHEMA_VALIDATION_FAILED | Payload failed JSON Schema |
Optional context fields on error_details (CLI warden list steps --errors / warden show step):
| Field | When present |
|---|---|
reason, turns_used, last_assistant_content | no_submit_call when the model exited with prose instead of _submit (reason: model_text_exit) |
reason, turns_used, last_tool_errors | no_submit_call when tool output matched MCP failure heuristics (e.g. MCP error: …) |
tokens_used, max_step_tokens, prompt_tokens, completion_tokens | STEP_TOKEN_LIMIT_EXCEEDED |
tool_result_preview | FACT_EXTRACTION_FAILED / TOOL_RESULT_TRUNCATED when tool text explains the failure |
truncation_limit | TOOL_RESULT_TRUNCATED when JSON was cut at the worker record limit |
response_preview | structured_output_failed on simple steps |
message | All normalized failures — human-readable summary |
Persistence and redeploy
When you start a saga, Warden saves the chosen agent_adapter on each step row. Deploy a new manifest version and start a fresh run to pick up a different strategy — running sagas keep what they started with.
Connecting workers to steps
Each step definition points at a worker with worker (name) and worker_version. Steps don't declare their own namespace — Warden uses the parent saga's namespace to look up both the catalog step and (namespace, worker, worker_version). Cross-namespace references fail when you deploy. Deploy workers before steps before sagas: Manifests and artifacts → Deploy order matters.
Later sections describe runtime behavior of those fields. Author capability on step manifests; author with / when / tighten-only on this page's composition model. Worker manifests define LLM and MCP config — see Worker manifests.
Tool allowlists
tools.allow lists the MCP tools a step may call. Author it on the step manifest; after start hydrate it is frozen on the instance. The worker rejects any tool not on the list.
| Step kind | Allowlist rule |
|---|---|
reason | Zero or more tools — list every tool the agent is allowed to use |
commit | Exactly one tool — the side effect to execute |
The worker manifest declares which MCP servers exist (tool_sources on the worker definition). The step definition declares what this step may use. When the step runs, the worker connects to those sources, discovers tool ids from the MCP server, and loads only the names in tools.allow. Execution strategy (react vs simple) is set per reason step — see Reason step execution (agent-adapter).
On a commit step, there is no agent loop. The worker calls the one allowed tool directly, using arguments from the saga ref's with bindings.
Names must match MCP tool ids exactly. If a listed tool is not exposed by the connected server, the step fails at runtime — Warden does not probe MCP connectivity when you deploy. See Worker manifests for tool_sources and MCP and tools for the full execution model.
# config/step.github-triage.yaml (excerpt)
kind: step
name: github-triage
step_kind: reason
worker: github-demo-worker
worker_version: "0.1.0"
prompt: github-triage.j2
tools:
allow:
- name: list_issues
- name: issue_read
# config/step.github-post-comment.yaml (excerpt)
kind: step
name: github-post-comment
step_kind: commit
worker: github-demo-worker
worker_version: "0.1.0"
tools:
allow:
- name: add_issue_comment
Resource allowlists (resources.allow)
Optional on react reason step manifests when the agent needs read-only MCP context (policy text, profile records) before or during tool calls. Incompatible with agent-adapter: simple. Commit steps have no ReAct loop, so resources.allow is rarely useful there.
Each entry under resources.allow requires a uri (and optional description). URI templates may include {placeholders} — the worker binds each placeholder to a resolved with value when the agent calls the virtual read_resource tool. Do not list read_resource in tools.allow; the worker injects it when this block is non-empty.
# Capability on the step; `with` stays on the saga `use:` ref
kind: step
name: review-risk
step_kind: reason
worker: risk-worker
worker_version: "1.0.0"
prompt: review.j2
inputs:
customer_id:
required: true
resources:
allow:
- uri: "file:///policies/fraud-v3.md"
- uri: "postgres://risk/profiles/{customer_id}"
tools:
allow:
- name: score_transaction
The worker must have MCP tool_sources — resource reads go through connected MCP servers, not arbitrary filesystem paths on the engine host. For traversal rules, parameterized URI matching, and how read_resource fits the ReAct loop, see MCP and tools → Resource allowlists.
Bindings (with)
Add a with block on the saga use: ref when a step needs data from the saga's start input or from steps that already finished. Keys must match the catalog step's declared inputs. Resolved values are used differently per step kind — see the table below.
Warden resolves your with blocks right before it kicks off the step. It uses standard JSONPath syntax ($.…) to grab data from the saga's initial input or any outputs saved by earlier steps, and passes that combined context into the new step. Use from for JSONPath lookups, or value for a literal.
| Step kind | How resolved with is used |
|---|---|
reason (react) | Every key renders the Jinja prompt. Optionally list a subset under tools.bind on the step manifest to also pin those values onto matching MCP tool args (saga wins; keys are stripped from the LLM-facing tool schema). |
reason (simple) | Prompt variables only. tools.bind is rejected. |
| commit | The full map becomes the single MCP tool's kwargs. Do not set tools.bind. |
| compensation | Same as commit for the undo tool. Do not set tools.bind. |
# Saga composition — config/saga.github-demo.yaml (excerpt)
steps:
- id: triage
use: github-triage
version: "0.1.0"
with:
owner:
from: $.input.owner
repo:
from: $.input.repo
- id: post-comment
use: github-post-comment
version: "0.1.0"
with:
body:
from: $.steps.triage.output.data.comment_body
# owner, repo, issue_number — see full demo saga
tools.bind example (capability on the step; values from the saga with):
# Step catalog — pin which with-keys overlay MCP args
kind: step
name: run-in-sandbox
step_kind: reason
worker: sandbox-worker
worker_version: "1.0.0"
prompt: sandbox-exec.j2
inputs:
container_id:
required: true
problem_statement:
required: true
tools:
bind:
- container_id
allow:
- name: sandbox_exec
# Saga ref — supply those ports
- id: run-in-sandbox
use: run-in-sandbox
version: "1.0.0"
with:
container_id:
from: $.steps.init-sandbox.output.data.container_id
problem_statement:
from: $.input.problem_statement
Pinning MCP args on reason steps (tools.bind)
On react reason steps, the model normally chooses every MCP argument. Session keys such as container_id often get garbled when the LLM re-emits them. Declare those keys under tools.bind (each name must also appear in with):
- Warden overlays the resolved saga value onto the tool invoke for any key present in that tool's
inputSchema.properties(saga always wins, including over empty/junk model args). - Bound properties are omitted from the LLM-facing
args_schema/required, so the model is not asked to invent them. - Keys that are not on a given tool's schema stay prompt-only (no auto-intersect of all
withkeys).
tools.bind is not supported on agent-adapter: simple, commit steps, or compensation undo YAML.
What you can fetch
Warden evaluates bindings right before the step runs, against the saga's context object. JSONPath expressions must start with $.
| Source | JSONPath | What you get |
|---|---|---|
| Saga start input | $.input.<field> | A field from the JSON passed to warden start saga --input |
| Prior step result | $.steps.<step_id>.output.data.<field> | Structured JSON from a completed step — reason steps (react or simple), MCP JSON on commit steps |
| Prior step tool facts | $.steps.<step_id>.facts.<into>.<field> | A value extracted from an MCP tool result on an earlier reason step — only when that step declared facts: and the tool ran. See Tool facts for how tool, into, and fields are declared. |
| Literal | value: <any> | A fixed value — no JSONPath lookup |
Reason → commit boundary
When a reason step finishes, the worker sends a STEP_COMPLETED envelope back to Warden — typically { "data": { … }, "facts": { … }? }. Warden validates output_schema against the inner data object, runs after_reason policy if you configured one, then saves:
| Stored where | Contents |
|---|---|
saga_step_instances.output_payload | Normalized envelope (data + optional facts) |
saga.context.steps.<step_id> | Same shape: output.data and facts for JSONPath / with bindings |
The ReAct message history (system, human, assistant, tool turns) is not copied into saga context. Downstream steps — including commit — only see what you bind explicitly from output, facts, or input.
When a commit step is about to run, Warden resolves its with block against the current saga context before dispatching the MCP call. That flat map becomes:
saga_step_instances.resolved_argumentson the commit step rowargumentson the worker command (single MCP tool invoke — no LLM loop)
- id: post-comment
use: github-post-comment
version: "0.1.0"
with:
body:
from: $.steps.triage.output.data.comment_body # structured reason output only
Custom AgentAdapterPort implementations must emit the same envelope on success; Warden never forwards raw adapter-internal state across steps. If you need a field at commit time, expose it in output.data or facts, then bind it in with.
Examples using triage → post-comment:
with:
owner:
from: $.input.owner
summary:
from: $.steps.triage.output.data.summary
open_count:
from: $.steps.triage.facts.triage_metrics.total_count
priority:
value: high
Only bind from steps that have already finished in forward order. When a saga starts, Warden pre-initializes every step id with empty output.data and facts, so a path to a future or incomplete step resolves to {} or null. Tool fact paths stay absent until the extractor's tool actually ran — use when.cel with has(...) for optional branches instead of relying on bindings alone. To populate facts buckets on a reason step, see Tool facts.
Once resolved, with values feed the step at run time. On commit steps they become MCP tool arguments. On reason steps they render the Jinja prompt; with tools.bind on react, selected keys also pin MCP args — see Prompts for how bindings become template variables and what Warden checks when you deploy.
Saga context is append-only per step id. When a step completes, Warden merges its output under steps.<step_id> — it does not mutate other steps' buckets. A later step cannot change what an earlier step stored.
Policies
Add policy: <path> on the step definition — a path relative to POLICIES_ROOT with extension (e.g. github-issue-comment.yaml or teams/marketing/gate.yaml). Warden loads and validates the file when you deploy the step, then evaluates it at the gate. See Policies for file format, CEL binding, phases, and outcomes.
# config/step.github-post-comment.yaml (excerpt)
kind: step
name: github-post-comment
step_kind: commit
worker: github-demo-worker
worker_version: "0.1.0"
policy: github-issue-comment.yaml
tools:
allow:
- name: add_issue_comment
Walkthrough with before_commit CEL and HITL: GitHub MCP demo.
If a policy denies a step or evaluation errors, Warden marks the step FAILED and stops forward progress. Depending on how far the saga got, it may flip to COMPENSATING and run your declared undo steps backward (LIFO). See Compensation.
Human-in-the-Loop (HITL)
Add hitl: true on the step definition to pause for operator review (sagas may tighten HITL further, never clear catalog hitl: true). Warden sets the saga to AWAITING_HUMAN until someone approves, rejects, or retries. If the step also has a policy, the policy gate runs first — HITL only applies when the policy passes. See Policies.
The hold point depends on step kind:
| Step kind | When the pause happens | What the reviewer sees |
|---|---|---|
reason | After the worker returns structured output | The validated reason-step payload in output.data (editable on approve) |
commit | Before the MCP tool is called | The resolved with arguments — the side effect has not run yet |
On post-comment:
# config/step.github-post-comment.yaml (excerpt)
kind: step
name: github-post-comment
step_kind: commit
worker: github-demo-worker
worker_version: "0.1.0"
hitl: true
# optional: hitl_max_retries / hitl_retry_guidance on the step or as tighten-only on the saga ref
tools:
allow:
- name: add_issue_comment
# Saga — wiring only
- id: post-comment
use: github-post-comment
version: "0.1.0"
with:
body:
from: $.steps.triage.output.data.comment_body
Optional retry limits while the step is held (on the step, or tighten-only on the saga use: ref):
hitl_max_retries: 2
hitl_retry_guidance: "Tighten the comment and cite the issue number."
hitl_max_retries caps how many times an operator may call warden review retry (omit for unlimited). hitl_retry_guidance is default text merged into the worker run as _hitl_retry.guidance; per-request --guidance on the CLI overrides it.
Operator actions (via warden review or the human-gate HTTP API):
| Action | Effect |
|---|---|
| Approve | Saga resumes — context merges on reason steps; commit tool dispatches on commit steps |
| Reject | Step fails; Warden runs compensation on completed forward steps (LIFO) |
| Retry | Re-runs the worker/LLM while still AWAITING_HUMAN (reason steps only in practice; respects hitl_max_retries) |
There is no built-in reviewer UI — the kernel exposes CLI and HTTP only. For commands, API paths, and async outbox behavior, see HITL review. End-to-end example: GitHub MCP demo.
Step budgets
reason steps accept independent caps:
| Field | Applies to | Default | Meaning |
|---|---|---|---|
max_turns | react only | 25 (max 200) | Cap on back-and-forth tool/LLM rounds. simple ignores it (always one LLM call). |
max_step_tokens | react and simple | unlimited (omit / null) | Financial guardrail: abort when accumulated provider-reported total_tokens (prompt + completion across the step) exceed this budget. |
max_completion_tokens | react and simple | no Warden override (omit / null) | Per-call generation cap passed to the provider as max_tokens. Distinct from max_step_tokens. |
max_step_tokens counts gross physical tokens from the provider usage metadata — not cache-discounted billed tokens. Prompt caching can make the invoice much smaller than the counted total; the budget still uses the raw counter. Token budgets apply to reason steps only; compensation undo is a single MCP call and does not use LLM budgets.
max_completion_tokens limits how much the model may generate on each LLM call (every ReAct turn shares the same cap). Omit it to leave the provider default (Anthropic via LangChain may still default to 8192).
Optional process-wide fallbacks: set worker env WARDEN_MAX_STEP_TOKENS / WARDEN_MAX_COMPLETION_TOKENS (see Configuration). Each applies only when the step omits the matching field. Unset or 0 means no fallback.
When the step budget is exceeded, the step fails with error_details.code: STEP_TOKEN_LIMIT_EXCEEDED (includes tokens_used, max_step_tokens, prompt_tokens, completion_tokens). Usage from completed LLM turns is still written to execution_usage on STEP_FAILED.
timeout_seconds is a safety clock for step execution (default 600 seconds). If a worker claims a step and then crashes or hangs, Warden waits for this window to expire, marks the step FAILED, and can trigger compensation — it won't auto-retry a stuck step. See Saga recovery for how the open kernel vs enterprise handle timeouts and stale claims.
On triage (catalog budgets; saga refs may only tighten these):
# config/step.github-triage.yaml (excerpt)
kind: step
name: github-triage
step_kind: reason
worker: github-demo-worker
worker_version: "0.1.0"
prompt: github-triage.j2
max_turns: 15
timeout_seconds: 600
# optional: max_step_tokens / max_completion_tokens
tools:
allow:
- name: list_issues
- name: issue_read
Compensation undo is always a single deterministic MCP call (exactly one tools.allow entry). See Compensation.
Structured output (output_schema)
Reason steps can require a fixed JSON shape for worker output in output.data. Set output_schema to the schema filename (relative to SCHEMAS_ROOT) — a JSON Schema .json file at config/<schema-file>.json.
agent-adapter | What gets validated |
|---|---|
react | _submit payload after the ReAct loop |
simple | Structured completion from the single LLM turn |
Supported schema subset
Warden binds output_schema for simple structured output via a Pydantic subset, then validates with JSON Schema for both adapters. Supported structural pieces:
type:object,string,integer,number,boolean,array, and nullable unions like["string","null"]properties,required,items,description- Validation-only constraints such as
minLength,enum,minimum additionalProperties: false— also applied at bind (extra="forbid"on the Pydantic model forsimple/_submit); omit it or settrueand bind allows extra keys (JSON Schema gate still follows the keyword)
Rejected at deploy / saga start (silent no-ops at the structured-output binder): if, then, else, allOf, anyOf, oneOf, $ref, $defs, definitions. Flatten conditionals into always-required fields or split steps; inline $ref targets.
On triage (react):
# config/step.github-triage.yaml (excerpt)
kind: step
name: github-triage
step_kind: reason
worker: github-demo-worker
worker_version: "0.1.0"
prompt: github-triage.j2
output_schema: github-triage-output.json
tools:
allow:
- name: list_issues
- name: issue_read
When you start a saga, Warden resolves config/<schema-file>.json, freezes the object onto instance frozen_steps as output_schema_definition, and copies it onto the step row output_schema. Loop mint and materialize read that embed. The worker validates output before sending STEP_COMPLETED. Warden validates again when it ingests that completion event.
Without output_schema: react still requires a non-empty _submit payload (any JSON shape); simple uses the built-in fallback requiring summary.
Omitting output_schema is fine for single-step smoke tests on simple. For anything that chains steps, treat a schema as the contract between the agent and saga context.
Downstream steps read prior results through paths like $.steps.triage.output.data.comment_body in with bindings, when.cel, and policy gates. HITL on a reason step exposes the same structured output.data object for review. If field names or types drift between runs, those bindings resolve to {} or null, or fail when the step schedules. A JSON Schema file fixes the shape Warden validates before the output lands in context.
Use tool facts when you need structured data from MCP tool JSON instead of from reason-step output (react only).
| Outcome | react | simple |
|---|---|---|
| Valid structured JSON | Proceeds (policy, optional HITL, context merge) | Same |
| Missing / empty output | no_submit_call / empty_submit_result | structured_output_failed / empty_structured_result |
| Schema mismatch | Soft-retry with validation feedback (WARDEN_LLM_SCHEMA_RETRY_*), then STEP_FAILED if still invalid | Same |
max_turns bounds ReAct tool/LLM iterations on react only. Schema soft-retries (WARDEN_LLM_SCHEMA_RETRY_*) are a separate attempt count, but on react each retry still consumes remaining max_turns from the same step budget — see Configuration → LLM schema soft-retries.
Commit steps can also attach output_schema for tool result validation.
Conditional steps (when)
Optional when.cel on a forward saga use: ref runs before Warden schedules the step. false skips the step; a runtime evaluation error fails it with WHEN_EVALUATION_FAILED. Syntax, CEL bindings, examples, and troubleshooting: Conditional branching (when.cel).
- id: post-comment
use: github-post-comment
version: "0.1.0"
when:
cel: "has(steps.triage.facts.triage_metrics) && steps.triage.facts.triage_metrics.total_count > 0"
Policy gates use a different CEL binding (no steps root) — see Policies.
Tool facts (facts)
On react reason steps only (agent-adapter: simple rejects facts: when you deploy), facts copies selected values out of MCP tool JSON into saga context after the ReAct loop finishes. Extraction uses whatever the agent actually called during the step — not the structured reason-step output in output.data. The same full tool payload is stored in tool_results and sent to the LLM transcript at record time — see MCP and tools → Tool payload hygiene.
Each extractor has three parts:
| Key | What it is | What it does |
|---|---|---|
tool | MCP tool id | Which tool call to read from. Must be the original MCP tool id (not the sanitized LLM wire name) and match a call the agent made during this step. If the agent never called that tool, this extractor is skipped entirely. tools.allow may list either the raw MCP id or its sanitized form. |
into | Bucket name you choose | Groups the extracted fields under steps.<step_id>.facts.<into>. Use a short, stable id (e.g. triage_metrics) — this is your saga-context name, not the MCP tool name. |
fields | Map of saga key → JSONPath | For each entry, the left key is the name you use in when.cel and with (total_count). The right value is JSONPath into the tool's JSON response ($.totalCount). |
MCP tool JSON (list_issues) Manifest facts block Saga context fragment
──────────────────────────── ──────────────────────── ───────────────────────────────────
{ - tool: list_issues steps.triage.facts.triage_metrics
"totalCount": 3, into: triage_metrics .total_count = 3
"issues": [...] fields:
} total_count: "$.totalCount"
Walkthrough for triage (facts on the step catalog):
# config/step.github-triage.yaml (excerpt)
facts:
- tool: list_issues
into: triage_metrics
fields:
total_count: "$.totalCount"
- During
triage, the agent calls thelist_issuesMCP tool (allowed intools.allow). - The tool returns JSON — for example
{"totalCount": 3, "issues": [...]}. - The worker runs
$.totalCounton that JSON and stores the result astotal_count. - Saga context gets
steps.triage.facts.triage_metrics.total_count == 3. - A later step can gate on it:
when.cel: "has(steps.triage.facts.triage_metrics) && steps.triage.facts.triage_metrics.total_count > 0".
MCP tool response (list_issues return value):
{
"totalCount": 3,
"issues": [
{"number": 1, "title": "Example issue"}
]
}
Saga context fragment after extraction (simplified — output.data holds the reason-step _submit payload separately):
{
"steps": {
"triage": {
"output": { "data": { "summary": "..." } },
"facts": {
"triage_metrics": {
"total_count": 3
}
}
}
}
}
Use steps.triage.facts.triage_metrics.total_count in when.cel and with.from — not totalCount (raw tool JSON) or list_issues (tool id).
On triage (full step context):
# config/step.github-triage.yaml (excerpt)
kind: step
name: github-triage
step_kind: reason
worker: github-demo-worker
worker_version: "0.1.0"
prompt: github-triage.j2
tools:
allow:
- name: list_issues
- name: issue_read
facts:
- tool: list_issues
into: triage_metrics
fields:
total_count: "$.totalCount"
tool: list_issues must match the MCP tool id. into: triage_metrics is your facts bucket in saga context — they need not match. In fields, the JSONPath reads the provider's JSON ($.totalCount); the field key is what you write in saga context (total_count). Use steps.triage.facts.triage_metrics.total_count in when and with, not totalCount or list_issues.
| Behavior | Result |
|---|---|
| Tool never called | into bucket omitted — no entry under steps.<id>.facts.<into> |
| Tool called, JSONPath matches | Values stored at steps.<id>.facts.<into>.<field> |
If the tool runs but your JSONPath doesn't match anything in the response, Warden fails the step with FACT_EXTRACTION_FAILED.
End-to-end example: GitHub MCP demo.
What's next
Every reasoning step needs a prompt template file to guide the agent. Head over to Prompts to write Jinja templates and map your with blocks into template variables.
Related
- Step manifests — catalog capability fields (
tools,policy,facts, …) - MCP and tools — tool vs resource allowlists,
read_resource, transport - Worker manifests
- Prompts
- Compensation
- Policies