Dynamic Workflows
Dynamic workflows are experimental and off by default. The API may change without a major version bump. A deployment must opt in with WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS=1, dynamic runs can only start on the current deployment, and the World must support dynamic-source storage. See Enabling dynamic workflows and World support.
Dynamic source runs with the full privileges of your deployment's functions. It can read every environment variable, use the network and the filesystem, and call any step in the deployment. experimental_dynamic.steps is not a security boundary. Only pass source you would merge into your codebase. See Security.
Normally a workflow function is compiled into your build: the code transform rewrites every "use workflow" function, the build bundles them, and start() names one by importing it.
A dynamic workflow skips that. You hand start() a string of JavaScript, and it runs — no build, no deploy:
Only the orchestration is dynamic. Every step the source calls was deployed with your app, and experimental_dynamic.steps names the ones it calls by alias. That map does not stop source from reaching other steps; see Security. There is no way to define a new step from source.
When to use this
Reach for dynamic workflows when the shape of the orchestration is only known after you deploy, and the source comes from code you trust as much as your own:
- Orchestration your application assembles from reviewed templates, over a fixed set of deployed steps.
- Experiments — try a new composition of existing steps without shipping a build.
Dynamic workflows are not a way to run code written by your end users or generated by a model from their input. That source would run with your deployment's privileges; see Security.
If your workflows are known at build time, use a normal workflow function. It has better types, better errors, no source validation, and no size limits.
Enabling dynamic workflows
Dynamic workflows are off unless the deployment sets:
Only 1 or true (case-insensitive) enables them; any other value, or no value, leaves them off. The runtime reads the variable where workflows execute, when it needs it, so set it on the deployment or dev server rather than at build time. It controls three things:
- Starting.
start()with source throws before it contacts the World, creates a run, or enqueues anything unless this process has opted in. - Delivery. When a dynamic run reaches a deployment that has not opted in, the runtime does not execute its stored code. It fails the run with a
RUNTIME_ERRORrather than retrying it. - Health check. A deployment advertises dynamic support in its health check only when it has opted in.
Same deployment only
A dynamic run must execute on the deployment that started it. start() rejects a dynamic start whose target differs from the current deployment. That includes an explicit deploymentId for another deployment, deploymentId: 'latest' when it resolves to a different deployment, and any concrete target when the current deployment cannot be determined. The rejection happens before any capability check, key lookup, upload, run creation, or queue message.
What the source can use
Dynamic source has no imports. Instead, the generated code predefines a small runtime surface:
| Binding | What it is |
|---|---|
steps | Frozen object of the aliases you passed in experimental_dynamic.steps. Calling one dispatches that registered step. |
sleep | The durable sleep primitive. |
createHook | The hook primitive, for waiting on an external signal. |
The source also runs inside the normal deterministic workflow VM, so the usual workflow globals — Date, Math.random, crypto, URL, TextEncoder, structuredClone, and the rest — are available with the same determinism guarantees as a static workflow.
Dynamic source exposes only the small set of primitives injected by its generated wrapper. createWebhook() also needs the static workflow module's URL and metadata helper, and getWritable() needs its workflow-stream helper, so neither is currently injected into dynamic source. Use createHook() with server-side resumeHook(), and perform streaming through registered steps or a statically compiled workflow.
Here is a longer example using a timer and a hook to wait for an approval:
Supply a unique, deterministic approval token from the caller. The workflow must recreate the same token during replay, while the external service needs that token to call resumeHook(); do not use a tenant or user ID alone when concurrent runs can overlap.
Rules for the source
start() validates the source before it writes anything, so a definition that could never run fails at the call site rather than on a queue delivery:
- It must declare
async function workflow(...). Passexperimental_dynamic.exportNameto use a different name; export names may contain letters, digits, and_, cannot start with a digit, and are at most 64 characters. - The function's first statement must be the
"use workflow"directive. - No
importorexport. Reach steps throughsteps, not through modules. - JavaScript only — no TypeScript syntax, no npm dependencies, no bundling.
- No inline
"use step"functions. Steps come fromexperimental_dynamic.steps. - At most 128 KB of source.
- On Vercel, the run's execution context is limited to 2,048 bytes of JSON, and the
dynamicWorkflowmetadata below counts against it. That leaves room for roughly 30 step aliases, depending on how long the aliases and step IDs are. A start that exceeds it fails before anything is written.
Everything a static workflow must obey still applies: the body has to be deterministic, and any side effect belongs in a step.
Workflow IDs
You do not choose the workflow ID. It is derived from the source and its step bindings:
Two consequences worth knowing:
- The same definition always gets the same ID. Runs of one generated workflow group together in observability and share a queue topic, even across processes.
- A caller cannot claim an ID. Because the hash covers the source and the step bindings, arbitrary source cannot be made to run under a static workflow's name — or under another definition's.
Changing the source, or pointing an alias at a different step, produces a different workflow.
How the code is stored
A dynamic run's workflow function is not in your deployment's bundle, so the run carries its own compiled workflow code — and replaying the run means replaying that code, not whatever your deployment contains now.
That code uses the same serialization path as workflow inputs. It is compressed when the run protocol supports compression and compression is worthwhile, and encrypted when the World supplies run key material (see Encryption). Vercel's supported configuration provides encrypted storage; the Local and Postgres Worlds store it in plaintext. Retention and deletion apply whether the stored bytes are plaintext or ciphertext.
When a run has key material, or was started with encryption, a delivery only executes code stored in the run's symmetric encr envelope. It refuses plaintext and sealed (encp) payloads and fails the run. Encryption keeps the code confidential; it does not prove who wrote it. See Security.
On Vercel, durable workflow code storage is ref-backed on the run. The definition's size changes only how those bytes reach the backend:
- Small definitions (the overwhelming majority) ride inline in the
run_createdrequest frame. The backend materializes those bytes into the run's ref-backed storage, with no upload request fromstart(). - Larger definitions are uploaded first, and
run_createdcarries the resulting reference. This costs one extra request atstart().
Both paths are transparent — there is nothing to configure. Here, “inline” describes request transport, not a second durable storage shape.
Alongside the serialized code, the run records small plaintext metadata on executionContext.dynamicWorkflow: the source hash, the export name, and the alias-to-step-ID map. That is what lets a run be identified as dynamic without decoding the source. It is plaintext even when the code is encrypted, so anyone who can read the run can see which step IDs it was given and the aliases they were given under.
In the observability UI, a dynamic run's detail view shows its stored code in a Workflow Code section, behind the same decrypt action as the run's input and output. Replay Run is unavailable for dynamic runs. To run the definition again, call start() with the same source.
World support
Dynamic workflows need a World that can store the run's workflow code.
| World | Support |
|---|---|
| Vercel | Encrypted, ref-backed storage (small definitions transported inline; large definitions uploaded first). If dynamic-source storage is not enabled for the project, the backend rejects the run's creation and start() throws. |
| Local | Stored in plaintext on the run record in the local filesystem store. |
| Postgres | Stored in plaintext on the run row. |
| Others | Supported when the World declares capabilities.dynamicWorkflowCode. |
After the opt-in and same-deployment checks, start() fails a dynamic start on a World that does not declare capabilities.dynamicWorkflowCode. On Vercel, start() then validates the final execution context against the 2,048-byte limit. All of this happens before serializing or uploading code, creating an event, or publishing a queue message.
Security
Dynamic source is trusted application code with the full privileges of your deployment's functions. The workflow VM is a determinism sandbox, not a security sandbox. Code in it can reach the host process: it can read every environment variable, use the network and the filesystem, and call any step registered in the deployment with any arguments.
stepsis not a boundary. Thestepsobject contains only the aliases you passed and is frozen, so ordinary code that callssteps.somethingElse()fails the run instead of dispatching a step it was not given. Code that is trying to reach other steps, or the host, can.- Only start source you would merge. Do not build source from end-user input, and do not run model output generated from untrusted input. Either one gives whoever controls that input your deployment's privileges.
- Opting in is a deployment decision. A deployment that sets
WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWSexecutes stored code for any dynamic run it receives. Anyone who can start a workflow on it can run code with its privileges. - Encryption gives confidentiality only. Where the World encrypts stored code, it cannot be read at rest without the run's key, and a delivery refuses code that is not encrypted with that key. Anyone who can obtain the run's key can still write valid code, so encryption does not replace the opt-in.
- Plaintext Worlds turn storage write access into code execution. The Local and Postgres Worlds store the code in plaintext. On an opted-in deployment, anyone who can write to the Postgres database or the local data directory can make every worker execute code of their choosing.
- The step map is readable.
executionContext.dynamicWorkflow.stepsstores the alias-to-step-ID map in plaintext, so anyone with read access to the run sees the step IDs the source was given.
Treat dynamic source the way you would treat code in a pull request: written or reviewed by someone you trust with the deployment.
Limitations
- Experimental — the API may change without a major version bump.
- Off unless the deployment sets
WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS=1. - Same-deployment starts only.
- JavaScript only. No TypeScript syntax, npm dependencies, or bundling.
- Steps must already be registered in the deployment; no runtime step registration.
- No inline
"use step"functions,createWebhook, orgetWritable. - No caller-provided workflow IDs.
- No Replay Run from the observability UI.
- Parser-based validation checks JavaScript syntax and the required source/wrapper shape without executing it. It does not validate behavior, determinism, or intent.
- On Vercel, roughly 30 step aliases fit the 2,048-byte execution-context limit.
- Requires a World with dynamic-source storage.