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 under workflows/. 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:

workflows/refund.workflow.ts
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 }.

workflows/qualify-lead.workflow.ts
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.

BUILT WITH ❤️ FROM CANADA AND SPAIN