Executors

Reference for all executor kinds -- the things that actually run when a transition fires.

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

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_issues

This resolves at config-load time to the named capability’s executor and merges its guards and reliability into the calling context.


noop

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:


cli

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

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

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

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

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.


rest

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

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

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

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"

Body

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

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

mcp

Calls a tool on a connected MCP server.

executor:
  kind: mcp
  connection: github
  tool: list_issues
  map:
    repo: "$.arguments.repo"

Connection

References a named MCP connection (stdio or SSE):

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

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

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"

human

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.

How it works

  1. The model calls praxec.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.

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

llm

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.

Config schema

The fields live in LlmExecutorConfig (crates/praxec-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

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

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

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:

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

The cost catalog (crates/praxec-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:

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

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.


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 empty

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

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

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.