Deterministic Runners¶
Deterministic runners let Obra execute native non-LLM stage work directly inside a workflow. They complement LLM stages instead of replacing them.
They are useful when a stage should call a program, hit an HTTP endpoint, or run trusted Python logic with clear inputs and outputs.
What they are¶
Deterministic runners are the workflow-native equivalent of "do a concrete operation now" steps:
- command stages run a process with explicit args and environment
- HTTP stages call an API with bounded capture and retry policy
- Python stages execute a registered callable with structured kwargs
- LLM review still decides whether the result is semantically good enough
Compared with other tools:
- like n8n or Zapier, they can call systems directly
- like LangChain or CrewAI, they stay inside one workflow runtime with stage state, review loops, artifacts, and lineage
- unlike external shell glue, they are visible to the same gateway and Workflow Studio control plane as the rest of the workflow
Runner kinds¶
| Kind | Typical use case | Activation source |
|---|---|---|
command |
CLI tools, tests, linters, local scripts | Command tuple |
http |
SaaS APIs, internal services, webhooks | Endpoint URL |
python |
Trusted local adapters and structured helpers | Factory import path |
workflow_runner |
Delegate to a child workflow | Runtime launch metadata |
The gateway discovery route currently exposes these kinds:
Today that list includes command, http, and python. workflow_runner is
real runtime behavior, but it is documented from the runtime and ADR rather
than the current gateway runner-kind discovery route.
Config schema discovery¶
Use the gateway as the source of truth for runner-config fields:
curl http://127.0.0.1:18790/api/runners/kinds/command/config-schema \
-H "Authorization: Bearer <token>"
curl http://127.0.0.1:18790/api/runners/kinds/http/config-schema \
-H "Authorization: Bearer <token>"
curl http://127.0.0.1:18790/api/runners/kinds/python/config-schema \
-H "Authorization: Bearer <token>"
Each response includes runner_kind, a fields array, and example_config.
Command runner config¶
| Field | Type | Notes |
|---|---|---|
args |
list[str] |
Additional argv entries |
env |
dict[str, str] |
Extra environment variables |
cwd |
str \| null |
Working directory override |
timeout_s |
int \| null |
Runner timeout override |
success_exit_codes |
list[int] |
Exit codes treated as success |
output_capture |
stdout_json \| stdout_text \| exit_code_only |
Output decoding mode |
Example config:
{
"args": ["-q", "--tb=short"],
"env": {"PYTHONUNBUFFERED": "1"},
"cwd": "/workspace/project",
"timeout_s": 120,
"success_exit_codes": [0],
"output_capture": "stdout_text"
}
HTTP runner config¶
| Field | Type | Notes |
|---|---|---|
method |
GET \| POST \| PUT \| PATCH \| DELETE \| HEAD |
HTTP method |
url |
str |
Target URL |
headers |
dict[str, str] |
Request headers |
body_template |
str \| null |
Body text or JSON template |
timeout_s |
int \| null |
Request timeout override |
success_codes |
list[int] |
Status codes treated as success |
response_capture |
body_json \| body_text \| status_only |
Response capture mode |
max_response_bytes |
int \| null |
Response truncation limit |
rate_limit_rps |
int \| null |
Per-host rate limit |
tls_verify |
bool |
TLS verification toggle |
retry_policy |
dict \| null |
Retry overrides |
Example config:
{
"method": "POST",
"url": "https://automation.example.com/hooks/claims",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer ${secrets.AUTOMATION_TOKEN}"
},
"body_template": "{\"invoice_id\": \"${stage_input.invoice_id}\"}",
"timeout_s": 30,
"success_codes": [200, 201, 202],
"response_capture": "body_json",
"max_response_bytes": 65536,
"rate_limit_rps": 5,
"tls_verify": true,
"retry_policy": {
"max_retries": 3,
"backoff_base_s": 1.0,
"backoff_max_s": 30.0,
"retryable_status_codes": [429, 500, 502, 503, 504]
}
}
Python runner config¶
| Field | Type | Notes |
|---|---|---|
timeout_s |
int \| null |
Execution timeout override |
kwargs |
dict[str, any] |
Structured keyword arguments |
Example config:
Variable interpolation¶
String values in runner_config support ${namespace.path} interpolation.
Grammar:
${stage_input.invoice_id}
${workflow.working_directory}
${stage.validate_invoice.output.exit_code}
${secrets.API_KEY}
$${literal}
Namespaces:
| Namespace | Source | Example |
|---|---|---|
stage_input |
Current stage input payload | ${stage_input.invoice_id} |
workflow |
Workflow refs and runtime metadata | ${workflow.working_directory} |
stage.<name>.output |
Prior-stage output artifact metadata | ${stage.validate_invoice.output.exit_code} |
secrets |
OBRA_SECRET_<NAME> environment snapshot |
${secrets.API_KEY} |
Security rules:
- interpolation is single-pass only
${secrets.*}is allowed in headers and body templates${secrets.*}is rejected in HTTP URLs and command args- array indexing is not supported in v1
$${escapes to a literal${
Validate expressions through the gateway:
curl -X POST "http://127.0.0.1:18790/api/interpolation/validate?resolve=true" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"expression": "${stage.validate_invoice.output.exit_code}",
"context_hint": {
"available_stages": ["validate_invoice", "notify_finance"]
}
}'
Cross-stage data flow¶
Deterministic runner output is normalized into
candidate_stage_output_artifacts. That matters because downstream stages read
named prior-stage output through stage.<name>.output.* interpolation and the
same artifact payload powers persistence, comparisons, and workflow-run detail.
In practice:
- a deterministic stage emits structured metadata
- the runtime persists it as stage output artifacts
- downstream stages resolve that output by stage name, not by "previous stage"
Two-tier retry¶
Obra uses two retry layers:
- runner-level retry handles transient transport or infrastructure failures
such as HTTP
503, connection errors, or retryable exit codes - quality-loop retry handles semantic failures, where the output technically completed but still fails review criteria
The quality loop can feed numeric retry overrides back into the runner through
the shared retry_with contract instead of forcing a manual reconfiguration.
LLM error interpretation¶
Deterministic stages still participate in the same review loop as LLM-backed stages. When a stage fails semantically:
- the deterministic runner returns structured context
- the reviewer sees the failure details and the emitted artifacts
- Obra can revise the same stage, escalate, or continue with findings
This is the key difference from a plain shell script glued onto a workflow.
For-each batch execution¶
Stages can fan out over a list with for_each. The runtime resolves the source
list once, runs the child work through the shared parallel executor, preserves
item order in the aggregate result, and honors fail_fast or all_settle.
Sub-workflow invocation¶
workflow_runner launches child workflows through the gateway control plane,
using POST /api/workflow-runs/ under the hood. It is intended for workflow
decomposition, not for bypassing the workflow runtime.
Runtime notes:
- the runner waits on child workflow terminal state through the same control plane
- cycle detection uses propagated
workflow_chain - recursion is bounded by explicit
workflow_runner_max_depth - child lineage can be inspected through
GET /api/workflow-runs/{run_id}/lineage
The current gateway runner-kind discovery route does not list
workflow_runner, so document it as a runtime capability rather than a
discovered kind.
Domain presets¶
Domains can ship runner catalog entries so common stage actions are available without bespoke factory wiring.
| Preset id | Kind | Intended use |
|---|---|---|
business.http.webhook |
HTTP | Call business system webhooks |
software.test.pytest |
Command | Run targeted pytest verification |
software.lint.ruff |
Command | Run ruff lint checks |
Discover the catalog-backed runners that are installed in the current environment: