Overview
step.sendEvent publishes an event to your workspace event bus from within a running workflow. The parent step completes immediately — it does not wait for any downstream workflow triggered by the event. This is the async counterpart to step.invoke: use step.sendEvent when you want to fan out work without blocking the current workflow.
The call is memoized: on parent retry, the event is not emitted a second time.
API shape
await step.sendEvent("spawn-enricher", {
name: "agent/contact.enrich.requested", // event name — dot-notation convention
data: { contactId: "c_123", priority: "high" },
ts: undefined, // optional: Unix timestamp in ms — omit for immediate emission
});// Full type signature:
sendEvent(
id: string,
opts: {
name: string;
data: unknown;
ts?: number; // optional Unix timestamp (ms). Future ts defers emission via BullMQ delay queue.
}
): Promise<void>Return value
step.sendEvent returns Promise<void>. There is no result to await — the operation is fire-and-forget from the parent workflow's perspective.
Future scheduling
When ts is set to a future Unix timestamp (milliseconds), the event emission is deferred via BullMQ's delay queue. The parent step completes immediately upon scheduling — it does not block until the future time.
const tomorrow = Date.now() + 24 * 60 * 60 * 1000;
await step.sendEvent("schedule-followup", {
name: "outreach/followup.scheduled",
data: { contactId, templateId: "follow-up-v2" },
ts: tomorrow, // event fires ~24h from now
});
// Parent workflow continues immediately after this line
await step.run("record-scheduled", () => db.outreach.markScheduled(contactId));BullMQ stores the deferred job and emits the event at ts. If the worker restarts before ts, BullMQ's durable queue ensures the event fires on recovery — no manual retry logic needed.
Memoization
On parent retry, step.sendEvent is a no-op for an already-emitted step:
- The serve adapter hashes
"stepId:counter"to a stable key. - If
completedSteps[hash]is already populated,step.sendEventreturns immediately without re-inserting to the events table. - No duplicate events are emitted.
This gives the same memoization guarantee as step.run: side effects happen exactly once per workflow execution.
Common patterns
Async fan-out to parallel agents
Spawn N sub-agents without waiting for any of them:
for (const contact of contacts) {
await step.sendEvent(`spawn-enricher-${contact.id}`, {
name: "agent/enrich.requested",
data: { contactId: contact.id, workflowRunId: runId },
});
}
// All N enrichment workflows start in parallel — parent does not waitScheduled sub-agent
Schedule follow-up work at a specific time without running a separate cron:
const sendAt = new Date("2026-05-01T09:00:00Z").getTime();
await step.sendEvent("schedule-campaign-blast", {
name: "campaign/blast.scheduled",
data: { campaignId, segmentId },
ts: sendAt,
});Chain without coupling
Trigger a downstream workflow by name, allowing you to evolve each workflow independently:
const { data: enriched } = await step.run("enrich", () => enrichContact(contactId));
// Hand off to a separate "scoring" workflow — no direct import required
await step.sendEvent("trigger-scoring", {
name: "scoring/contact.ready",
data: enriched,
});Limits
| Constraint | Value |
|---|---|
| Workspace scope | Events are workspace-scoped — no cross-workspace emit |
| Deferred sendEvent durability | BullMQ delay queue — durable across worker restarts |
| Deferred sendEvent idempotency | NOT idempotent across worker crashes before the delay job is persisted (Phase 11 follow-up) |
ts precision | Millisecond Unix timestamp — use Date.now() + offset |
Deferred sendEvent is not yet idempotent across worker crashes. If the
worker crashes between the serve adapter call and the BullMQ add(delay:) call,
the event may not be scheduled. Phase 11 (idempotency keys) closes this window.
For critical scheduled events, use a dedicated step.sleep + step.sendEvent
sequence instead — the sleep memoizes durably and the sendEvent fires after
resume.