Skip to content

Executors

Executors are what happen when a transition fires. You declare them in YAML. The gateway handles the wiring.

KindPurpose
noopReturns immediately. Great for stubs and testing.
cliRuns a shell command, captures stdout.
restMakes an HTTP request.
mcpCalls a tool on a connected MCP server.
humanQueues for human action, emits an audit event.
workflowStarts a sub-workflow (internal).
scriptExecutes a curated, hash-pinned script from the scripts: library by subject. Safe for deterministic transitions. See Script verbs.
parallelFans out N executor branches inside a single transition and aggregates results. Branches can be any executor kind (including nested parallel). See Parallel execution.
pipelineRuns 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.
llmHosts 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.
agentA 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_issues

This 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: noop

Use 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.

If you don’t need a named connection, you can specify the command directly:

executor:
kind: cli
command: lint-check
args: ["$.input.service"]

The executor builds a structured output object. Its shape is:

PathTypeValue
$.output.exitCodenumber / nullThe process exit code.
$.output.successbooleantrue when the process exited zero.
$.output.stdoutstringRaw captured stdout.
$.output.stderrstringRaw captured stderr.
$.output.jsonany / nullParsed 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.*.

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"

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}"

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.

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 prefixResolves 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

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: true
reliability:
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"

References a named MCP connection (stdio or SSE):

connections:
github:
kind: mcp
command: github-mcp-server

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.

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-approvals

The queue is a logical name. It shows up in audit events so your approval system knows where to route the request.

  1. The model calls flowgate.command({workflowId, transition, expectedVersion}) (submit) with a human executor transition.
  2. The gateway records a human.approval.requested audit event with the queue name and a generated request ID.
  3. The executor returns success (output status "queued"); the transition fires and advances state as configured.
  4. 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.

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: approvals

Hosts 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.25

A worked example sits at examples/issue_triager.yaml — one workflow with three LLM-driven triage transitions routing to bug / feature / noise outcomes.

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.

FieldRequiredPurpose
modelOne of model / affinityDirect provider:model string. The cost catalog is keyed by this exact name.
affinityOne of model / affinityAn 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_templateyesRendered against {$.blackboard, $.context, $.input} per the existing template engine.
max_iterationsno (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_secondsnoPer-turn wall-clock cap.
max_tokensnoPer-turn token cap.
max_cost_usdnoPer-workflow cumulative cost cap. Requires the model to be in the cost catalog — see “Cost catalog” below.
reasoning_effortnoProvider-specific hint (low / medium / high / xhigh) passed through to aether_llm::ReasoningEffort where supported.
capture_reasoningno (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.)

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:

FieldTypeDescription
event_typestringAlways "llm.invocation".
workflow_idstringOwning workflow instance ID.
statestringState the executor ran from.
modelstringThe resolved model name.
tokens_innumber / nullInput tokens reported by the provider.
tokens_outnumber / nullOutput tokens reported by the provider.
tokens_reasoningnumber / nullReasoning tokens (where the provider distinguishes them).
latency_msnumberWall-clock latency from invocation to drained response.
cost_usdnumber / nullDerived 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_presentbooleanfalse flags turns where the provider returned no usage block — ops graph this to spot provider drift.
stop_reasonstring / nullProvider-supplied stop reason (end_turn, max_tokens, etc.).
tool_call_emittedstring / nullThe transition name the model chose, or null on the FMECA F1 / F2 paths.
error_codestring / nullOne of the LLM_* codes when the turn failed; null on success.
reasoningstring / absentCaptured reasoning text, or the sentinel "<elided>" when capture_reasoning: false. Absent when no reasoning was emitted.
correlation_idstring / absentThreaded through from the runtime when present.

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.

CodeFailureMitigation
F1Model loops without ever picking a transitionmax_iterations cap per turn; LLM_EXECUTION_EXHAUSTED surfaces when exhausted. max_seconds covers session-level runaway.
F2Provider returns a malformed / partial responseResponse drainer validates structure; partials don’t get dropped on the floor — they surface as typed errors.
F3Workflow 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.
F5Adversarial response shapes go untestedMock provider’s scenario catalog covers every documented failure variant; an integration test pins “every enum variant has a scenario.”
F6Provider returns no usage block but a max_cost_usd cap is setTurn fails with LLM_USAGE_MISSING; cost cap stays meaningful rather than silently bypassed.
F7Two transitions share the same rel so the model’s tool name is ambiguousDuplicate-rel check fires before the provider call; the turn fails fast with a clear error.
F8Operator sets max_cost_usd on a model not in the cost catalogDoctor 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 enforce max_seconds.

Operators don’t read these directly. They show up in audit and let the executor enforce caps without polluting the user-visible blackboard.

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 verified verified_at) or drops the cap.
  • COST_CATALOG_STALE — the model is in the catalog but verified_at is 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.


Starts a sub-workflow. This is used internally when a transition needs to kick off a separate workflow definition.

executor:
kind: workflow
definitionId: sub_process

The sub-workflow runs as its own instance with its own state and context. The parent transition waits for it to complete.


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 empty
ConditionWhen it applies
timeoutExecutor exceeded its time limit
transient_errorTemporary failure (e.g. HTTP 503)
rate_limitedBackend returned a rate limit response
connection_errorCouldn’t reach the backend at all

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.

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.