diff --git a/.dockerignore b/.dockerignore
index 8ec9bf851..19d349853 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -31,3 +31,15 @@ examples/
**/.env
**/.env.*
!**/.env.example
+
+# certificate material is mounted at runtime — never built into an image. The
+# Dockerfile needs one file from deploy/, so the rest stays out of the context:
+# a TEMPORAL_TLS_DIR under deploy/ cannot reach COPY . . whatever it is named.
+deploy/
+!deploy/ai-studio/nginx
+**/*.pem
+**/*.key
+**/*.crt
+**/*.cer
+**/*.p12
+**/*.pfx
diff --git a/.github/workflows/deploy-ai-studio.yml b/.github/workflows/deploy-ai-studio.yml
index 0a1850c10..9adba0961 100644
--- a/.github/workflows/deploy-ai-studio.yml
+++ b/.github/workflows/deploy-ai-studio.yml
@@ -77,6 +77,9 @@ jobs:
needs: build-and-push
steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
- name: Log in to Azure
uses: azure/login@v2
with:
@@ -84,18 +87,48 @@ jobs:
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
+ # The VM runs the repo's compose files, shipped here on every deploy (base64,
+ # so the script stays free of quoting). Compose is run from the project
+ # directory, not with -f: that is what applies docker-compose.override.yml
+ # by default and honours COMPOSE_FILE from the VM's .env.
+ #
+ # The retired-key check runs before anything is written, so a refused deploy
+ # leaves the VM exactly as it was. It lives here rather than in the compose
+ # file because Compose 2.21 and older evaluate a nested `${A:+${B:?}}` guard
+ # eagerly and fail on every command, key set or not.
+ #
+ # The image tags are written into that .env rather than exported: an export
+ # dies with this shell, and the next `docker compose up -d worker` on the VM
+ # would fall back to the local ai-studio-* names. Only the two image lines
+ # are replaced; the rest of .env is the VM's own and stays untouched.
- name: Refresh docker compose on Azure VM
+ env:
+ IMAGE: ${{ env.REGISTRY }}/${{ env.APP }}:${{ needs.build-and-push.outputs.image_tag }}
run: |
+ COMPOSE_B64=$(base64 -w0 deploy/ai-studio/docker-compose.yml)
+ OVERRIDE_B64=$(base64 -w0 deploy/ai-studio/docker-compose.override.yml)
+ SCRIPT=$(cat < docker-compose.yml
+ echo "$OVERRIDE_B64" | base64 -d > docker-compose.override.yml
+ touch .env
+ { grep -vE '^(RUNTIME_IMAGE|WEB_IMAGE)=' .env || true; printf 'RUNTIME_IMAGE=%s\nWEB_IMAGE=%s\n' "$IMAGE-runtime" "$IMAGE-web"; } > .env.tmp
+ chmod --reference=.env .env.tmp && chown --reference=.env .env.tmp && mv .env.tmp .env
+ az acr login --name synergycodes
+ docker compose pull
+ docker compose up -d --no-build --force-recreate --remove-orphans
+ echo DEPLOY_SCRIPT_SUCCEEDED
+ EOF
+ )
OUTPUT=$(az vm run-command invoke \
--name ${{ vars.AI_STUDIO_VM_NAME }} \
--resource-group ${{ vars.AI_STUDIO_VM_RG }} \
--command-id RunShellScript \
- --scripts '
- set -e
- az acr login --name synergycodes
- docker compose -f /app/ai-studio/docker-compose.yml pull
- docker compose -f /app/ai-studio/docker-compose.yml up -d --no-build --force-recreate
- echo DEPLOY_SCRIPT_SUCCEEDED
- ')
+ --scripts "$SCRIPT")
echo "$OUTPUT"
echo "$OUTPUT" | grep -q DEPLOY_SCRIPT_SUCCEEDED
diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml
index 9e01a1c89..451c19315 100644
--- a/.github/workflows/pr-check.yml
+++ b/.github/workflows/pr-check.yml
@@ -6,8 +6,8 @@ name: PR Check
# @workflowbuilder/ui-tokens build) and @workflowbuilder/temporal, and the
# execution pipeline (execution-core, backend, execution-worker) — whose
# determinism tests guard Temporal replay safety and so must not be able to
-# regress silently. Plus
-# global format consistency. apps/docs has its own path-filtered workflow
+# regress silently. Plus the deploy compose files, which ship to the demo VM on
+# every deploy, and global format consistency. apps/docs has its own path-filtered workflow
# (pr-check-docs.yml); demo and ai-studio are not checked here — they're
# internal and have their own broken-state tolerances.
@@ -170,6 +170,34 @@ jobs:
fi
fi
+ deploy-compose:
+ name: Deploy compose files parse
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Parse both compose modes on the runner's Compose and on the oldest supported one
+ # These files reach the demo VM on every deploy, so a parse error is only
+ # discovered there, with the stack already down. 2.21 is the floor: it
+ # interpolates a nested `${A:+${B:?}}` default eagerly where newer
+ # compose-go is lazy, so a file that parses on the runner can still fail
+ # on a VM. Both modes are covered because COMPOSE_FILE in the VM's .env
+ # decides whether the override file is applied at all.
+ working-directory: deploy/ai-studio
+ run: |
+ cp .env.example .env
+ # An empty COMPOSE_FILE is not the same as an unset one — compose then
+ # reads the working directory as a file — so the default mode runs with
+ # the variable absent and `-e` forwards it only once it is exported.
+ parse() {
+ docker compose config --quiet
+ docker run --rm -v "$PWD:/w" -w /w -e COMPOSE_FILE docker:24.0.5-cli docker compose config --quiet
+ }
+ parse
+ export COMPOSE_FILE=docker-compose.yml
+ parse
+
ui:
name: UI + UI tokens lint + typecheck + test + build
runs-on: ubuntu-latest
@@ -219,10 +247,11 @@ jobs:
execution:
name: Execution pipeline lint + typecheck + test
runs-on: ubuntu-latest
- # No `services:` block: all three suites are pure unit tests against
- # in-memory fakes — no Postgres, no Temporal, no API keys. If a suite here
- # ever needs real infra, give it its own job rather than adding services
- # to this one.
+ # No `services:` block: the suites run against in-memory fakes — no Postgres,
+ # no API keys. The one exception is temporal-connection's TLS test, which
+ # starts Temporal's dev server itself (@temporalio/testing downloads the CLI
+ # on first run). If a suite here ever needs infra it cannot start itself,
+ # give it its own job rather than adding services to this one.
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -244,10 +273,10 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Lint
- run: pnpm --filter @workflow-builder/execution-core --filter @workflow-builder/backend --filter @workflow-builder/execution-worker lint
+ run: pnpm --filter @workflow-builder/execution-core --filter @workflow-builder/ai-config --filter @workflow-builder/temporal-connection --filter @workflow-builder/backend --filter @workflow-builder/execution-worker lint
- name: Typecheck
- run: pnpm --filter @workflow-builder/execution-core --filter @workflow-builder/backend --filter @workflow-builder/execution-worker typecheck
+ run: pnpm --filter @workflow-builder/execution-core --filter @workflow-builder/ai-config --filter @workflow-builder/temporal-connection --filter @workflow-builder/backend --filter @workflow-builder/execution-worker typecheck
- name: Test
- run: pnpm --filter @workflow-builder/execution-core --filter @workflow-builder/backend --filter @workflow-builder/execution-worker test
+ run: pnpm --filter @workflow-builder/execution-core --filter @workflow-builder/ai-config --filter @workflow-builder/temporal-connection --filter @workflow-builder/backend --filter @workflow-builder/execution-worker test
diff --git a/CLAUDE.md b/CLAUDE.md
index ae6a3d247..5af220bce 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -60,11 +60,13 @@ apps/
icons/ - Icon generation pipeline
tools/ - @workflow-builder/tools workspace (decision-log collector, lint-staged config)
packages/
+ ai-config/ - Private, source-only: the AI_API_KEY / AI_BASE_URL / AI_MODEL contract, one copy shared by backend and worker
sdk/ - @workflowbuilder/sdk public package (WorkflowBuilder compound component, plugin API, components)
ui/ - @workflowbuilder/ui published component library (Base UI), consumed by sdk/demo/ai-studio
tokens/ - @workflowbuilder/ui-tokens private design-token build (style-dictionary), feeds packages/ui
execution-core/ - Pure topological graph runner + node executor registry
temporal/ - @workflowbuilder/temporal published Temporal Plugin (activities + workflow runner); bundles execution-core + types into its dist
+ temporal-connection/ - Private, source-only: TEMPORAL_* env -> validated connection options + namespace, one copy shared by backend and worker
types/ - Shared TypeScript types
```
@@ -74,21 +76,25 @@ Where to put a new script: root `tools/` for pure-Node bootstrap (runs before an
Each workspace has its own context. Read the relevant file before extending a workspace.
-| Workspace | Authoritative docs |
-| ------------------------- | ------------------------------------------------------- |
-| `packages/sdk` | `packages/sdk/README.md` |
-| `packages/ui` | `packages/ui/README.md` (+ `packages/ui/css-layers.md`) |
-| `packages/tokens` | `packages/tokens/README.md` |
-| `packages/execution-core` | `packages/execution-core/README.md` |
-| `packages/temporal` | `packages/temporal/README.md` |
-| `apps/demo` | `apps/demo/CLAUDE.md` |
-| `apps/ai-studio` | `apps/ai-studio/README.md` |
-| `apps/backend` | `apps/backend/README.md` |
-| `apps/execution-worker` | `apps/execution-worker/README.md` |
+| Workspace | Authoritative docs |
+| ------------------------------ | ------------------------------------------------------- |
+| `packages/sdk` | `packages/sdk/README.md` |
+| `packages/ui` | `packages/ui/README.md` (+ `packages/ui/css-layers.md`) |
+| `packages/tokens` | `packages/tokens/README.md` |
+| `packages/ai-config` | `packages/ai-config/README.md` |
+| `packages/execution-core` | `packages/execution-core/README.md` |
+| `packages/temporal` | `packages/temporal/README.md` |
+| `packages/temporal-connection` | `packages/temporal-connection/README.md` |
+| `apps/demo` | `apps/demo/CLAUDE.md` |
+| `apps/ai-studio` | `apps/ai-studio/README.md` |
+| `apps/backend` | `apps/backend/README.md` |
+| `apps/execution-worker` | `apps/execution-worker/README.md` |
## Types & Aliases
Shared types: `packages/types/` (imported as `@workflow-builder/types/*`).
+AI configuration contract: `packages/ai-config/` (imported as `@workflow-builder/ai-config`; `aiConfig()` tells backend and worker whether the LLM is configured and what is missing).
+Temporal connection config: `packages/temporal-connection/` (imported as `@workflow-builder/temporal-connection`; `temporalConfig()` gives backend and worker their connect options and namespace).
Icons: `apps/icons/` (imported as `@workflow-builder/icons`).
SDK: `packages/sdk/` (imported as `@workflowbuilder/sdk`).
UI: `packages/ui/` (imported as `@workflowbuilder/ui`; styles via `@workflowbuilder/ui/styles.css`, `/index.css`, `/tokens.css`).
@@ -102,7 +108,7 @@ UI: `packages/ui/` (imported as `@workflowbuilder/ui`; styles via `@workflowbuil
- Temporal server on `7233` (gRPC)
- Temporal UI on http://localhost:8233
-Backend reads `DATABASE_URL` and `TEMPORAL_ADDRESS`; defaults work out of the box. `pnpm infra:down` stops everything.
+Backend reads `DATABASE_URL` and `TEMPORAL_ADDRESS`; defaults work out of the box. Pointing either app at a secured cluster or Temporal Cloud is env-only (`TEMPORAL_NAMESPACE`, `TEMPORAL_TLS`, `TEMPORAL_API_KEY`, `TEMPORAL_TLS_*_PATH`) - see `apps/backend/README.md` "Connecting to a secured Temporal cluster". `pnpm infra:down` stops everything.
## Code Quality
diff --git a/README.md b/README.md
index 4bec5f419..2922fbf08 100644
--- a/README.md
+++ b/README.md
@@ -197,30 +197,31 @@ Temporal ready
[ai-studio] ➜ Local: http://127.0.0.1:4201/
```
-Open `http://localhost:4201`. Pick the "Sales Inquiry" template, click Play. The Temporal UI at `http://localhost:8233` shows the running execution.
+Open `http://localhost:4201`. Every bundled template contains AI Agent nodes, so either connect an LLM first (next section) or expect the run to stop at its first AI Agent node with `ai_not_configured` while the Trigger, Decision and Visualize nodes before it run. Pick a template, click Play. The Temporal UI at `http://localhost:8233` shows the running execution.
To stop: `Ctrl+C`, then `pnpm infra:down`.
#### Connect a real LLM (optional)
-AI Studio works with stub responses out of the box. To use a real model, add to both `apps/backend/.env` and `apps/execution-worker/.env`:
+The stack starts without an LLM: Trigger, Decision and Visualize nodes run as usual, and an AI Agent node fails with `ai_not_configured` when the run reaches it. AI nodes need three variables in both `apps/backend/.env` and `apps/execution-worker/.env`. The files `pnpm setup:env` created already carry an endpoint and a model for [OpenRouter](https://openrouter.ai), so only the key is missing:
```env
-OPENROUTER_API_KEY=sk-or-v1-...
-AI_MODEL=anthropic/claude-3.5-haiku
+AI_API_KEY=sk-or-v1-...
+AI_BASE_URL=https://openrouter.ai/api/v1
+AI_MODEL=mistralai/mistral-small-3.2-24b-instruct
```
-If the key is missing the worker fails to start with `OPENROUTER_API_KEY is required`. If the model id is wrong the first AI node fails at runtime and the error surfaces in the UI log panel.
+None of the three has a built-in default. Any OpenAI-compatible endpoint works: set `AI_BASE_URL` to a gateway or to a model hosted inside your own network, `AI_MODEL` to an id that endpoint understands, and model requests stay inside it. That covers the model only: the optional web-search tool calls Tavily's API when `TAVILY_API_KEY` is set, so leave it unset if nothing may call out. If the model id is wrong, the first AI node fails at runtime and the error surfaces in the UI log panel.
### Troubleshooting
-| Symptom | Cause | Fix |
-| ----------------------------------------------------------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
-| `EADDRINUSE` on 3001, 4200, 4201, 5432, 5433, 7233, or 8233 | Another process holds the port | `pnpm preflight` shows the conflict. Stop the other process or change the port |
-| Temporal UI loads but the `default` namespace is missing | Migrations not run | `pnpm -F backend db:migrate` |
-| Worker exits with `OPENROUTER_API_KEY is required` | Real LLM env var missing | Set it in `apps/execution-worker/.env`. Optional unless you want a real LLM call |
-| `pnpm dev:demo` shows TypeScript errors but the dev server still starts | `concurrently` runs typecheck alongside Vite. TS errors are non-fatal | Fix the errors or ignore them temporarily |
-| Vite acts up after a dependency change | Stale `node_modules/.vite` | `rm -rf node_modules/.vite` and rerun |
+| Symptom | Cause | Fix |
+| ----------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
+| `EADDRINUSE` on 3001, 4200, 4201, 5432, 5433, 7233, or 8233 | Another process holds the port | `pnpm preflight` shows the conflict. Stop the other process or change the port |
+| Temporal UI loads but the `default` namespace is missing | Migrations not run | `pnpm -F backend db:migrate` |
+| AI Agent node fails with `ai_not_configured` | LLM not configured — the worker starts anyway, only AI nodes are unavailable | Set `AI_API_KEY`, `AI_BASE_URL` and `AI_MODEL` in `apps/execution-worker/.env` |
+| `pnpm dev:demo` shows TypeScript errors but the dev server still starts | `concurrently` runs typecheck alongside Vite. TS errors are non-fatal | Fix the errors or ignore them temporarily |
+| Vite acts up after a dependency change | Stale `node_modules/.vite` | `rm -rf node_modules/.vite` and rerun |
For the full command reference, see the table in [`CLAUDE.md`](./CLAUDE.md) or the documentation site.
diff --git a/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx b/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx
index 01961e551..4c7855f57 100644
--- a/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx
+++ b/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx
@@ -68,7 +68,7 @@ export function DisclaimerModal() {
workflow editors.
- The workflows here run for real: every AI step calls a live model through OpenRouter.
+ The workflows here run for real: every AI step calls a live model.
It is not a place to test or benchmark AI models. The model is just the engine — the point
diff --git a/apps/backend/.env.example b/apps/backend/.env.example
index d639840f1..ab1034d78 100644
--- a/apps/backend/.env.example
+++ b/apps/backend/.env.example
@@ -1,5 +1,26 @@
DATABASE_URL=postgresql://wb:wb@127.0.0.1:5432/workflow_builder
TEMPORAL_ADDRESS=127.0.0.1:7233
+# Must match the worker's namespace. Leave as `default` for the bundled dev cluster.
+TEMPORAL_NAMESPACE=default
+# Connection security. All optional, and all default to a plaintext connection —
+# which is what the bundled dev cluster expects.
+#
+# TEMPORAL_TLS: leave empty to infer (setting any credential below turns TLS on),
+# `true` to require TLS with the OS trust store, `false` to assert plaintext.
+TEMPORAL_TLS=
+# API key auth, as used by Temporal Cloud. Implies TLS.
+TEMPORAL_API_KEY=
+# Paths to PEM files, read when the connection opens. CA for a private issuer;
+# the cert/key pair for mTLS (set both or neither, and not alongside an API key).
+TEMPORAL_TLS_CA_PATH=
+TEMPORAL_TLS_CERT_PATH=
+TEMPORAL_TLS_KEY_PATH=
+#
+# Temporal Cloud looks like this:
+# TEMPORAL_ADDRESS=..tmprl.cloud:7233
+# TEMPORAL_NAMESPACE=.
+# TEMPORAL_API_KEY=
+
PORT=3001
# Hostname to bind. Default 127.0.0.1 (loopback only - single-tenant local dev).
# Change ONLY if you understand: this server has no auth, anyone reachable on
@@ -15,8 +36,14 @@ WB_AUTH_PORT=allow-all
# verification (local dev). When set, POST /api/workflows/:id/execute requires a
# valid Turnstile token sent by the frontend as the cf-turnstile-token header.
TURNSTILE_SECRET_KEY=
-# OpenRouter key for the Visualize "AI adapt" endpoint (POST /api/visualize/adapt).
-# Optional: leave empty to disable AI adapt (the endpoint returns 501). The
-# execution worker keeps its own key for running workflows.
-OPENROUTER_API_KEY=
+# API key for the Visualize "AI adapt" endpoint (POST /api/visualize/adapt).
+# Optional: leave empty to disable AI adapt (the endpoint returns 501). The three
+# AI_* variables are all-or-nothing (see packages/ai-config/README.md). The
+# execution worker reads its own copy of them. OpenRouter keys look like sk-or-v1-...
+AI_API_KEY=
+# Any OpenAI-compatible endpoint — a hosted gateway or a model inside your own
+# network. Must be the base URL, without a trailing /chat/completions.
+# Pre-filled with OpenRouter's URL; there is no built-in default.
+AI_BASE_URL=https://openrouter.ai/api/v1
+# Model id as the endpoint above understands it.
AI_MODEL=mistralai/mistral-small-3.2-24b-instruct
diff --git a/apps/backend/README.md b/apps/backend/README.md
index fdd487c41..936bbdadf 100644
--- a/apps/backend/README.md
+++ b/apps/backend/README.md
@@ -7,7 +7,7 @@
> **Note:** setup is in [root README "Path C. Run the full stack demo"](../../README.md#path-c-run-the-full-stack-demo). This file documents the backend's internals, not how to start it.
-Backend execution layer for Workflow Builder AI Studio plugin. Runs AI workflows defined on the canvas via Temporal + OpenRouter.
+Backend execution layer for Workflow Builder AI Studio plugin. Runs AI workflows defined on the canvas via Temporal and an OpenAI-compatible LLM endpoint (`AI_BASE_URL`).
## Architecture
@@ -56,7 +56,36 @@ DATABASE_URL=postgresql://wb:wb@127.0.0.1:5432/workflow_builder
TEMPORAL_ADDRESS=127.0.0.1:7233
```
-Worker additionally needs `OPENROUTER_API_KEY` and optionally `AI_MODEL`. See [`apps/execution-worker/README.md`](../execution-worker/README.md).
+Both also read `AI_API_KEY`, `AI_BASE_URL` and `AI_MODEL` — all three or none, through
+[`@workflow-builder/ai-config`](../../packages/ai-config/README.md), which is the canonical description
+of that contract. Each side degrades on its own when they are missing: the backend's AI adapt endpoint
+returns 501, and the worker runs everything except AI Agent nodes. See
+[`apps/execution-worker/README.md`](../execution-worker/README.md).
+
+### Connecting to a secured Temporal cluster
+
+The defaults above open a plaintext connection to the bundled dev cluster. Everything about the
+connection is env-driven, so a hardened cluster or Temporal Cloud needs no code change. The
+variables are read and validated by [`@workflow-builder/temporal-connection`](../../packages/temporal-connection/README.md),
+the same code the worker uses:
+
+| Var | Purpose | Default |
+| ------------------------ | ------------------------------------------------------------ | ------------- |
+| `TEMPORAL_NAMESPACE` | Namespace to use. Must match the worker's | `default` |
+| `TEMPORAL_TLS` | `true` requires TLS, `false` asserts plaintext, empty infers | empty (infer) |
+| `TEMPORAL_API_KEY` | API key auth (Temporal Cloud). Implies TLS | — |
+| `TEMPORAL_TLS_CA_PATH` | PEM for a private certificate authority | — |
+| `TEMPORAL_TLS_CERT_PATH` | Client certificate for mTLS. Set with the key | — |
+| `TEMPORAL_TLS_KEY_PATH` | Client private key for mTLS. Set with the certificate | — |
+
+Any credential turns TLS on by itself, so `TEMPORAL_TLS` only has to be set to force TLS with no
+credentials, or to assert plaintext. Contradictory combinations — half an mTLS pair, an API key
+together with a client certificate, or credentials alongside `TEMPORAL_TLS=false` — are rejected
+with an explanatory error at startup, rather than being silently ignored. The connection itself is
+opened on the first run, so booting does not require Temporal to be reachable.
+
+For Temporal Cloud, set `TEMPORAL_ADDRESS` to `..tmprl.cloud:7233`,
+`TEMPORAL_NAMESPACE` to `.`, and `TEMPORAL_API_KEY` to your key.
## Scripts
diff --git a/apps/backend/package.json b/apps/backend/package.json
index 85f2e79b7..3fbcf3545 100644
--- a/apps/backend/package.json
+++ b/apps/backend/package.json
@@ -17,13 +17,15 @@
"db:studio": "drizzle-kit studio"
},
"dependencies": {
+ "@ai-sdk/openai-compatible": "catalog:",
"@hono/node-server": "^1.14.0",
- "@openrouter/ai-sdk-provider": "^2.8.0",
"@temporalio/client": "catalog:",
+ "@workflow-builder/ai-config": "workspace:*",
"@workflow-builder/execution-core": "workspace:*",
+ "@workflow-builder/temporal-connection": "workspace:*",
"@workflow-builder/types": "workspace:*",
"@workflowbuilder/temporal": "workspace:*",
- "ai": "^6.0.168",
+ "ai": "catalog:",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.44.0",
"hono": "^4.7.0",
diff --git a/apps/backend/src/engine/index.test.ts b/apps/backend/src/engine/index.test.ts
new file mode 100644
index 000000000..69b280f28
--- /dev/null
+++ b/apps/backend/src/engine/index.test.ts
@@ -0,0 +1,42 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+// The engine module reads TEMPORAL_* when it is imported, so each case needs a fresh
+// module and an environment free of whatever the runner's shell carries.
+const TEMPORAL_NAMES = [
+ 'TEMPORAL_ADDRESS',
+ 'TEMPORAL_NAMESPACE',
+ 'TEMPORAL_TLS',
+ 'TEMPORAL_API_KEY',
+ 'TEMPORAL_TLS_CA_PATH',
+ 'TEMPORAL_TLS_CERT_PATH',
+ 'TEMPORAL_TLS_KEY_PATH',
+];
+
+async function loadEngine(values: Record = {}) {
+ vi.resetModules();
+ for (const name of TEMPORAL_NAMES) {
+ // undefined is Vitest's delete signal, and unstubAllEnvs restores the variable
+ // eslint-disable-next-line unicorn/no-useless-undefined
+ vi.stubEnv(name, undefined);
+ }
+ for (const [name, value] of Object.entries(values)) {
+ vi.stubEnv(name, value);
+ }
+ return import('./index');
+}
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+});
+
+describe('getWorkflowEngine', () => {
+ it('rejects a contradictory TEMPORAL_* combination at import, not on the first submit', async () => {
+ await expect(loadEngine({ TEMPORAL_TLS_CERT_PATH: '/tls/client.pem' })).rejects.toThrow(/TEMPORAL_TLS_CERT_PATH/);
+ });
+
+ it('builds the engine without reaching Temporal, so the backend boots while the cluster is down', async () => {
+ const { getWorkflowEngine } = await loadEngine({ TEMPORAL_ADDRESS: '203.0.113.1:7233' });
+
+ expect(getWorkflowEngine()).toBe(getWorkflowEngine());
+ });
+});
diff --git a/apps/backend/src/engine/index.ts b/apps/backend/src/engine/index.ts
index 179170e7d..87bf268ac 100644
--- a/apps/backend/src/engine/index.ts
+++ b/apps/backend/src/engine/index.ts
@@ -2,9 +2,13 @@ import { Client, Connection } from '@temporalio/client';
import { TemporalWorkflowEngine } from '@workflowbuilder/temporal/client';
import type { WorkflowEnginePort } from '@workflow-builder/execution-core/workflow';
+import { temporalConfig } from '@workflow-builder/temporal-connection';
import type { BaseNode } from '@workflow-builder/types/workflow-execution/execution-model';
-import { env } from '../env';
+// Read here rather than inside the factory below, so a contradictory combination or
+// an unreadable certificate stops the backend at boot, as it stops the worker.
+// Neither parsing nor reading the PEM files needs Temporal to be reachable.
+const temporal = temporalConfig();
let engine: WorkflowEnginePort | undefined;
@@ -13,7 +17,10 @@ export function getWorkflowEngine(): WorkflowEnginePort {
engine = new TemporalWorkflowEngine({
// A factory rather than a ready client: the connection is opened on the first
// submit, so booting the backend does not require Temporal to be reachable.
- client: async () => new Client({ connection: await Connection.connect({ address: env.TEMPORAL_ADDRESS }) }),
+ client: async () => {
+ const connection = await Connection.connect(temporal.connection);
+ return new Client({ connection, namespace: temporal.namespace });
+ },
});
}
return engine;
diff --git a/apps/backend/src/env.test.ts b/apps/backend/src/env.test.ts
new file mode 100644
index 000000000..8b0272b45
--- /dev/null
+++ b/apps/backend/src/env.test.ts
@@ -0,0 +1,115 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { env as shape } from './env';
+
+// The keys of `env` are the variable names, so a variable added to env.ts is
+// cleared here without anyone remembering to list it.
+const ENV_NAMES = Object.keys(shape);
+
+// env.ts reads process.env once at module load, so every case needs a fresh module
+// and a clean environment: whatever the runner's shell carries is unset first.
+async function loadEnv(values: Record) {
+ vi.resetModules();
+ for (const name of ENV_NAMES) {
+ // undefined is Vitest's delete signal, and unstubAllEnvs restores the variable
+ // eslint-disable-next-line unicorn/no-useless-undefined
+ vi.stubEnv(name, undefined);
+ }
+ for (const [name, value] of Object.entries(values)) {
+ vi.stubEnv(name, value);
+ }
+ const module = await import('./env');
+ return module.env;
+}
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+});
+
+describe('loadEnv', () => {
+ it('ignores variables inherited from the runner', async () => {
+ vi.stubEnv('TURNSTILE_SECRET_KEY', 'ambient-secret');
+
+ const env = await loadEnv({});
+
+ expect(env.TURNSTILE_SECRET_KEY).toBeNull();
+ });
+});
+
+describe('local defaults', () => {
+ // Not `localhost`: on some Windows / Node configs it resolves to ::1 first, which the
+ // IPv4-only docker mapping rejects. See local-dev-binding.decision-log.md.
+ it.each(['HOST', 'DATABASE_URL'] as const)('spells the loopback address of %s as 127.0.0.1', async (name) => {
+ const env = await loadEnv({});
+
+ expect(env[name]).toContain('127.0.0.1');
+ });
+
+ it('serves port 3001', async () => {
+ const env = await loadEnv({});
+
+ expect(env.PORT).toBe(3001);
+ });
+
+ it('reads a port that is set', async () => {
+ const env = await loadEnv({ PORT: '8080' });
+
+ expect(env.PORT).toBe(8080);
+ });
+
+ it('reads a database url that is set', async () => {
+ const url = 'postgresql://wb:wb@app-db:5432/workflow_builder';
+ const env = await loadEnv({ DATABASE_URL: url });
+
+ expect(env.DATABASE_URL).toBe(url);
+ });
+});
+
+describe('TRUST_PROXY', () => {
+ // Decides whether X-Forwarded-For is believed, so only the exact string opts in:
+ // anything else must leave the rate limiter keying on the socket address.
+ it.each(['true'])('trusts the proxy on %s', async (value) => {
+ const env = await loadEnv({ TRUST_PROXY: value });
+
+ expect(env.TRUST_PROXY).toBe(true);
+ });
+
+ it.each(['TRUE', 'True', '1', 'yes', ''])('does not trust the proxy on %s', async (value) => {
+ const env = await loadEnv({ TRUST_PROXY: value });
+
+ expect(env.TRUST_PROXY).toBe(false);
+ });
+
+ it('does not trust the proxy when unset', async () => {
+ const env = await loadEnv({});
+
+ expect(env.TRUST_PROXY).toBe(false);
+ });
+});
+
+describe('execute rate limits', () => {
+ // server.ts mounts the limiter only when one of them is above zero, so the default
+ // has to be the number 0 rather than NaN — `Number('')` and `Number(undefined)` differ.
+ it.each(['RATE_LIMIT_EXECUTE_PER_MINUTE', 'RATE_LIMIT_EXECUTE_PER_DAY'] as const)(
+ 'leaves %s disabled by default',
+ async (name) => {
+ const env = await loadEnv({});
+
+ expect(env[name]).toBe(0);
+ },
+ );
+
+ it('reads both limits when they are set', async () => {
+ const env = await loadEnv({ RATE_LIMIT_EXECUTE_PER_MINUTE: '10', RATE_LIMIT_EXECUTE_PER_DAY: '50' });
+
+ expect(env).toMatchObject({ RATE_LIMIT_EXECUTE_PER_MINUTE: 10, RATE_LIMIT_EXECUTE_PER_DAY: 50 });
+ });
+});
+
+describe('TURNSTILE_SECRET_KEY', () => {
+ it('reads a secret that is set', async () => {
+ const env = await loadEnv({ TURNSTILE_SECRET_KEY: 'secret' });
+
+ expect(env.TURNSTILE_SECRET_KEY).toBe('secret');
+ });
+});
diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts
index 278e18aa8..b36d8f5ce 100644
--- a/apps/backend/src/env.ts
+++ b/apps/backend/src/env.ts
@@ -11,14 +11,12 @@ export const env = {
PORT: Number(envOr('PORT', '3001')),
HOST: envOr('HOST', '127.0.0.1'),
DATABASE_URL: envOr('DATABASE_URL', 'postgresql://wb:wb@127.0.0.1:5432/workflow_builder'),
- TEMPORAL_ADDRESS: envOr('TEMPORAL_ADDRESS', '127.0.0.1:7233'),
+ // TEMPORAL_*: read at connect time by @workflow-builder/temporal-connection.
+ // AI_API_KEY / AI_BASE_URL / AI_MODEL: read per request by @workflow-builder/ai-config.
// 0 disables (dev default); the deploy compose sets both
RATE_LIMIT_EXECUTE_PER_MINUTE: Number(envOr('RATE_LIMIT_EXECUTE_PER_MINUTE', '0')),
RATE_LIMIT_EXECUTE_PER_DAY: Number(envOr('RATE_LIMIT_EXECUTE_PER_DAY', '0')),
TRUST_PROXY: envOr('TRUST_PROXY', 'false') === 'true',
// Null = Turnstile verification disabled (local dev runs unprotected).
TURNSTILE_SECRET_KEY: process.env['TURNSTILE_SECRET_KEY'] ?? null,
- // Null = the "AI adapt" endpoint is disabled (returns 501). The worker keeps its own key.
- OPENROUTER_API_KEY: process.env['OPENROUTER_API_KEY'] ?? null,
- AI_MODEL: envOr('AI_MODEL', 'mistralai/mistral-small-3.2-24b-instruct'),
};
diff --git a/apps/backend/src/routes/visualize.test.ts b/apps/backend/src/routes/visualize.test.ts
new file mode 100644
index 000000000..6743c73cb
--- /dev/null
+++ b/apps/backend/src/routes/visualize.test.ts
@@ -0,0 +1,63 @@
+import { Hono } from 'hono';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import type { AssertAuthorized, AuthVariables } from '../auth';
+import type { TenantVariables } from '../tenant';
+
+const { warn } = vi.hoisted(() => ({ warn: vi.fn() }));
+
+vi.mock('../logger', () => ({
+ logger: { child: () => ({ warn, error: vi.fn(), info: vi.fn(), debug: vi.fn() }) },
+}));
+
+const { createVisualizeRoutes } = await import('./visualize');
+
+const AI_NAMES = ['AI_API_KEY', 'AI_BASE_URL', 'AI_MODEL', 'OPENROUTER_API_KEY'];
+
+function adapt(env: Record = {}) {
+ for (const name of AI_NAMES) {
+ // undefined is Vitest's delete signal, and unstubAllEnvs restores the variable
+ // eslint-disable-next-line unicorn/no-useless-undefined
+ vi.stubEnv(name, undefined);
+ }
+ for (const [name, value] of Object.entries(env)) {
+ vi.stubEnv(name, value);
+ }
+
+ const app = new Hono<{ Variables: AuthVariables & TenantVariables }>();
+ app.route('/api/visualize', createVisualizeRoutes((async () => {}) as unknown as AssertAuthorized));
+
+ return app.request('/api/visualize/adapt', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ content: 'anything', format: 'text' }),
+ });
+}
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+ warn.mockClear();
+});
+
+describe('POST /api/visualize/adapt without an LLM', () => {
+ it('answers 501', async () => {
+ const response = await adapt();
+
+ expect(response.status).toBe(501);
+ });
+
+ it('names a retired variable that is set, so an ignored key is not a silent 501', async () => {
+ await adapt({ OPENROUTER_API_KEY: 'old-key' });
+
+ expect(warn).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({ missing: ['AI_API_KEY', 'AI_BASE_URL', 'AI_MODEL'], retired: ['OPENROUTER_API_KEY'] }),
+ );
+ });
+
+ it('reports no retired key when none is set', async () => {
+ await adapt();
+
+ expect(warn).toHaveBeenCalledWith(expect.any(String), expect.not.objectContaining({ retired: expect.anything() }));
+ });
+});
diff --git a/apps/backend/src/routes/visualize.ts b/apps/backend/src/routes/visualize.ts
index 8ebc5066c..f59e967dc 100644
--- a/apps/backend/src/routes/visualize.ts
+++ b/apps/backend/src/routes/visualize.ts
@@ -1,10 +1,11 @@
-import { createOpenRouter } from '@openrouter/ai-sdk-provider';
+import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import { generateText } from 'ai';
import { Hono } from 'hono';
import { z } from 'zod';
+import { aiConfig, retiredAiVariables } from '@workflow-builder/ai-config';
+
import type { AssertAuthorized, AuthVariables } from '../auth';
-import { env } from '../env';
import { logger as backendLogger } from '../logger';
import { guardExecution } from '../security/execution-guard';
import type { TenantVariables } from '../tenant';
@@ -50,9 +51,19 @@ export function createVisualizeRoutes(
return blocked;
}
- if (!env.OPENROUTER_API_KEY) {
+ // After authorization and the guard on purpose: an unconfigured server still gates the call.
+ const ai = aiConfig();
+ if (!ai.available) {
+ // `retired` names a variable that is set and no longer read — the reason a key
+ // that used to work now yields a 501. Only the name is logged, never the value.
+ const retired = retiredAiVariables();
+ logger.warn('adapt requested while AI is not configured', {
+ missing: ai.missing,
+ ...(retired.length > 0 ? { retired } : {}),
+ });
return c.json({ code: 'adapt_disabled', message: 'AI adapt is not configured on this server.' }, 501);
}
+ const { apiKey, baseURL, modelId } = ai.config;
const parsed = z.safeParse(adaptSchema, await c.req.json());
if (!parsed.success) {
@@ -61,11 +72,9 @@ export function createVisualizeRoutes(
const { content, format } = parsed.data;
try {
- const openrouter = createOpenRouter({ apiKey: env.OPENROUTER_API_KEY });
- // Unlike the worker's AI agent activity, this route has no outer retry
- // policy, so the SDK's default retries stay on.
+ const provider = createOpenAICompatible({ name: 'ai', baseURL, apiKey });
const result = await generateText({
- model: openrouter.chat(env.AI_MODEL),
+ model: provider.chatModel(modelId),
system: FORMAT_PROMPTS[format],
// Low temperature for stable structured output.
temperature: 0.2,
diff --git a/apps/docs/src/content/docs/get-started/quick-start/standalone-app.mdx b/apps/docs/src/content/docs/get-started/quick-start/standalone-app.mdx
index 97877fc9d..ef537ec21 100644
--- a/apps/docs/src/content/docs/get-started/quick-start/standalone-app.mdx
+++ b/apps/docs/src/content/docs/get-started/quick-start/standalone-app.mdx
@@ -154,30 +154,68 @@ Temporal ready
[ai-studio] ➜ Local: http://127.0.0.1:4201/
```
-Open [http://localhost:4201](http://localhost:4201). Pick the "Sales Inquiry" template, click Play. The Temporal UI at [http://localhost:8233](http://localhost:8233) shows the running execution.
+Open [http://localhost:4201](http://localhost:4201). Every bundled template contains AI Agent nodes, so either connect an LLM first (next section) or expect the run to stop at its first AI Agent node with `ai_not_configured` while the Trigger, Decision and Visualize nodes before it run. Pick a template, click Play. The Temporal UI at [http://localhost:8233](http://localhost:8233) shows the running execution.
To stop: `Ctrl+C`, then `pnpm infra:down`.
### Connect a real LLM (optional)
-AI Studio works with stub responses out of the box. To use a real model, add to both `apps/backend/.env` and `apps/execution-worker/.env`:
+The stack starts without an LLM: Trigger, Decision and Visualize nodes run as usual, and an AI Agent node fails with `ai_not_configured` when the run reaches it. AI nodes need three variables in both `apps/backend/.env` and `apps/execution-worker/.env`. The files `pnpm setup:env` created already carry an endpoint and a model for [OpenRouter](https://openrouter.ai), so only the key is missing:
-```env
-OPENROUTER_API_KEY=sk-or-v1-...
-AI_MODEL=anthropic/claude-3.5-haiku
+```dotenv
+AI_API_KEY=sk-or-v1-...
+AI_BASE_URL=https://openrouter.ai/api/v1
+AI_MODEL=mistralai/mistral-small-3.2-24b-instruct
```
-If the key is missing, the worker fails to start with `OPENROUTER_API_KEY is required`. If the model id is wrong, the first AI node fails at runtime and the error surfaces in the UI log panel.
+None of the three has a built-in default. Any OpenAI-compatible endpoint works: set `AI_BASE_URL` to a gateway or to a model hosted inside your own network, `AI_MODEL` to an id that endpoint understands, and model requests stay inside it. That covers the model only: the optional web-search tool calls Tavily's API when `TAVILY_API_KEY` is set, so leave it unset if nothing may call out. If the model id is wrong, the first AI node fails at runtime and the error surfaces in the UI log panel.
+
+### Connect a secured or external Temporal (optional)
+
+`pnpm infra:up` runs a plaintext dev cluster on `localhost:7233`. The connection is entirely env-driven, so an operated cluster or Temporal Cloud needs no code change. Set the same values in both `apps/backend/.env` and `apps/execution-worker/.env` — the two must agree on the namespace, or the worker polls a queue nobody submits to.
+
+| Variable | Purpose | Default |
+| ------------------------ | -------------------------------------------------------------- | ---------------- |
+| `TEMPORAL_ADDRESS` | `host:port` of the cluster | `127.0.0.1:7233` |
+| `TEMPORAL_NAMESPACE` | Namespace to use | `default` |
+| `TEMPORAL_TLS` | `true` requires TLS, `false` asserts plaintext, empty infers | empty (infer) |
+| `TEMPORAL_API_KEY` | API-key authentication (Temporal Cloud). Implies TLS | — |
+| `TEMPORAL_TLS_CA_PATH` | PEM of a private certificate authority | — |
+| `TEMPORAL_TLS_CERT_PATH` | Client certificate for mTLS. Set together with the key | — |
+| `TEMPORAL_TLS_KEY_PATH` | Client private key for mTLS. Set together with the certificate | — |
+
+Any credential turns TLS on by itself, so `TEMPORAL_TLS` is only needed to force TLS without credentials or to assert plaintext. Contradictions — half an mTLS pair, an API key together with a client certificate, or credentials alongside `TEMPORAL_TLS=false` — are rejected with an explanatory error when the connection opens.
+
+Temporal Cloud:
+
+```dotenv
+TEMPORAL_ADDRESS=..tmprl.cloud:7233
+TEMPORAL_NAMESPACE=.
+TEMPORAL_API_KEY=
+```
+
+A self-hosted cluster behind mTLS with a private CA:
+
+```dotenv
+TEMPORAL_ADDRESS=temporal.internal:7233
+TEMPORAL_NAMESPACE=workflows
+TEMPORAL_TLS_CA_PATH=/etc/workflowbuilder/tls/ca.pem
+TEMPORAL_TLS_CERT_PATH=/etc/workflowbuilder/tls/client.pem
+TEMPORAL_TLS_KEY_PATH=/etc/workflowbuilder/tls/client-key.pem
+```
+
+The Docker Compose deployment under `deploy/ai-studio/` reads the same variables and additionally lets you retire its bundled cluster; see its README for the `COMPOSE_FILE` switch and the `tls/` mount for certificate files.
## Troubleshooting
-| Symptom | Cause | Fix |
-| ----------------------------------------------------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
-| `EADDRINUSE` on 3001, 4200, 4201, 5432, 5433, 7233, or 8233 | Another process holds the port | `pnpm preflight` shows the conflict. Stop the other process or change the port. |
-| Temporal UI loads but the `default` namespace is missing | Migrations not run | `pnpm -F backend db:migrate` |
-| Worker exits with `OPENROUTER_API_KEY is required` | Real LLM env var missing | Set it in `apps/execution-worker/.env`. Optional unless you want a real LLM call. |
-| `pnpm dev:demo` shows TypeScript errors but the dev server still starts | `concurrently` runs typecheck alongside Vite. TS errors are non-fatal | Fix the errors or ignore them temporarily. |
-| Vite acts up after a dependency change | Stale `node_modules/.vite` | `rm -rf node_modules/.vite` and rerun. |
+| Symptom | Cause | Fix |
+| ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
+| `EADDRINUSE` on 3001, 4200, 4201, 5432, 5433, 7233, or 8233 | Another process holds the port | `pnpm preflight` shows the conflict. Stop the other process or change the port. |
+| Temporal UI loads but the `default` namespace is missing | Migrations not run | `pnpm -F backend db:migrate` |
+| AI Agent node fails with `ai_not_configured` | LLM not configured — the worker starts anyway, only AI nodes are unavailable | Set `AI_API_KEY`, `AI_BASE_URL` and `AI_MODEL` in `apps/execution-worker/.env`. |
+| Backend or worker exits at boot with a `TEMPORAL_TLS` or `TEMPORAL_TLS_*_PATH` error | Contradictory Temporal settings (half an mTLS pair, API key plus client cert, credentials with `TEMPORAL_TLS=false`) | Remove one side, as the message says. Both `.env` files must carry the same values. |
+| `pnpm dev:demo` shows TypeScript errors but the dev server still starts | `concurrently` runs typecheck alongside Vite. TS errors are non-fatal | Fix the errors or ignore them temporarily. |
+| Vite acts up after a dependency change | Stale `node_modules/.vite` | `rm -rf node_modules/.vite` and rerun. |
## See also
diff --git a/apps/execution-worker/.env.example b/apps/execution-worker/.env.example
index 94e2ecd4c..afb9418dc 100644
--- a/apps/execution-worker/.env.example
+++ b/apps/execution-worker/.env.example
@@ -1,8 +1,36 @@
DATABASE_URL=postgresql://wb:wb@127.0.0.1:5432/workflow_builder
TEMPORAL_ADDRESS=127.0.0.1:7233
+# Must match the backend's namespace. Leave as `default` for the bundled dev cluster.
+TEMPORAL_NAMESPACE=default
+# Connection security. All optional, and all default to a plaintext connection —
+# which is what the bundled dev cluster expects.
+#
+# TEMPORAL_TLS: leave empty to infer (setting any credential below turns TLS on),
+# `true` to require TLS with the OS trust store, `false` to assert plaintext.
+TEMPORAL_TLS=
+# API key auth, as used by Temporal Cloud. Implies TLS.
+TEMPORAL_API_KEY=
+# Paths to PEM files, read when the connection opens. CA for a private issuer;
+# the cert/key pair for mTLS (set both or neither, and not alongside an API key).
+TEMPORAL_TLS_CA_PATH=
+TEMPORAL_TLS_CERT_PATH=
+TEMPORAL_TLS_KEY_PATH=
+#
+# Temporal Cloud looks like this:
+# TEMPORAL_ADDRESS=..tmprl.cloud:7233
+# TEMPORAL_NAMESPACE=.
+# TEMPORAL_API_KEY=
-# OpenRouter — any model
-OPENROUTER_API_KEY=sk-or-...
+# LLM for AI Agent nodes. Optional: leave empty and the worker still starts and
+# runs every other node type — AI Agent nodes then fail with `ai_not_configured`.
+# The three AI_* variables are all-or-nothing (see packages/ai-config/README.md).
+# OpenRouter keys look like sk-or-v1-...
+AI_API_KEY=
+# Any OpenAI-compatible endpoint — a hosted gateway or a model inside your own
+# network. Must be the base URL, without a trailing /chat/completions.
+# Pre-filled with OpenRouter's URL; there is no built-in default.
+AI_BASE_URL=https://openrouter.ai/api/v1
+# Model id as the endpoint above understands it.
AI_MODEL=mistralai/mistral-small-3.2-24b-instruct
# Tavily web search (optional). Enables the AI Agent's "Web search" tool. Get a
diff --git a/apps/execution-worker/README.md b/apps/execution-worker/README.md
index a9aa5d107..b2140ba7e 100644
--- a/apps/execution-worker/README.md
+++ b/apps/execution-worker/README.md
@@ -26,21 +26,44 @@ Requires Postgres + Temporal running. Start them with `pnpm infra:up`.
## Environment
-See `.env.example`. Required:
-
-| Var | Purpose | Default |
-| -------------------- | ---------------------------------- | ---------------------------------------------------- |
-| `OPENROUTER_API_KEY` | AI agent activities (**required**) | — |
-| `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` |
-| `AI_MODEL` | OpenRouter model ID | `anthropic/claude-3.5-haiku` |
+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`](../../packages/ai-config/README.md), 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`](../../packages/temporal-connection/README.md), so the rules
+cannot drift, but each environment must still agree on the namespace — the full table is in
+[`apps/backend/README.md`](../backend/README.md#connecting-to-a-secured-temporal-cluster).
## Structure
```
src/
├── database.ts # Raw SQL for exec events + status updates (no Drizzle — avoids backend schema coupling)
-├── env.ts # Centralized env validation — fail fast at module load
+├── 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
@@ -54,9 +77,10 @@ own: one executor per node type and the database as the store port.
## Temporal specifics
- **Task queue:** `workflow-execution`, read from `plugin.taskQueue` so the backend and the worker cannot drift apart. Both default to the same constant in the package.
+- **Namespace:** `TEMPORAL_NAMESPACE`, default `default`. 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-` — 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_PROFILE` and `DEFAULT_NODE_ACTIVITY_PROFILE`.
-- **Retries per failure:** an executor throwing `PermanentNodeExecutionError` stops on its first attempt; `TransientNodeExecutionError` retries within the profile's limit. An unclassified throw keeps today's behavior — the reference executors have not been classified yet.
+- **Retries per failure:** an executor throwing `PermanentNodeExecutionError` stops on its first attempt; `TransientNodeExecutionError` retries within the profile's limit. An unclassified throw keeps today's behavior. Of the reference executors, only the AI Agent's `ai_not_configured` is classified (permanent) so far; the rest are still unclassified.
- **Sandbox constraint:** `workflows.ts` is 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 run `pnpm build:temporal` after changing `packages/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.md`](../../packages/execution-core/replay-audit.md) rule 9.
diff --git a/apps/execution-worker/package.json b/apps/execution-worker/package.json
index b0ee15a9b..d1c51274d 100644
--- a/apps/execution-worker/package.json
+++ b/apps/execution-worker/package.json
@@ -14,13 +14,15 @@
"test:watch": "vitest"
},
"dependencies": {
- "@openrouter/ai-sdk-provider": "^2.5.0",
+ "@ai-sdk/openai-compatible": "catalog:",
"@temporalio/worker": "catalog:",
"@temporalio/workflow": "catalog:",
+ "@workflow-builder/ai-config": "workspace:*",
"@workflow-builder/execution-core": "workspace:*",
+ "@workflow-builder/temporal-connection": "workspace:*",
"@workflow-builder/types": "workspace:*",
"@workflowbuilder/temporal": "workspace:*",
- "ai": "^6.0.0",
+ "ai": "catalog:",
"dotenv": "^17.4.2",
"postgres": "^3.4.5",
"tsx": "^4.19.3"
diff --git a/apps/execution-worker/src/engines/temporal/worker.ts b/apps/execution-worker/src/engines/temporal/worker.ts
index 60732cc47..db0ce4831 100644
--- a/apps/execution-worker/src/engines/temporal/worker.ts
+++ b/apps/execution-worker/src/engines/temporal/worker.ts
@@ -3,22 +3,35 @@ import { WorkflowBuilderPlugin } from '@workflowbuilder/temporal';
import 'dotenv/config';
import { fileURLToPath } from 'node:url';
-import { executeAiAgent } from '../../activities/ai-agent';
+import { aiConfig, retiredAiVariables } from '@workflow-builder/ai-config';
+import { temporalConfig } from '@workflow-builder/temporal-connection';
+
import { database } from '../../database';
import type { AiStudioNode } from '../../domain/ai-studio-nodes';
import { env } from '../../env';
+import { createAiAgentExecutor } from '../../executors/ai-agent';
import { executeDecision } from '../../executors/decision';
import { executeTrigger } from '../../executors/trigger';
import { executeVisualize } from '../../executors/visualize';
import { logger } from '../../logger';
import { withPayloadSizeWarning } from '../../store-payload-warning';
-const { createOpenRouter } = await import('@openrouter/ai-sdk-provider');
-
-const openrouter = createOpenRouter({ apiKey: env.OPENROUTER_API_KEY });
-const model = openrouter.chat(env.AI_MODEL);
-
-const aiAgentLogger = logger.child({ component: 'ai-agent' });
+const ai = aiConfig();
+if (!ai.available) {
+ // `retired` names a variable that is set and no longer read — the reason a key that
+ // used to work is now ignored. Only the name is logged, never the value.
+ const retired = retiredAiVariables();
+ logger.warn('AI not configured — AI Agent nodes will fail; every other node type runs as usual', {
+ missing: ai.missing,
+ ...(retired.length > 0 ? { retired } : {}),
+ });
+}
+
+const executeAIAgent = createAiAgentExecutor({
+ ai,
+ logger: logger.child({ component: 'ai-agent' }),
+ tavilyApiKey: env.TAVILY_API_KEY,
+});
// The plugin contributes the three activities that execute a graph. What each node
// type actually does stays here, and so does where events are persisted.
@@ -26,22 +39,24 @@ const plugin = new WorkflowBuilderPlugin({
executors: {
'ai-studio/trigger': executeTrigger,
'ai-studio/decision': executeDecision,
- 'ai-studio/ai-agent': (node, context) =>
- executeAiAgent(node, context, { model, logger: aiAgentLogger, tavilyApiKey: env.TAVILY_API_KEY }),
+ 'ai-studio/ai-agent': executeAIAgent,
'ai-studio/visualize': executeVisualize,
},
store: withPayloadSizeWarning(database, logger),
});
-// without an explicit connection, Worker.create dials 127.0.0.1:7233 and ignores TEMPORAL_ADDRESS
-const connection = await NativeConnection.connect({ address: env.TEMPORAL_ADDRESS });
+// without an explicit connection, Worker.create dials 127.0.0.1:7233 and ignores TEMPORAL_ADDRESS.
+// Contradictory TEMPORAL_* values throw here, before the worker starts polling.
+const temporal = temporalConfig();
+const connection = await NativeConnection.connect(temporal.connection);
const worker = await Worker.create({
connection,
+ namespace: temporal.namespace,
taskQueue: plugin.taskQueue,
workflowsPath: fileURLToPath(new URL('workflows.ts', import.meta.url)),
plugins: [plugin],
});
-logger.info('execution worker started', { taskQueue: plugin.taskQueue });
+logger.info('execution worker started', { taskQueue: plugin.taskQueue, namespace: temporal.namespace });
await worker.run();
diff --git a/apps/execution-worker/src/env.test.ts b/apps/execution-worker/src/env.test.ts
new file mode 100644
index 000000000..790976481
--- /dev/null
+++ b/apps/execution-worker/src/env.test.ts
@@ -0,0 +1,52 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { env as shape } from './env';
+
+// The keys of `env` are the variable names, so a variable added to env.ts is
+// cleared here without anyone remembering to list it.
+const ENV_NAMES = Object.keys(shape);
+
+// env.ts reads process.env once at module load, so every case needs a fresh module
+// and a clean environment: whatever the runner's shell carries is unset first.
+async function loadEnv(values: Record) {
+ vi.resetModules();
+ for (const name of ENV_NAMES) {
+ // undefined is Vitest's delete signal, and unstubAllEnvs restores the variable
+ // eslint-disable-next-line unicorn/no-useless-undefined
+ vi.stubEnv(name, undefined);
+ }
+ for (const [name, value] of Object.entries(values)) {
+ vi.stubEnv(name, value);
+ }
+ const module = await import('./env');
+ return module.env;
+}
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+});
+
+describe('loadEnv', () => {
+ it('ignores variables inherited from the runner', async () => {
+ vi.stubEnv('TAVILY_API_KEY', 'ambient-key');
+
+ const env = await loadEnv({});
+
+ expect(env.TAVILY_API_KEY).toBeUndefined();
+ });
+});
+
+describe('TAVILY_API_KEY', () => {
+ // compose passes it through as `${TAVILY_API_KEY:-}`, so '' must disable the tool like unset does
+ it('reads an empty value as unset', async () => {
+ const env = await loadEnv({ TAVILY_API_KEY: '' });
+
+ expect(env.TAVILY_API_KEY).toBeUndefined();
+ });
+
+ it('reads a key', async () => {
+ const env = await loadEnv({ TAVILY_API_KEY: 'tvly-key' });
+
+ expect(env.TAVILY_API_KEY).toBe('tvly-key');
+ });
+});
diff --git a/apps/execution-worker/src/env.ts b/apps/execution-worker/src/env.ts
index 5bbd6a61a..7e5d5b83a 100644
--- a/apps/execution-worker/src/env.ts
+++ b/apps/execution-worker/src/env.ts
@@ -1,12 +1,3 @@
-// Centralized env — fail fast at module load with a readable message.
-function requireEnv(name: string): string {
- const value = process.env[name];
- if (!value) {
- throw new Error(`${name} is required — see apps/execution-worker/.env.example`);
- }
- return value;
-}
-
function envOr(name: string, defaultValue: string): string {
return process.env[name] ?? defaultValue;
}
@@ -15,10 +6,9 @@ function envOr(name: string, defaultValue: string): string {
// bindings; see apps/backend/src/env.ts for the full reason.
export const env = {
DATABASE_URL: envOr('DATABASE_URL', 'postgresql://wb:wb@127.0.0.1:5432/workflow_builder'),
- TEMPORAL_ADDRESS: envOr('TEMPORAL_ADDRESS', '127.0.0.1:7233'),
- OPENROUTER_API_KEY: requireEnv('OPENROUTER_API_KEY'),
- // Cheap, fast default for the public demo; quality-per-cost over frontier capability.
- AI_MODEL: envOr('AI_MODEL', 'mistralai/mistral-small-3.2-24b-instruct'),
+ // TEMPORAL_*: read at startup by @workflow-builder/temporal-connection.
+ // AI_API_KEY / AI_BASE_URL / AI_MODEL: read at startup by @workflow-builder/ai-config.
// Optional. Enables the AI Agent's web-search tool; agents run without it when unset.
- TAVILY_API_KEY: process.env['TAVILY_API_KEY'],
+ // Empty counts as unset: compose passes it through as `${TAVILY_API_KEY:-}`.
+ TAVILY_API_KEY: process.env['TAVILY_API_KEY'] || undefined,
};
diff --git a/apps/execution-worker/src/executors/ai-agent.test.ts b/apps/execution-worker/src/executors/ai-agent.test.ts
new file mode 100644
index 000000000..087229b5c
--- /dev/null
+++ b/apps/execution-worker/src/executors/ai-agent.test.ts
@@ -0,0 +1,90 @@
+import { describe, expect, it } from 'vitest';
+
+import { aiConfig } from '@workflow-builder/ai-config';
+import {
+ type ExecutionContext,
+ NodeExecutionError,
+ PermanentNodeExecutionError,
+ classifyNodeError,
+} from '@workflow-builder/execution-core';
+
+import type { AiAgentNode } from '../domain/ai-studio-nodes';
+import { createAiAgentExecutor } from './ai-agent';
+
+function context(): ExecutionContext {
+ return {
+ workflowId: 'wf',
+ executionId: 'exec',
+ triggerPayload: {},
+ nodeOutputs: {},
+ variables: {},
+ global: {},
+ };
+}
+
+const node: AiAgentNode = {
+ id: 'a1',
+ type: 'ai-studio/ai-agent',
+ config: { systemPrompt: 'Summarise the input.' },
+};
+
+const endpoint = { AI_BASE_URL: 'https://openrouter.ai/api/v1', AI_MODEL: 'some/model' };
+
+describe('createAiAgentExecutor without a key', () => {
+ const executor = createAiAgentExecutor({ ai: aiConfig(endpoint) });
+
+ it('fails the node instead of the worker boot', () => {
+ // The factory itself must not throw — that is what lets the worker start and
+ // keep serving Trigger/Decision/Visualize nodes.
+ expect(() => executor(node, context())).toThrow(NodeExecutionError);
+ });
+
+ it('reports a code the UI can key off, and names the variable to set', () => {
+ try {
+ executor(node, context());
+ expect.unreachable('executor should have thrown');
+ } catch (error) {
+ expect(error).toBeInstanceOf(NodeExecutionError);
+ expect((error as NodeExecutionError).code).toBe('ai_not_configured');
+ expect((error as NodeExecutionError).message).toContain('AI_API_KEY');
+ }
+ });
+
+ it('is permanent, so the engine adapter stops after one attempt', () => {
+ try {
+ executor(node, context());
+ expect.unreachable('executor should have thrown');
+ } catch (error) {
+ expect(error).toBeInstanceOf(PermanentNodeExecutionError);
+ expect(classifyNodeError(error)).toBe('permanent');
+ }
+ });
+});
+
+describe('createAiAgentExecutor with a key', () => {
+ it('builds the executor without calling the endpoint', () => {
+ // Construction is eager (the model is built once per worker), so it has to
+ // stay free of network I/O — the endpoint may not even be reachable at boot.
+ const executor = createAiAgentExecutor({ ai: aiConfig({ ...endpoint, AI_API_KEY: 'test-key' }) });
+
+ expect(executor).toBeTypeOf('function');
+ });
+});
+
+describe('createAiAgentExecutor with a key but no endpoint or model', () => {
+ // Neither has a built-in default, so they gate the node exactly like the key does.
+ it('fails the node with the same code and names only the missing variables', () => {
+ const executor = createAiAgentExecutor({ ai: aiConfig({ AI_API_KEY: 'key' }) });
+
+ try {
+ executor(node, context());
+ expect.unreachable('executor should have thrown');
+ } catch (error) {
+ expect(error).toBeInstanceOf(NodeExecutionError);
+ expect((error as NodeExecutionError).code).toBe('ai_not_configured');
+ expect((error as NodeExecutionError).message).toContain('AI_BASE_URL');
+ expect((error as NodeExecutionError).message).toContain('AI_MODEL');
+ expect((error as NodeExecutionError).message).not.toContain('AI_API_KEY');
+ }
+ });
+});
diff --git a/apps/execution-worker/src/executors/ai-agent.ts b/apps/execution-worker/src/executors/ai-agent.ts
new file mode 100644
index 000000000..25c801a93
--- /dev/null
+++ b/apps/execution-worker/src/executors/ai-agent.ts
@@ -0,0 +1,38 @@
+import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
+
+import type { AiConfigResult } from '@workflow-builder/ai-config';
+import { type LoggerPort, type NodeExecutor, PermanentNodeExecutionError } from '@workflow-builder/execution-core';
+
+import { executeAiAgent } from '../activities/ai-agent';
+import type { AiAgentNode } from '../domain/ai-studio-nodes';
+
+type AiAgentExecutorOptions = {
+ // Unavailable is allowed: the worker still boots; only this node type is
+ // unavailable, so a graph of Trigger/Decision/Visualize nodes runs fine.
+ ai: AiConfigResult;
+ logger?: LoggerPort;
+ tavilyApiKey?: string;
+};
+
+export function createAiAgentExecutor(options: AiAgentExecutorOptions): NodeExecutor {
+ const { ai, logger, tavilyApiKey } = options;
+
+ if (!ai.available) {
+ const missing = ai.missing.join(', ');
+ // Thrown when the node is reached rather than at boot, so missing config
+ // costs one failed node instead of the whole worker. Permanent: a retry
+ // cannot find configuration that is not there.
+ return () => {
+ throw new PermanentNodeExecutionError(
+ 'ai_not_configured',
+ `AI is not configured on this worker — set ${missing} (see apps/execution-worker/.env.example).`,
+ );
+ };
+ }
+
+ const { apiKey, baseURL, modelId } = ai.config;
+ const provider = createOpenAICompatible({ name: 'ai', baseURL, apiKey });
+ const model = provider.chatModel(modelId);
+
+ return (node, context) => executeAiAgent(node, context, { model, logger, tavilyApiKey });
+}
diff --git a/deploy/ai-studio/.env.example b/deploy/ai-studio/.env.example
index 6d250ca1d..a72795501 100644
--- a/deploy/ai-studio/.env.example
+++ b/deploy/ai-studio/.env.example
@@ -1,15 +1,27 @@
-# Copy to .env next to docker-compose.yml and fill in. Everything except
-# OPENROUTER_API_KEY has a working default.
+# Copy to .env next to docker-compose.yml and fill in. The stack comes up with
+# none of these set. AI Agent nodes need AI_API_KEY, AI_BASE_URL and AI_MODEL;
+# without them the stack runs and every other node type works, while AI Agent
+# nodes fail with `ai_not_configured`.
-# --- required ---------------------------------------------------------------
+# --- LLM --------------------------------------------------------------------
-# Server-side only; never reaches the browser. Pair it with an OpenRouter
-# account Guardrail (hard $/day ceiling) — see README "Spend safety".
-OPENROUTER_API_KEY=
+# Server-side only; never reaches the browser. Pair it with a provider-side
+# spend cap (hard $/day ceiling) — see README "Spend safety". Empty keeps AI
+# Agent nodes off; OpenRouter keys look like sk-or-v1-...
+# Renamed from OPENROUTER_API_KEY, which is no longer read: rename it here rather
+# than adding this one next to it. The deploy workflow refuses to run while a
+# deployed .env still carries the old name.
+AI_API_KEY=
-# --- LLM --------------------------------------------------------------------
+# Any OpenAI-compatible endpoint. There is no built-in default: the value below
+# is the OpenRouter setup the stack used before the endpoint became configurable.
+# Point it at a gateway or a model inside your own network and model requests
+# stay inside it (the web search below is separate: leave TAVILY_API_KEY empty
+# if nothing may call out).
+AI_BASE_URL=https://openrouter.ai/api/v1
-# Demo model. Cheap, EU-hosted, solid tool calling.
+# Model id as the endpoint above understands it. This one
+# is the demo pick: cheap, EU-hosted, solid tool calling.
# ~$0.075/M input + $0.20/M output => ~$0.0004 per 3-call template run.
AI_MODEL=mistralai/mistral-small-3.2-24b-instruct
@@ -35,6 +47,61 @@ WEB_PORT=8080
# served from a different host than the backend.
VITE_BACKEND_URL=
+# --- temporal -----------------------------------------------------------------
+
+# Leave these alone to use the bundled dev-grade cluster (see README "Known
+# limitations"). To run against an operated cluster or Temporal Cloud instead,
+# point them at it — the backend and the worker read the same values and must
+# agree on the namespace — and set COMPOSE_FILE so the bundled cluster is not
+# started at all (it lives in docker-compose.override.yml, which compose applies
+# by default; the apps then depend only on app-db):
+#
+# COMPOSE_FILE=docker-compose.yml
+# TEMPORAL_ADDRESS=..tmprl.cloud:7233
+# TEMPORAL_NAMESPACE=.
+# TEMPORAL_API_KEY=
+#
+# Run `docker compose down --remove-orphans` once when switching, so the retired
+# temporal containers from the bundled setup are removed.
+#
+# TEMPORAL_TLS: empty infers (any credential turns TLS on by itself), `true` requires
+# TLS with the OS trust store, `false` asserts plaintext.
+TEMPORAL_ADDRESS=temporal:7233
+TEMPORAL_NAMESPACE=default
+TEMPORAL_TLS=
+TEMPORAL_API_KEY=
+
+# Private CA or mTLS. Drop the PEM files into ./tls, or point TEMPORAL_TLS_DIR at a
+# directory OUTSIDE the checkout (an absolute path such as /etc/wb-tls). Both
+# containers see it read-only at /etc/workflowbuilder/tls, so the three paths below
+# are container paths:
+#
+# TEMPORAL_TLS_CA_PATH=/etc/workflowbuilder/tls/ca.pem
+# TEMPORAL_TLS_CERT_PATH=/etc/workflowbuilder/tls/client.pem
+# TEMPORAL_TLS_KEY_PATH=/etc/workflowbuilder/tls/client-key.pem
+#
+# CA alone covers a private issuer without client auth. Cert and key go together,
+# and a client certificate excludes TEMPORAL_API_KEY.
+#
+# Never use any other directory inside the repository. A local `docker compose
+# up --build` sends the whole checkout as the build context, and the Dockerfile
+# copies it into the runtime image; the read-only mount does not remove that
+# second copy. Only ./tls (and deploy/ as a whole, plus *.pem/*.key/*.crt/*.p12/
+# *.pfx anywhere) is excluded by .dockerignore, so a key parked elsewhere in the
+# checkout ships to everyone who can pull the image.
+TEMPORAL_TLS_DIR=./tls
+TEMPORAL_TLS_CA_PATH=
+TEMPORAL_TLS_CERT_PATH=
+TEMPORAL_TLS_KEY_PATH=
+
+# --- images -------------------------------------------------------------------
+
+# Prebuilt runtime and web images from a registry. Leave empty to build locally
+# (`docker compose up --build`). On the deploy VM the workflow rewrites these two
+# lines with the exact tags it pushed, so later compose commands keep using them.
+RUNTIME_IMAGE=
+WEB_IMAGE=
+
# --- databases (internal network only, not published) -------------------------
APP_DB_PASSWORD=wb
diff --git a/deploy/ai-studio/README.md b/deploy/ai-studio/README.md
index 530155ac3..266b54332 100644
--- a/deploy/ai-studio/README.md
+++ b/deploy/ai-studio/README.md
@@ -5,15 +5,21 @@ any Docker host — an Azure VM, AWS, on-prem — with no cloud-specific glue.
## What runs
-| Service | Image | Role | Exposed |
-| ------------- | ------------------------------ | ----------------------------------------------- | ------------------------ |
-| `web` | `ai-studio-web` (nginx) | Serves the SPA, proxies `/api` to the backend | `${WEB_PORT}` (only one) |
-| `backend` | `ai-studio-runtime` | Hono REST + SSE event stream | internal |
-| `worker` | `ai-studio-runtime` | Temporal worker, makes the OpenRouter LLM calls | internal |
-| `temporal` | `temporalio/auto-setup` pinned | Workflow engine | internal |
-| `app-db` | `postgres:16` | Workflow snapshots + execution events | internal |
-| `temporal-db` | `postgres:16` | Temporal's own state store | internal |
-| `temporal-ui` | `temporalio/ui` pinned | Debug only (`--profile debug`) | `127.0.0.1:8233` |
+| Service | Image | Role | Exposed |
+| ------------- | ------------------------------ | ---------------------------------------------------------------------- | ------------------------ |
+| `web` | `ai-studio-web` (nginx) | Serves the SPA, proxies `/api` to the backend | `${WEB_PORT}` (only one) |
+| `backend` | `ai-studio-runtime` | Hono REST + SSE event stream; calls the LLM for `/api/visualize/adapt` | internal |
+| `worker` | `ai-studio-runtime` | Temporal worker, runs the nodes; AI Agent nodes call the LLM | internal |
+| `temporal` | `temporalio/auto-setup` pinned | Workflow engine | internal |
+| `app-db` | `postgres:16` | Workflow snapshots + execution events | internal |
+| `temporal-db` | `postgres:16` | Temporal's own state store | internal |
+| `temporal-ui` | `temporalio/ui` pinned | Debug only (`--profile debug`) | `127.0.0.1:8233` |
+
+The three Temporal rows come from
+[`docker-compose.override.yml`](docker-compose.override.yml), which compose
+applies on top of [`docker-compose.yml`](docker-compose.yml) by default. The base
+file alone has no cluster: the apps connect to whatever `TEMPORAL_ADDRESS` names
+and depend only on `app-db` — see "Pointing at a different Temporal".
Both images build from one Dockerfile (`deploy/ai-studio/Dockerfile`) with the
repo root as context. Backend and worker share a single image and differ only
@@ -25,7 +31,7 @@ service or step.
```bash
cd deploy/ai-studio
-cp .env.example .env # set OPENROUTER_API_KEY
+cp .env.example .env # set AI_API_KEY to enable AI Agent nodes
docker compose up -d --build
```
@@ -77,9 +83,59 @@ this compose never publishes them; don't undo that.
## Configuration
See [.env.example](.env.example) — every variable is documented there.
-Swapping the LLM is a one-liner: change `AI_MODEL` to any
-[OpenRouter model id](https://openrouter.ai/models) and
-`docker compose up -d worker`.
+Swapping the model is a one-liner: change `AI_MODEL` to any id the endpoint
+understands (for OpenRouter, an [OpenRouter model id](https://openrouter.ai/models))
+and `docker compose up -d worker`.
+
+**Pointing at a different LLM.** `AI_BASE_URL` takes any OpenAI-compatible
+endpoint, so a gateway or a model hosted inside your own network works without
+a code change — set it alongside `AI_API_KEY` and `AI_MODEL`. None of the three
+has a built-in default; `.env.example` pre-fills the OpenRouter values the stack
+used before the endpoint became configurable. Leave any of them empty and the
+stack still comes up: every node type runs except AI Agent nodes, which fail
+with `ai_not_configured`.
+
+
+
+**Before deploying this version.** The key is now `AI_API_KEY`, and the endpoint
+and model are no longer built in, so a `.env` written for an earlier version
+needs three lines before this one is deployed:
+
+```bash
+AI_API_KEY=
+AI_BASE_URL=https://openrouter.ai/api/v1
+AI_MODEL=mistralai/mistral-small-3.2-24b-instruct
+```
+
+Renaming only the key is not enough: the stack comes up with every AI Agent
+node failing `ai_not_configured`, because the endpoint and the model have no
+built-in defaults any more. The deploy workflow refuses to run while
+`OPENROUTER_API_KEY` is still set, before it writes anything to the VM, so a
+stale `.env` stops the deploy instead of coming up with AI silently off. An
+operator deploying by hand can run the same check:
+
+```bash
+grep -E '^OPENROUTER_API_KEY=.+' .env # a hit means .env still needs the rename
+```
+
+**Pointing at a different Temporal.** Every `TEMPORAL_*` variable reaches the
+backend and the worker from one shared block in the compose file, so the two
+cannot disagree. `TEMPORAL_ADDRESS`, `TEMPORAL_NAMESPACE`, `TEMPORAL_TLS` and
+`TEMPORAL_API_KEY` are all an operated cluster or Temporal Cloud needs. Add
+`COMPOSE_FILE=docker-compose.yml` to `.env` at the same time: it leaves the
+override file out, so the bundled cluster is not started and cannot block the
+apps, and `backend` / `worker` depend only on `app-db`. Run
+`docker compose down --remove-orphans` once when switching. A contradictory
+`TEMPORAL_*` combination stops both apps at boot with an explanatory error
+(`docker compose logs backend worker`). The bundled debug
+UI (`--profile debug`) is part of the override and only ever shows the bundled
+cluster — an external cluster has its own UI. For a private CA or mTLS, drop the PEM files into [`tls/`](tls/) (git-ignored, mounted
+read-only into both containers at `/etc/workflowbuilder/tls`) and set
+`TEMPORAL_TLS_CA_PATH` / `_CERT_PATH` / `_KEY_PATH` to those container paths —
+see [.env.example](.env.example) for the exact lines. `TEMPORAL_TLS_DIR` may
+point at `./tls` or at a directory outside the checkout, nothing else: the
+whole repository is the image build context, so a key placed in any other
+in-repo directory is copied into the runtime image by a local build.
## Operations
@@ -91,6 +147,19 @@ docker compose down # stop (volumes survive)
docker exec ai-studio-app-db-1 pg_dump -U wb workflow_builder > backup.sql
```
+The public demo is deployed by the `Deploy AI Studio` GitHub Actions workflow:
+it builds and pushes both images to the registry, copies `docker-compose.yml`
+and `docker-compose.override.yml` from the repo to the VM, writes the tags it
+just pushed into the VM's `.env` as `RUNTIME_IMAGE` / `WEB_IMAGE`, and runs
+compose there. A first deploy of this version onto a VM whose `.env` still
+carries `OPENROUTER_API_KEY` stops before writing anything — see [Before
+deploying this version](#before-deploying-this-version).
+Because the tags live in `.env`, every later compose command on
+the VM (`docker compose up -d worker` after a model change, `--profile debug`)
+resolves the deployed images, not the local `ai-studio-*` build names. The VM's
+compose files are that copy — change them in the repo, never on the VM. Only
+`.env` lives on the VM alone; the deploy replaces just its two image lines.
+
Workflow data is treated as ephemeral for the public demo — losing the
volumes is acceptable; there is nothing precious in them.
@@ -98,8 +167,8 @@ volumes is acceptable; there is nothing precious in them.
emitted**, let in-flight executions finish. Temporal replays a running
workflow's history against the deployed code, so a run started on the old
emit sequence diverges when replayed on the new one. Check for active runs in
-the Temporal UI (`--profile debug`), or accept that any still running will
-fail. Deploys that leave the emit sequence alone are unaffected. See
+the Temporal UI (`--profile debug` for the bundled cluster, your cluster's own UI
+otherwise), or accept that any still running will fail. Deploys that leave the emit sequence alone are unaffected. See
[`replay-audit.md`](../../packages/execution-core/replay-audit.md) rule 9.
## Known limitations (accepted for the lean MVP)
@@ -110,6 +179,7 @@ fail. Deploys that leave the emit sequence alone are unaffected. See
- **Single backend replica.** The rate limiter is process-local. Scaling out
needs a shared store (Redis) — deferred to the scale-ready task.
- **`temporalio/auto-setup` is dev-grade.** Fine for a demo; move to Temporal
- Cloud or an operated cluster for sustained load.
+ Cloud or an operated cluster for sustained load. That move is configuration
+ only — see "Pointing at a different Temporal" above.
- **Anyone-can-edit demo content.** Visitors share one workspace; data is
wiped whenever you decide to recreate the volumes.
diff --git a/deploy/ai-studio/docker-compose.override.yml b/deploy/ai-studio/docker-compose.override.yml
new file mode 100644
index 000000000..fd8e4067a
--- /dev/null
+++ b/deploy/ai-studio/docker-compose.override.yml
@@ -0,0 +1,61 @@
+# The bundled dev-grade Temporal cluster, plus the start-order edges that make the
+# apps wait for it. Compose merges this over docker-compose.yml automatically, so
+# a plain `docker compose up` runs everything locally. To use an operated cluster
+# or Temporal Cloud instead, set COMPOSE_FILE=docker-compose.yml in .env: this
+# file is then skipped, nothing here starts, and the apps depend only on app-db.
+
+services:
+ temporal-db:
+ image: postgres:16
+ environment:
+ POSTGRES_DB: temporal
+ POSTGRES_USER: temporal
+ POSTGRES_PASSWORD: ${TEMPORAL_DB_PASSWORD:-temporal}
+ volumes:
+ - temporal-db-data:/var/lib/postgresql/data
+ healthcheck:
+ test: ['CMD', 'pg_isready', '-U', 'temporal', '-d', 'temporal']
+ interval: 5s
+ timeout: 3s
+ retries: 12
+ restart: unless-stopped
+
+ # auto-setup is dev-grade; sustained load should move to Temporal Cloud or an
+ # operated cluster — the apps only consume TEMPORAL_ADDRESS
+ temporal:
+ image: temporalio/auto-setup:1.29.6.1
+ depends_on:
+ temporal-db:
+ condition: service_healthy
+ environment:
+ DB: postgres12
+ DB_PORT: 5432
+ POSTGRES_USER: temporal
+ POSTGRES_PWD: ${TEMPORAL_DB_PASSWORD:-temporal}
+ POSTGRES_SEEDS: temporal-db
+ restart: unless-stopped
+
+ # Inspects this bundled cluster only. An external cluster comes with its own UI.
+ temporal-ui:
+ image: temporalio/ui:2.51.0
+ profiles: [debug]
+ depends_on:
+ - temporal
+ environment:
+ TEMPORAL_ADDRESS: temporal:7233
+ ports:
+ - '127.0.0.1:8233:8080'
+ restart: unless-stopped
+
+ backend:
+ depends_on:
+ temporal:
+ condition: service_started
+
+ worker:
+ depends_on:
+ temporal:
+ condition: service_started
+
+volumes:
+ temporal-db-data:
diff --git a/deploy/ai-studio/docker-compose.yml b/deploy/ai-studio/docker-compose.yml
index 5eed67bf7..975072b05 100644
--- a/deploy/ai-studio/docker-compose.yml
+++ b/deploy/ai-studio/docker-compose.yml
@@ -1,6 +1,15 @@
# AI Studio production stack (WB-229). Usage: cp .env.example .env, set
-# OPENROUTER_API_KEY, then `docker compose up -d --build`. Only `web`
+# AI_API_KEY, then `docker compose up -d --build`. Only `web`
# publishes a port.
+#
+# This file has no Temporal cluster of its own: the apps connect to whatever
+# TEMPORAL_ADDRESS names. The bundled dev-grade cluster lives in
+# docker-compose.override.yml, which compose applies on top of this file by
+# default; COMPOSE_FILE=docker-compose.yml in .env leaves it out.
+#
+# RUNTIME_IMAGE / WEB_IMAGE name prebuilt images from a registry; unset, the
+# services build locally under the default names. The deploy workflow writes the
+# tags it just pushed into the VM's .env, so this file is the one the demo VM runs too.
name: ai-studio
@@ -9,6 +18,24 @@ x-runtime-build: &runtime-build
dockerfile: deploy/ai-studio/Dockerfile
target: runtime
+# Shared by backend and worker via YAML merge, so the two can never drift apart:
+# the namespace must match or the worker polls a queue nobody submits to.
+x-temporal-env: &temporal-env
+ TEMPORAL_ADDRESS: ${TEMPORAL_ADDRESS:-temporal:7233}
+ TEMPORAL_NAMESPACE: ${TEMPORAL_NAMESPACE:-default}
+ TEMPORAL_TLS: ${TEMPORAL_TLS:-}
+ TEMPORAL_API_KEY: ${TEMPORAL_API_KEY:-}
+ # container paths under the mount below — see .env.example
+ TEMPORAL_TLS_CA_PATH: ${TEMPORAL_TLS_CA_PATH:-}
+ TEMPORAL_TLS_CERT_PATH: ${TEMPORAL_TLS_CERT_PATH:-}
+ TEMPORAL_TLS_KEY_PATH: ${TEMPORAL_TLS_KEY_PATH:-}
+
+# PEM files for a private CA or mTLS. ./tls ships empty (and git-ignored) so the
+# mount always resolves; plaintext deployments never touch it. TEMPORAL_TLS_DIR
+# must stay ./tls or leave the checkout — anything else in-repo is build context.
+x-temporal-tls-volumes: &temporal-tls-volumes
+ - ${TEMPORAL_TLS_DIR:-./tls}:/etc/workflowbuilder/tls:ro
+
services:
app-db:
image: postgres:16
@@ -25,57 +52,16 @@ services:
retries: 12
restart: unless-stopped
- temporal-db:
- image: postgres:16
- environment:
- POSTGRES_DB: temporal
- POSTGRES_USER: temporal
- POSTGRES_PASSWORD: ${TEMPORAL_DB_PASSWORD:-temporal}
- volumes:
- - temporal-db-data:/var/lib/postgresql/data
- healthcheck:
- test: ['CMD', 'pg_isready', '-U', 'temporal', '-d', 'temporal']
- interval: 5s
- timeout: 3s
- retries: 12
- restart: unless-stopped
-
- # auto-setup is dev-grade; sustained load should move to Temporal Cloud
- # or an operated cluster — the apps only consume TEMPORAL_ADDRESS
- temporal:
- image: temporalio/auto-setup:1.29.6.1
- depends_on:
- temporal-db:
- condition: service_healthy
- environment:
- DB: postgres12
- DB_PORT: 5432
- POSTGRES_USER: temporal
- POSTGRES_PWD: ${TEMPORAL_DB_PASSWORD:-temporal}
- POSTGRES_SEEDS: temporal-db
- restart: unless-stopped
-
- temporal-ui:
- image: temporalio/ui:2.51.0
- profiles: [debug]
- depends_on:
- - temporal
- environment:
- TEMPORAL_ADDRESS: temporal:7233
- ports:
- - '127.0.0.1:8233:8080'
- restart: unless-stopped
-
# applies migrations at boot; on failure exits and `restart` retries
backend:
- image: ai-studio-runtime
+ image: ${RUNTIME_IMAGE:-ai-studio-runtime}
build: *runtime-build
command: ['pnpm', '--filter', 'backend', 'start:prod']
environment:
+ <<: *temporal-env
HOST: 0.0.0.0
PORT: 3001
DATABASE_URL: postgresql://wb:${APP_DB_PASSWORD:-wb}@app-db:5432/workflow_builder
- TEMPORAL_ADDRESS: temporal:7233
# explicit opt-in — a forgotten env var fails loudly instead of exposing the API
WB_AUTH_PORT: allow-all
# only nginx can reach the backend, so X-Forwarded-For is trustworthy
@@ -83,13 +69,13 @@ services:
RATE_LIMIT_EXECUTE_PER_MINUTE: ${RATE_LIMIT_EXECUTE_PER_MINUTE:-10}
RATE_LIMIT_EXECUTE_PER_DAY: ${RATE_LIMIT_EXECUTE_PER_DAY:-50}
# the backend calls the LLM itself for /api/visualize/adapt
- OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:?set OPENROUTER_API_KEY in deploy/ai-studio/.env}
- AI_MODEL: ${AI_MODEL:-mistralai/mistral-small-3.2-24b-instruct}
+ AI_API_KEY: ${AI_API_KEY:-}
+ AI_BASE_URL: ${AI_BASE_URL:-}
+ AI_MODEL: ${AI_MODEL:-}
+ volumes: *temporal-tls-volumes
depends_on:
app-db:
condition: service_healthy
- temporal:
- condition: service_started
healthcheck:
test:
[
@@ -106,28 +92,29 @@ services:
# crash-loops until Temporal answers (no usable healthcheck); restart converges it
worker:
- image: ai-studio-runtime
+ image: ${RUNTIME_IMAGE:-ai-studio-runtime}
build: *runtime-build
command: ['pnpm', '--filter', 'execution-worker', 'start:prod']
environment:
+ <<: *temporal-env
DATABASE_URL: postgresql://wb:${APP_DB_PASSWORD:-wb}@app-db:5432/workflow_builder
- TEMPORAL_ADDRESS: temporal:7233
- OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:?set OPENROUTER_API_KEY in deploy/ai-studio/.env}
- AI_MODEL: ${AI_MODEL:-mistralai/mistral-small-3.2-24b-instruct}
+ # empty is allowed: the worker starts and runs every node except AI Agent ones
+ AI_API_KEY: ${AI_API_KEY:-}
+ AI_BASE_URL: ${AI_BASE_URL:-}
+ AI_MODEL: ${AI_MODEL:-}
# optional - empty disables the AI Agent's web search tool
TAVILY_API_KEY: ${TAVILY_API_KEY:-}
+ volumes: *temporal-tls-volumes
depends_on:
app-db:
condition: service_healthy
# backend healthy = migrations applied
backend:
condition: service_healthy
- temporal:
- condition: service_started
restart: unless-stopped
web:
- image: ai-studio-web
+ image: ${WEB_IMAGE:-ai-studio-web}
build:
context: ../..
dockerfile: deploy/ai-studio/Dockerfile
@@ -143,4 +130,3 @@ services:
volumes:
app-db-data:
- temporal-db-data:
diff --git a/deploy/ai-studio/tls/.gitignore b/deploy/ai-studio/tls/.gitignore
new file mode 100644
index 000000000..75f8fade2
--- /dev/null
+++ b/deploy/ai-studio/tls/.gitignore
@@ -0,0 +1,4 @@
+# Mounted read-only into the backend and worker as /etc/workflowbuilder/tls.
+# Certificates and keys dropped here must never reach git.
+*
+!.gitignore
diff --git a/knip.config.js b/knip.config.js
index e5b8cc0bc..0737955c1 100644
--- a/knip.config.js
+++ b/knip.config.js
@@ -48,6 +48,17 @@ export default {
'packages/execution-core': {
entry: ['src/index.ts'],
},
+ 'packages/ai-config': {
+ entry: ['src/index.ts'],
+ },
+ 'packages/temporal-connection': {
+ // test/fixtures/tls-probe-workflow.ts is handed to Temporal's bundler by path, so nothing imports it
+ entry: ['src/index.ts', 'test/fixtures/tls-probe-workflow.ts'],
+ project: ['src/**/*.ts', 'test/**/*.ts'],
+ // Never imported here, but Temporal's workflow bundler resolves it from this
+ // workspace while compiling the test fixture.
+ ignoreDependencies: ['@temporalio/workflow'],
+ },
'apps/execution-worker': {
entry: ['src/engines/temporal/worker.ts', 'src/engines/temporal/workflows.ts'],
// @temporalio/workflow is never imported by this app's code, but Temporal's
diff --git a/packages/ai-config/README.md b/packages/ai-config/README.md
new file mode 100644
index 000000000..56dd5c956
--- /dev/null
+++ b/packages/ai-config/README.md
@@ -0,0 +1,40 @@
+# @workflow-builder/ai-config
+
+Private, source-only. The one place that says what "AI is configured" means for the reference backend and execution worker.
+
+## The contract
+
+Three variables, all or nothing:
+
+| Variable | Meaning |
+| ------------- | ---------------------------------------------------------------------------------------------------------------------------- |
+| `AI_API_KEY` | Key for the endpoint. OpenRouter keys look like `sk-or-v1-...` |
+| `AI_BASE_URL` | Any OpenAI-compatible base URL (a hosted gateway or a model inside your own network), without a trailing `/chat/completions` |
+| `AI_MODEL` | Model id as that endpoint spells it |
+
+- None has a built-in default: with the three unset there is no model endpoint to call. Both `.env.example` files pre-fill the OpenRouter values the stack used before the endpoint became configurable.
+- An empty value counts as unset (compose passes absent optionals through as `${VAR:-}`).
+- `OPENROUTER_API_KEY`, the old name of the key, is not read. `retiredAiVariables()` reports whether it is still set, so an app can say why a key that used to work is ignored; the value is never read.
+
+```ts
+import { aiConfig } from '@workflow-builder/ai-config';
+
+const ai = aiConfig(); // reads process.env when called; never throws
+// { available: true, config: { apiKey, baseURL, modelId } }
+// { available: false, missing: ['AI_BASE_URL', 'AI_MODEL'] }
+
+retiredAiVariables(); // ['OPENROUTER_API_KEY'] while the old name is still set, else []
+```
+
+`TAVILY_API_KEY` is not part of this contract. It is a worker-only, independently optional key that enables the AI Agent's web-search tool on nodes that ask for it, and the one other outbound call an AI Agent node can make — an internal `AI_BASE_URL` keeps model requests in your network, but only an unset Tavily key keeps the search from calling out — see [`apps/execution-worker/README.md`](../../apps/execution-worker/README.md).
+
+## What happens when it is unavailable
+
+Deliberately not decided here. Each app reacts in its own way so that a missing model never blocks graphs without AI:
+
+- **Backend** — `POST /api/visualize/adapt` answers `501 adapt_disabled`, after authorization and the execution guard have run (`apps/backend/src/routes/visualize.ts`).
+- **Worker** — boots, logs a warning naming the missing variables, and runs every node type. An AI Agent node that a run reaches fails with the permanent `ai_not_configured` code and the same names (`apps/execution-worker/src/executors/ai-agent.ts`).
+
+Both warnings also name any retired variable still present, which is what tells an operator that yesterday's key is being ignored rather than misread.
+
+Logging, provider lifetime and retries also stay in the apps. Sharing the parser keeps the two readings of the rule identical; it cannot make two independently configured processes agree on the values — set the variables in both `.env` files.
diff --git a/packages/ai-config/eslint.config.mjs b/packages/ai-config/eslint.config.mjs
new file mode 100644
index 000000000..eee9610de
--- /dev/null
+++ b/packages/ai-config/eslint.config.mjs
@@ -0,0 +1 @@
+export { default } from '../../eslint.config.mjs';
diff --git a/packages/ai-config/lint-staged.config.mjs b/packages/ai-config/lint-staged.config.mjs
new file mode 100644
index 000000000..63809e0a3
--- /dev/null
+++ b/packages/ai-config/lint-staged.config.mjs
@@ -0,0 +1 @@
+export { default } from '../../lint-staged.config.mjs';
diff --git a/packages/ai-config/package.json b/packages/ai-config/package.json
new file mode 100644
index 000000000..305aedc96
--- /dev/null
+++ b/packages/ai-config/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "@workflow-builder/ai-config",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "scripts": {
+ "typecheck": "tsc --noEmit",
+ "lint": "eslint",
+ "lint:fix": "eslint --fix",
+ "test": "vitest run",
+ "test:watch": "vitest"
+ },
+ "devDependencies": {
+ "@types/node": "^22.12.0",
+ "vitest": "^3.0.4"
+ }
+}
diff --git a/packages/ai-config/src/index.test.ts b/packages/ai-config/src/index.test.ts
new file mode 100644
index 000000000..ae742784a
--- /dev/null
+++ b/packages/ai-config/src/index.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { aiConfig, retiredAiVariables } from './index';
+
+const complete = {
+ AI_API_KEY: 'sk-or-v1-key',
+ AI_BASE_URL: 'http://vllm.internal:8000/v1',
+ AI_MODEL: 'some/model',
+};
+
+describe('aiConfig', () => {
+ it('is available only when all three variables are set', () => {
+ expect(aiConfig(complete)).toEqual({
+ available: true,
+ config: { apiKey: 'sk-or-v1-key', baseURL: 'http://vllm.internal:8000/v1', modelId: 'some/model' },
+ });
+ });
+
+ // Booting without an LLM is the point: a deployment that runs no AI nodes should
+ // not need an LLM account, so this never throws.
+ it('names every variable when nothing is set', () => {
+ expect(aiConfig({})).toEqual({ available: false, missing: ['AI_API_KEY', 'AI_BASE_URL', 'AI_MODEL'] });
+ });
+
+ it.each([
+ ['AI_API_KEY', ['AI_API_KEY']],
+ ['AI_BASE_URL', ['AI_BASE_URL']],
+ ['AI_MODEL', ['AI_MODEL']],
+ ] as const)('names only the missing variable when %s is absent', (absent, missing) => {
+ const env: NodeJS.ProcessEnv = { ...complete };
+ delete env[absent];
+
+ expect(aiConfig(env)).toEqual({ available: false, missing });
+ });
+
+ it('names two missing variables in declaration order', () => {
+ expect(aiConfig({ AI_API_KEY: 'key' })).toEqual({ available: false, missing: ['AI_BASE_URL', 'AI_MODEL'] });
+ });
+
+ // compose passes absent optionals through as `${VAR:-}`, so '' must not count as configured
+ it('treats an empty string like an unset variable', () => {
+ expect(aiConfig({ ...complete, AI_MODEL: '' })).toEqual({ available: false, missing: ['AI_MODEL'] });
+ });
+
+ // The alias was dropped rather than scoped: a provider-named key that silently
+ // applies to any AI_BASE_URL is a credential leak waiting to happen, and there are
+ // no external deployments to keep working. Rename the variable in .env instead.
+ it('does not read the retired OPENROUTER_API_KEY name', () => {
+ expect(aiConfig({ ...complete, AI_API_KEY: '', OPENROUTER_API_KEY: 'old-key' })).toEqual({
+ available: false,
+ missing: ['AI_API_KEY'],
+ });
+ });
+
+ it('reads process.env when no environment is given', () => {
+ vi.stubEnv('AI_API_KEY', 'from-process-env');
+ vi.stubEnv('AI_BASE_URL', complete.AI_BASE_URL);
+ vi.stubEnv('AI_MODEL', complete.AI_MODEL);
+ try {
+ expect(aiConfig()).toMatchObject({ available: true, config: { apiKey: 'from-process-env' } });
+ } finally {
+ vi.unstubAllEnvs();
+ }
+ });
+});
+
+describe('retiredAiVariables', () => {
+ it('names a retired variable that is still set', () => {
+ expect(retiredAiVariables({ OPENROUTER_API_KEY: 'old-key' })).toEqual(['OPENROUTER_API_KEY']);
+ });
+
+ it('is empty when no retired variable is set', () => {
+ expect(retiredAiVariables(complete)).toEqual([]);
+ });
+
+ // same rule as the contract's own variables: compose writes an absent one as ''
+ it('treats an empty string like an unset variable', () => {
+ expect(retiredAiVariables({ OPENROUTER_API_KEY: '' })).toEqual([]);
+ });
+
+ it('reads process.env when no environment is given', () => {
+ vi.stubEnv('OPENROUTER_API_KEY', 'old-key');
+ try {
+ expect(retiredAiVariables()).toEqual(['OPENROUTER_API_KEY']);
+ } finally {
+ vi.unstubAllEnvs();
+ }
+ });
+});
diff --git a/packages/ai-config/src/index.ts b/packages/ai-config/src/index.ts
new file mode 100644
index 000000000..01753d725
--- /dev/null
+++ b/packages/ai-config/src/index.ts
@@ -0,0 +1,36 @@
+const AI_VARIABLES = ['AI_API_KEY', 'AI_BASE_URL', 'AI_MODEL'] as const;
+
+// Names this contract dropped. Still set somewhere, they are dead weight the
+// operator cannot see: the app reads none of them.
+const RETIRED_AI_VARIABLES = ['OPENROUTER_API_KEY'] as const;
+
+export type AiVariable = (typeof AI_VARIABLES)[number];
+
+export type RetiredAiVariable = (typeof RETIRED_AI_VARIABLES)[number];
+
+export type AiConfig = { apiKey: string; baseURL: string; modelId: string };
+
+// Either everything an OpenAI-compatible client needs, or which variables are missing.
+// What to do about `available: false` is each app's call: the backend answers 501, the
+// worker boots and fails an AI Agent node only when a run reaches one.
+export type AiConfigResult = { available: true; config: AiConfig } | { available: false; missing: AiVariable[] };
+
+export function aiConfig(env: NodeJS.ProcessEnv = process.env): AiConfigResult {
+ // Empty string counts as unset: compose passes absent optionals through as
+ // `${VAR:-}`, and a bare `?? null` would read '' as a configured value.
+ const value = (name: AiVariable) => env[name] || null;
+ const apiKey = value('AI_API_KEY');
+ const baseURL = value('AI_BASE_URL');
+ const modelId = value('AI_MODEL');
+
+ // No built-in endpoint or model: unset means there is no model endpoint to call.
+ return apiKey && baseURL && modelId
+ ? { available: true, config: { apiKey, baseURL, modelId } }
+ : { available: false, missing: AI_VARIABLES.filter((name) => !value(name)) };
+}
+
+// Which retired names an environment still carries, so an app can say why a key that
+// used to work is ignored. The value is never read, only whether one is present.
+export function retiredAiVariables(env: NodeJS.ProcessEnv = process.env): RetiredAiVariable[] {
+ return RETIRED_AI_VARIABLES.filter((name) => Boolean(env[name]));
+}
diff --git a/packages/ai-config/tsconfig.json b/packages/ai-config/tsconfig.json
new file mode 100644
index 000000000..08eedd0d1
--- /dev/null
+++ b/packages/ai-config/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "lib": ["es2022"],
+ "types": ["node"]
+ },
+ "include": ["src"]
+}
diff --git a/packages/temporal-connection/README.md b/packages/temporal-connection/README.md
new file mode 100644
index 000000000..34b33af3a
--- /dev/null
+++ b/packages/temporal-connection/README.md
@@ -0,0 +1,20 @@
+# @workflow-builder/temporal-connection
+
+Private, source-only. Turns the `TEMPORAL_*` environment variables into everything the apps need to reach Temporal — connection options and the namespace — and holds the one copy of the rules: the defaults, which combinations are contradictory, when TLS is inferred, and how certificate files are read.
+
+Two consumers hand the result straight to their SDK: `apps/backend/src/engine/index.ts` (`@temporalio/client`) and `apps/execution-worker/src/engines/temporal/worker.ts` (`@temporalio/worker`). Change a rule here and both apps follow; a rule that only one of them should have does not belong here.
+
+Nothing is validated when this package is imported. `temporalConfig` reads `process.env` (or the environment it is given) when called and throws on a bad combination. Both apps call it as they start, before serving or polling, so a bad combination stops the process instead of surfacing on the first run. Reading it costs nothing at run time: the certificate files are read with it, and Temporal does not have to be reachable.
+
+```ts
+import { temporalConfig } from '@workflow-builder/temporal-connection';
+
+const { connection, namespace } = temporalConfig();
+// connection: { address } for plaintext, { address, tls: true } for the OS trust store,
+// { address, tls: { serverRootCACertificate, clientCertPair? }, apiKey? } otherwise
+// namespace: TEMPORAL_NAMESPACE, 'default' when unset
+```
+
+Tests: `src/index.test.ts` is the validation matrix. `test/tls.test.ts` drives the built options through a real TLS handshake on both SDK transports (grpc-js and the worker's native core) against a Temporal dev server behind a TLS-terminating proxy (`test/harness/`), with certificates minted per run — private CA, mutual TLS, untrusted server CA, wrong client certificate, an API key inside the TLS session, and work in a non-default namespace. The plaintext default is covered against the dev server directly, with no proxy, alongside the same server refusing a client that demands TLS. Handing the connection options to both SDKs' connect calls there is the compile-time proof that the contract fits both.
+
+This module is engine plumbing, not part of the execution model, so it is neither in `execution-core` nor in the published `@workflowbuilder/temporal` API.
diff --git a/packages/temporal-connection/eslint.config.mjs b/packages/temporal-connection/eslint.config.mjs
new file mode 100644
index 000000000..eee9610de
--- /dev/null
+++ b/packages/temporal-connection/eslint.config.mjs
@@ -0,0 +1 @@
+export { default } from '../../eslint.config.mjs';
diff --git a/packages/temporal-connection/lint-staged.config.mjs b/packages/temporal-connection/lint-staged.config.mjs
new file mode 100644
index 000000000..63809e0a3
--- /dev/null
+++ b/packages/temporal-connection/lint-staged.config.mjs
@@ -0,0 +1 @@
+export { default } from '../../lint-staged.config.mjs';
diff --git a/packages/temporal-connection/package.json b/packages/temporal-connection/package.json
new file mode 100644
index 000000000..b057bafe5
--- /dev/null
+++ b/packages/temporal-connection/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@workflow-builder/temporal-connection",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "scripts": {
+ "typecheck": "tsc --noEmit",
+ "lint": "eslint",
+ "lint:fix": "eslint --fix",
+ "test": "vitest run",
+ "test:watch": "vitest"
+ },
+ "devDependencies": {
+ "@temporalio/client": "catalog:",
+ "@temporalio/testing": "catalog:",
+ "@temporalio/worker": "catalog:",
+ "@temporalio/workflow": "catalog:",
+ "@types/node": "^22.12.0",
+ "@types/node-forge": "^1.3.14",
+ "node-forge": "^1.4.0",
+ "vitest": "^3.0.4"
+ }
+}
diff --git a/packages/temporal-connection/src/index.test.ts b/packages/temporal-connection/src/index.test.ts
new file mode 100644
index 000000000..d35c9a7c7
--- /dev/null
+++ b/packages/temporal-connection/src/index.test.ts
@@ -0,0 +1,140 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { temporalConfig } from './index';
+
+// Keyed by path so a test can tell the CA apart from the client cert.
+function fakeReader() {
+ return vi.fn((path: string) => new TextEncoder().encode(`contents-of:${path}`));
+}
+
+function bytes(path: string) {
+ return new TextEncoder().encode(`contents-of:${path}`);
+}
+
+const ADDRESS = 'temporal.example:7233';
+
+// TLS / API-key cases are about everything but the address, so they pin it.
+function tlsOptions(env: NodeJS.ProcessEnv, readFile = fakeReader()) {
+ return temporalConfig({ TEMPORAL_ADDRESS: ADDRESS, ...env }, readFile).connection;
+}
+
+describe('temporalConfig', () => {
+ it('defaults to the local docker stack on the default namespace', () => {
+ expect(temporalConfig({}, fakeReader())).toEqual({
+ connection: { address: '127.0.0.1:7233' },
+ namespace: 'default',
+ });
+ });
+
+ it('reads the address and namespace', () => {
+ const env = { TEMPORAL_ADDRESS: 'ns.acct.tmprl.cloud:7233', TEMPORAL_NAMESPACE: 'ns.acct' };
+
+ expect(temporalConfig(env, fakeReader())).toEqual({
+ connection: { address: 'ns.acct.tmprl.cloud:7233' },
+ namespace: 'ns.acct',
+ });
+ });
+
+ it('reads process.env when no environment is given', () => {
+ vi.stubEnv('TEMPORAL_NAMESPACE', 'from-process-env');
+ try {
+ expect(temporalConfig().namespace).toBe('from-process-env');
+ } finally {
+ vi.unstubAllEnvs();
+ }
+ });
+});
+
+describe('temporalConfig().connection TLS', () => {
+ it('stays plaintext when nothing is configured — the local-dev default', () => {
+ expect(tlsOptions({})).toEqual({ address: ADDRESS });
+ });
+
+ // compose passes absent optionals through as `${VAR:-}`, so '' must not count as configured
+ it('treats an empty string like an unset variable', () => {
+ const env = { TEMPORAL_TLS: '', TEMPORAL_API_KEY: '', TEMPORAL_TLS_CA_PATH: '' };
+
+ expect(tlsOptions(env)).toEqual({ address: ADDRESS });
+ });
+
+ it('enables TLS with the OS trust store on TEMPORAL_TLS=true', () => {
+ expect(tlsOptions({ TEMPORAL_TLS: 'true' })).toEqual({ address: ADDRESS, tls: true });
+ });
+
+ it('stays plaintext on an explicit TEMPORAL_TLS=false', () => {
+ expect(tlsOptions({ TEMPORAL_TLS: 'false' })).toEqual({ address: ADDRESS });
+ });
+
+ // Mirrors the SDK's own normalizeTlsConfig, which turns TLS on whenever an
+ // apiKey is present. Temporal Cloud rejects an API key sent in the clear.
+ it('infers TLS from an API key alone', () => {
+ expect(tlsOptions({ TEMPORAL_API_KEY: 'tmprl-key' })).toEqual({
+ address: ADDRESS,
+ tls: true,
+ apiKey: 'tmprl-key',
+ });
+ });
+
+ it('loads a private CA certificate', () => {
+ const read = fakeReader();
+
+ expect(tlsOptions({ TEMPORAL_TLS_CA_PATH: '/certs/ca.pem' }, read)).toEqual({
+ address: ADDRESS,
+ tls: { serverRootCACertificate: bytes('/certs/ca.pem') },
+ });
+ expect(read).toHaveBeenCalledWith('/certs/ca.pem');
+ });
+
+ it('loads a full mTLS pair alongside the CA', () => {
+ const env = {
+ TEMPORAL_TLS_CA_PATH: '/certs/ca.pem',
+ TEMPORAL_TLS_CERT_PATH: '/certs/client.pem',
+ TEMPORAL_TLS_KEY_PATH: '/certs/client.key',
+ };
+
+ expect(tlsOptions(env)).toEqual({
+ address: ADDRESS,
+ tls: {
+ serverRootCACertificate: bytes('/certs/ca.pem'),
+ clientCertPair: { crt: bytes('/certs/client.pem'), key: bytes('/certs/client.key') },
+ },
+ });
+ });
+});
+
+describe('temporalConfig rejects contradictory TLS config at connect time', () => {
+ it('refuses half an mTLS pair', () => {
+ expect(() => tlsOptions({ TEMPORAL_TLS_CERT_PATH: '/certs/client.pem' })).toThrow(/must be set together/);
+ expect(() => tlsOptions({ TEMPORAL_TLS_KEY_PATH: '/certs/client.key' })).toThrow(/must be set together/);
+ });
+
+ it('refuses an API key and a client certificate together', () => {
+ const both = {
+ TEMPORAL_API_KEY: 'k',
+ TEMPORAL_TLS_CERT_PATH: '/certs/client.pem',
+ TEMPORAL_TLS_KEY_PATH: '/certs/client.key',
+ };
+
+ expect(() => tlsOptions(both)).toThrow(/not both/);
+ });
+
+ it('refuses credentials that TEMPORAL_TLS=false would silently discard', () => {
+ const contradiction = { TEMPORAL_TLS: 'false', TEMPORAL_API_KEY: 'k' };
+
+ expect(() => tlsOptions(contradiction)).toThrow(/contradicts/);
+ });
+
+ it('refuses a TEMPORAL_TLS value that is neither true nor false', () => {
+ expect(() => tlsOptions({ TEMPORAL_TLS: 'yes' })).toThrow(/must be 'true'/);
+ });
+
+ it('names the variable and the path when a certificate cannot be read', () => {
+ const explode = vi.fn(() => {
+ throw new Error('ENOENT');
+ });
+
+ expect(() => tlsOptions({ TEMPORAL_TLS_CA_PATH: '/nope.pem' }, explode)).toThrow(
+ /TEMPORAL_TLS_CA_PATH \(\/nope\.pem\)/,
+ );
+ });
+});
diff --git a/packages/temporal-connection/src/index.ts b/packages/temporal-connection/src/index.ts
new file mode 100644
index 000000000..100f4928e
--- /dev/null
+++ b/packages/temporal-connection/src/index.ts
@@ -0,0 +1,119 @@
+import { readFileSync } from 'node:fs';
+
+export type TemporalTlsOptions = {
+ serverRootCACertificate?: Uint8Array;
+ clientCertPair?: { crt: Uint8Array; key: Uint8Array };
+};
+
+// The subset both SDKs accept as-is: the client also takes an apiKey function and
+// tls: false | null, neither of which this module ever produces.
+export type TemporalConnectionOptions = {
+ address: string;
+ tls?: true | TemporalTlsOptions;
+ apiKey?: string;
+};
+
+export type TemporalConfig = {
+ connection: TemporalConnectionOptions;
+ // Not a connection option — it goes to the Client and the Worker — but it must
+ // match between the two, so it is read here alongside the rest.
+ namespace: string;
+};
+
+// 127.0.0.1, not `localhost`: the local docker stack binds loopback IPv4 only, and
+// some Node setups resolve `localhost` to ::1 first (see apps/backend/src/env.ts).
+const DEFAULT_ADDRESS = '127.0.0.1:7233';
+// Temporal Cloud spells it `.`.
+const DEFAULT_NAMESPACE = 'default';
+
+type Config = {
+ // Raw TEMPORAL_TLS. Tri-state on purpose: unset means "infer from the rest",
+ // which is not the same as an explicit 'false'.
+ tls: string | null;
+ apiKey: string | null;
+ caPath: string | null;
+ certPath: string | null;
+ keyPath: string | null;
+};
+
+export function temporalConfig(
+ env: NodeJS.ProcessEnv = process.env,
+ readFile: (path: string) => Uint8Array = readFileSync,
+): TemporalConfig {
+ return {
+ connection: { address: env['TEMPORAL_ADDRESS'] || DEFAULT_ADDRESS, ...connectionOptions(env, readFile) },
+ namespace: env['TEMPORAL_NAMESPACE'] || DEFAULT_NAMESPACE,
+ };
+}
+
+function connectionOptions(
+ env: NodeJS.ProcessEnv,
+ readFile: (path: string) => Uint8Array,
+): Omit {
+ const { tls, apiKey, caPath, certPath, keyPath } = read(env);
+
+ if (tls !== null && tls !== 'true' && tls !== 'false') {
+ throw new Error(`TEMPORAL_TLS must be 'true' or 'false' (got '${tls}').`);
+ }
+ if (Boolean(certPath) !== Boolean(keyPath)) {
+ throw new Error(
+ 'TEMPORAL_TLS_CERT_PATH and TEMPORAL_TLS_KEY_PATH must be set together — mTLS needs both halves of the pair.',
+ );
+ }
+ if (apiKey && certPath) {
+ throw new Error('Set either TEMPORAL_API_KEY or an mTLS client certificate pair, not both.');
+ }
+
+ const hasTlsMaterial = Boolean(apiKey || caPath || certPath);
+ if (tls === 'false' && hasTlsMaterial) {
+ throw new Error(
+ 'TEMPORAL_TLS=false contradicts the TEMPORAL_API_KEY / TEMPORAL_TLS_*_PATH values that are set — remove one side.',
+ );
+ }
+
+ // Material implies TLS, matching what the SDKs already do for apiKey. Being
+ // explicit here keeps the client and the worker in step and makes it testable.
+ if (tls !== 'true' && !hasTlsMaterial) {
+ // Plaintext — the local-dev default.
+ return {};
+ }
+
+ const certificates: TemporalTlsOptions = {
+ ...(caPath ? { serverRootCACertificate: readPemFile(readFile, caPath, 'TEMPORAL_TLS_CA_PATH') } : {}),
+ ...(certPath && keyPath
+ ? {
+ clientCertPair: {
+ crt: readPemFile(readFile, certPath, 'TEMPORAL_TLS_CERT_PATH'),
+ key: readPemFile(readFile, keyPath, 'TEMPORAL_TLS_KEY_PATH'),
+ },
+ }
+ : {}),
+ };
+
+ return {
+ // `true` means TLS with the OS trust store — enough for Temporal Cloud.
+ tls: Object.keys(certificates).length > 0 ? certificates : true,
+ ...(apiKey ? { apiKey } : {}),
+ };
+}
+
+function read(env: NodeJS.ProcessEnv): Config {
+ // Empty string counts as unset: compose passes absent optionals through as
+ // `${VAR:-}`, and a bare `?? null` would read '' as a configured value.
+ const optional = (name: string) => env[name] || null;
+ return {
+ tls: optional('TEMPORAL_TLS'),
+ apiKey: optional('TEMPORAL_API_KEY'),
+ caPath: optional('TEMPORAL_TLS_CA_PATH'),
+ certPath: optional('TEMPORAL_TLS_CERT_PATH'),
+ keyPath: optional('TEMPORAL_TLS_KEY_PATH'),
+ };
+}
+
+function readPemFile(readFile: (path: string) => Uint8Array, path: string, variable: string): Uint8Array {
+ try {
+ return readFile(path);
+ } catch (error) {
+ throw new Error(`Could not read ${variable} (${path}).`, { cause: error });
+ }
+}
diff --git a/packages/temporal-connection/test/fixtures/tls-probe-workflow.ts b/packages/temporal-connection/test/fixtures/tls-probe-workflow.ts
new file mode 100644
index 000000000..451f4e2ea
--- /dev/null
+++ b/packages/temporal-connection/test/fixtures/tls-probe-workflow.ts
@@ -0,0 +1,5 @@
+// Handed to Temporal's bundler by path from tls.test.ts. All the test needs is proof
+// that a task round-trips through the worker's TLS connection.
+export async function tlsProbe(): Promise {
+ return 'pong';
+}
diff --git a/packages/temporal-connection/test/harness/authorization-sink.ts b/packages/temporal-connection/test/harness/authorization-sink.ts
new file mode 100644
index 000000000..902058c61
--- /dev/null
+++ b/packages/temporal-connection/test/harness/authorization-sink.ts
@@ -0,0 +1,47 @@
+import { type Http2SecureServer, type Http2Session, createSecureServer } from 'node:http2';
+import type { AddressInfo } from 'node:net';
+
+import type { PemPair } from './certificates';
+
+type AuthorizationSink = {
+ address: string;
+ /** The `authorization` header of every gRPC call received, in order. */
+ authorizations: string[];
+ close: () => Promise;
+};
+
+/**
+ * A TLS endpoint that records the `authorization` header of each gRPC request and
+ * answers UNAUTHENTICATED, so a client's connect attempt fails fast instead of hanging.
+ * The header travels inside the encrypted HTTP/2 stream, so a TCP-level proxy cannot see it.
+ */
+export async function startAuthorizationSink(server: PemPair): Promise {
+ const authorizations: string[] = [];
+ const sessions = new Set();
+
+ const http2Server: Http2SecureServer = createSecureServer({ cert: server.cert, key: server.key, allowHTTP1: false });
+ http2Server.on('session', (session) => {
+ sessions.add(session);
+ session.on('close', () => sessions.delete(session));
+ });
+ http2Server.on('stream', (stream, headers) => {
+ authorizations.push(String(headers.authorization ?? ''));
+ stream.respond(
+ { ':status': 200, 'content-type': 'application/grpc', 'grpc-status': '16', 'grpc-message': 'authorization sink' },
+ { endStream: true },
+ );
+ });
+
+ await new Promise((resolve) => http2Server.listen(0, resolve));
+ const { port } = http2Server.address() as AddressInfo;
+
+ return {
+ address: `localhost:${port}`,
+ authorizations,
+ close: () =>
+ new Promise((resolve) => {
+ for (const session of sessions) session.destroy();
+ http2Server.close(() => resolve());
+ }),
+ };
+}
diff --git a/packages/temporal-connection/test/harness/certificates.ts b/packages/temporal-connection/test/harness/certificates.ts
new file mode 100644
index 000000000..5206774ba
--- /dev/null
+++ b/packages/temporal-connection/test/harness/certificates.ts
@@ -0,0 +1,78 @@
+import forge from 'node-forge';
+import { mkdtempSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+
+export type PemPair = { cert: string; key: string };
+
+/** A throwaway CA with one server leaf (SAN localhost / 127.0.0.1 / ::1) and one client leaf. */
+export type TestPki = { ca: PemPair; server: PemPair; client: PemPair };
+
+/** The PEM files a TEMPORAL_TLS_*_PATH-style config can point at; `directory` holds them all, for cleanup. */
+export type TestPkiFiles = { directory: string; ca: string; clientCert: string; clientKey: string };
+
+type Issued = { cert: forge.pki.Certificate; key: forge.pki.rsa.PrivateKey; pem: PemPair };
+
+let nextSerial = 1;
+
+function issue(commonName: string, issuer: Issued | null, extensions: object[]): Issued {
+ const keys = forge.pki.rsa.generateKeyPair(2048);
+ const cert = forge.pki.createCertificate();
+ cert.publicKey = keys.publicKey;
+ cert.serialNumber = (nextSerial++).toString(16).padStart(2, '0');
+ cert.validity.notBefore = new Date(Date.now() - 60 * 60 * 1000);
+ cert.validity.notAfter = new Date(Date.now() + 24 * 60 * 60 * 1000);
+ const subject = [{ name: 'commonName', value: commonName }];
+ cert.setSubject(subject);
+ cert.setIssuer(issuer ? issuer.cert.subject.attributes : subject);
+ cert.setExtensions(extensions);
+ // rustls, the worker's native transport, rejects forge's default SHA-1 signature.
+ cert.sign(issuer ? issuer.key : keys.privateKey, forge.md.sha256.create());
+ return {
+ cert,
+ key: keys.privateKey,
+ pem: { cert: forge.pki.certificateToPem(cert), key: forge.pki.privateKeyToPem(keys.privateKey) },
+ };
+}
+
+export function createTestPki(name: string): TestPki {
+ const ca = issue(`${name} test CA`, null, [
+ { name: 'basicConstraints', cA: true, critical: true },
+ { name: 'keyUsage', keyCertSign: true, cRLSign: true, critical: true },
+ { name: 'subjectKeyIdentifier' },
+ ]);
+ const server = issue(`${name} server`, ca, [
+ { name: 'basicConstraints', cA: false, critical: true },
+ { name: 'keyUsage', digitalSignature: true, keyEncipherment: true, critical: true },
+ { name: 'extKeyUsage', serverAuth: true },
+ {
+ name: 'subjectAltName',
+ altNames: [
+ { type: 2, value: 'localhost' },
+ { type: 7, ip: '127.0.0.1' },
+ { type: 7, ip: '::1' },
+ ],
+ },
+ ]);
+ const client = issue(`${name} client`, ca, [
+ { name: 'basicConstraints', cA: false, critical: true },
+ { name: 'keyUsage', digitalSignature: true, critical: true },
+ { name: 'extKeyUsage', clientAuth: true },
+ ]);
+ return { ca: ca.pem, server: server.pem, client: client.pem };
+}
+
+/** Writes the CA and client PEMs to a fresh temp directory, so config paths resolve like in production. */
+export function writeTestPki(pki: TestPki, name: string): TestPkiFiles {
+ const directory = mkdtempSync(path.join(tmpdir(), `wb-tls-${name}-`));
+ const files = {
+ directory,
+ ca: path.join(directory, 'ca.pem'),
+ clientCert: path.join(directory, 'client.pem'),
+ clientKey: path.join(directory, 'client-key.pem'),
+ };
+ writeFileSync(files.ca, pki.ca.cert);
+ writeFileSync(files.clientCert, pki.client.cert);
+ writeFileSync(files.clientKey, pki.client.key);
+ return files;
+}
diff --git a/packages/temporal-connection/test/harness/index.ts b/packages/temporal-connection/test/harness/index.ts
new file mode 100644
index 000000000..5ed146f47
--- /dev/null
+++ b/packages/temporal-connection/test/harness/index.ts
@@ -0,0 +1,5 @@
+// Harness for tls.test.ts: throwaway certificates, a TLS-terminating proxy in front
+// of a plaintext dev server, and an endpoint that records bearer tokens.
+export { type TestPki, type TestPkiFiles, createTestPki, writeTestPki } from './certificates';
+export { startAuthorizationSink } from './authorization-sink';
+export { startTlsProxy } from './tls-proxy';
diff --git a/packages/temporal-connection/test/harness/tls-proxy.ts b/packages/temporal-connection/test/harness/tls-proxy.ts
new file mode 100644
index 000000000..6343f54e3
--- /dev/null
+++ b/packages/temporal-connection/test/harness/tls-proxy.ts
@@ -0,0 +1,73 @@
+import { type AddressInfo, type Socket, connect } from 'node:net';
+import { type TlsOptions, createServer } from 'node:tls';
+
+import type { PemPair } from './certificates';
+
+type TlsProxy = {
+ /** host:port a Temporal client can dial; the hostname is covered by the server certificate's SAN. */
+ address: string;
+ /** One entry per failed handshake, whichever side aborted it. */
+ handshakeErrors: string[];
+ close: () => Promise;
+};
+
+type TlsProxyOptions = {
+ /** host:port of the plaintext Temporal server behind the proxy. */
+ upstream: string;
+ server: PemPair;
+ /** When set, a client certificate signed by this CA is required. */
+ clientCa?: string;
+};
+
+/**
+ * Terminates TLS in front of a plaintext Temporal server and forwards the bytes as-is.
+ * gRPC frames pass through untouched, so what is exercised is the client's transport:
+ * server-certificate trust, hostname check, ALPN and, with `clientCa`, mutual TLS.
+ */
+export async function startTlsProxy({ upstream, server, clientCa }: TlsProxyOptions): Promise {
+ const [upstreamHost, upstreamPort] = splitAddress(upstream);
+ const handshakeErrors: string[] = [];
+ const sockets = new Set();
+
+ const options: TlsOptions = {
+ cert: server.cert,
+ key: server.key,
+ // gRPC clients hang up on a server that does not select h2
+ ALPNProtocols: ['h2'],
+ ...(clientCa ? { ca: clientCa, requestCert: true, rejectUnauthorized: true } : {}),
+ };
+
+ const tlsServer = createServer(options, (downstream) => {
+ const upstreamSocket = connect({ host: upstreamHost, port: upstreamPort });
+ sockets.add(downstream);
+ sockets.add(upstreamSocket);
+ downstream.pipe(upstreamSocket).pipe(downstream);
+ const drop = () => {
+ downstream.destroy();
+ upstreamSocket.destroy();
+ };
+ downstream.on('error', drop);
+ upstreamSocket.on('error', drop);
+ downstream.on('close', drop);
+ upstreamSocket.on('close', drop);
+ });
+ tlsServer.on('tlsClientError', (error) => handshakeErrors.push(error.message));
+
+ await new Promise((resolve) => tlsServer.listen(0, resolve));
+ const { port } = tlsServer.address() as AddressInfo;
+
+ return {
+ address: `localhost:${port}`,
+ handshakeErrors,
+ close: () =>
+ new Promise((resolve) => {
+ for (const socket of sockets) socket.destroy();
+ tlsServer.close(() => resolve());
+ }),
+ };
+}
+
+function splitAddress(address: string): [string, number] {
+ const separator = address.lastIndexOf(':');
+ return [address.slice(0, separator), Number(address.slice(separator + 1))];
+}
diff --git a/packages/temporal-connection/test/tls.test.ts b/packages/temporal-connection/test/tls.test.ts
new file mode 100644
index 000000000..764c04476
--- /dev/null
+++ b/packages/temporal-connection/test/tls.test.ts
@@ -0,0 +1,232 @@
+// Drives the options this package builds through a real TLS handshake on both SDK
+// transports: grpc-js in @temporalio/client and the Rust core in @temporalio/worker.
+// A Temporal dev server sits behind a TLS-terminating proxy; certificates are minted
+// per run. The unit tests prove the shape of the options; this file proves they connect.
+import { Client, Connection } from '@temporalio/client';
+import { TestWorkflowEnvironment } from '@temporalio/testing';
+import { NativeConnection, Worker } from '@temporalio/worker';
+import { rmSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import { temporalConfig } from '../src/index';
+import {
+ type TestPki,
+ type TestPkiFiles,
+ createTestPki,
+ startAuthorizationSink,
+ startTlsProxy,
+ writeTestPki,
+} from './harness';
+
+const NAMESPACE = 'tls-test';
+const TASK_QUEUE = 'tls-probe';
+
+type Pki = { pki: TestPki; files: TestPkiFiles };
+
+function mint(name: string): Pki {
+ const pki = createTestPki(name);
+ return { pki, files: writeTestPki(pki, name) };
+}
+
+type Transport = {
+ name: string;
+ connect: (address: string, env: NodeJS.ProcessEnv) => Promise<{ close(): Promise }>;
+};
+
+// Handing the built options to each SDK's own connect call is also the compile-time
+// proof that the shared contract is assignable to both option types without a cast.
+function connectClient(address: string, env: NodeJS.ProcessEnv) {
+ const { connection } = temporalConfig({ TEMPORAL_ADDRESS: address, ...env });
+ return Connection.connect({ connectTimeout: '3s', ...connection });
+}
+
+function connectWorker(address: string, env: NodeJS.ProcessEnv) {
+ return NativeConnection.connect(temporalConfig({ TEMPORAL_ADDRESS: address, ...env }).connection);
+}
+
+const transports: Transport[] = [
+ { name: '@temporalio/client (grpc-js)', connect: connectClient },
+ { name: '@temporalio/worker (native core)', connect: connectWorker },
+];
+
+let env: TestWorkflowEnvironment;
+// `trusted` is what the server presents and requires; `stranger` is a second, unrelated CA.
+let trusted: Pki;
+let stranger: Pki;
+
+beforeAll(async () => {
+ [env, trusted, stranger] = await Promise.all([
+ TestWorkflowEnvironment.createLocal({ server: { extraArgs: ['--namespace', NAMESPACE] } }),
+ mint('trusted'),
+ mint('stranger'),
+ ]);
+}, 300_000);
+
+afterAll(async () => {
+ await env?.teardown();
+ for (const minted of [trusted, stranger]) {
+ if (minted) rmSync(minted.files.directory, { recursive: true, force: true });
+ }
+});
+
+describe.each(transports)('$name over TLS', ({ connect }) => {
+ it('connects through a private CA', async () => {
+ const proxy = await startTlsProxy({ upstream: env.address, server: trusted.pki.server });
+ try {
+ const connection = await connect(proxy.address, { TEMPORAL_TLS_CA_PATH: trusted.files.ca });
+ await connection.close();
+ expect(proxy.handshakeErrors).toEqual([]);
+ } finally {
+ await proxy.close();
+ }
+ }, 60_000);
+
+ it('authenticates with a client certificate when the server requires one', async () => {
+ const proxy = await startTlsProxy({
+ upstream: env.address,
+ server: trusted.pki.server,
+ clientCa: trusted.pki.ca.cert,
+ });
+ try {
+ const connection = await connect(proxy.address, {
+ TEMPORAL_TLS_CA_PATH: trusted.files.ca,
+ TEMPORAL_TLS_CERT_PATH: trusted.files.clientCert,
+ TEMPORAL_TLS_KEY_PATH: trusted.files.clientKey,
+ });
+ await connection.close();
+ expect(proxy.handshakeErrors).toEqual([]);
+ } finally {
+ await proxy.close();
+ }
+ }, 60_000);
+
+ it('refuses a server certificate from a CA it does not trust', async () => {
+ const proxy = await startTlsProxy({ upstream: env.address, server: trusted.pki.server });
+ try {
+ await expect(connect(proxy.address, { TEMPORAL_TLS_CA_PATH: stranger.files.ca })).rejects.toThrow();
+ // the proxy records the failed handshake asynchronously, after the client has given up
+ await expect.poll(() => proxy.handshakeErrors.length).toBeGreaterThan(0);
+ } finally {
+ await proxy.close();
+ }
+ }, 60_000);
+
+ it('is refused when its client certificate comes from the wrong CA', async () => {
+ const proxy = await startTlsProxy({
+ upstream: env.address,
+ server: trusted.pki.server,
+ clientCa: trusted.pki.ca.cert,
+ });
+ try {
+ await expect(
+ connect(proxy.address, {
+ TEMPORAL_TLS_CA_PATH: trusted.files.ca,
+ TEMPORAL_TLS_CERT_PATH: stranger.files.clientCert,
+ TEMPORAL_TLS_KEY_PATH: stranger.files.clientKey,
+ }),
+ ).rejects.toThrow();
+ await expect.poll(() => proxy.handshakeErrors.length).toBeGreaterThan(0);
+ } finally {
+ await proxy.close();
+ }
+ }, 60_000);
+
+ it('sends TEMPORAL_API_KEY as a bearer token inside the TLS session', async () => {
+ const sink = await startAuthorizationSink(trusted.pki.server);
+ try {
+ // the sink answers UNAUTHENTICATED on purpose; the header having arrived is the assertion
+ await expect(
+ connect(sink.address, { TEMPORAL_API_KEY: 'synthetic-key', TEMPORAL_TLS_CA_PATH: trusted.files.ca }),
+ ).rejects.toThrow();
+ expect(sink.authorizations).toContain('Bearer synthetic-key');
+ } finally {
+ await sink.close();
+ }
+ }, 60_000);
+});
+
+// The local-dev default, which no proxy is involved in: straight to the plaintext dev
+// server. The unit tests pin the option shape for an unconfigured environment; these pin
+// that the shape actually connects, and that demanding TLS from the same server does not.
+describe.each(transports)('$name without TLS', ({ connect }) => {
+ it.each([
+ ['nothing is configured', {}],
+ ['TEMPORAL_TLS=false asserts plaintext', { TEMPORAL_TLS: 'false' }],
+ ])(
+ 'connects to a plaintext server when %s',
+ async (_label, variables) => {
+ const connection = await connect(env.address, variables);
+
+ await connection.close();
+ },
+ 60_000,
+ );
+
+ it('is refused by that same server once TEMPORAL_TLS demands TLS', async () => {
+ await expect(connect(env.address, { TEMPORAL_TLS: 'true' })).rejects.toThrow();
+ }, 60_000);
+});
+
+describe('work in a non-default namespace over a private CA', () => {
+ it('a client starts a workflow that lands in the configured namespace', async () => {
+ const proxy = await startTlsProxy({ upstream: env.address, server: trusted.pki.server });
+ try {
+ const config = temporalConfig({
+ TEMPORAL_ADDRESS: proxy.address,
+ TEMPORAL_NAMESPACE: NAMESPACE,
+ TEMPORAL_TLS_CA_PATH: trusted.files.ca,
+ });
+ const connection = await Connection.connect(config.connection);
+ const client = new Client({ connection, namespace: config.namespace });
+ const handle = await client.workflow.start('tls-probe', {
+ taskQueue: TASK_QUEUE,
+ workflowId: `tls-probe-client-${Date.now()}`,
+ });
+
+ // Read back over the dev server's own plaintext connection, so the assertion
+ // does not depend on the connection under test.
+ const inNamespace = new Client({ connection: env.connection, namespace: NAMESPACE });
+ await expect(inNamespace.workflow.getHandle(handle.workflowId).describe()).resolves.toMatchObject({
+ status: { name: 'RUNNING' },
+ });
+ await expect(env.client.workflow.getHandle(handle.workflowId).describe()).rejects.toThrow();
+
+ await handle.terminate('tls test done');
+ await connection.close();
+ expect(proxy.handshakeErrors).toEqual([]);
+ } finally {
+ await proxy.close();
+ }
+ }, 60_000);
+
+ it('a worker polls the configured namespace and completes a workflow', async () => {
+ const proxy = await startTlsProxy({ upstream: env.address, server: trusted.pki.server });
+ try {
+ const config = temporalConfig({
+ TEMPORAL_ADDRESS: proxy.address,
+ TEMPORAL_NAMESPACE: NAMESPACE,
+ TEMPORAL_TLS_CA_PATH: trusted.files.ca,
+ });
+ const connection = await NativeConnection.connect(config.connection);
+ const worker = await Worker.create({
+ connection,
+ namespace: config.namespace,
+ taskQueue: TASK_QUEUE,
+ workflowsPath: fileURLToPath(new URL('fixtures/tls-probe-workflow.ts', import.meta.url)),
+ });
+ // The client submits over the dev server's own plaintext connection; only the
+ // worker's polling and completion travel through TLS.
+ const client = new Client({ connection: env.connection, namespace: NAMESPACE });
+ const result = await worker.runUntil(
+ client.workflow.execute('tlsProbe', { taskQueue: TASK_QUEUE, workflowId: `tls-probe-worker-${Date.now()}` }),
+ );
+
+ expect(result).toBe('pong');
+ await connection.close();
+ expect(proxy.handshakeErrors).toEqual([]);
+ } finally {
+ await proxy.close();
+ }
+ }, 120_000);
+});
diff --git a/packages/temporal-connection/tsconfig.json b/packages/temporal-connection/tsconfig.json
new file mode 100644
index 000000000..c93019dec
--- /dev/null
+++ b/packages/temporal-connection/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "lib": ["es2022"],
+ "types": ["node"]
+ },
+ "include": ["src", "test"]
+}
diff --git a/packages/temporal/test/error-boundary.test.ts b/packages/temporal/test/error-boundary.test.ts
new file mode 100644
index 000000000..ce08fea5e
--- /dev/null
+++ b/packages/temporal/test/error-boundary.test.ts
@@ -0,0 +1,129 @@
+// Runs graphs through a real Temporal dev server, so every assertion crosses the actual
+// activity → workflow boundary where a thrown error is serialized and its class is lost.
+import { WorkflowFailedError } from '@temporalio/client';
+import { TestWorkflowEnvironment } from '@temporalio/testing';
+import { Worker, bundleWorkflowCode } from '@temporalio/worker';
+import { fileURLToPath } from 'node:url';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import {
+ type BaseNode,
+ DEFAULT_NODE_ACTIVITY_PROFILE,
+ NodeExecutionError,
+ type NodeExecutorRegistry,
+ PermanentNodeExecutionError,
+ RUN_WORKFLOW_NAME,
+ WorkflowBuilderPlugin,
+ type WorkflowDefinition,
+ type WorkflowExecutionInput,
+ executionWorkflowId,
+} from '../src/index';
+import { type RecordingStore, createRecordingStore } from './fixtures/graph';
+
+type BoundaryNode = (BaseNode & { type: 'test/step' }) | (BaseNode & { type: 'test/fail' });
+
+const TASK_QUEUE = 'error-boundary-test';
+
+function graph(workflowId: string): WorkflowDefinition {
+ return {
+ workflowId,
+ nodes: [
+ { id: 'start', type: 'test/step', role: 'start', config: {} },
+ { id: 'fail', type: 'test/fail', config: {} },
+ ],
+ edges: [{ id: 'e-start-fail', sourceNodeId: 'start', targetNodeId: 'fail' }],
+ };
+}
+
+type Run = { store: RecordingStore; attempts: number; failure: unknown };
+
+function nodeFailedPayload(store: RecordingStore): unknown {
+ return store.events.find((event) => event.type === 'node_failed' && event.nodeId === 'fail')?.payload;
+}
+
+describe('error classification across the activity boundary', () => {
+ let env: TestWorkflowEnvironment;
+ let workflowBundle: { code: string };
+
+ beforeAll(async () => {
+ [workflowBundle, env] = await Promise.all([
+ bundleWorkflowCode({ workflowsPath: fileURLToPath(new URL('fixtures/workflows.ts', import.meta.url)) }),
+ TestWorkflowEnvironment.createLocal(),
+ ]);
+ }, 300_000);
+
+ afterAll(async () => {
+ await env?.teardown();
+ });
+
+ async function run(executionId: string, thrown: () => Error): Promise {
+ const store = createRecordingStore();
+ let attempts = 0;
+
+ const executors: NodeExecutorRegistry = {
+ 'test/step': () => ({ output: null }),
+ 'test/fail': () => {
+ attempts += 1;
+ throw thrown();
+ },
+ };
+
+ const plugin = new WorkflowBuilderPlugin({ store, executors, taskQueue: TASK_QUEUE });
+ const worker = await Worker.create({
+ connection: env.nativeConnection,
+ namespace: env.namespace,
+ taskQueue: plugin.taskQueue,
+ workflowBundle,
+ plugins: [plugin],
+ });
+
+ const workflowId = `wf-${executionId}`;
+ const input: WorkflowExecutionInput = {
+ workflowId,
+ executionId,
+ definition: graph(workflowId),
+ triggerPayload: {},
+ variables: {},
+ global: {},
+ };
+
+ const failure = await worker.runUntil(
+ env.client.workflow
+ .execute(RUN_WORKFLOW_NAME, {
+ taskQueue: plugin.taskQueue,
+ workflowId: executionWorkflowId(executionId),
+ args: [input],
+ })
+ .catch((error: unknown) => error),
+ );
+
+ return { store, attempts, failure };
+ }
+
+ it('a permanent throw stops on its first attempt and reaches node_failed with its code', async () => {
+ const { store, attempts, failure } = await run(
+ 'permanent',
+ () => new PermanentNodeExecutionError('ai_not_configured', 'AI is not configured on this worker'),
+ );
+
+ expect(attempts).toBe(1);
+ expect(nodeFailedPayload(store)).toEqual({
+ error: { message: 'AI is not configured on this worker', code: 'ai_not_configured', attempt: 1 },
+ });
+ expect(store.statuses.at(-1)).toMatchObject({ status: 'failed' });
+
+ // The code also names the workflow's terminal failure type.
+ expect(failure).toBeInstanceOf(WorkflowFailedError);
+ expect((failure as WorkflowFailedError).cause).toMatchObject({ type: 'ai_not_configured' });
+ }, 60_000);
+
+ it('an unclassified throw retries per the profile and is reported exactly as before', async () => {
+ const { store, attempts } = await run(
+ 'unclassified',
+ () => new NodeExecutionError('no_branch_matched', 'No branch matched'),
+ );
+
+ expect(attempts).toBe(DEFAULT_NODE_ACTIVITY_PROFILE.retry.maximumAttempts);
+ expect(nodeFailedPayload(store)).toEqual({ error: { message: 'No branch matched' } });
+ }, 60_000);
+});
diff --git a/packages/temporal/test/fixtures/graph.ts b/packages/temporal/test/fixtures/graph.ts
index 215bf2429..fd73b1755 100644
--- a/packages/temporal/test/fixtures/graph.ts
+++ b/packages/temporal/test/fixtures/graph.ts
@@ -33,7 +33,7 @@ export const replayTestExecutors: NodeExecutorRegistry = {
};
export type RecordingStore = ExecutionStore & {
- events: { sequence: number; type: string; nodeId?: string }[];
+ events: { sequence: number; type: string; nodeId?: string; payload?: unknown }[];
statuses: { status: string; errorMessage?: string }[];
};
@@ -44,8 +44,8 @@ export function createRecordingStore(): RecordingStore {
return {
events,
statuses,
- async emitExecutionEvent(_executionId, sequence, type, _payload, nodeId) {
- events.push({ sequence, type, nodeId });
+ async emitExecutionEvent(_executionId, sequence, type, payload, nodeId) {
+ events.push({ sequence, type, nodeId, payload });
},
async updateExecutionStatus(_executionId, status, errorMessage) {
statuses.push({ status, errorMessage });
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index adecd8493..61ab82a5c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -6,6 +6,9 @@ settings:
catalogs:
default:
+ '@ai-sdk/openai-compatible':
+ specifier: ^2.0.74
+ version: 2.0.74
'@base-ui/react':
specifier: 1.7.0
version: 1.7.0
@@ -42,6 +45,9 @@ catalogs:
'@xyflow/react':
specifier: 12.10.0
version: 12.10.0
+ ai:
+ specifier: ^6.0.168
+ version: 6.0.168
ajv:
specifier: ^8.18.0
version: 8.18.0
@@ -204,18 +210,24 @@ importers:
apps/backend:
dependencies:
+ '@ai-sdk/openai-compatible':
+ specifier: 'catalog:'
+ version: 2.0.74(zod@4.3.6)
'@hono/node-server':
specifier: ^1.14.0
version: 1.19.14(hono@4.12.14)
- '@openrouter/ai-sdk-provider':
- specifier: ^2.8.0
- version: 2.8.0(ai@6.0.168(zod@4.3.6))(zod@4.3.6)
'@temporalio/client':
specifier: 'catalog:'
version: 1.23.0
+ '@workflow-builder/ai-config':
+ specifier: workspace:*
+ version: link:../../packages/ai-config
'@workflow-builder/execution-core':
specifier: workspace:*
version: link:../../packages/execution-core
+ '@workflow-builder/temporal-connection':
+ specifier: workspace:*
+ version: link:../../packages/temporal-connection
'@workflow-builder/types':
specifier: workspace:*
version: link:../../packages/types
@@ -223,7 +235,7 @@ importers:
specifier: workspace:*
version: link:../../packages/temporal
ai:
- specifier: ^6.0.168
+ specifier: 'catalog:'
version: 6.0.168(zod@4.3.6)
dotenv:
specifier: ^17.4.2
@@ -438,18 +450,24 @@ importers:
apps/execution-worker:
dependencies:
- '@openrouter/ai-sdk-provider':
- specifier: ^2.5.0
- version: 2.8.0(ai@6.0.168(zod@4.3.6))(zod@4.3.6)
+ '@ai-sdk/openai-compatible':
+ specifier: 'catalog:'
+ version: 2.0.74(zod@4.3.6)
'@temporalio/worker':
specifier: 'catalog:'
version: 1.23.0(tslib@2.8.1)
'@temporalio/workflow':
specifier: 'catalog:'
version: 1.23.0
+ '@workflow-builder/ai-config':
+ specifier: workspace:*
+ version: link:../../packages/ai-config
'@workflow-builder/execution-core':
specifier: workspace:*
version: link:../../packages/execution-core
+ '@workflow-builder/temporal-connection':
+ specifier: workspace:*
+ version: link:../../packages/temporal-connection
'@workflow-builder/types':
specifier: workspace:*
version: link:../../packages/types
@@ -457,7 +475,7 @@ importers:
specifier: workspace:*
version: link:../../packages/temporal
ai:
- specifier: ^6.0.0
+ specifier: 'catalog:'
version: 6.0.168(zod@4.3.6)
dotenv:
specifier: ^17.4.2
@@ -510,6 +528,15 @@ importers:
specifier: ^2.19.2
version: 2.20.0
+ packages/ai-config:
+ devDependencies:
+ '@types/node':
+ specifier: ^22.12.0
+ version: 22.12.0
+ vitest:
+ specifier: ^3.0.4
+ version: 3.0.4(@types/debug@4.1.12)(@types/node@22.12.0)(jiti@2.6.1)(jsdom@26.0.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)
+
packages/execution-core:
dependencies:
'@workflow-builder/types':
@@ -560,7 +587,7 @@ importers:
version: 4.1.0
i18next:
specifier: ^24.0.0
- version: 24.2.3(typescript@5.6.3)
+ version: 24.2.3(typescript@5.9.3)
i18next-browser-languagedetector:
specifier: ^8.0.0
version: 8.0.5
@@ -581,7 +608,7 @@ importers:
version: 19.1.0(react@19.1.0)
react-i18next:
specifier: ^15.0.0
- version: 15.4.1(i18next@24.2.3(typescript@5.6.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ version: 15.4.1(i18next@24.2.3(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react-mentions-ts:
specifier: ^5.4.7
version: 5.4.7(class-variance-authority@0.7.1)(clsx@2.1.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwind-merge@3.5.0)
@@ -621,10 +648,10 @@ importers:
version: 6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)
vite-plugin-dts:
specifier: ^4.5.0
- version: 4.5.4(@types/node@22.12.0)(rollup@4.57.1)(typescript@5.6.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4))
+ version: 4.5.4(@types/node@22.12.0)(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4))
vite-plugin-svgr:
specifier: ^4.3.0
- version: 4.3.0(rollup@4.57.1)(typescript@5.6.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4))
+ version: 4.3.0(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4))
vitest:
specifier: ^3.0.4
version: 3.0.4(@types/debug@4.1.12)(@types/node@22.12.0)(jiti@2.6.1)(jsdom@26.0.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)
@@ -675,6 +702,33 @@ importers:
specifier: ^3.0.4
version: 3.0.4(@types/debug@4.1.12)(@types/node@22.12.0)(jiti@2.6.1)(jsdom@26.0.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)
+ packages/temporal-connection:
+ devDependencies:
+ '@temporalio/client':
+ specifier: 'catalog:'
+ version: 1.23.0
+ '@temporalio/testing':
+ specifier: 'catalog:'
+ version: 1.23.0(tslib@2.8.1)
+ '@temporalio/worker':
+ specifier: 'catalog:'
+ version: 1.23.0(tslib@2.8.1)
+ '@temporalio/workflow':
+ specifier: 'catalog:'
+ version: 1.23.0
+ '@types/node':
+ specifier: ^22.12.0
+ version: 22.12.0
+ '@types/node-forge':
+ specifier: ^1.3.14
+ version: 1.3.14
+ node-forge:
+ specifier: ^1.4.0
+ version: 1.4.0
+ vitest:
+ specifier: ^3.0.4
+ version: 3.0.4(@types/debug@4.1.12)(@types/node@22.12.0)(jiti@2.6.1)(jsdom@26.0.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)
+
packages/tokens:
devDependencies:
'@tokens-studio/sd-transforms':
@@ -773,12 +827,28 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
+ '@ai-sdk/openai-compatible@2.0.74':
+ resolution: {integrity: sha512-HdYUgacC08HjHyzL8Y59bjeOJTcsZWpHYZS0K8T4ChV/zYGktqbktzT0nJuhn3r9lVoMzA9Miw7abGQcdhI2yw==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ zod: ^3.25.76 || ^4.1.8
+
'@ai-sdk/provider-utils@4.0.23':
resolution: {integrity: sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
+ '@ai-sdk/provider-utils@4.0.50':
+ resolution: {integrity: sha512-YAcB+7M1JhAYsHorTrWyldCyZihjCKr/QRXH2vFrara/+lwqNE7q5KzoucKLZ7ktFiUonhnhFhRoiymsq/2K2Q==}
+ engines: {node: '>=18.17'}
+ peerDependencies:
+ zod: ^3.25.76 || ^4.1.8
+
+ '@ai-sdk/provider@3.0.15':
+ resolution: {integrity: sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q==}
+ engines: {node: '>=18'}
+
'@ai-sdk/provider@3.0.8':
resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==}
engines: {node: '>=18'}
@@ -2306,13 +2376,6 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
- '@openrouter/ai-sdk-provider@2.8.0':
- resolution: {integrity: sha512-oDDW/0KMqz4suHVloB9sNv0YyKLGNYf1FTevXH6adDkid5dsmbbcYuiEsbIhpZSZtHa6o5AVjK1jEAfePOLxww==}
- engines: {node: '>=18'}
- peerDependencies:
- ai: ^6.0.0
- zod: ^3.25.0 || ^4.0.0
-
'@opentelemetry/api@1.9.0':
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
engines: {node: '>=8.0.0'}
@@ -3108,6 +3171,9 @@ packages:
'@types/nlcst@2.0.3':
resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==}
+ '@types/node-forge@1.3.14':
+ resolution: {integrity: sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==}
+
'@types/node@12.20.55':
resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==}
@@ -4836,8 +4902,8 @@ packages:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
- eventsource-parser@3.0.6:
- resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==}
+ eventsource-parser@3.1.1:
+ resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==}
engines: {node: '>=18.0.0'}
expect-type@1.1.0:
@@ -6247,6 +6313,10 @@ packages:
node-fetch-native@1.6.7:
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
+ node-forge@1.4.0:
+ resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==}
+ engines: {node: '>= 6.13.0'}
+
node-mock-http@1.0.4:
resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==}
@@ -7392,10 +7462,6 @@ packages:
tailwind-merge@3.5.0:
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
- tapable@2.3.2:
- resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==}
- engines: {node: '>=6'}
-
tapable@2.3.3:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
@@ -7648,6 +7714,10 @@ packages:
undici-types@6.20.0:
resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==}
+ undici@6.28.0:
+ resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==}
+ engines: {node: '>=18.17'}
+
undici@7.24.4:
resolution: {integrity: sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==}
engines: {node: '>=20.18.1'}
@@ -8310,13 +8380,31 @@ snapshots:
'@vercel/oidc': 3.2.0
zod: 4.3.6
+ '@ai-sdk/openai-compatible@2.0.74(zod@4.3.6)':
+ dependencies:
+ '@ai-sdk/provider': 3.0.15
+ '@ai-sdk/provider-utils': 4.0.50(zod@4.3.6)
+ zod: 4.3.6
+
'@ai-sdk/provider-utils@4.0.23(zod@4.3.6)':
dependencies:
'@ai-sdk/provider': 3.0.8
'@standard-schema/spec': 1.1.0
- eventsource-parser: 3.0.6
+ eventsource-parser: 3.1.1
zod: 4.3.6
+ '@ai-sdk/provider-utils@4.0.50(zod@4.3.6)':
+ dependencies:
+ '@ai-sdk/provider': 3.0.15
+ '@standard-schema/spec': 1.1.0
+ eventsource-parser: 3.1.1
+ undici: 6.28.0
+ zod: 4.3.6
+
+ '@ai-sdk/provider@3.0.15':
+ dependencies:
+ json-schema: 0.4.0
+
'@ai-sdk/provider@3.0.8':
dependencies:
json-schema: 0.4.0
@@ -10002,11 +10090,6 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.18.0
- '@openrouter/ai-sdk-provider@2.8.0(ai@6.0.168(zod@4.3.6))(zod@4.3.6)':
- dependencies:
- ai: 6.0.168(zod@4.3.6)
- zod: 4.3.6
-
'@opentelemetry/api@1.9.0': {}
'@oslojs/encoding@1.1.0': {}
@@ -10348,17 +10431,6 @@ snapshots:
'@svgr/babel-plugin-transform-react-native-svg': 8.1.0(@babel/core@7.26.7)
'@svgr/babel-plugin-transform-svg-component': 8.0.0(@babel/core@7.26.7)
- '@svgr/core@8.1.0(typescript@5.6.3)':
- dependencies:
- '@babel/core': 7.26.7
- '@svgr/babel-preset': 8.1.0(@babel/core@7.26.7)
- camelcase: 6.3.0
- cosmiconfig: 8.3.6(typescript@5.6.3)
- snake-case: 3.0.4
- transitivePeerDependencies:
- - supports-color
- - typescript
-
'@svgr/core@8.1.0(typescript@5.9.3)':
dependencies:
'@babel/core': 7.26.7
@@ -10375,16 +10447,6 @@ snapshots:
'@babel/types': 7.29.0
entities: 4.5.0
- '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.6.3))':
- dependencies:
- '@babel/core': 7.26.7
- '@svgr/babel-preset': 8.1.0(@babel/core@7.26.7)
- '@svgr/core': 8.1.0(typescript@5.6.3)
- '@svgr/hast-util-to-babel-ast': 8.0.0
- svg-parser: 2.0.4
- transitivePeerDependencies:
- - supports-color
-
'@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.9.3))':
dependencies:
'@babel/core': 7.26.7
@@ -10525,6 +10587,31 @@ snapshots:
- uglify-js
- webpack-cli
+ '@temporalio/testing@1.23.0(tslib@2.8.1)':
+ dependencies:
+ '@temporalio/activity': 1.23.0
+ '@temporalio/client': 1.23.0
+ '@temporalio/common': 1.23.0
+ '@temporalio/core-bridge': 1.23.0
+ '@temporalio/proto': 1.23.0
+ '@temporalio/worker': 1.23.0(tslib@2.8.1)
+ '@temporalio/workflow': 1.23.0
+ transitivePeerDependencies:
+ - '@minify-html/node'
+ - '@swc/css'
+ - '@swc/helpers'
+ - '@swc/html'
+ - clean-css
+ - cssnano
+ - csso
+ - esbuild
+ - html-minifier-terser
+ - lightningcss
+ - postcss
+ - tslib
+ - uglify-js
+ - webpack-cli
+
'@temporalio/worker@1.23.0(esbuild@0.27.3)(postcss@8.5.6)(tslib@2.8.1)':
dependencies:
'@grpc/grpc-js': 1.14.3
@@ -10863,6 +10950,10 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
+ '@types/node-forge@1.3.14':
+ dependencies:
+ '@types/node': 22.12.0
+
'@types/node@12.20.55': {}
'@types/node@17.0.45': {}
@@ -11153,6 +11244,19 @@ snapshots:
optionalDependencies:
typescript: 5.6.3
+ '@vue/language-core@2.2.0(typescript@5.9.3)':
+ dependencies:
+ '@volar/language-core': 2.4.28
+ '@vue/compiler-dom': 3.5.33
+ '@vue/compiler-vue2': 2.7.16
+ '@vue/shared': 3.5.33
+ alien-signals: 0.4.14
+ minimatch: 9.0.5
+ muggle-string: 0.4.1
+ path-browserify: 1.0.1
+ optionalDependencies:
+ typescript: 5.9.3
+
'@vue/shared@3.5.33': {}
'@webassemblyjs/ast@1.14.1':
@@ -12016,15 +12120,6 @@ snapshots:
jiti: 2.6.1
typescript: 5.6.3
- cosmiconfig@8.3.6(typescript@5.6.3):
- dependencies:
- import-fresh: 3.3.0
- js-yaml: 4.1.0
- parse-json: 5.2.0
- path-type: 4.0.0
- optionalDependencies:
- typescript: 5.6.3
-
cosmiconfig@8.3.6(typescript@5.9.3):
dependencies:
import-fresh: 3.3.0
@@ -12951,7 +13046,7 @@ snapshots:
events@3.3.0: {}
- eventsource-parser@3.0.6: {}
+ eventsource-parser@3.1.1: {}
expect-type@1.1.0: {}
@@ -13591,12 +13686,6 @@ snapshots:
dependencies:
'@babel/runtime': 7.29.7
- i18next@24.2.3(typescript@5.6.3):
- dependencies:
- '@babel/runtime': 7.27.0
- optionalDependencies:
- typescript: 5.6.3
-
i18next@24.2.3(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.27.0
@@ -14836,6 +14925,8 @@ snapshots:
node-fetch-native@1.6.7: {}
+ node-forge@1.4.0: {}
+
node-mock-http@1.0.4: {}
node-releases@2.0.37: {}
@@ -15351,15 +15442,6 @@ snapshots:
react: 19.1.0
scheduler: 0.26.0
- react-i18next@15.4.1(i18next@24.2.3(typescript@5.6.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
- dependencies:
- '@babel/runtime': 7.27.0
- html-parse-stringify: 3.0.1
- i18next: 24.2.3(typescript@5.6.3)
- react: 19.1.0
- optionalDependencies:
- react-dom: 19.1.0(react@19.1.0)
-
react-i18next@15.4.1(i18next@24.2.3(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@babel/runtime': 7.27.0
@@ -16266,8 +16348,6 @@ snapshots:
tailwind-merge@3.5.0: {}
- tapable@2.3.2: {}
-
tapable@2.3.3: {}
tar@7.5.11:
@@ -16511,6 +16591,8 @@ snapshots:
undici-types@6.20.0: {}
+ undici@6.28.0: {}
+
undici@7.24.4: {}
unified@11.0.5:
@@ -16733,6 +16815,25 @@ snapshots:
- rollup
- supports-color
+ vite-plugin-dts@4.5.4(@types/node@22.12.0)(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)):
+ dependencies:
+ '@microsoft/api-extractor': 7.58.7(@types/node@22.12.0)
+ '@rollup/pluginutils': 5.3.0(rollup@4.57.1)
+ '@volar/typescript': 2.4.28
+ '@vue/language-core': 2.2.0(typescript@5.9.3)
+ compare-versions: 6.1.1
+ debug: 4.4.3
+ kolorist: 1.8.0
+ local-pkg: 1.1.2
+ magic-string: 0.30.21
+ typescript: 5.9.3
+ optionalDependencies:
+ vite: 6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)
+ transitivePeerDependencies:
+ - '@types/node'
+ - rollup
+ - supports-color
+
vite-plugin-lib-inject-css@2.2.2(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)):
dependencies:
'@ast-grep/napi': 0.36.3
@@ -16749,17 +16850,6 @@ snapshots:
picocolors: 1.1.1
vite: 6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)
- vite-plugin-svgr@4.3.0(rollup@4.57.1)(typescript@5.6.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)):
- dependencies:
- '@rollup/pluginutils': 5.3.0(rollup@4.57.1)
- '@svgr/core': 8.1.0(typescript@5.6.3)
- '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.6.3))
- vite: 6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)
- transitivePeerDependencies:
- - rollup
- - supports-color
- - typescript
-
vite-plugin-svgr@4.3.0(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)):
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.57.1)
@@ -16966,7 +17056,7 @@ snapshots:
minimizer-webpack-plugin: 5.8.0(@swc/core@1.15.26)(webpack@5.110.1(@swc/core@1.15.26))
neo-async: 2.6.2
schema-utils: 4.3.3
- tapable: 2.3.2
+ tapable: 2.3.3
watchpack: 2.5.2
webpack-sources: 3.5.1
transitivePeerDependencies:
@@ -17001,7 +17091,7 @@ snapshots:
minimizer-webpack-plugin: 5.8.0(@swc/core@1.15.26)(esbuild@0.27.3)(postcss@8.5.6)(webpack@5.110.1(@swc/core@1.15.26)(esbuild@0.27.3)(postcss@8.5.6))
neo-async: 2.6.2
schema-utils: 4.3.3
- tapable: 2.3.2
+ tapable: 2.3.3
watchpack: 2.5.2
webpack-sources: 3.5.1
transitivePeerDependencies:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index dd77a2389..fd2f49212 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -29,6 +29,11 @@ catalog:
'@temporalio/testing': ^1.23.0
'@temporalio/worker': ^1.23.0
'@temporalio/workflow': ^1.23.0
+ # AI SDK. The provider major is tied to the `ai` major — v2 speaks to `ai` v6,
+ # v3 to `ai` v7 — so the two only move together, and the backend and the worker
+ # have to agree or they build the same model against different request shapes.
+ 'ai': ^6.0.168
+ '@ai-sdk/openai-compatible': ^2.0.74
useNodeVersion: 22.12.0
engineStrict: true