Durable workflows
A workflow run outlives the request that started it. These routes start one, follow it, read the schema it declares, and clear the human decisions it parks on. A workflow is a
'use workflow'function underworkflows/. Durability comes from the upstream Workflow SDK: each'use step'runs once, the runtime persists its result to an append-only event log and deterministically replays the run on restart, so a workflow can pause (sleep, hooks) for hours or days and resume where it left off. See Workflows for the authoring model.
Start a run
POST /api/workflows/:name/start
Content-Type: application/json
{ "orderId": "ord_42", "amount": 19.99 }The runtime validates the input against the workflow's input schema at the
frontier. A bad input returns 400 with code: "workflow_input_invalid"
and the offending { path, message } issues, and no run starts. A
valid input returns the run handle:
{
"workflowName": "refund",
"status": "started",
"runId": "…", // correlate with `stackbone runs get <runId>`
"worldRunId": "…",
"trigger": "POST /api/workflows/refund/start",
}Read status before the ids. A workflow declared serial runs one at a time,
so a trigger that arrives while an earlier run is active is enqueued instead:
the receipt comes back "status": "queued" with no runId and no
worldRunId, because no run exists yet. It starts on its own when the lock
frees.
These are the other answers this route gives:
| Status | code |
When |
|---|---|---|
400 |
workflow_input_invalid |
the declared input schema rejected the body, and no run started |
404 |
workflow_not_found |
no workflow by that name (details.known lists the ones there are) |
422 |
workflow_guardrail_blocked |
a guardrail refused this payload (details.guardrail names the rule) |
503 |
workflow_compile_failed / workflow_runtime_not_armed |
the name is registered, but the workflow could not be made runnable |
Stream a run
POST /api/workflows/:name/chat
Content-Type: application/json
{ "…": "…" }A server-sent-event stream: a leading run frame carrying the worldRunId
for correlation, followed by the run's own event frames as its steps
execute (including any agent turn a step delegates to).
It runs the same 404 and 400 checks as /start before it opens the stream. A
serial workflow is refused here with 409: this route has to hold the
stream open, so it cannot queue behind an active run. Start those through
/start instead.
Catalog and schema
| Route | Returns |
|---|---|
GET /api/workflows |
{ items }, the workflow catalog (name, trigger, whether it has a schema) |
GET /api/workflows/:name |
one workflow's catalog row on its own |
GET /api/workflows/:name/schema |
the workflow's input/output JSON Schema |
GET /api/discovery |
the combined { agents, workflows } view |
GET /api/recurring-jobs |
the timers armed right now, when each fires next, and how its last execution went |
From the CLI, the same data is stackbone workflows list and
stackbone workflows schema <name>, and you start a run by name with
stackbone workflows start <name>.
Human-in-the-loop for workflows
A workflow pauses for a human decision by calling requestApproval() from
@stackbone/sdk/workflow (the raw defineHook + sleep are the escape
hatch underneath it). The run parks durably until the decision arrives:
import { requestApproval } from '@stackbone/sdk/workflow';
export async function refundWorkflow(input: { orderId: string; amount: number }) {
'use workflow';
const decision = await requestApproval({
topic: 'refund',
payload: { orderId: input.orderId, amount: input.amount },
title: 'Approve refund',
timeout: '24h',
fallback: 'reject',
});
if (decision.status !== 'approved') {
return { orderId: input.orderId, refunded: false };
}
// …perform the refund in a 'use step'…
return { orderId: input.orderId, refunded: true };
}The decision comes back as { status, payload?, timedOut }. status is
'approved' or 'rejected', and it is the only thing to branch the
side-effect on. timedOut is true when nobody decided and the fallback
was applied instead, so a human rejection and a lapsed timeout stay apart.
Leave token unset. The runtime mints a resume token that is unique per run,
so a retry or a second concurrent run never collides with a pause another run
still holds. You read the token back from the pending-hooks list below, or
from stackbone hitl list. Pass your own only when an outside system has to
reconstruct the token without reading it back, and then you keep it unique
across active runs.
Posting a decision to the run's hook resumes it:
| Route | Purpose |
|---|---|
POST /api/workflows/hooks/:token/resume |
record a decision and resume the run |
GET /api/workflows/runs/:traceId/hooks |
list a run's pending hooks |
The runtime owns this round-trip: you call requestApproval() in the
workflow body, never a webhook receiver. From the CLI it is
stackbone hitl list|get|approve|reject.
Calling an agent from a workflow
A workflow step reaches an agent in-process, not over this HTTP surface:
callDeepAgent(name, input) from @stackbone/sdk/workflow runs one turn of
the named agent in the same process and resolves with { text }.
import { callDeepAgent } from '@stackbone/sdk/workflow';
async function askAgent(question: string) {
'use step';
return callDeepAgent('lead-qualifier', question);
}See Workflow agents for the full pattern.