DromioWorkflow SDK
Workflow SDKAuthoringAPIWorkflow Apps & Harnesses

Expose and operate

Workflow AppsGUI, CLI, TUI, and SVGExecution and durabilityEvents and traces

External harnesses

Architecture and setup
Expose and operate
Workflow Apps
GUI, CLI, TUI, and SVG
Execution and durability
Events and traces
External harnesses
Architecture and setup

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​

MemberPurpose
runId, status, stateRun identity, lifecycle status, and accumulated output state
events, checkpointsRetained execution facts and rerun boundaries
pendingQuestions, pendingHooksInteractions 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​

  • maxRetries belongs to the step definition. It limits explicit retry() results from @dromio/workflow/core; the default is one retry.
  • fail(message) produces step.failed and run.failed events and a failed session without throwing from start().
  • An unexpected thrown error rejects execution. No session or hosted run is returned on that path; retain diagnostics in onEvent and 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.

On this page

  • Start a run
  • Resume human input
  • Resume a hook
  • Snapshot and hydrate
  • Workflow App host
  • Failure behavior
  • Test execution