Note: setup is in root README "Path C. Run the full stack demo". This file documents the worker's internals.
Background process that executes workflow graphs submitted by the backend. Currently backed by Temporal; structured so other engines (in-memory, BullMQ, …) can slot in without touching domain logic.
backend ──▶ Temporal (queue) ──▶ execution-worker ──┬──▶ execute node (AI agent, decision, …)
├──▶ emit execution events → Postgres
└──▶ update execution status → Postgres
The worker polls a task queue, runs activities, and persists side-effects. All workflow logic lives in @workflow-builder/execution-core; the worker is the adapter that wires that logic to Temporal primitives.
For debugging only. pnpm dev:ai-studio from the root starts the worker alongside backend and frontend.
pnpm dev:worker # alias for pnpm --filter execution-worker devRequires Postgres + Temporal running. Start them with pnpm infra:up.
See .env.example. Everything the bundled dev stack needs has a working default; the AI_* trio is optional:
| Var | Purpose | Default |
|---|---|---|
DATABASE_URL |
Execution events + status | postgresql://wb:wb@127.0.0.1:5432/workflow_builder |
TEMPORAL_ADDRESS |
Temporal server address | 127.0.0.1:7233 |
TEMPORAL_NAMESPACE |
Namespace. Must match the backend's | default |
AI_API_KEY |
LLM for AI Agent nodes (optional) | — (AI Agent nodes fail) |
AI_BASE_URL |
Any OpenAI-compatible endpoint | — (AI Agent nodes fail) |
AI_MODEL |
Model id, as the endpoint spells it | — (AI Agent nodes fail) |
TAVILY_API_KEY |
AI Agent's web-search tool (optional) | — (tool disabled) |
The three AI_* variables are optional by design, but all-or-nothing — the contract is described
once in @workflow-builder/ai-config, which both apps read
through. The worker boots without them and runs every non-AI node, and an AI Agent node that is
reached fails with the ai_not_configured code rather than taking the whole worker down.
AI_API_KEY was previously called OPENROUTER_API_KEY; the old name is no longer read.
Point AI_BASE_URL at any OpenAI-compatible server — a gateway, or a model hosted inside your
own network — and model requests stay inside it. That covers the model only: the optional
web-search tool calls Tavily's API whenever TAVILY_API_KEY is set, a node enables web search and
the model invokes the tool, so leave the key unset if nothing may call out; Temporal and the
database go wherever TEMPORAL_ADDRESS and DATABASE_URL point. There is no built-in endpoint or model:
.env.example pre-fills the OpenRouter values the worker used before they became configurable.
The connection to Temporal is env-driven too: TEMPORAL_TLS, TEMPORAL_API_KEY and the
TEMPORAL_TLS_CA_PATH / TEMPORAL_TLS_CERT_PATH / TEMPORAL_TLS_KEY_PATH trio cover a hardened
cluster or Temporal Cloud. Both apps read them through
@workflow-builder/temporal-connection, so the rules
cannot drift, but each environment must still agree on the namespace — the full table is in
apps/backend/README.md.
src/
├── database.ts # Raw SQL for exec events + status updates (no Drizzle — avoids backend schema coupling)
├── env.ts # Env reading with the defaults documented above (TEMPORAL_* come from @workflow-builder/temporal-connection)
└── engines/
└── temporal/
├── worker.ts # Worker bootstrap: executors + store, handed to WorkflowBuilderPlugin
└── workflows.ts # One-line re-export of runWorkflow for Temporal's bundler
The workflow itself, the activity contract and the event emitter live in
@workflowbuilder/temporal. This app only supplies what is its
own: one executor per node type and the database as the store port.
- Task queue:
workflow-execution, read fromplugin.taskQueueso the backend and the worker cannot drift apart. Both default to the same constant in the package. - Namespace:
TEMPORAL_NAMESPACE, defaultdefault. Unlike the task queue this is not shared through the plugin: both apps read it through@workflow-builder/temporal-connection, but each environment has to set the same value — a mismatch is silent, the worker simply never sees the backend's submissions. - Workflow ID:
execution-<executionId>— deterministic, lets the backend cancel by execution ID. Also owned by the package. - Activity timeouts: DB activities get 30s / 5 retries; node activities (may call LLMs) get 10m / 2 retries. Exported as
DEFAULT_DATABASE_ACTIVITY_PROFILEandDEFAULT_NODE_ACTIVITY_PROFILE. - Retries per failure: an executor throwing
PermanentNodeExecutionErrorstops on its first attempt;TransientNodeExecutionErrorretries within the profile's limit. An unclassified throw keeps the profile's uniform retry. Every failure the reference executors make a judgment on is in the table below. - Sandbox constraint:
workflows.tsis bundled into V8 with no Web APIs. It may only re-export from@workflowbuilder/temporal/workflow, never from the package root. - Editing the package: the worker imports its built
dist, so runpnpm build:temporalafter changingpackages/temporal/src. - Deploys that change the emitted event set: drain in-flight runs first. Replaying an old run's history against a new emit sequence diverges — see
replay-audit.mdrule 9.
Each judgment is made at the throw site that owns the error. The runner and the adapter never infer a class from a status code, so a consumer's own executors are unaffected by this table.
| Failure | Class | Code |
|---|---|---|
| AI Agent: provider answered 401 or 403 | permanent | provider_auth_rejected |
| AI Agent: provider answered any other 4xx except 408 and 429 | permanent | provider_rejected_request |
| AI Agent: provider answered 429 | transient | provider_rate_limited |
| AI Agent: provider answered 5xx | transient | provider_unavailable |
| AI Agent: provider answered 408, or the connection failed | transient | provider_unreachable |
AI Agent: AI_* variables missing |
permanent | ai_not_configured |
| AI Agent, Decision: template reference malformed or unresolved | permanent | template_malformed, template_unresolved |
| Decision: no branch matched | permanent | no_branch_matched |
The provider's own error is attached as cause, and node_failed reports the deepest non-empty cause's text, so the provider's message reaches the UI as it did before classification. A refused connection is the exception: the SDK reports it as Cannot connect to API: with nothing after the colon, because the reason sits in an AggregateError it wraps — one entry per address tried. Only messages survive the activity boundary, so the classifier attaches the first entry (connect ECONNREFUSED ::1:11434) as the cause instead of the SDK error. The classifier's own message, which names the HTTP status, is one level up and visible only in Temporal's failure record. 409 is permanent on purpose, unlike the AI SDK's own retry default: no chat provider is known to answer 409 for a condition a retry would clear. Two kinds of SDK error stay unclassified and keep the profile's uniform retry: a response the SDK could not parse (a 2xx with a non-JSON body, typically a proxy answering with HTML) and errors raised without any provider response (a malformed tool call from the model, no output generated), which describe model behaviour a retry can change. Marking a failure transient does not buy extra attempts — the node profile still caps them.
- Create
src/engines/<name>/with:- a bootstrap (equivalent of
worker.ts) that wires upNodeExecutorRegistryand connects to the queue - an adapter in
apps/backend/src/engine/<name>-engine.tsimplementingWorkflowEnginePort
- a bootstrap (equivalent of
- Point
getWorkflowEngine()inapps/backend/src/engine/index.tsat the new adapter (or add config-driven selection). - Reuse
runGraphfrom@workflow-builder/execution-core/workflow— the graph traversal is engine-agnostic.
The domain layer (execution-core) never has to change.