Execution and durability
An authored workflow is directly executable. Use workflow.start() for an
in-process run, or package workflows with workflowApp() when you need a
runner or durable host boundary.
Start and inspect a run
const session = await reviewWorkflow.start(
{prompt: "Review this response"},
{
runId: crypto.randomUUID(),
onEvent(event) {
telemetry.write(event);
},
},
);
console.log(session.status, session.state, session.events);
start() runs until it completes, fails through an explicit fail() result,
or reaches a question, signal, approval, or timer wait. An exception thrown by
step code rejects start(); catch it at the caller boundary and retain events
through onEvent or a LoopStore. There is no public StepExecutionError
contract to depend on during beta.
Session statuses are idle, running, waiting, paused, completed,
failed, and cancelled.
Resume human input
if (session.status === "waiting" && session.pendingQuestions[0]) {
await session.answer({
questionId: session.pendingQuestions[0].id,
value: "developers",
});
await session.resume();
}
Answers are validated by step.ask() when the step runs again. Invalid answers
reject the resume rather than entering workflow state.
Resume a hook
Delegation, signals, approvals, and timers wait through hooks. A host delivers the typed value using the exact pending token:
const hook = session.pendingHooks[0];
if (!hook) throw new Error("No pending hook");
await session.resumeHook({
token: hook.token,
value: {
occurrenceId: "occurrence-123",
occurredAt: new Date().toISOString(),
payload: {transactionId: "transaction-456"},
},
});
resumeHook() resumes the workflow itself. Hook tokens are single-use. Signal
ingress must authenticate callers, store receipts when required, and make
delivery idempotent before calling the runtime.
A step.delegate() hook has kind handoff_requested. Resume it with output
matching the hook's machine-readable schema. Validation happens before token
consumption: invalid output leaves the run waiting with the same token, so a
corrected value can be submitted. A duplicate valid resume returns the existing
terminal result without replaying completed work. Required capabilities are
checked before the hook is consumed; the resume source records the capabilities
that actually ran.
Snapshot and hydrate
session.snapshot() returns the serializable run state needed to continue a
waiting or paused run. Restore it against the same workflow definition:
const snapshot = session.snapshot();
await snapshots.put(snapshot.runId, snapshot);
const restored = reviewWorkflow.hydrate(
await snapshots.get(snapshot.runId),
{onEvent: telemetry.write},
);
await restored.resume();
Persist the snapshot after every state-changing operation. The durable section records the current step, attempts, retries, consumed hook tokens, and event index so completed steps are not replayed. Hydration must use a compatible workflow document and catalog; changing a route while resuming is rejected.
The public core type is LoopHydrationSnapshot<TInput>:
type LoopHydrationSnapshot<TInput> = {
input: TInput;
runId: string;
status: LoopStatus | string;
state?: unknown;
answers?: Record<string, unknown>;
events?: EventRecord[];
checkpoints?: LoopCheckpoint[];
pendingHooks?: HookRequest[];
pendingQuestions?: Question[];
parentRunId?: string;
parentCheckpointId?: string;
durable?: {
version: 1;
currentStepIndex: number;
hasStarted: boolean;
createdStepIds: string[];
consumedHookTokens: string[];
hookAnswers: Record<string, unknown>;
retryCounts: Record<string, number>;
stepRunCounts: Record<string, number>;
nextEventIndex: number;
};
};
Import the exact type and its nested event, checkpoint, hook, and question types
from @dromio/workflow/core rather than copying this abbreviated shape.
Session reference
| Member | Purpose |
|---|---|
runId, status, state | Run identity, lifecycle status, and accumulated output state |
events, checkpoints | Retained execution facts and rerun boundaries |
pendingQuestions, pendingHooks | Interactions currently blocking progress |
answer() / resume() | Record a human answer, then continue |
resumeHook() | Deliver a delegation, signal, approval, or timer value and continue |
snapshot() | Capture serializable hydration state |
pause() / cancel() | Suspend explicitly or terminate the run |
rerunFromCheckpoint() | Create a child run from prior state |
For lower-level persistence, pass a LoopStore from
@dromio/workflow/core to start() or hydrate(). Its optional methods store
sessions, events, checkpoints, and actions. The SDK calls those methods as the
run changes; production durability depends on the adapter you provide.
Use the Workflow App host
The root host API supplies a runtime store, signal delivery, and an HTTP control-plane adapter:
import { createWorkflowAppHost } from "@dromio/workflow";
const host = await createWorkflowAppHost(reviewApp, {
storage: {kind: "sqlite", path: "./workflow-runs.sqlite"},
});
const run = await host.runtime.startRun({
workflowId: reviewApp.defaultWorkflowId,
input: "Review this response",
});
await host.deliverSignals();
startRun() returns a WorkflowAppRun, which wraps the direct session:
run.runId;
run.workflowId;
run.status;
run.events;
run.artifacts;
run.session.state;
run.session.pendingQuestions;
run.session.pendingHooks;
The host wraps startRun(), answerQuestion(), resumeRun(), resumeHook(),
checkpoint reruns, and hydration with automatic persistence. Do not call
persistRun() again for their returned runs. That method exists for a
WorkflowAppRun created or imported outside the wrapped host runtime.
Use the runtime to change a hosted run so persistence happens automatically:
await host.runtime.answerQuestion(run.runId, {
questionId: "audience",
value: "developers",
});
const resumed = await host.runtime.resumeRun(run.runId);
const approval = resumed.session.pendingHooks?.find((hook) =>
hook.kind === "approval"
);
if (approval) {
await host.runtime.resumeHook({token: approval.token, value: "approve"});
}
The approval hook value is the value declared by the step's decision
contract—in this example the string "approve", not the signal occurrence
envelope. host.runtime.resumeHook() finds the owning run from the token,
continues it, and persists the resulting WorkflowAppRun.
The control-plane MCP tools adapt those same methods. Use
dromio.answer_question for pending human questions and dromio.resume_hook
for hook tokens. Both return the updated persisted run snapshot; neither owns a
separate execution engine.
{kind: "memory"} is process-local and is suitable for tests. SQLite survives
process restarts. runWorkflowServer() exposes the same host over HTTP and
requires an auth verifier when binding to a non-loopback address; its bundled
server currently runs on Bun.
The Workflow App host accepts the same encoded string input as its root app
runners and therefore shares the exactly-one-trigger-input-key constraint.
Use direct workflow.start() or a custom adapter for structured multi-key
input.
Publish a signal over HTTP
runWorkflowServer() publishes OpenAPI at /api/openapi.json. A typed signal
declared as payment.confirmed accepts occurrences at:
curl -X POST http://127.0.0.1:4323/api/signals/payment.confirmed/occurrences \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: payment-event-123' \
-d '{
"correlation": {"orderId": "order-123"},
"payload": {"transactionId": "transaction-456"},
"occurredAt": "2026-07-21T12:00:00.000Z"
}'
The server validates correlation and payload contracts, stores an idempotent
occurrence receipt, matches it to a pending wait by signal ID and correlation,
and runs delivery passes. Reusing an idempotency key with different content is
rejected. occurredAt is optional; correlation and payload are required.
For authenticated hosting, implement the exported AuthTokenVerifier from the
control-plane subpath:
import type { AuthTokenVerifier } from "@dromio/workflow/workflow-control-plane";
const auth: AuthTokenVerifier = {
verifyBearer: ({capability, token}) =>
accessControl.allows({capability, token}),
};
await runWorkflowServer(reviewApp, {
hostname: "0.0.0.0",
port: 4323,
auth,
storage: {kind: "sqlite", path: "./workflow-runs.sqlite"},
});
The verifier receives the bearer token and requested capability, such as
signals.read or signal.publish:payment.confirmed. Terminate TLS and apply
normal rate limits at the deployment boundary.
Run durable timers
Persisting a step.sleep() wait creates a timer trigger job. Signal delivery
passes do not fire timers; run the exported trigger worker beside the server:
import { runTriggerWorker } from "@dromio/workflow/workflow-control-plane";
const server = await runWorkflowServer(reviewApp, {
storage: {kind: "sqlite", path: "./workflow-runs.sqlite"},
});
const shutdown = new AbortController();
const worker = runTriggerWorker({
controlPlane: server.controlPlane,
workerId: "workflow-worker-1",
signal: shutdown.signal,
});
// During application shutdown:
shutdown.abort();
server.stop();
await worker;
The worker claims due timer jobs, resumes their hook with {firedAt}, and marks
the job complete. SQLite-backed jobs survive restart; a new worker can claim
them after their lease and due time. The same worker also processes configured
trigger jobs unless disableScheduleLoop is set.
Retries, failures, and cancellation
maxRetriesbelongs to the step definition. It limits explicitretry()results from@dromio/workflow/core; the default is one retry.fail(message)producesstep.failedandrun.failedevents and a failed session without throwing fromstart().- An unexpected thrown error rejects execution. No session or hosted run is
returned on that path; retain diagnostics in
onEventand durable telemetry. session.pause()preserves resumable state;session.cancel()terminates the run and consumes pending hooks.session.rerunFromCheckpoint()creates a child run with lineage back to the original run and checkpoint.- The core loop does not impose a generic step timeout. Use
step.sleep()for a durable timer, provider-specific request limits for model calls, or an app-owned timeout policy at the integration boundary.
Side effects should use context.step.idempotencyKey as part of their external
deduplication key. A retry or process recovery must not duplicate irreversible
work merely because the workflow can resume.
start() does not return a session handle while a synchronous step is still
running. pause() and cancel() therefore control a session already returned
in a waiting or paused state; there is no public out-of-band cancellation handle
for an in-flight synchronous step in 0.1.x. Hosted HTTP APIs likewise expose
resume, answers, and hook delivery, but not run pause/cancel endpoints.
Provide a core loop store
import type { LoopStore } from "@dromio/workflow/core";
const store: LoopStore = {
appendEvent: (event) => database.events.insert(event),
appendCheckpoint: (checkpoint) => database.checkpoints.insert(checkpoint),
appendAction: (action) => database.actions.insert(action),
saveSession: (record) => database.sessions.upsert(record),
};
const session = await responseReview.start({prompt}, {store});
const restored = responseReview.hydrate(snapshot, {store});
Store methods may be synchronous or asynchronous. The session serializes its persistence effects and rejects the state-changing operation if a store method fails. If your backend requires event and session-status atomicity, implement that transaction inside the adapter.
Test execution behavior
Use deterministic workers and stores in unit tests:
import { expect, test } from "bun:test";
test("greets a named user", async () => {
const session = await helloWorkflow.start({name: "Ada"});
expect(session.status).toBe("completed");
expect(session.state.greeting).toBe("Hello, Ada!");
expect(session.events.map((event) => event.type)).toContain("run.completed");
});
Add separate tests for invalid contracts, waiting and resumption, retry limits, cancellation, snapshot hydration, and duplicate external delivery. Live model quality belongs in an opt-in integration suite with a real provider.