How workflows work
An agent finishes its run by answering with JSON. When that answer carries a workflow key, the platform executes it: a chain of HTTP requests, in order, against your systems.
This is what turns "the agent read the invoice" into "the payable exists in the ERP" — without you polling our API or writing a service that listens for webhooks and then calls yourself back.
Where it sits in the pipeline
SES ──▶ Email (received)
│
├─ filters ──▶ rejected
│
▼
approved ──▶ AgentRun (LLM call) ──▶ Email processed
│ │
│ answer = { result, workflow }
│ │
└──────────────▶ WorkflowRun ──▶ step 0 ──▶ step 1 ──▶ …
│
workflow.run.succeeded | failed
Two things follow from that position, and they are the whole design:
- The workflow runs after the analysis is already recorded. The
AgentRunissuccessand theEmailisprocessedbefore the first request goes out. A destination being down is a workflow failure, never an agent failure — you never lose the analysis you already paid the model for. - The workflow is a separate job. It has its own record (
WorkflowRun), its own status, its own events. Nothing about it blocks e-mail ingestion.
The answer format
{
"result": { "supplier": "ACME", "amount": 1250.5, "due_date": "2026-10-01" },
"workflow": [
{
"type": "request",
"method": "POST",
"url": "https://erp.example.com/payables",
"headers": { "Authorization": "Bearer ${{secrets.FINANCE_TOKEN}}" },
"body": { "supplier": "ACME", "amount": 1250.5, "email_id": "${{kobana.email_id}}" },
"expected_status": 201
},
{
"type": "request",
"method": "POST",
"url": "https://erp.example.com/payables/${{steps.0.response.body.data.id}}/attachments",
"headers": { "Authorization": "Bearer ${{secrets.FINANCE_TOKEN}}" },
"form": { "file": "${{email.attachments.0}}" }
}
]
}
result keeps the meaning it always had and is still stored on Email.agent_metadata. workflow is optional — an agent that returns no workflow behaves exactly as before.
See Steps and placeholders for every field.
Execution model
Sequential. Steps run in array order, one at a time. Step 1 starts only after step 0 finished.
A failure stays isolated. Every step is attempted, in order. A step that fails is failed on its own; the steps after it still run. The exception is a step that references (${{steps.N.…}}) a step that did not succeed — failed or skipped: it is skipped, never sent, and its error reads depends on step N, which did not succeed. "Attach the document to the payable that was just created" is wrong to send when the payable was never created, so it is skipped; a second, independent payable in the same workflow is still created. The run is failed if any step failed. Put first the independent steps you want to happen even if others fail — a dependent step only runs when what it uses succeeded.
Exactly once. There is no automatic retry, at the step level or at the run level. Every step is an arbitrary request to your systems; repeating it could duplicate a payable, a notification, or a charge. A run that dies mid-flight (a worker restart) stays running and is surfaced as such rather than replayed blindly — re-running is a human decision, made with POST /v1/emails/{id}/reprocess.
A step fails when any of these is true:
| Condition | Stored error |
|---|---|
Status does not match expected_status (or is not 2xx when it is absent) | expected status 201, got 422 |
| The destination did not answer within 30s | timeout after 30000ms |
| Connection failure, DNS failure, TLS failure | ECONNREFUSED …, ENOTFOUND … |
| The destination answered a redirect | expected status 2xx, got 302 — 3xx is never followed |
| A placeholder could not be resolved | secret \X` is not defined in this workspace` |
| The resolved URL is not allowed | url not allowed: private_ip |
What the platform guarantees
- Credentials never reach the model. The agent writes
${{secrets.FINANCE_TOKEN}}; the value is substituted at send time, from the Secret store. - Credentials never reach the database. The request persisted on each step has every secret value replaced by
[REDACTED]. What you read back inGET /v1/workflow-runs/{id}is not the plaintext, and there is no endpoint that returns it. - The URL is checked after substitution.
https://only (outside development), and private, loopback, link-local and cloud-metadata addresses are refused — so a secret holding a base URL cannot be used to point a step at an internal network. - The full attempt is recorded. Request as sent, response status, response body (up to 4000 characters), duration, and the error. Enough to diagnose without re-running.
- A malformed workflow is loud. If the agent emits something that does not validate, a
WorkflowRunis recorded asfailedcarrying the reason (invalid workflow at \0.url`: …`). An agent that meant to fire three requests must never look like an agent that meant to fire none.
Limits
| Limit | Value |
|---|---|
| Steps per workflow | 20 |
| Timeout per step | 30s |
| Response read per step | 1 MiB |
| Request/response body stored | 4000 characters |
| Retries | none |
Next
- Quickstart — wire one up end to end.
- Steps and placeholders — the full field and placeholder reference.
- Troubleshooting — what each failure means and what to do about it.