Deploy
Felix ships to two Cloudflare Workers environments out of the box (staging and production) and runs locally via Wrangler. Every environment has isolated D1, KV, R2, Vectorize, and Queue resources — the two never share state.
Bindings
Section titled “Bindings”From apps/api/wrangler.jsonc — your gitignored copy of the tracked template (cp apps/api/wrangler.example.jsonc apps/api/wrangler.jsonc, then fill in the REPLACE_* account/resource ids). Bare wrangler commands in this guide run from apps/api/. The dev/local set is the top-level block; env.staging and env.production override D1 / KV / R2 / Vectorize / Queue with isolated resources.
| Binding | Type | Purpose |
|---|---|---|
AI |
Workers AI | Native Llama / Mistral inference. External providers (Anthropic, OpenAI) go through AI Gateway via fetch instead. |
DB |
D1 | Harness core: audit_events, plans, jobs, approvals, skill_activation, oauth_token_cache, manifests + manifest_active, eval_datasets + eval_dataset_items + eval_runs. Commerce (migrations 0006–0018): products/orders/order_items, acp_checkout_sessions, brands/brand_domains, data_sources, B2B accounts/buyers/quotes/invoices/contract_prices/billing_settings, geo_queries/geo_observations, consents/order_attribution, personalization + dynamic-pricing tables. |
CACHE |
KV | JWKS cache, outbound OAuth token cache, manifest cache. |
BUNDLES |
R2 | Signed PolicyBundle, per-tenant manifest overrides at manifests/<tenant_id>/<name>.json, global overrides at manifests/<name>.json, and artifact spills at artifacts/<tenant_id>/<thread_id>/<tool_call_id>.json (when spec.artifacts.enabled). |
MEMORY_VEC |
Vectorize | 768-dim BGE index (@cf/baai/bge-base-en-v1.5) — semantic memory, JIT tool retrieval, semantic:N session strategy, procedural memory. |
AUDIT_QUEUE |
Queue (producer) | Audit events fan out from the producer in audit/store.ts. |
felix-audit consumer |
Queue (consumer) | Batched persist into D1 (max_batch_size: 50, max_batch_timeout: 5s). |
METRICS |
Analytics Engine | orchestrator_* counters + histograms via recordCounter / recordHistogram. Falls back to structured console.log when absent. |
AGENT_WORKFLOW |
Workflows | Durable agent execution (AgentWorkflow) for manifests with execution.mode: durable. Optional — falls back to in-isolate execution when absent. |
CONVERSATION_DO |
Durable Object | One per thread id; session event log backing the Session abstraction. Exposes /events with slice + cursor. |
A2A_TASK_DO |
Durable Object | One per ${tenant}#${task}; A2A task lifecycle. |
APPROVALS_DO |
Durable Object | One per ${tenant}#${approval}; serializes concurrent decide writes. |
FEDERATION_DO |
Durable Object | Singleton; holds the cached active PolicyBundle. |
TENANT_RATE_LIMIT |
Rate Limiting | Sliding window, 100 req/60s per tenant. Soft-fails open if absent. |
Environment variables
Section titled “Environment variables”Set under vars in wrangler.jsonc (per-env overrides supported).
| Var | Required | Notes |
|---|---|---|
ENVIRONMENT |
yes | "development", "staging", or "production". Controls SSRF strictness, federation signature enforcement, and at-rest-key fail-closed behavior. |
AI_GATEWAY_SLUG |
yes | Account-scoped AI Gateway slug, e.g. felix-prod. |
AI_GATEWAY_ACCOUNT_ID |
yes | Cloudflare account id for AI Gateway. |
DEFAULT_MODEL_ID |
yes | Logical model id used when a manifest’s spec.model.id is empty. |
MODEL_ROUTES |
optional | JSON map override of logical id → { provider, model }. Unset = the DEFAULT_MODEL_ROUTES baked into src/env.ts. Set only to diverge per env (canary models, region routing). See below. |
JWT_VERIFIERS |
optional | The sole inbound-auth config. Comma-separated verifiers; each is whitespace-separated <scheme> <issuer> [audience] (scheme ∈ access | cognito). Empty = no verifiers (every bearer rejected in non-dev). See examples below. |
POLICY_BUNDLE_KEY |
optional | R2 key for the active signed PolicyBundle (default bundles/active.json). |
SSRF_ALLOW_HOSTS |
optional | Comma-separated hostname allow-list for outbound (mcp/peers). |
CONTINUOUS_EVAL |
optional | JSON tuning for the continuous-eval cron (sample_rate / max_replays_per_tick / window_ms); bad values degrade to defaults. |
GEO_MONITOR |
optional | JSON tuning for the GEO monitor cron (engine model, max_queries_per_tick, window). |
ACP_MERCHANT_TENANT |
optional | Tenant that owns the ACP merchant surface (default default). |
COMMERCE_* / STRIPE_SUCCESS_URL / STRIPE_CANCEL_URL / STRIPE_AUTOMATIC_TAX / BILLING_PROVIDER_DEFAULT |
optional | Commerce configuration (tax bps, shipping/carrier JSON, ship countries, recovery webhook, consent gate + terms/privacy, checkout redirects, billing default). See the commerce docs. |
Secrets
Section titled “Secrets”Set with pnpm exec wrangler secret put <NAME> --env <staging|production> — the command prompts for the value. Never commit these to wrangler.jsonc. Use .dev.vars for local-only values; wrangler dev reads it but deployed envs do not.
| Secret | Required | Notes |
|---|---|---|
ANTHROPIC_API_KEY |
for Anthropic models | Forwarded through AI Gateway as x-api-key. |
OPENAI_API_KEY |
for OpenAI models | Forwarded through AI Gateway as authorization: Bearer .... |
CF_AIG_TOKEN |
when slug has Authenticated Gateway on | Sent as cf-aig-authorization: Bearer ${token} on every gateway call. Generate per-slug in the dashboard. Leave unset for unauthenticated gateways. |
POLICY_BUNDLE_PUBKEY |
staging/prod (if federation) | Base64-encoded raw Ed25519 public key (32 bytes). In dev, signature verification logs a warning and proceeds. |
OAUTH_CACHE_KEY |
staging/prod | Base64-encoded 32-byte AES-256 key for encrypting oauth_token_cache.access_token. In dev, missing key falls back to plaintext with a one-shot warning. |
CONSUMER_SHARED_SECRET |
when any manifest declares spec.queues[] or entity push is used |
Shared secret on POST /internal/sessions/:thread_id/events and POST /entities/:type/push. Consumers send it as x-consumer-secret. Generate with openssl rand -base64 32 and put it on both the Felix Worker and every consumer Worker. Missing on Felix → the route returns 503 (refuses to authenticate anyone). |
STRIPE_SECRET_KEY |
for commerce checkout / billing | Stripe API key (sk_…). Without it commerce_checkout, ACP complete, and Stripe invoicing return not-configured errors; the rest of the harness is unaffected. |
STRIPE_WEBHOOK_SECRET |
for commerce webhooks | Stripe webhook signing secret (whsec_…) verifying POST /commerce/stripe/webhook and POST /b2b/billing/webhook. |
ACP_API_KEY |
for the /acp surface |
Bearer key external buyer agents present; compared in constant time. Unset → /acp returns 503 not_configured. |
JWKS_PUBLIC |
for self-issued JWTs | JWKS JSON document served at /.well-known/jwks.json and used to verify self-issued tokens (mint with scripts/mint-jwt.ts). Staging/prod use this for scoped control-plane writes. |
First-time setup checklist
Section titled “First-time setup checklist”When standing up a new env, run these once before pnpm deploy:<env>:
pnpm exec wrangler secret put ANTHROPIC_API_KEY --env <staging|production>pnpm exec wrangler secret put OAUTH_CACHE_KEY --env <staging|production> # see openssl recipe belowpnpm exec wrangler secret put POLICY_BUNDLE_PUBKEY --env <staging|production> # only if federation is onpnpm exec wrangler secret put CF_AIG_TOKEN --env <staging|production> # only if Authenticated Gateway is onpnpm exec wrangler secret put CONSUMER_SHARED_SECRET --env <staging|production> # only if any manifest declares spec.queues[]# OPENAI_API_KEY only if a manifest routes to provider: openaiVerify with pnpm exec wrangler secret list --env <staging|production> — the secret names show up but values do not.
AI Gateway slugs
Section titled “AI Gateway slugs”The AI_GATEWAY_SLUG value must be an already-existing slug in the Cloudflare AI Gateway dashboard. Default config expects:
felix-dev(top-level /wrangler dev)felix-staging(env.staging)felix-prod(env.production)
A non-existent slug returns code 2009 Unauthorized from the gateway URL — the same response as Authenticated Gateway rejecting an unauthed call, so the diagnostic is ambiguous. If you see 2009: confirm the slug exists and check whether Authenticated Gateway is on.
Authenticated Gateway (a per-slug toggle in the dashboard — check the slug’s settings page; Cloudflare doesn’t document a default) requires every gateway call to send a cf-aig-authorization: Bearer ${token} header. The model client honors env.CF_AIG_TOKEN automatically — set it via wrangler secret put CF_AIG_TOKEN --env <staging|production> after generating the token in the slug’s settings page. Recommended for staging/prod (defends against a leaked ANTHROPIC_API_KEY being usable by anyone who knows the gateway URL); leave OFF for felix-dev so local work doesn’t need a token.
Local dev secrets
Section titled “Local dev secrets”wrangler dev reads apps/api/.dev.vars (gitignored). Copy apps/api/.dev.vars.example to apps/api/.dev.vars and fill in. None of these propagate to staging or production — those envs are populated only via wrangler secret put.
Generate the at-rest key:
openssl rand -base64 32 | pnpm wrangler secret put OAUTH_CACHE_KEY --env productionGenerate an Ed25519 keypair:
openssl genpkey -algorithm Ed25519 -out federation.pemopenssl pkey -in federation.pem -pubout -outform DER | tail -c 32 | base64 \ | pnpm wrangler secret put POLICY_BUNDLE_PUBKEY --env productionMODEL_ROUTES
Section titled “MODEL_ROUTES”A JSON object mapping logical model ids (what a manifest writes) to physical routes ({ provider, model }). Resolved by parseModelRoutes(env) at startup (src/env.ts); unset or unparseable values fall back to the baked-in DEFAULT_MODEL_ROUTES.
Currently shipped routes (DEFAULT_MODEL_ROUTES in src/env.ts):
{ "claude-sonnet-4": { "provider": "anthropic", "model": "claude-sonnet-4-6" }, "claude-opus-4": { "provider": "anthropic", "model": "claude-opus-4-8" }, "claude-haiku-4": { "provider": "anthropic", "model": "claude-haiku-4-5" }, "llama-3-fast": { "provider": "workers-ai", "model": "@cf/meta/llama-3.1-8b-instruct" }, "llama-3-pro": { "provider": "workers-ai", "model": "@cf/meta/llama-3.3-70b-instruct-fp8-fast" }}A manifest’s spec.model.id: "claude-sonnet-4" will dispatch to Anthropic via the gateway slug for the current env (${AI_GATEWAY_BASE}/${AI_GATEWAY_SLUG}/anthropic/v1/messages). For Workers AI logical ids the runtime calls env.AI.run(...) directly — no AI Gateway round-trip. The provider field is a key into the open model-provider registry; built-ins (anthropic, openai, workers-ai) self-register at module load. New providers can be added via registerModelProvider(name, factory) from apps/api/src/composition.ts without editing src/patterns/model.ts — see internals/model-client.md.
Tool calling on Workers AI requires a tool-capable model. The current whitelist (src/patterns/model.ts):
@hf/nousresearch/hermes-2-pro-mistral-7b@cf/meta/llama-3.1-8b-instruct@cf/meta/llama-3.3-70b-instruct-fp8-fast@cf/mistralai/mistral-small-3.1-24b-instruct
Other Workers AI models still answer, but tool_calls will be empty and the react loop will terminate after the first model turn.
JWT_VERIFIERS
Section titled “JWT_VERIFIERS”Inbound JWT verification is configured entirely through this one var (parseVerifiers in src/auth/jwt.ts) — there are no provider-specific vars. It’s a comma-separated list of verifiers; each verifier is whitespace-separated <scheme> <issuer> [audience] (whitespace delimits the fields so issuer URLs, which contain colons, parse unambiguously). Two schemes are supported; the scheme only selects how the JWKS URL is derived. Malformed or unknown-scheme entries are skipped.
Cloudflare Access — issuer is the team host; JWKS is fetched from https://<issuer>/cdn-cgi/access/certs and cached for 1 hour:
"vars": { "JWT_VERIFIERS": "access acme.cloudflareaccess.com 01234abc-aud-claim"}Cognito (or any standard OIDC issuer) — issuer is the full issuer URL; JWKS is at <issuer>/.well-known/jwks.json:
"vars": { "JWT_VERIFIERS": "cognito https://cognito-idp.us-west-2.amazonaws.com/us-west-2_AbCdEfGhI client-id-here"}Both at once — comma-separate the verifiers (the loop tries each in order):
"vars": { "JWT_VERIFIERS": "access acme.cloudflareaccess.com my-aud, cognito https://cognito-idp.us-west-2.amazonaws.com/us-west-2_AbCdEfGhI client-id-here"}Tenant id is derived from JWT claims in this order: custom:tenant_id → tenant_id → first label of the issuer hostname → default.
Anonymous traffic (no Authorization header) is always accepted by the middleware; the route then decides whether the manifest allows it via auth.inbound.allow_anonymous. A bearer with an unrecognized issuer demotes to anonymous in dev (so unit tests pass) and is rejected with 401 in staging/production. An expired or malformed bearer always 401s.
SSRF allow-list
Section titled “SSRF allow-list”Outbound URLs from mcp_servers and peers are checked at parse time and again before every fetch. The defaults block:
- non-HTTPS (except
localhostin development) - loopback (
127.0.0.1,::1) - RFC1918 (
10/8,172.16/12,192.168/16) - link-local (
169.254/16,fe80::/10) - IPv6 ULA (
fc00::/7) .internal,.cluster.local,.svc,.svc.cluster.local
To override for explicit internal-network targets:
"vars": { "SSRF_ALLOW_HOSTS": "mcp.internal.example,billing.svc.cluster.local"}Container gateways
Section titled “Container gateways”Manifests can declare container-backed tools under spec.containers[]. Each entry becomes a Tool whose executor is a ContainerExecutor pointing at an HTTPS gateway. Felix is transport-agnostic — the gateway can be a Cloudflare Container, an internal sandbox service, or an external code-exec provider. The gateway only has to accept this protocol:
POST {gateway_url}{ "image": "<image>", "tool": "<container_tool_name>", "arguments": { ... } }
200 { "content": "...", "exit_code"?: number, "stderr"?: string }See manifest-reference.md#speccontainers for the manifest field reference and examples/python-sandbox/ for an end-to-end sample (a 30-line mock-gateway Worker plus the manifest that calls it).
Wiring a real Cloudflare Container
Section titled “Wiring a real Cloudflare Container”When CF Containers GA, the wiring is:
- Build the image (Dockerfile in
examples/python-sandbox/runtime/) and push to a registry accessible from your account. - Register the container binding in
wrangler.jsonc:"containers": [{"name": "PYTHON_SANDBOX","image": "ghcr.io/yourorg/python-sandbox:latest","instance_type": "standard"}] - Deploy a thin gateway Worker (or co-host the gateway in this Worker) that translates
POST { image, tool, arguments }into agetContainer(env.PYTHON_SANDBOX).fetch(...)call. The gateway is the trust boundary — it decides what images this caller is allowed to run, attaches any per-image secrets, and shapes the response. - Point the manifest’s
gateway_urlat the gateway endpoint. Add the gateway host toSSRF_ALLOW_HOSTSif it’s on a private network.
Credentials never reach the sandbox
Section titled “Credentials never reach the sandbox”When containers[].auth is set, Felix asks the outbound auth broker for an Authorization header on the gateway request — the value never goes into arguments. If a manifest author needs a token inside the container (e.g., for the container to call a downstream API), that’s an explicit “put it in args” decision the author owns. The article’s vault-backed-tools pattern: the brain never sees the credential, the harness mints a header for the gateway, the gateway scopes what runs inside.
Cron triggers
Section titled “Cron triggers”"triggers": { "crons": ["*/10 * * * *"] }Every 10 minutes the scheduled handler runs these in waitUntil, each guarded so one failure doesn’t starve the rest (apps/api/src/index.ts:scheduled):
federationStub(env).fetch('https://do/refresh')— pulls the latest signedPolicyBundlefrom R2 and updates theFederationDOcache.runScheduledJobs(env)— sweeps thejobstable fornext_run_at <= now, re-verifies each schedule withcronMatches, records the run, and emits ajob_runaudit event.sweepOrphanQueueDispatches(env)— reclaimstransport: queuetool dispatches whosetool_resultnever landed.runAnomalyScan(env)— firesanomaly_detectedevents when a tool’s error rate exceeds its 24h EWMA baseline, andauto_rollback(zeroescanary_weight) when the flagged manifest is an active canary.runAbandonedCartScan(env)— flags carts with purchase intent but no completed purchase (idle > 1h), records them, and posts toCOMMERCE_RECOVERY_WEBHOOKwhen set.runContinuousEvalTick(env, tools, opts, now, ctx)— online-benchmarks every in-flight canary: samples recent production inputs, replays each through the canary version, judges the result, and emitsjudge_scoreevents taggedpayload.source: 'continuous'. No-op when no canaries are live.runGeoMonitorTick(env, opts, now, ctx)— replays tracked shopping queries (geo_queries) through a generative engine and records brand presence/rank intogeo_observations. No-op when no queries are registered.
The whole body runs inside runWithContext(buildAnonymousContext(env, ctx), …) so audit events actually persist (cron runs outside authMiddleware).
Supported cron syntax
Section titled “Supported cron syntax”5-field UTC (minute hour day-of-month month day-of-week). Implemented in src/jobs/cron.ts:72-138.
| Form | Example | Meaning |
|---|---|---|
* |
* |
any value |
| literal | 5 |
exact value |
| list | 1,3,5 |
one of the listed values |
| range | 1-5 |
inclusive range |
| step | */5 or 0-30/5 |
every Nth value, optionally over a range |
Not supported: named day/month aliases, L, W, #.
Custom domains
Section titled “Custom domains”wrangler.jsonc routes each env to a make.felix.run subdomain:
| Env | Custom domain |
|---|---|
| development | localhost:8787 |
| staging | staging-make.felix.run |
| production | make.felix.run |
These were renamed from api.* to make.* (see recent commit Rename custom domains: api.* → make.*). Cloudflare provisions TLS certs automatically on first deploy.
Federation bundle workflow
Section titled “Federation bundle workflow”The signed bundle distributed via R2 is documented in internals/governance.md. The minimal flow:
- Author the bundle JSON:
{ version, issuer, policies: [...], approvals: [...] }. - Sign deterministically with the Ed25519 private key whose public key is in
POLICY_BUNDLE_PUBKEY. The signing target is the JSON withsignatureremoved, key-sorted (deterministic). - Set
signatureto the base64 Ed25519 signature. - Upload to R2:
pnpm wrangler r2 object put felix-orchestrator-bundles-prod/<POLICY_BUNDLE_KEY> --file=bundle.json --env production. - The next cron tick refreshes the FederationDO cache; all isolates pick it up within 10 minutes.
In staging and production the bundle must verify or the previous active bundle is kept. In development the verification logs a warning but proceeds.
Deploy commands
Section titled “Deploy commands”pnpm build:manifestspnpm migrate:local # local dev DB (sqlite)pnpm migrate:staging # apply migrations to orchestrator-stagingpnpm migrate:production # apply migrations to orchestrator-prod
pnpm deploy:staging # wrangler deploy --env stagingpnpm deploy # wrangler deploy --env productionBoth pnpm dev and pnpm deploy rebuild manifests first.
Note: there is intentionally no
migrate:remotescript. Remote migrations are destructive and must name an environment explicitly — usemigrate:stagingormigrate:production. The same reasoning applies todeploy(--env production) vs.deploy:staging(--env staging); never invokewrangler deploywithout--envagainst this config or it’ll target the top-level placeholder bindings.