Step Events & Emissions
Publish live step output, receive durable events, and inspect prior emissions.
step.do callbacks receive a WorkflowStepTx. In addition to database and service mutations, this
transaction can publish step emissions and observe durable events while the step is active.
Publish with tx.emit
Use tx.emit(payload) for progress, tokens, status changes, or other output that observers should
receive before the step completes:
await step.do("generate-report", async (tx) => {
tx.emit({ type: "status", phase: "started" });
const report = await generateReport((completed, total) => {
tx.emit({ type: "progress", completed, total });
});
tx.emit({ type: "status", phase: "finished" });
return report;
});Emissions are persisted with an actor, stepKey, attempt epoch, sequence, payload, and creation
time. The runner also publishes system control emissions such as step-started and
step-committed; do not assume every emission payload was authored by your workflow.
Emissions are live, step-scoped data rather than a permanent audit log. After an attempt commits, the durable cleanup hook removes that attempt's persisted emissions. Consume the current-step stream for live UI and use step results or your own tables for durable domain history.
Inspect with tx.previousEmissions
await tx.previousEmissions() returns emissions for the same step key that were already persisted
when the current attempt was loaded. This can help an attempt inspect partial streamed output that
is still available from an earlier epoch:
await step.do("stream-answer", { retries: { limit: 2, delay: "1 s" } }, async (tx) => {
const previous = await tx.previousEmissions();
const previousTokens = previous
.filter((emission) => emission.actor === "user")
.map((emission) => emission.payload)
.filter(
(payload): payload is { type: "token"; value: string } =>
typeof payload === "object" &&
payload !== null &&
"type" in payload &&
payload.type === "token",
);
const answer = previousTokens.map((token) => token.value).join("");
return await continueGeneration(answer, (value) => {
tx.emit({ type: "token", value });
});
});The result can include workflow-authored and system emissions. It does not include calls to
tx.emit made during the current attempt because those were not loaded before the attempt began.
Use stepKey, epoch, sequence, actor, and the payload shape to select the records you need.
previousEmissions is not a durable checkpoint store. The cleanup hook can remove completed attempt
epochs before a later retry begins. Persist recovery state in your own schema when it must survive
cleanup.
Receive events with tx.onEvent
tx.onEvent(type, handler) subscribes an active step.do callback to durable workflow events of an
exact type. It returns an unsubscribe function.
await step.do("interactive-review", async (tx) => {
const reviewed = new Promise<{ approved: boolean }>((resolve) => {
const unsubscribe = tx.onEvent("review", (event) => {
event.consume();
unsubscribe();
resolve(event.payload as { approved: boolean });
});
});
tx.emit({ type: "status", phase: "waiting-for-review" });
return await reviewed;
});Each event provides:
{
id: string;
type: string;
payload: Readonly<unknown>;
timestamp: Date;
consume(): void;
}Calling event.consume() queues durable consumption. That consumption commits only when the
surrounding step completes successfully. If the attempt retries, the handler may receive the event
again. Make handlers replay-safe, and call the returned unsubscribe function when the active step no
longer needs delivery.
Use step.waitForEvent(...) when the event itself is the durable step boundary. Use
tx.onEvent(...) when a long-running step.do callback must receive events while it continues
running.
Observe current-step emissions
The HTTP route streams both persisted snapshot rows and new live emissions:
GET /:workflowName/instances/:instanceId/current-step/emissionsPass once=true for the current snapshot without keeping the stream open. Framework clients expose
the same route through useCurrentStepEmissions.
The instance history route also returns emissions that are still persisted at read time. Because completed attempt emissions are cleaned up asynchronously, do not treat the history response as a permanent emission archive.
Event consumption hooks
The onConsume callback of step.waitForEvent receives a narrower transaction that also supports
emit and previousEmissions:
await step.waitForEvent("approval", {
type: "approval",
onConsume: async (tx, event) => {
tx.emit({ type: "approval-received", payload: event.payload });
const previous = await tx.previousEmissions();
// Queue mutate-only database work if needed.
},
});Work queued by onConsume commits atomically with consumption of the event and completion of the
wait step.