Executors
Executors are what happen when a transition fires. You declare them in YAML. The gateway handles the wiring.
Executor kinds
Section titled “Executor kinds”| Kind | Purpose |
|---|---|
noop | Returns immediately. Great for stubs and testing. |
cli | Runs a shell command, captures stdout. |
rest | Makes an HTTP request. |
mcp | Calls a tool on a connected MCP server. |
human | Queues for human action, emits an audit event. |
workflow | Starts a sub-workflow (internal). |
script | Executes a curated, hash-pinned script from the scripts: library by subject. Safe for deterministic transitions. See Script verbs. |
parallel | Fans out N executor branches inside a single transition and aggregates results. Branches can be any executor kind (including nested parallel). See Parallel execution. |
pipeline | Runs a sequence of executor steps inside a single transition, threading each step’s output into the next. Like parallel, it’s registry-backed and its steps can be any executor kind. |
llm | Hosts a governed LLM call inside the runtime. The transitions available at the current state become the model’s tool list; the model picks one and the runtime advances. SPEC §33. |
agent | A feature-gated LLM overlay — a sibling to llm that the binary wires in when the llm-executor feature is built. |
A handful of authoring-time kinds (registry, dry_run, structural_analysis, ingest) also exist for definition management and analysis rather than ordinary transition work.
You can also reference a named capability instead of declaring an executor inline:
executor: capability: github.list_issuesThis resolves at config-load time to the named capability’s executor and merges its guards and reliability into the calling context.
Returns immediately with no side effects. The output is always an empty object {} — the input is not echoed back.
executor: kind: noopUse it for:
- Stubbing out capabilities while you build the workflow
- Testing the gateway without real backends
- Transitions that only need to move state (no external call)
Runs a shell command through a CLI connection. Captures stdout as the executor’s output.
executor: kind: cli connection: dotnet args: - test - "$.arguments.project"The connection references a named CLI connection from your config. The args array supports path expressions that resolve against the workflow context, input, and arguments.
Inline command
Section titled “Inline command”If you don’t need a named connection, you can specify the command directly:
executor: kind: cli command: lint-check args: ["$.input.service"]Output
Section titled “Output”The executor builds a structured output object. Its shape is:
| Path | Type | Value |
|---|---|---|
$.output.exitCode | number / null | The process exit code. |
$.output.success | boolean | true when the process exited zero. |
$.output.stdout | string | Raw captured stdout. |
$.output.stderr | string | Raw captured stderr. |
$.output.json | any / null | Parsed stdout when it’s valid JSON; null when stdout isn’t JSON. |
So the raw text is always at $.output.stdout; if stdout parses cleanly as JSON, the parsed value is also available at $.output.json.*.
Reliability
Section titled “Reliability”CLI executors benefit most from reliability policies. Network-free commands can still hang or flake:
executor: kind: cli connection: dotnet args: [test]reliability: timeoutMs: 120000 retry: maxAttempts: 3 backoff: exponential initialDelayMs: 1000 maxDelayMs: 10000 retryOn: [timeout, transient_error] fallback: strategy: first_success executors: - kind: cli command: dotnet args: [test, --no-build]That config says: try the test command, retry up to 3 times with exponential backoff if it times out or fails transiently, and if all retries fail, try a fallback command.
Makes an HTTP request through a REST connection.
executor: kind: rest connection: payroll method: POST path: /reimbursements body: employee: "$.workflow.input.employee" amount: "$.workflow.input.amount" currency: "$.workflow.input.currency"Connection
Section titled “Connection”The connection references a named REST connection, which provides the base URL and default headers:
connections: payroll: kind: rest baseUrl: https://payroll.example.com headers: Authorization: "Bearer ${PAYROLL_TOKEN}"URL templating
Section titled “URL templating”The path supports variable interpolation from workflow context:
executor: kind: rest connection: docs method: PUT path: "/documents/{documentId}" body: content: "$.arguments.revisedDraft"Path variables in {braces} are resolved from the workflow context.
Headers
Section titled “Headers”Per-request headers can be added alongside the connection’s default headers:
executor: kind: rest connection: api method: POST path: /submit headers: X-Request-Id: "$.context.correlationId" body: data: "$.arguments.payload"The body object supports path expressions. Each value is resolved at execution time:
| Path prefix | Resolves to |
|---|---|
$.workflow.input.* | The input passed when the workflow was started |
$.context.* | The workflow’s accumulated context |
$.arguments.* | The arguments passed to the current transition |
Idempotency
Section titled “Idempotency”For retried requests, set idempotencyKey to prevent duplicate side effects:
executor: kind: rest connection: payroll method: POST path: /reimbursements body: employee: "$.workflow.input.employee" amount: "$.workflow.input.amount" idempotencyKey: truereliability: retry: maxAttempts: 3 backoff: exponential initialDelayMs: 1000 retryOn: [transient_error, timeout, rate_limited, connection_error]When idempotencyKey is true, the gateway auto-derives a key from workflowId + transition + correlationId. Same key across retries, so your backend can deduplicate. You can also provide a custom template string:
idempotencyKey: "{workflowId}-{transition}-{correlationId}"Calls a tool on a connected MCP server.
executor: kind: mcp connection: github tool: list_issues map: repo: "$.arguments.repo"Connection
Section titled “Connection”References a named MCP connection (stdio or SSE):
connections: github: kind: mcp command: github-mcp-serverTool and argument mapping
Section titled “Tool and argument mapping”The tool field is the tool name on the remote MCP server. The map object maps workflow data to the tool’s expected arguments:
executor: kind: mcp connection: planner tool: normalize_plan map: goal: "$.workflow.input.goal" plan: "$.arguments.plan"Each key in map becomes an argument to the remote tool. Values are path expressions resolved at execution time.
Output
Section titled “Output”The remote tool’s response is available in output mappings at $.output.*:
executor: kind: mcp connection: risk tool: fmeca_analyze map: plan: "$.context.plan"output: fmeca: "$.output" maxRpn: "$.output.maxResidualRpn"Queues a transition for human action. The executor itself succeeds immediately: it records a human.approval.requested audit event and returns output with status "queued", then the transition completes. The actual blocking is the job of the actor: human gate (see “Combining with actor gates” below), which stops the model from submitting the approving transition — a human watching the queue makes the actual decision and submits it.
executor: kind: human queue: engineering-approvalsThe queue is a logical name. It shows up in audit events so your approval system knows where to route the request.
How it works
Section titled “How it works”- The model calls
flowgate.command({workflowId, transition, expectedVersion})(submit) with ahumanexecutor transition. - The gateway records a
human.approval.requestedaudit event with the queue name and a generated request ID. - The executor returns success (output status
"queued"); the transition fires and advances state as configured. - A human uses a separate interface (your approval tool, a dashboard, a Slack bot) to review and submit the actual approving transition — which is the one gated by
actor: human.
Combining with actor gates
Section titled “Combining with actor gates”The human executor records the request and emits the audit event. The actor: human gate is what actually stops the model — it blocks the model from submitting the approving transition, so only a human principal can. Use both together: the executor routes the request to a queue, the gate enforces that a human is the one who resolves it.
transitions: approve: title: Approve the change actor: human # only humans can submit this target: approved guards: - { kind: permission, permission: workflow.approve } executor: kind: human # and the action itself waits for a human queue: approvalsHosts a governed LLM call inside the runtime. The transitions available at the current workflow state become the model’s tool list — the model picks exactly one per turn and the runtime advances the workflow accordingly. SPEC §33 has the full design; this section is the operator-facing reference.
executor: kind: llm model: anthropic:claude-sonnet-4-6 prompt_template: | Triage this issue. The body is in $.blackboard.issue_body. Pick exactly one transition.
Issue body: {{ blackboard.issue_body }} max_iterations: 3 max_seconds: 60 max_tokens: 2000 max_cost_usd: 0.25A worked example sits at examples/issue_triager.yaml — one workflow with three LLM-driven triage transitions routing to bug / feature / noise outcomes.
Config schema
Section titled “Config schema”The fields live in LlmExecutorConfig (crates/mcp-flowgate-llm-executor/src/config.rs). deny_unknown_fields is set, so typos and forbidden fields (notably tools:) fail at parse time rather than silently dropping.
| Field | Required | Purpose |
|---|---|---|
model | One of model / affinity | Direct provider:model string. The cost catalog is keyed by this exact name. |
affinity | One of model / affinity | An affinity label resolved to a concrete provider:model-id against the models file. When the gateway config sets gateway.models_yaml: <path>, the binary loads it and wires the resolver into the LLM executor; the label is resolved per turn. If no resolver is wired (the key is absent or the file fails to load), affinity: configs fail loud per request rather than silently falling back. |
prompt_template | yes | Rendered against {$.blackboard, $.context, $.input} per the existing template engine. |
max_iterations | no (default 3) | Per-turn retry budget for malformed responses (FMECA F1). The reliability layer does not auto-retry LLM_NO_TOOL_CALL; this is the only retry budget. |
max_seconds | no | Per-turn wall-clock cap. |
max_tokens | no | Per-turn token cap. |
max_cost_usd | no | Per-workflow cumulative cost cap. Requires the model to be in the cost catalog — see “Cost catalog” below. |
reasoning_effort | no | Provider-specific hint (low / medium / high / xhigh) passed through to aether_llm::ReasoningEffort where supported. |
capture_reasoning | no (default true) | When false, the audit event records reasoning as the literal sentinel "<elided>" instead of the captured text. Privacy / compliance opt-out per workflow. |
The tools: field is not accepted. The per-turn tool list is the workflow’s available transitions; injecting an arbitrary tool list would let workflow authors bypass governance. Adding it produces a deserialization error naming the field. (FMECA F3.)
llm.invocation audit event
Section titled “llm.invocation audit event”Every turn emits one llm.invocation event regardless of outcome. The payload field list is part of the public contract — downstream sinks rely on these names:
| Field | Type | Description |
|---|---|---|
event_type | string | Always "llm.invocation". |
workflow_id | string | Owning workflow instance ID. |
state | string | State the executor ran from. |
model | string | The resolved model name. |
tokens_in | number / null | Input tokens reported by the provider. |
tokens_out | number / null | Output tokens reported by the provider. |
tokens_reasoning | number / null | Reasoning tokens (where the provider distinguishes them). |
latency_ms | number | Wall-clock latency from invocation to drained response. |
cost_usd | number / null | Derived from the cost catalog. Null when the model isn’t in the catalog (and no max_cost_usd cap was set, so doctor allowed load). |
usage_present | boolean | false flags turns where the provider returned no usage block — ops graph this to spot provider drift. |
stop_reason | string / null | Provider-supplied stop reason (end_turn, max_tokens, etc.). |
tool_call_emitted | string / null | The transition name the model chose, or null on the FMECA F1 / F2 paths. |
error_code | string / null | One of the LLM_* codes when the turn failed; null on success. |
reasoning | string / absent | Captured reasoning text, or the sentinel "<elided>" when capture_reasoning: false. Absent when no reasoning was emitted. |
correlation_id | string / absent | Threaded through from the runtime when present. |
FMECA mitigations
Section titled “FMECA mitigations”The executor was designed against an FMECA pass. Each mitigation is enforced at the layer named below; they’re listed here so operators can map an in-the-wild failure to its containment.
| Code | Failure | Mitigation |
|---|---|---|
| F1 | Model loops without ever picking a transition | max_iterations cap per turn; LLM_EXECUTION_EXHAUSTED surfaces when exhausted. max_seconds covers session-level runaway. |
| F2 | Provider returns a malformed / partial response | Response drainer validates structure; partials don’t get dropped on the floor — they surface as typed errors. |
| F3 | Workflow author tries to inject extra tools (tools: [...]) | deny_unknown_fields rejects at config parse; an early-pass check in execute() also catches a tools key that slipped past the parse layer. |
| F5 | Adversarial response shapes go untested | Mock provider’s scenario catalog covers every documented failure variant; an integration test pins “every enum variant has a scenario.” |
| F6 | Provider returns no usage block but a max_cost_usd cap is set | Turn fails with LLM_USAGE_MISSING; cost cap stays meaningful rather than silently bypassed. |
| F7 | Two transitions share the same rel so the model’s tool name is ambiguous | Duplicate-rel check fires before the provider call; the turn fails fast with a clear error. |
| F8 | Operator sets max_cost_usd on a model not in the cost catalog | Doctor rejects at workflow load with COST_CATALOG_MISSING_ENTRY / COST_CATALOG_STALE. See “Cost catalog” below. |
Caps and the synthetic _llm.* blackboard slots
Section titled “Caps and the synthetic _llm.* blackboard slots”Cumulative caps (cross-turn cost, total iterations, consecutive no-tool-call streaks) are tracked through reserved blackboard slots under the _llm. prefix. These slots are namespace-reserved: any workflow that declares a blackboard slot whose name starts with _llm. fails at load. The reserved prefix is the only way the executor can carry cumulative state across turns without giving workflow authors a way to forge it.
The slots written after a successful turn:
_llm.cumulative_tokens— sum of input + output tokens across the session._llm.cumulative_cost_usd— sum of cost-catalog-derived cost across the session. Stays at the pre-turn value when cost lookup returns null._llm.cumulative_iterations— count of LLM turns this session._llm.consecutive_no_tool_call— count of consecutive failed-to-pick-a-transition turns. Resets to 0 on any successful tool call._llm.session.<state>.started_at— RFC3339 timestamp set on first entry to a state; used to enforcemax_seconds.
Operators don’t read these directly. They show up in audit and let the executor enforce caps without polluting the user-visible blackboard.
Cost catalog
Section titled “Cost catalog”The cost catalog (crates/mcp-flowgate-llm-executor/src/cost.rs) maps model names to USD-per-token rates. Each entry carries a verified_at ISO date; entries older than 90 days at workflow-load time are considered stale.
Two doctor checks fire when the operator sets max_cost_usd on a kind: llm executor:
COST_CATALOG_MISSING_ENTRY— the model isn’t in the catalog at all. Without a price, the cumulative-cost cap can’t be enforced; budget enforcement would silently no-op. Load fails. Operator either adds the model to the catalog (with a verifiedverified_at) or drops the cap.COST_CATALOG_STALE— the model is in the catalog butverified_atis more than 90 days old. The price may have drifted; load fails until the catalog is re-verified.
When max_cost_usd isn’t set, missing / stale entries produce warnings instead of errors — the executor can still run, but cost_usd is null in the audit and the operator gets visibility into catalog drift.
The freshness gate is the runtime’s answer to “should the doctor try to predict costs at load time” (SPEC §33.10 Q5): operators set their cap, the catalog vouches for the price, and the doctor refuses to load anything where those two are out of sync.
workflow
Section titled “workflow”Starts a sub-workflow. This is used internally when a transition needs to kick off a separate workflow definition.
executor: kind: workflow definitionId: sub_processThe sub-workflow runs as its own instance with its own state and context. The parent transition waits for it to complete.
Reliability policies
Section titled “Reliability policies”Any executor can have a reliability policy attached. You define it alongside the executor in a transition or capability.
reliability: timeoutMs: 30000 # kill the executor if it takes longer than 30s retry: maxAttempts: 3 # try up to 3 times total backoff: exponential # none | fixed | exponential initialDelayMs: 1000 # first retry after 1s maxDelayMs: 8000 # cap delay at 8s retryOn: # which failures trigger a retry - timeout - transient_error - rate_limited - connection_error fallback: strategy: first_success # try fallback executors in order executors: - kind: cli command: backup-command - kind: noop # last resort: return emptyRetry conditions
Section titled “Retry conditions”| Condition | When it applies |
|---|---|
timeout | Executor exceeded its time limit |
transient_error | Temporary failure (e.g. HTTP 503) |
rate_limited | Backend returned a rate limit response |
connection_error | Couldn’t reach the backend at all |
Fallback
Section titled “Fallback”If all retry attempts fail, the gateway tries each fallback executor in order. The first one that succeeds wins. This lets you degrade gracefully — try the primary, fall back to a simpler version, fall back to noop.
Idempotency across retries
Section titled “Idempotency across retries”When idempotencyKey is set on an executor, the same key is used across retries and fallback candidates. Your backend can use this to deduplicate requests even if the gateway switches to a fallback executor.