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 under details.issues. No run starts. Each path is the segments to the field (["items", 0, "name"], or [] for the whole payload). 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 the runtime enqueues a trigger that arrives while an earlier run is active: 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. The first frame is a run control frame the route writes itself, carrying { worldRunId, runId } for correlation. After it come the run's own event frames as its steps execute, including any agent turn a step delegates to. The workflow decides what those frames are named, so a client reads the type on each one rather than a fixed vocabulary.

The stream ends on a session.waiting or session.completed frame. One call serves one turn: a workflow session parks for the next message instead of ending, so the route stops forwarding there and lets the response close. Send the next turn as a fresh call carrying the same session key in the input. A failure after the stream opened rides an error frame, because the 200 is already sent.

It runs the same 400, 404, 422 and 503 checks as /start before it opens the stream. It refuses a serial workflow 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, or { input: null, output: null }
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, conflict? }. status is 'approved' or 'rejected', and it is the only thing to branch the side-effect on. Only an explicit approval sets it to 'approved': a value the runtime cannot read, including a resume with no body, is a rejection. timedOut is true whenever nobody decided, so a human rejection and a lapsed timeout stay apart. conflict is true on the one case the fallback does not cover: your own token was already held by another live run, which is always rejected.

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, keyed by its worldRunId

:traceId is the worldRunId off the start receipt, not the runId. A run this workspace never saw answers an empty list rather than a 404, so an empty answer means "nothing is parked", never "no such run".

The resume token addresses a pause. It does not authorise one: both routes sit behind the same gate as the rest of the workflow surface, so present whatever credential your caller holds. The body of a resume is whatever the parked requestApproval() call was written to receive. Omit it when the pause needs nothing back beyond a go-ahead.

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