Building a World
Implement the World interface to run workflows on any custom infrastructure.
A World is the abstraction that allows workflows to run on any infrastructure. It handles workflow storage, step execution queuing, and data streaming. This guide explains the World interface and how to implement your own.
Before building a custom World, check the Worlds ecosystem page. There may already be a community implementation for your infrastructure.
Reference implementation: The Postgres World source code is a complete example of how to implement the World interface with a database backend and graphile-worker for queuing. It is not optimized for scale, speed, or security and should be used as a reference to fill in those gaps in a custom World. Please see the Postgres World docs for what a self-hosted World has to gate itself.
If you have a World on the 4.x spec, see Upgrading a World to v5 for the interface and contract changes.
What is a World?
A World connects workflows to the infrastructure that powers them. The World interface abstracts three core responsibilities:
- Storage: Persists workflow runs, steps, hooks, and the event log
- Queue: Enqueues and processes workflow and step invocations
- Streamer: Manages real-time data streams between workflows and clients
specVersion is required. See Declaring the spec version.
The optional capabilities object advertises additional behavior, and every capability fails closed. A missing member means "unsupported," and the runtime keeps its conservative behavior. Set hookRetention.active to true only when the World implements hook token retention. Set maxConcurrency only when the World's queue supports maxConcurrency-limited consumption, which WORKFLOW_SEQUENTIAL_REPLAYS=1 uses. Set hookForceClaim only when the World implements hook token takeover. Without it, createHook({ experimental_force: true }) fails the workflow when it registers the hook. Set dynamicWorkflowCode only when the World persists dynamicWorkflowCode from run_created (and from a run_started that creates the run), echoes it on the created run, and returns it from runs.get with resolveData: 'all'. Without it, start() refuses dynamic workflows. The code is sent inline unless the World also implements uploadDynamicWorkflowCode for large definitions. Set inBandFence only when the World implements the in-band writer fence.
Slot-numbered event IDs are a contract requirement rather than a capability, so there is no flag or fallback path.
The remaining optional members:
analyticsprovides metadata-only listings of runs, steps, events, hooks, waits, and attributes for observability surfaces.workflow inspectand the local web UI read from it, including for attribute search. See Analytics interface.start()initializes background tasks (for example, queue polling);close()releases resources like connection pools and listeners.getEncryptionKeyForRun()returns the AES-256 key used to encrypt data for a run; if it is not implemented, encryption is disabled.createRunId()mints the ID for a new run. Implementations may embed World-specific metadata as long as the result remains a valid ULID. This is how multi-region placement works.@workflow/world-vercelreadsoptions.regionfrom thestart()options and embeds a region tag. When omitted, the runtime generates a standard monotonic ULID.describeRun()returns world-specific display fields for a run (for example, a region decoded from its ID) that tooling likeworkflow inspectrenders as extra columns. It must be cheap, tolerate missing fields, and never throw.processExitTriggersQueueRedeliverytells the runtime how to handle an exhausted replay budget. Whentrue, it exits and relies on queue redelivery. Whenfalseor absent, it attempts to writerun_failedand returns.
Declaring the spec version
specVersion is the protocol version your World implements, and the version stamped on every run it creates. Export SPEC_VERSION_CURRENT from @workflow/world rather than writing a number:
The runtime checks this before it creates or replays anything and throws if the version is outside the range it supports, naming both the range and what your World declared. A version below the range means your World allocates event IDs the runtime cannot read positions out of; above it means your World speaks a protocol this runtime has not learned.
Using the constant keeps that check passing across upgrades. It moves with the @workflow/world version your package resolves, so a spec bump raises your declaration and the runtime's requirement together. A hard-coded number leaves your World a version behind the next bump, and the runtime rejects it. Keep @workflow/world in the same release channel as the workflow version your users install.
The event log model
Workflow storage uses an append-only event log. All state changes happen through events. Instead of modifying runs, steps, or hooks directly, you create events that update the materialized state.
Events fall into three categories: run lifecycle events, step lifecycle events, and hook lifecycle events. See the Event Sourcing documentation for a complete list of event types and their semantics.
Storage interface
The Storage interface provides read access to materialized entities and write access through events:
Key implementation details
Event creation: When events.create() is called, your implementation must:
- Persist the event to the event log
- Atomically update the affected entity (run, step, or hook)
- Return both the created event and the updated entity
Batch writes: events.createBatch() is optional, and implementing it is what declares it. The runtime folds a suspension's step_created and wait_created writes into batches only when the method exists, and otherwise takes the single-event path unchanged. Implement it with real atomicity per attempt, so a lost race leaves nothing behind, or do not implement it at all. The events land in request order at consecutive positions, and a concurrent writer may push the whole batch above the caller's view of the log. No skipped-event report accompanies the result, so a position-tracking caller compares the committed positions against what it expected and reloads. Reject the whole batch, with a request-level error, for run_created, run_started, run_cancelled, hook_created, hook_disposed, attr_set, for more events targeting one entity than a single write can express, and for a batch over your own size caps. The one legal same-entity pair is step_created followed by step_started, which creates the step born-running, and there the input must ride the step_created.
Throttled step results: step_completed, step_retrying, and a step_failed marked with afterStepBody: true in CreateEventParams record a step body that already ran. If your World retries throttled writes, keep these waiting longer than other writes, because losing one to queue redelivery runs the body again. @workflow/world-vercel waits until the invocation's deadline. The flag is advisory, and a World may ignore it.
Run creation: For run_created events, the runId parameter may be a client-provided string or null. When null, your World generates and returns a new runId.
Event data resolution: events.list() and events.create() accept resolveData: 'skip-step-inputs', and the runtime replays with it. The World may leave input out of step_created and step_started events, and returns everything else as for 'all'. Treat any value other than 'none' as 'all', because a World that tests resolveData === 'all' strips step results and breaks every replay. Map the value with entityResolveData() from @workflow/world before passing it to an entity read.
Run listing: runs.list({ status }) accepts a single status or an array. With an array, return runs whose status matches any listed value, and return no runs for an empty array (the same semantics as SQL IN ()). Treat an omitted status as no filter.
Hook tokens: Hook tokens must be unique. If a hook_created event conflicts with an active hook or a token still reserved after its run ended, return a hook_conflict event and include the owner's run ID as eventData.conflictingRunId.
Keep the owning run available for at least as long as its token remains unavailable because conflicts return that run.
Automatic hook cleanup: When a run ends, remove its live hooks. Make each token available unless its tokenRetentionUntil is still in the future. A hook_disposed event always makes the token available immediately.
Terminal-run start fence: reject a step_started write once the run has
reached a terminal status, including a redelivery whose step row still reads
running because an earlier delivery claimed it. Starting work on a finished run
is never valid, and the outcome of such a step cannot be consumed by anything.
The queued-step path depends on this rejection, because a step message carries
run identity in place of a run fetch and has no other liveness check. A step that
was already in flight when the run ended still writes its step_completed or
step_failed unchanged: the fence is on starting, not on finishing.
Listing runs: runs.list() takes an optional workflowName and an optional
status, and status is either one WorkflowRunStatus or an array of them. An
array matches runs in any of the listed statuses, and an empty array matches
nothing, mirroring SQL IN (). An unset filter means "every status", so treat
status: [] and an omitted status as different requests. If your backend
cannot express the array form, reject it with an explicit error rather than
filtering on the first element or ignoring the filter: a silently wrong result
set is worse than a failed call. @workflow/world exports
TERMINAL_WORKFLOW_RUN_STATUSES so neither you nor your callers restate the
status vocabulary.
Optional: waiting for a terminal run status
await run.returnValue must determine when a run finishes. Without help, it rereads the run every second, so it reports a run that finishes just after a read up to 1s late. Implement runs.waitForTerminalStatus(id, { timeoutMs, signal, resolveData }) so the runtime asks once and receives an answer when the run ends.
The contract is deliberately forgiving, because "wait" means something different in every store:
- Resolve as soon as the run's status is terminal, returning the same entity
getreturns. - Resolve no later than roughly
timeoutMswith the latest snapshot, whatever its status. A timeout is a normal return, never an error. A run that is still running is a valid response, and the runtime asks again. timeoutMsis an upper bound, not a lower one. You may return a nonterminal snapshot early, and the runtime paces its own retries.- Fail exactly like
get. A missing run throwsWorkflowRunNotFoundError.
Choose a waiting mechanism that fits your store. The reference Worlds use a server-side long poll (world-vercel holds GET /v2/runs/:runId/status open), LISTEN/NOTIFY (world-postgres), and an in-process emitter over the run files (world-local), respectively. Treat the notification only as a signal, and reread the run before responding. Back the wait with a periodic reread so a lost notification increases latency instead of causing a hang until the budget expires.
Omitting the method is supported. A store with no change notification, or a deterministic simulator such as world-sim where a real wait would stall a virtual clock, can leave it out. The runtime continues polling get at intervals, and no other behavior degrades. There is no capability to declare.
Event ID allocation
Required. Your World assigns every event ID, and every ID must be a position in its run's log. The runtime reads a position from every ID it loads and fails the run when it cannot. A World whose IDs use another format, such as a ULID, UUID, or database sequence shared across runs, cannot replay a workflow. There is no capability flag or fallback path.
An ID is evnt_ followed by the event's 1-based position in that run's log, zero-padded to 26 characters, so the run's first event is evnt_00000000000000000000000001. Use slotToEventId() from @workflow/world to format one.
Two properties have to hold, and both are about what a reader can conclude from the log:
- Uniqueness. Two writers racing to append must not both take a position. Settle it where the store settles it, with a unique constraint on
(runId, eventId)or a conditional write, rather than reading the maximum and adding one in your own process. - Density. Positions run from 1 with no holes, which is what lets a reader tell a complete log from a truncated one by its length alone. A writer that loses a race must re-derive its position from the store and take the next free one. Incrementing a local number after a loss leaves a permanent hole, and the runtime treats a hole as a log it cannot safely replay across.
Allocate the position at the commit in the same operation that appends the event. This makes a reader's log a prefix of the run's log rather than a prefix with a hole. Nothing can land behind a position that a reader has passed. Assigning a position earlier, such as in a request handler, and committing later breaks the property that every replay depends on. This is the one case where you may need a stale-write rejection to compensate.
Optional: pre-assigned positions and noop sealing
Spec version 7 supports one alternative to allocate-at-commit for Worlds whose stores make commit-time allocation a contention bottleneck. Assign positions from a per-run atomic counter before the commit, and restore density at read time. With preassignment, concurrent writers hold distinct positions and never race for one. However, a writer that claims a position and stops leaves a permanent hole. A World that allocates this way MUST seal provably abandoned positions by writing a noop event into them. The seal races the original writer at the same uniqueness fence, and losing that race means the real event landed successfully. The World MUST NOT return a page with an interior hole. Return the dense prefix below the hole, and let the caller's next page continue past it after the position resolves to an event or a seal.
The runtime skips noop events during replay. It never delivers them to a consumer or uses them to advance the deterministic clock, so a sealed log replays identically to one whose writers filled the holes. noop isn't user-creatable and is never sent to events.create(). Only your read path may write one. Worlds that allocate at the commit, through a synchronous counter or unique-constraint append, maintain perfect density and don't need sealing. world-local and world-postgres never seal, and a World that allocates at the commit is spec 7 compliant without additional work.
The version a World stamps comes from mintedSpecVersion(): 8 by default or the slot-identity version (6) when WORKFLOW_SEALED_LOG=0 disables it. Declare mintedSpecVersion() instead of a literal so your World moves with the fleet. A runtime other than the one that created a spec 7 run may read it, so readers must understand noop before anything stamps 7 in that environment.
events.create() params carry eventCount: how many events the writer held in the log it replayed from, which is the position it expects to land on minus one. Attempt eventCount + 1. When that position is taken, do not reject the write. Advance to the next free position, commit there, and return the events occupying the positions you skipped over on the success response, in events with a matching cursor and hasMore. The writer merges them into its own log and replays once, rather than paying a second round trip to discover it was behind. A caller with a stale count is the normal case for a fan-out, and rejecting it would serialize writes the runtime issues in parallel.
Not every write carries eventCount, and a World is free to skip the report read for event types whose writer never holds a log to merge it into (the step executor's step_started, step_retrying, step_completed, and step_failed, and the run-terminal run_completed, run_failed, and run_cancelled). Which writes send eventCount or sinceCursor, and why, is tabulated in Events returned on a write. When a write carries both, the sinceCursor delta is a superset of the report, so return the delta and skip the report.
Optional: rejecting a stale write
A replay writes events derived from the event log it loaded, so a write made from an event log that no longer matches the store can commit events no correct replay would produce. Rejecting one means answering events.create() with a PreconditionFailedError when the run's log already holds more events than the caller's eventCount says it had loaded.
No World in this repository does. Reporting is the better mechanism because it removes the need to reject, not only because it costs less. A reader's log is a prefix of the run's log rather than a prefix with a hole in it, replay is deterministic on a prefix, and the writer's next write brings back what it missed. A shorter log therefore means a run that has not caught up, never a run that will decide differently.
Implement rejection when your store allocates positions outside the commit and cannot reliably report the skipped span. Refusing a stale write is then safer than accepting one out of order.
Two rules make this safe:
- Only reject on evidence. If your record of a run's events is incomplete, expired, or cannot answer the question, accept the creation. A rejection must always indicate a real discrepancy because the runtime responds by discarding a replay.
- Accept a creation that carries no
eventCount. It came from a caller with no loaded log that could be stale, such as a queued step body or out-of-band writer, rather than a caller claiming the log is empty.
A rejection may include the events the caller was missing as { events, cursor } on the error's details. Include them only when you can prove that the set fully accounts for the discrepancy, isn't truncated, and contains only events from the run being written. The runtime merges them directly into the replay's event log, so incorrect data is worse than no delta. Otherwise, omit them, and the runtime performs a full reload.
The runtime handles a rejection wherever it arrives. It restarts the replay in-process from a corrected log and invokes a fresh replay after spending that budget. It never retries the rejected write as-is because a replay working from a corrected log derives different events. This behavior requires no declaration or capability.
Optional: the in-band writer fence
The in-band writer fence keeps a run to one orchestrator writer when two orchestrator invocations of the same run overlap, for example a delivery that outlives its queue lease while a redelivery of the run is already running. A write made by the run's orchestrator is in-band (params.inBand: true). Everything else is out-of-band: a background step's own events, hook_received from resumeHook() and webhooks, run_cancelled from cancel(), and attr_set from setAttributes() outside the workflow.
The fence is optional, and the runtime does not send inBand yet, so a World without it stays fully supported. Declare capabilities.inBandFence: true when you implement all of the following:
- Two counters per run. Next to whatever allocates event positions, keep
seq, the highest position allocated to any write, andseqInBand, the number of positions allocated to in-band writes.run_createdcounts as in-band and is never fenced itself, so a new run holdsseq = 1, seqInBand = 1(IN_BAND_SEQ_AT_RUN_CREATIONfrom@workflow/world). - List snapshot, or counters on a refusal. On every
events.list()page of a slot-numbered run, returnsnapshot: { seq, seqInBand }, read before the listing. A full load, followed tohasMore: false, covers every position up to the first page'sseq. A fenced writer takes its count from that first page, never from counting events, because a count cannot tell which positions were in-band. If your store cannot read the counters on a list, you may list no snapshot, as long as every refusal carries both counters, read together (see below). - Fenced in-band creates. An in-band write carries
expectedSeqInBand. Accept it only when that equalsseqInBand, and advance both counters by the positions it allocates in the same atomic operation as the allocation. On a mismatch, throwInBandSupersededErrorfrom@workflow/errors(HTTP 412,in-band-superseded) and write and allocate nothing, so a refusal never leaves a hole. A batch is one fenced allocation for the whole block. - Fence first. Decide the fence before anything else the write does. A stale writer must touch no entity row. If the write updates entity state as well as appending, make the check, the update and the append one atomic operation (in Postgres, one transaction that locks the run's counter row first).
- Missing count. Answer an in-band write without
expectedSeqInBandwith a 400 (WorkflowWorldErrorwithstatus: 400). - Out-of-band creates (
inBand: falseor absent) allocate unconditionally, moveseq, and never moveseqInBand. The fence never refuses them.
world-local, world-postgres and world-vercel declare the fence. world-vercel's backend fences in-band writes on every run with an event sequencer (spec version 7 and later). On an older run it answers an in-band write with a 400 (WorkflowWorldError, code: 'in-band-fence-unsupported') rather than writing it unfenced, so a writer decides whether to fence from the run's spec version. Its list responses carry the snapshot only where the backend reports one, so a writer there can find none.
A writer that finds no snapshot takes its count from a refusal:
- Get the counters. On a run it has just created, before any other in-band write, the count is
IN_BAND_SEQ_AT_RUN_CREATION. Otherwise, send an in-band write with a count no run holds, such asNumber.MAX_SAFE_INTEGER. The World refuses it and writes and allocates nothing, and theInBandSupersededErrorcarriesseqandseqInBand, read together after the fence refused the write. - Load up to
seq. List the log until it holds positionseqitself. A position can be allocated before its event is committed, so a listing that ends belowseqis retried, not trusted. - Write at
seqInBand. That log holds every in-band writeseqInBandcounts, so the writer sends its next in-band write withexpectedSeqInBand: seqInBand, and advances by the positions each accepted write allocated.
A writer that was refused while writing stops writing from the log it held; only a writer that loads the log again, as above, takes up the refusal's counters.
The conformance suite in packages/world/src/test-support/in-band-fence-conformance.ts checks each rule against a World's events storage, and createTestSuite() from @workflow/world-testing checks the counters (from the snapshot, or from a refusal where the World lists none) and a stale in-band write end to end when the World declares the capability.
Queue interface
The Queue interface handles asynchronous workflow execution, including queued step work:
meta.messageId should be stable across redeliveries of one message. The optional meta.deliveryCount is 1 on a message's first delivery and increments on every redelivery; leave it undefined when your queue cannot say. The optional meta.createdAt is when the message was first enqueued, unchanged across its redeliveries.
Queue prefixes and delivery routes
A queue name is a prefix followed by an id. The Workflow SDK uses the prefix __wkf_workflow_, or __{namespace}_wkf_workflow_ when a queue namespace is set. A library that keeps its own runs in a World and consumes its own messages, such as a durable agent harness, uses a namespace of its own (__agent_wkf_workflow_session) so that its messages never reach the Workflow SDK's handler, and the Workflow SDK's never reach it.
Only the Workflow SDK's handler is mounted at /.well-known/workflow/v1/flow (WORKFLOW_FLOW_ROUTE from @workflow/world). A World decides where every other prefix's messages go:
- By subscription. A World whose transport pushes each message to whatever subscribed to its topic, like the Vercel World, needs nothing more: the app subscribes the consumer's route to that prefix's topics.
- By path. A World that POSTs deliveries to the app itself should implement the optional
registerRoute(prefix, path): messages ofprefixgo topathon the app's base URL, and a prefix without a route goes to the flow route. Read the route at each delivery attempt, so a route registered after a message was enqueued takes its next attempt. - In process. A World whose queue runs inside the app's process can also implement the optional
registerHandler(prefix, handler), which delivers a prefix's messages tohandler(request)without HTTP. The request is the one a route servingcreateQueueHandler(prefix, ...)would receive, and it takes precedence over a route for the same prefix.
Keep a message's full queue name from queue() to its delivery, including its prefix. A World that delivers by path should also have createQueueHandler(prefix, ...) refuse a message outside prefix (the Local World answers 400), so a misrouted message is retried rather than run by the wrong consumer.
If your World re-enqueues active runs when it starts, send each run back to the queue namespace it recorded at creation, executionContext.queueNamespace (read it with getRunQueueNamespace() from @workflow/world), rather than to the starting process's own namespace. reenqueueActiveRuns() from @workflow/world does this. The Workflow SDK's start() records the namespace when one is set, and a library that creates its own runs records its own.
Invocation delivery
Implement the optional invoke method to send an input to a workflow runner and
return the runner's response. Enable capabilities.invoke only when your World
supports this operation.
Your World must guarantee at most one active workflow runner per runId across
all worker processes. Different runs may execute concurrently. A replacement
runner can take over after the previous runner stops.
Route inputs to the active runner, or start or resume the runner when none is
active. Call the existing createQueueHandler callback with
{ runId, invoke: true, requestId, input }. The callback validates the input,
waits for required event writes, and returns a result. Deliver that result to the
original caller. Support input processing while the runner awaits step work,
without starting a second runner for the same run.
requestId identifies the logical input across retries and is separate from a
queue delivery ID. InvokeOptions accepts an idempotencyKey and a timeoutMs
response-wait limit.
Treat invocation return values as response data, including any timeoutSeconds
property. Ordinary workflow wake results use timeoutSeconds to schedule another
execution.
You can transport results with InvocationOutcome, using { ok: true, value }
for success or { ok: false, error: SerializedWorkflowError } for an error. The
helpers in @workflow/errors/invocation capture handler results and restore
errors with their known Workflow classes and diagnostic fields. Return the value
or throw the restored error from invoke().
Keep failures to store or deliver a response distinct from handler errors. A
transport failure leaves processing unknown, and a handler error may follow
committed event writes. If your World advertises hookResumeDedup, enforce that
capability when retrying a hook event whose response was lost.
Batched publishing
queueBatch is optional. Implement it when your transport can accept several messages in one round trip, and the runtime will use it to dispatch a wide Promise.all fan-out. A fan-out otherwise costs one round trip per branch, paid before the first branch's step body runs, so it lands directly on time-to-first-step.
Its contract differs from queue in one important way: a message that fails is reported, not thrown. Return one result per input message, in input order, where error is set on the entries that were rejected and retryable says whether republishing might succeed. Reject the promise only for a request-level failure, where you cannot say what happened to any individual message.
The count must match: return exactly as many results as you were given messages. The runtime rejects the whole batch if it does not, because an omitted result is indistinguishable from a message that was never published, and treating it as success would strand that step with nothing reporting an error.
messageId: null with no error means accepted without an ID yet, exactly as for queue, so callers test error === undefined for success rather than a non-null messageId.
Every message targets one logical queueName, but you may split the batch internally — by a transport cap, or by any per-message routing dimension you derive from the payload — as long as the returned order still matches the input. The runtime passes a distinct idempotencyKey per message, because its recovery for both a request-level failure and a retryable entry is to republish the whole batch.
Queue names
Queue names follow a specific pattern:
__wkf_workflow_<name>: Used for workflow orchestration and queued step invocations
Message payloads
Workflow orchestration and step execution use the same payload. stepId and stepName identify a queued step invocation:
Workflow Invocations:
Run identity on step messages: a step-execution message carries runContext
with the run's deploymentId, specVersion, startedAt and rootRunId. Each
of those is fixed for the life of a run, so a consumer that receives them starts
the step from the message alone instead of fetching the run first. Run status
is deliberately absent: the liveness check for a queued step is the
step_started write itself, which your World must reject on a terminal run (see
Key implementation details). Messages from older
producers omit runContext and take the older path that fetches the run, so a
World needs to do nothing to support either.
The SDK also sends an internal HealthCheckPayload through the same workflow queue.
Implementation considerations
- Messages must be delivered at-least-once
- Support configurable retry policies
- Track attempt counts for observability
- Implement idempotency using the
idempotencyKeyoption when provided - Honor the
delaySecondsoption. Waits (sleep()) are delivered as ordinary delayed continuations on the workflow queue, so a World that ignoresdelaySecondsredelivers immediately and causes every sleeping run to enter a busy loop - Optionally honor the
regionoption, a routing hint naming the region a message should be dispatched in; Worlds without a regional dimension ignore it
Streamer interface
The Streamer interface enables real-time data streaming:
Streams are identified by a combination of runId and name. Each workflow run can have multiple named streams.
writeMulti() is an optional optimization for batching multiple writes.
createWriteSession() is optional. Implement it when your transport can do
better by holding state across a writer's chunks, for example by keeping one
connection open instead of reopening it per write. The runtime creates at most
one session per in-memory WritableStream and passes a writerId identifying
that lifetime, so the session's write(chunkSeq, chunks) receives a sequence
number that is writer-local rather than stream-global. That is what lets a
World order concurrent writers to the same stream. The session's close() must
not resolve before every prior write is durable; dispose() is optional and
releases transport resources without ending the stream. Optional release()
retires an idle transport after the runtime drains a released writer. Unlike
dispose(), it must leave later write() and close() usable, for example
by switching that writer to stateless HTTP. A World that does not
implement createWriteSession is unaffected: the runtime uses
write/writeMulti/close exactly as before.
getChunks returns a paginated snapshot of currently available chunks, unlike get, which returns a live ReadableStream that waits for new chunks. getInfo returns the tail index (last chunk index, 0-based, or -1 when empty) and whether the stream is complete. Use this information to resolve negative startIndex values into absolute positions.
Analytics interface (optional)
The optional analytics namespace provides metadata-only access to runs and their related records. It is intended for observability and discovery surfaces such as dashboards, workflow inspect, and the local web UI. Implementations can optimize these queries independently of payload storage.
Tooling detects whether this namespace is available. When world.analytics is available, tooling prefers it for listings and attribute search. Otherwise, it uses the Storage APIs. Among the first-party Worlds, only the Vercel World currently implements it. The Local and Postgres Worlds leave it undefined.
If you implement this namespace, observe the following requirements:
- Metadata only. Analytics responses must not include run inputs or outputs, step data, hook tokens, or other payload data. The
Analytics*schemas exported by@workflow/worlddefine the complete set of permitted fields. Payload retrieval remains exclusively available through the Storage APIs. - Attribute filters use the latest value.
runs.list({ attributes })evaluates each filter against the run's most recently written value for that key. A request may contain up to eight key-value pairs. Reserved$-prefixed attributes are valid filters, although users cannot write them directly. - Time boundaries must be paired.
startTimeandendTimemay either both be omitted or both be supplied. Responses may includepageInfodescribing retention and the available query window. Implementations with retention limits should return this information so tooling can present valid date ranges. - Page limits are part of the contract.
pagination.limitdefaults to 40 and caps at 1000 for the run-scoped listings (steps.list,events.list,waits.list) and at 100 for the cross-run ones (runs.list,attributes.list,hooks.list);events.getManyaccepts 1 to 100 ids. Callers hit these bounds before a request leaves their process, as aRangeError, so a request that reaches your implementation is already within them. - Results may be eventually consistent. Analytics records may lag live workflow state. Consumers use this namespace for discovery and listing; Storage remains the authoritative interface for current workflow state and payload access.
See the Analytics API reference for per-method parameters, row shapes, and pageInfo semantics.
Process-wide state
Hold state that must be process-wide on globalThis, not at module scope.
A World is loaded in one of two ways, and only one of them gives your package a single module instance:
- Loaded at runtime.
WORKFLOW_TARGET_WORLD=@your-org/world-foois resolved withrequire()at runtime, so Node's module cache dedupes it and one process holds one copy. - Bundled. The host application's bundler compiles your package into its
server build. Bundlers key module identity on
(resource, layer), and a framework routinely builds several server layers. Next.js compilesinstrument, app-route,ssrandedgeas separate module graphs. Your package is then compiled into each one, so a single process holds several copies of every one of your modules, each with its own module scope.
The two built-in worlds are bundled. A custom world is not today, but that is a
property of how it is loaded rather than of how it is written, and it can change
under you. @workflow/world-vercel was external until it wasn't, and every
module-scope variable in it silently became per-copy state.
So a top-level let or a const holding a Map is not the singleton it looks
like:
Reach for globalThis under a Symbol.for() key instead, so every copy shares
one object:
Version the key. Two releases of your package can end up in one process, and a key without a version lets an older copy read a state object it does not understand.
Inside this repository, globalSingleton() from @workflow/utils does exactly
this and is what the first-party worlds use; the hand-rolled form above is
written out so a world published outside this repository does not need the
dependency. scripts/lint/module-scope-state.mjs accepts either.
Better still, keep the state on the World instance your createWorld() returns.
Connection pools, caches, and open channels are usually per-World rather than
per-process, and instance state cannot be duplicated by a bundler. Reserve the
global for the few things that are genuinely process-wide: ID generators whose
sequence must not fork, and log-once latches.
Reference implementations
Study these implementations for guidance:
- Local World: A file-system-based implementation for understanding the basics
- Postgres World: A database-backed implementation with Graphile Worker for queuing
Testing your World
Workflow SDK includes an E2E test suite that validates World implementations. Once your World is published to npm:
- Add your world to
worlds-manifest.json - Open a PR to the Workflow repository
- CI will automatically run the E2E test suite against your implementation
Your World will then appear on the Worlds ecosystem page with its compatibility status and performance benchmarks.
Publishing your World
- Package your World: Export a default World instance from your package
- Publish to npm: Publish your package to npm
- Add to the manifest: Submit a PR adding your World to
worlds-manifest.json - Document configuration: Document any required environment variables