Development vs production
What everyapp dev starts and in what order, the seeded dev identity and why it is loopback-only, which perimeter rules are identical to production and what has no Gateway behind it locally (AI, capabilities, email, members, secrets, D1, MCP), which .dev.vars names the ready block's Dev vars line asks for, and why only everyapp deploy makes a production build.
everyapp dev runs the app's own dev server (cf dev, or the manifest's dev command) on an internal loopback port and puts a gateway-lite in front of it: the real perimeter from packages/perimeter/src/gateway.ts with this one app registered, signing identities with the SDK's well-known dev keypair. Nothing is written into the app: everyAppWorker() gives a development build the dev public key and issuer itself. Source: packages/cli/src/commands/dev/index.ts.
The command is pnpm everyapp dev from the app's directory (the template's pnpm dev script runs it). It has no --json flag: output is for a person, the run is long-running, and the skill tells agents to start it in the background and read the printed URL (packages/cli/skills/everyapp/SKILL.md).
Flags and environment
| Flag | Default | Meaning |
|---|---|---|
--port |
$PORT, then 8787 |
The gateway-lite's port. $PORT is honored so portless can assign one. The app's dev server never runs on it. |
--host |
127.0.0.1 |
Address the gateway-lite listens on. 0.0.0.0 lets other machines in, each as the dev user (stub mode warns DEV_EXPOSED). Takes a value; Vite's bare --host is not supported (TODO.md). |
--mode |
stub |
stub (seeded dev user) or mirror (real login through a separately running local Gateway). Case-insensitive. Any other value: INVALID_DEV_MODE. |
--gateway |
http://localhost:3000 |
Mirror mode only: URL of the separately running local Gateway. |
--base-host |
everyapp.localhost |
Mirror mode only: the two-label base host the session cookie is scoped to. --baseHost is accepted too. |
--skip-migrations |
off | Start without applying local D1 migrations (migrations step reports skip, reason flag). |
EVERYAPP_DEV_MODE |
unset | Environment alternative to --mode; the flag wins over the variable, the variable over the stub default. An unknown value names the source it came from: EVERYAPP_DEV_MODE must be stub or mirror, not "prod". |
Sources: packages/cli/src/commands/dev/command.ts, packages/cli/src/commands/dev/mode.ts.
What starts, in order
- Mode.
resolveDevMode(flags.mode, env.EVERYAPP_DEV_MODE). Inmirrormode the base host is checked first: fewer than two DNS labels (port ignored) isMIRROR_BASE_HOSTbefore anything else runs. - Manifest.
everyapp.config.tsmust exist in the current directory (MANIFEST_NOT_FOUND,No everyapp.config.ts in this directory,data.fixrun this from the app's directory: the same code every command raises, fromloadEveryAppManifestinpackages/cli/src/lib/manifest.ts) and validate (INVALID_MANIFEST, messageeveryapp.config.ts isn't validplus the schema's message). Only aManifestErrorfrom the strict schema becomesINVALID_MANIFEST: a file that fails to load at all (a syntax error, a failing import, a default export jiti cannot evaluate) is rethrown as is, with noEveryAppErrorcode (loadManifestinindex.ts,loadEveryAppManifestFileinpackages/cli/src/lib/manifest.ts). It is loaded once: a later edit needs a restart (see Pitfalls). - Worker config.
cloudflare.config.tsis read textually (packages/cli/src/lib/app/cloudflareConfig.ts: whether it containseveryAppWorkerand whether/\bcrons\s*:/matches). With neither a manifestdevcommand nor acloudflare.config.ts:MISSING_CLOUDFLARE_CONFIG(fix:copy cloudflare.config.ts from starter/). - Port check. The gateway port is probed before anything starts:
EADDRINUSEisDEV_PORT_IN_USE(Port 8787 is already in use, withdata.freethe next free port above it and a rerunpnpm everyapp dev … --port <free>); any other listen error isDEV_PORT_UNAVAILABLE(Can't listen on <host>:<port>, fix:pass another --port or --host). The real listen happens later and fails the same way if the port was taken meanwhile. - cf binary (
resolveCfBinaryinpackages/cli/src/lib/cf/bin.ts). The app's owncf(itsnode_modules) is used when it has one, because it is pinned next to the app's Vite plugin, socf devandcf d1 … --localrun the same Miniflare against.cloudflare/state. With nocfin the app, the CLI's own pinnedcfruns (dependencies.cfinpackages/cli/package.json,1.0.0-beta.14, resolved from the installedeveryapppackage; a globalcfis never used). An appcfwhose version differs from the pin warnsCF_VERSION_DIFFERS:Using the app's cf <version>; everyapp is tested with <pinned>, and runs. - Mirror probe (mirror mode only): a
HEADto the--gatewayURL, up to 5000 ms, elseMIRROR_GATEWAY_UNREACHABLE(No local Gateway at <url>, fix:run pnpm dev in apps/every-app-gateway, or pass --gateway <url>). Stepdev.gateway. - Worker types (step
dev.types): whencloudflare.config.tsexists and.cloudflare/types/index.d.tsdoes not,cf workers typeswrites it. Existing types are left alone; the template'stypes:checkscript regenerates them after config changes. Source:packages/cli/src/lib/app/workerTypes.ts. - Local migrations (step
dev.migrations,packages/cli/src/commands/dev/migrations.ts): skipped silently with reasonno_d1(noresources.d1) orno_migrations_dir(the migrations directory is missing), with reasonflagunder--skip-migrations; otherwisecf d1 migrations apply <local id> --local --persist-to .cloudflare/state --dir <migrations dir>runs before the app starts, so a fresh clone's first page load does not hit an empty database. A failure isMIGRATIONS_FAILED(A local migration failed, the migration's own message,Or start with --skip-migrations if the local database is already usable.) and dev does not start. A.wrangler/statedirectory with no.cloudflare/stateyet warnsLEGACY_LOCAL_STATE(Local data now lives in .cloudflare/state; old rows were not copied). - App dev server (step
dev.app,packages/cli/src/commands/dev/appServer.ts): a free loopback port is picked, then either the manifest'sdevcommand runs as a shell command line withPORT=<port>in its environment, orcf devruns withPORT=<port>and the dev-registry variables (below). Either runs in its own process group so stopping it stops Vite and workerd too, and its output is held until it is ready because its startup banner names the internal port, which nobody should open.cf devforwards no port flag to Vite: the template'svite.config.tsreadsNumber(process.env.PORT ?? 3001)withstrictPort: trueandhost: "127.0.0.1". - Wait for it. The CLI polls
http://127.0.0.1:<app port>/for up to 90 s. If the process exits first:DEV_SERVER_FAILED(<id>'s dev server exited before it was ready, its held output is replayed above). If it never answers:DEV_SERVER_TIMEOUT(It must listen on the port everyapp passes in PORT.; fix:in vite.config.ts, set server.port: Number(process.env.PORT), or for a manifestdevcommand, make it listen onPORT). - Gateway-lite listens on
--host:--port, prints the ready block, then releases the app's output. Stepdev.readycarries{ mode, appUrl, mcpUrl, host, port, appPort }. Stub mode on a non-loopback address warnsDEV_EXPOSED(anyone who can reach port <port> gets in as the dev user). - Runs until the app's dev server exits. Ctrl+C (SIGINT) stops the app's process group through the interrupt handler in
packages/cli/src/lib/interrupt.ts. What it prints depends on when it arrives (sayOnInterruptinindex.ts):Stopping the local migrationswhile they run,Stopping <id>between the migrations and the ready block, and nothing once the app is ready (stopping(""): an empty announcement prints no line). Then stepdev.stop(packages/cli/src/lib/output/events.ts) prints<id> stopped, orStopped before <id> started.when it never became ready, and the process exits130; SIGTERM exits143. A second Ctrl+C exits at once. That is the way to stop it: Ctrl+C, or aSIGINT/SIGTERMto theeveryappprocess. The app's dev server is spawneddetached, leading its own process group, andsignalChild(packages/cli/src/lib/longRunning.ts) signals the whole group, so Vite and workerd stop with it.pkill -f <app path>misses them, since their command lines do not name the path (TODO.md), and a pattern kill onviteorworkerdhits every Vite and workerd process on the machine, other projects' included (packages/cli/skills/everyapp/SKILL.md). Through the template'spnpm dev, pnpm then reports the130as anELIFECYCLEfailure (Command failed with exit code 130): pnpm relaying the exit code, not a dev failure. While dev runs,pnpm install(the rootpostinstallispnpm -C packages/sdk build) and anypnpm everyappcommand, a deploy included, whose launcher findspackages/sdk/srcnewer thanpackages/sdk/dist(packages/cli/bin/everyapp.js) rewritepackages/sdk/dist, which the running app loads: dev breaks for a few seconds while it is rewritten.
URLs and the Host rule
| URL | What answers |
|---|---|
http://<app-id>.localhost:8787/ |
The app, through the perimeter, as the dev user (stub). Mirror mode: http://<app-id>.<base-host>:<port>/. |
http://<app-id>.localhost:8787/mcp |
The app's defineTools() catalog from a local ToolBus (below). |
http://localhost:8787/ (bare base host) |
Stub: a minimal HTML launcher, Signed in as dev@everyapp.localhost, linking to the app. Mirror (http://everyapp.localhost:8787/): forwarded to the real Gateway (launcher, login, /api/auth, admin, its HMR socket). |
http://other.localhost:8787/ |
421 This dev server serves <id>.localhost, not other.localhost:8787. and a DEV_WRONG_HOST warning once per host. |
http://127.0.0.1:8787/ |
An IP names no app: the perimeter answers 404 {"error":"no_app_for_host"}. |
http://127.0.0.1:<app port>/ (the internal port) |
401: the app verifies the identity header and nothing injected it. By design. |
POST http://<app-id>.localhost:8787/__everyapp/cron |
Runs the Worker's scheduled() for the schedule in the body (-d '*/5 * * * *'); empty body is 400. Handled before the perimeter, forwarded to http://127.0.0.1:<app port>/cdn-cgi/local/explorer/api/local/scheduled?worker=<worker name> as {"cron":"…"}. A POST carrying Origin, Referer, Sec-Fetch-Mode or Sec-Fetch-Site (a web page's, which browsers send even on a preflight-free simple POST) is not run: 403 The cron trigger takes a terminal's POST (curl -X POST ... -d '<schedule>'), not a web page's. (isBrowserRequest in mode.ts). |
http://127.0.0.1:<app port>/cdn-cgi/local/explorer |
The Cloudflare Vite plugin's local explorer: start, inspect, pause and restart Workflow instances. Printed when resources.workflows is non-empty. |
POST http://127.0.0.1:<app port>/cdn-cgi/local/email?from=…&to=… |
Delivers a raw message (--data-binary @message.eml, with From, To, Subject and Message-ID headers, to any address) to the app's email() handler, with no identity. Printed when the manifest has email: { inbound: true }. |
Only this machine's names are served, before the cron trigger and everything else: a Host that is not localhost or a name under it, an IP literal (127.0.0.1, [::1], the LAN address under --host 0.0.0.0), or, in mirror mode, the base host or a name under it, is 421 This dev server serves <id>.localhost, not <host>. with the same DEV_WRONG_HOST warning once per host (isDevHost in packages/cli/src/commands/dev/mode.ts; any other DNS name is a page's DNS-rebinding trick, its own name pointed at 127.0.0.1 so the page reads the app as the dev user). A WebSocket upgrade on such a Host gets HTTP/1.1 421 Misdirected Request and the socket is closed, with no warning. Within those names the Host header is parsed dynamically (packages/perimeter/src/host.ts): the first DNS label is the app, the remainder is the base host, and a host with no dot (localhost:8787) is the base host itself. So todo.localhost:8787 and portless's todo.fix-ui.everyapp.localhost route to the same app with no configuration. Browsers resolve *.localhost natively; the system resolver answers ::1 first while the gateway-lite listens on 127.0.0.1, so curl and scripts pin it: curl --resolve todo.localhost:8787:127.0.0.1 http://todo.localhost:8787/ (docs/TESTING.md).
Requests the gateway-lite cannot complete answer 502 {"error":"dev_gateway_error"} and warn DEV_REQUEST_FAILED (GET / failed in the local Gateway: connect ECONNREFUSED …), once per distinct reason: the perimeter itself answers 502 and keeps the reason to itself.
The ready block
Printed as step dev.app once the app answers (packages/cli/src/commands/dev/ready.ts), one fact per line:
| Label | Value |
|---|---|
App |
http://<app-id>.localhost:<port> (mirror: on the base host) |
MCP |
<app url>/mcp, then any bearer token acts as the dev user (stub) or bearer: a personal access token from the local Gateway (mirror) |
Dev user |
Stub only: dev@everyapp.localhost (owner of dev-org) |
Log in |
Mirror only: http://<base-host>:<port> the session carries to the app |
Gateway |
Mirror only: the --gateway URL (real login and launcher) |
Workflows |
http://127.0.0.1:<app port>/cdn-cgi/local/explorer, when the manifest declares Workflows |
Email |
The curl … /cdn-cgi/local/email … command, when email.inbound is true |
Secrets Store |
The resources.secretsStore bindings, then dimmed is empty locally: get() throws until seeded (https://everyapp.com/docs/agent/dev#secrets-store-in-development), when the manifest declares any (Secrets Store in development, below) |
Cron |
curl -X POST <app url>/__everyapp/cron -d '<schedule>', when cloudflare.config.ts declares crons: |
Dev vars |
Last: .dev.vars is missing <names> or no .dev.vars yet: add <names>, then dimmed (the app's keys for local runs; see pnpm everyapp dev --help); only when a name the app cannot run without is unset (below) |
Then Every request goes through the real perimeter. Ctrl+C to stop.
The Dev vars fact (devVarsHint in ready.ts). It follows the manifest's settings model, appSettings(manifest) in packages/sdk/src/internal/appSettings.ts (the model behind "needs setup" everywhere; see /docs/agent/secrets): every setting the manifest declares, its providers, capabilities and secrets in that order, each with the need its entry marks, required (the default), optional, or a member of an anyOf group. A required setting is named when a name of it is unset: an AI provider's key (OPENAI_API_KEY, OPENROUTER_API_KEY, WORKERS_AI_API_TOKEN), an api_key capability's key (DATAFORSEO_API_KEY, CONTEXT_API_KEY), an OAuth client's id and secret (MASTODON_CLIENT_ID and MASTODON_CLIENT_SECRET, FACEBOOK_APP_ID and FACEBOOK_APP_SECRET, …) and its host setting only when the capability has no defaultOrigin (so never MASTODON_URL, whose default is https://mastodon.social), and the app's own secrets (MAIN_SECRET). An anyOf group is met once any member is set (every name of that member); until then the line asks for one of OPENROUTER_API_KEY, OPENAI_API_KEY, the members in manifest order, a member with several names joined with with. An optional setting is never named: the app opens without it, and the feature using it says what is missing where it is used. Two rules are dev's own, where the model says nothing about local runs (devSettings in ready.ts). There is no install, so a provider needs everything its URL does: for workers-ai, CLOUDFLARE_ACCOUNT_ID besides the token (the Gateway's view leaves it out as the install's). And a service capability's settings are not asked for, since the SDK's stand-ins work without them: email's EMAIL_FROM and EMAIL_FROM_NAME and its transports EMAIL_REST_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, RESEND_API_KEY, EMAIL_PROVIDER (sendEmail() prints until EMAIL_FROM and a transport are set), and members, which has no settings at all (listMembers() answers from the dev identity); nor is AI_PROXY_EXTRA_ROUTES, which the direct calls don't go through. An unknown provider or capability (anthropic, github) contributes nothing, and EVERYAPP_EMAIL_SENDERS is never asked for: it is no capability setting, and EVERYAPP_* cannot be an app secret. Pinned in packages/cli/src/commands/dev/index.test.ts.
Only .dev.vars in the app's directory is read for it (readDevVars: parseEnv from node:util, dotenv syntax); .dev.vars.development and the .env files everyAppWorker() falls back to are not consulted. A name set to an empty value counts as unset (!set[name]), as does a commented-out line. With the file present the wording is .dev.vars is missing CLOUDFLARE_ACCOUNT_ID, DATAFORSEO_API_KEY, MAIN_SECRET; with no file, or one that cannot be read (readDevVars returns null: "a hint never ends the run"), every name is listed as no .dev.vars yet: add WORKERS_AI_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, DATAFORSEO_API_KEY, MAIN_SECRET (the examples are packages/cli/src/commands/dev/index.test.ts's, for providers: ["workers-ai"], capabilities.dataforseo and secrets: ["MAIN_SECRET"]). The required names come first, in manifest order and each once, then each unmet anyOf group, joined with and: .dev.vars is missing MAIN_SECRET and one of OPENROUTER_API_KEY, WORKERS_AI_API_TOKEN with CLOUDFLARE_ACCOUNT_ID for a manifest whose openrouter and workers-ai entries share an anyOf group. Either is followed by two spaces and the dim trailer (the app's keys for local runs; see pnpm everyapp dev --help), where pnpm everyapp is CLI from packages/cli/src/lib/output/command.ts, the invocation that started this run (everyapp when run directly). Names only, never values. Nothing is printed when the app needs no name or the file sets them all. It is a hint, not an error: dev runs, and "the SDK decides what it does without them (an AI call or integration answers that it isn't set up)".
Modes: stub and mirror
stub (default) |
mirror |
|
|---|---|---|
| Session source | The seeded dev user, for every request (devAuthenticator() in packages/perimeter/src/dev/devGateway.ts) |
A real Better Auth session resolved by the separately running local Gateway (packages/cli/src/commands/dev/remoteAuth.ts) |
| Needs | Nothing; works on bare localhost |
pnpm dev in apps/every-app-gateway (default http://localhost:3000) and a two-label base host |
| App URL | http://<app-id>.localhost:<port> |
http://<app-id>.<base-host>:<port> |
| Bare base host | Minimal launcher page | Forwarded to the real Gateway |
| Anonymous | x-everyapp-dev-anon: 1 header or everyapp_dev_anon=1 cookie |
No cookie or token |
/mcp bearer |
Any value | A personal access token (epat_…) the local Gateway issued |
| Browser MCP clients | The ToolBus answers the credential-less CORS preflight (the stub session is always there) | 401 from the perimeter on the preflight: only clients that send no preflight (curl, Claude Code) connect |
What does not change between them: the perimeter, the identity JWT (dev kid, dev issuer), CSRF, header strip/inject. Only the session source does.
The two-label host rule. The session cookie is set on the Gateway's base host and must ride to the app's subdomain, which needs a cookie scoped to a parent domain: impossible on a single label like localhost. checkMirrorBaseHost counts labels after dropping the port (localhost is 1, everyapp.localhost 2, fix-ui.everyapp.localhost 3) and throws MIRROR_BASE_HOST below 2: Mirror mode needs a two-label base host, like everyapp.localhost, with a rerun pnpm everyapp dev … --base-host everyapp.localhost and the fix on the local Gateway, set EVERYAPP_DEV_COOKIE_DOMAIN=everyapp.localhost and GATEWAY_URL=http://everyapp.localhost:<port> (GATEWAY_URL is the origin the browser uses, the base host on the gateway-lite, not :3000). The host reaches loopback through portless or /etc/hosts entries for everyapp.localhost and <app>.everyapp.localhost.
Routing in mirror mode (classifyHost in mode.ts): a Host equal to the base host is the launcher and is forwarded, <label>.<base-host> is an app (only this app's id is served; others get 421), anything else, including a one-label host or a deeper subdomain, is 421.
How a session resolves. The browser's Cookie header is forwarded unchanged to the Gateway's GET /api/dev/identity (apps/every-app-gateway/src/routes/api/dev/identity.ts), which resolves the real user and active organization and answers { session, hasAccess: true, reason: null }; the gateway-lite then mints a dev-kid identity for that real user. An Authorization: Bearer epat_… personal access token is forwarded instead of the cookie, never mixed with it. Any other bearer value is ignored by the mirror authenticator (extractEveryAppBearer in packages/perimeter/src/headers.ts returns a token only when it starts with epat_): with a cookie present the cookie is forwarded as usual, without one the request resolves to no session. The endpoint is gated on import.meta.env.DEV: a production build of the Gateway answers 404. Per-app access is not enforced in mirror mode (hasAppAccess() returns true): it gives a real identity, not real access control. A missing or rejected session is a terminal warning MIRROR_SESSION, once per reason. Today the endpoint answers only { session: null, reason: "no_session" } or a session, so the sentence seen is No Gateway session yet: log in at http://<base-host>:<port>, then reload (no_session); the CLI also has sentences ready should the Gateway ever send other reasons: No active organization: create or pick one in the Gateway, then reload (no_active_organization), Your Gateway session expired: log in again, then reload (expired), and the generic The Gateway rejected the session (<reason>). An unreachable Gateway hints Can't reach the local Gateway at <url> … Is pnpm dev running in apps/every-app-gateway?.
The seeded dev user and the dev identity
DEV_USER in packages/perimeter/src/dev/devGateway.ts:
| Claim | Value |
|---|---|
sub |
dev-user |
email |
dev@everyapp.localhost |
orgId |
dev-org |
orgRole |
owner |
The app is registered in an in-memory registry (packages/cli/src/commands/dev/registerDevApp.ts) as hostname: <id>.localhost, workerName: every-<id> (an id already starting with every- is used as is), tier: service_binding, scope: org, organizationId: dev-org, status: active, with the loaded manifest. The dev gateway resolves the app by the first Host label, not by full hostname as production does.
The identity JWT the gateway-lite injects as x-everyapp-identity is minted by the same mintIdentityJwt production uses (packages/sdk/src/internal/protocol.ts), differing only in:
| Development | Production | |
|---|---|---|
| Signing key | DEV_PRIVATE_KEY_PEM from packages/sdk/src/dev/keypair.ts: committed to the repository, public by design |
The Gateway's private key |
Header kid |
everyapp-identity-dev (IDENTITY_DEV_KEY_ID) |
everyapp-identity |
iss |
https://gateway.dev.localhost (DEV_ISSUER) |
The Gateway's URL |
aud |
The app id (audienceFor: (app) => app.appId) |
The app's Worker name (EVERYAPP_AUDIENCE) |
Everything else is identical: alg RS256, typ: "user", email, org_id, org_role, chan (web, or mcp on a tool call with a tc claim), act: { sub }, scopes: ["*"] for a session, a random jti, iat, and exp = iat + 120 s (IDENTITY_TTL_SECONDS). Public routes get the same signed x-everyapp-public marker production mints (typ: "public", pub: true), under the dev kid and issuer. WebSocket upgrades get a fresh identity or marker at the upgrade hop (packages/cli/src/commands/dev/proxy.ts, handleUpgrade), since a 101 cannot pass through the request handler in Node; that hop checks the Host label, matches public routes, mints the identity and strips headers, and runs no Origin check (see What differs from production).
Why loopback only. The private key is in the repository, so a token signed with it proves nothing outside dev: anything that can reach the gateway-lite's port is the dev user. That is the local trust boundary, so everyapp dev listens on 127.0.0.1 unless --host says otherwise. The platform's trust boundaries are catalogued in /docs/agent/repo/threat-model (assets, actors, trust boundaries, one testable invariant per boundary), mirrored from docs/threat-model.md; the invariants register behind it stays on GitHub (docs/threat-model-invariants.md). The Worker side accepts the dev kid only when all three hold (packages/sdk/src/internal/identity.ts, packages/sdk/src/server/everyApp.ts):
EVERYAPP_DEVin the Worker's env is1ortrue(case-insensitive).everyAppWorker()sets it in a development build; a deploy never does.- The issuer is
https://gateway.dev.localhost. The dev opt-in with any other issuer is aConfigurationError:Dev identities are accepted only from the dev issuer https://gateway.dev.localhost, not "<issuer>". EVERYAPP_DEV is for `everyapp dev` alone.So a dev-kid token naming a real Gateway never verifies, whatever the env says. - The audience:
EVERYAPP_AUDIENCEwhen set; a development build has none, and the app id (EVERYAPP_APP_ID) stands in only under the dev opt-in. Without either:EVERYAPP_AUDIENCE is not set — ….
packages/sdk/src/dev/index.test.ts pins that the Worker side of the SDK (server/, internal/) never imports dev/; the Worker keeps its own copy of the issuer in protocol.ts, and cloudflare.config.ts loads the keypair through @every-app/sdk/cloudflare, which the Cloudflare Vite plugin hides from the Worker along with every other import of that file.
Anonymous requests in stub mode. Public routes are auth-optional, so the always-present dev session would mask anonymous behavior. Send the header x-everyapp-dev-anon: 1 (curl) or the cookie everyapp_dev_anon=1 (browser) and the stub authenticator returns no session; a private route then answers 401.
Identical to production
The gateway-lite calls handleGatewayRequest from packages/perimeter/src/gateway.ts, the production proxy core, with the dev registry, authenticator and key. Every request goes through, in order:
- The tools contract
/__everyapp/toolsand/__everyapp/tools/*answers404 {"error":"not_found"}from outside under any spelling (dot segments, encoded separators, case:isReservedToolsPathinpackages/perimeter/src/publicRoutes.ts), because only the ToolBus calls it; so does/.well-known/oauth-protected-resourceon an app host. The rest of/__everyapp/*(EVERYAPP_INTERNAL_PREFIXinpackages/perimeter/src/manifest/manifest.ts) can never be public (matchPublicRouterefuses it before anypublicglob,internal namespace is never public) but is otherwise a private route:401without a session, proxied to the app with one. In stub mode every request has the dev session, soGET http://<id>.localhost:8787/__everyapp/pingreaches the app as the dev user; the exception isPOST /__everyapp/cron, which the gateway-lite handles itself before the perimeter. - The server-function guard (
packages/perimeter/src/serverFunctions.ts): unless the manifest setsserverFnGuard: false, a request under/_serverFn(or the manifest'sserverFnBase) must carryx-tsr-serverFn: trueorx-tsr-redirect: manual, on every method, before any auth; otherwise403 {"error":"server_fn_denied"}. - The cookie-tossing check runs (
hasDuplicateSessionCookiewithdeps.authenticator.sessionCookieNames) but is a no-op locally: both dev authenticators declaresessionCookieNames: [](stub:No session cookie to toss; mirror: theCookieheader goes to the Gateway unchanged), so the gateway-lite never answersreason: "duplicate_session_cookie". In mirror mode a duplicated session cookie is refused by the Gateway before/api/dev/identityruns (refuseDuplicateSessionCookieinapps/every-app-gateway/src/server.tsanswers401 {"error":"duplicate_session_cookie"}on every control-plane request), which the gateway-lite sees as no session (if (!res.ok) return nullinremoteAuth.ts):401 unauthenticated, with noMIRROR_SESSIONhint since no reason came back. - CSRF (
packages/perimeter/src/csrf.ts):GET,HEADandOPTIONSpass; other methods needSec-Fetch-Site: same-originor anOriginwhose host is the app's. A failing request that carries a cookie and no bearer on a private route is403 {"error":"csrf_denied"}; one with no cookie is treated as anonymous instead, so a private route answers401 {"error":"unauthenticated"}with aWWW-Authenticatechallenge. (Production also refuses a cookie-authenticated WebSocket handshake from anotherOriginhere,evaluateWebSocketOrigin; the gateway-lite's upgrade hop does not, see below.) - Public routes (
publicin the manifest) are auth-optional: a signed-in caller still gets an identity, an anonymous one the signed public marker. - Every inbound
x-everyapp-*header,cookieandauthorizationis stripped (prepareOutboundHeadersinpackages/perimeter/src/headers.ts);authorizationis restored only on a public route, and only when the Gateway did not consume it (anepat_or Gateway OAuth token is never forwarded). - An MCP-only credential answers at
/mcpand nowhere else (403 token_scope);/mcpgoes to the local ToolBus whatever the manifest says (createDevGatewayHandlerfills inmcp: { by: "gateway" }for an app without the field, since dev is local only), unless the manifest setsmcp: { by: "app" }, which proxies it to the app's own MCP server. Deployed, an app host serves/mcponly with the field (Manifest). - Security headers (see
headers.ts) are stamped on every response: HSTS,nosniff,X-Frame-Options: DENY,X-Robots-Tag: noindex, nofollowandReferrer-Policy: strict-origin-when-cross-origin(the deployed Gateway's own pages sendno-referrer); the CSP floor is on HTML responses only. A crashing app is502 {"error":"app_error"}, an unreachable one503 {"error":"app_unreachable"}.
What this means for curl (verified in docs/TESTING.md, the rules are in csrf.ts):
Request without Authorization |
Answer |
|---|---|
POST, no cookie, no Origin, no Sec-Fetch-Site |
401 unauthenticated |
POST, no cookie, another host's Origin |
401 unauthenticated |
POST, no cookie, Origin: http://<app-id>.localhost:8787 |
Reaches the app as the dev user |
POST, no cookie, Sec-Fetch-Site: same-origin |
Reaches the app as the dev user |
POST, a cookie, no Origin |
403 csrf_denied |
POST, a cookie, the app's Origin |
Reaches the app as the dev user |
GET |
Reaches the app as the dev user |
What differs from production
Development (everyapp dev) |
Production | |
|---|---|---|
| Session | Seeded dev@everyapp.localhost (stub) or a session the local Gateway resolves (mirror) |
Better Auth session cookie, personal access token or Gateway OAuth token |
| App access | hasAppAccess() is always true |
The Gateway's membership and per-app grants |
| Unauthenticated HTML navigation | 401 JSON: the dev handler sets no loginUrl, so there is no 302 to a login page |
302 to the Gateway's login with return_to |
| Cloudflare Access | None | The outer ring on a Gateway that has it on (/docs/agent/access) |
WebSocket Origin |
No check at the upgrade hop (handleUpgrade in packages/cli/src/commands/dev/proxy.ts checks the Host label, public routes, identity and headers only): a page on any origin can open a WebSocket to the dev app, in stub mode as the dev user |
A cookie-authenticated handshake whose Origin is not the app's is 403 csrf_denied (evaluateWebSocketOrigin in gateway.ts) |
| Cookie tossing | No-op: both dev authenticators declare sessionCookieNames: []; in mirror mode the Gateway refuses a duplicated cookie before /api/dev/identity runs (apps/every-app-gateway/src/server.ts), which the gateway-lite sees as no session |
401 with reason: "duplicate_session_cookie" before any session is read |
EVERY_APP_GATEWAY binding |
Absent: the SDK falls back to the developer's own keys from .dev.vars, or refuses (next section) |
The AppGateway service binding on the Gateway |
| Secrets | everyAppWorker() declares the manifest's secrets that are set in .dev.vars (or .env files); everyapp secret set is not involved |
Set on the Worker by everyapp secret set (/docs/agent/secrets) |
| Required settings | A hint only (Dev vars): dev runs without them, and an AI call or integration answers that it isn't set up |
An app with a required setting unset shows Needs setup and doesn't open (302 to the launcher's setup dialog for a private HTML navigation, else 503 app_needs_setup); its tools stay out of /mcp (/docs/agent/secrets) |
| D1 | A local sqlite database under .cloudflare/state/v3/d1, migrated at every start and by everyapp db migrate |
The deploy's database, migrated only by everyapp deploy after a restore point |
| KV, R2 | Local namespaces and buckets in Miniflare's persist root, .cloudflare/state/v3/kv and v3/r2 (below; R2 named by the production rule: every-<id> for the binding R2, otherwise every-<id>--<binding> with the binding lowercased and _ as -) |
The resources the deploy provisioned |
| Durable Objects, Workflows, helper Workers | Bound as in production (classBindings and the workers loop in worker.ts, next section); their local state is Miniflare's too, .cloudflare/state/v3/do and v3/workflows |
The deploy's classes, the Workflows every-<id>--<binding> and the helper Workers every-<id>--<name> |
| Hyperdrive | Only where .dev.vars or .env sets CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_<binding> to a local Postgres URL; otherwise the binding is absent (Env marks each optional; Postgres) |
The config the deploy provisioned |
| Queues | Local queues Miniflare makes and runs, named by the production rule (every-<id>--<binding>, queueNameFor): every Worker sends to each, and the consuming Worker's queue() handler runs on each batch, its dead-letter queue local too; nothing to set, no Cloudflare account touched |
The queues the deploy provisioned, and the consumers cf publishes with the upload |
Workers AI (resources.ai) |
{ type: "ai", dev: { remote: true } }, but Miniflare has no Workers AI: every call throws Binding AI needs to be run remotely until vite.config.ts sets remoteBindings: true and cf auth login has run; then it calls the account's Workers AI, billed to it |
{ type: "ai" }, the account's Workers AI |
Browser Rendering (resources.browser) |
{ type: "browser" }, served by Miniflare's local stand-in: the first session downloads Chrome 126.0.6478.182 into the global .wrangler cache and starts it on your machine; no login, nothing billed |
{ type: "browser" }, Cloudflare's headless Chrome, billed to the account (Workers Paid) |
Images (resources.images) |
{ type: "images" }, answered locally by Miniflare with sharp (503, code 9523, where sharp can't load); no login, nothing billed |
{ type: "images" }, Cloudflare Images, transformations billed to the account |
Analytics Engine (resources.analyticsEngine) |
{ type: "analytics-engine-dataset", name }, the default named after the dev Worker; Miniflare accepts every writeDataPoint() and keeps nothing |
The dataset as declared, written to Cloudflare and read through the SQL API |
EVERYAPP_SHARED, EVERY_APP_ORG_ID, GATEWAY_URL |
Absent | Set by the deploy |
| Tools catalog | Re-read from the running app every 2 s | A snapshot the Gateway takes itself and stores in D1: on activation or a deploy through the Gateway, when the app runs a new version, when the consent page or /mcp finds no snapshot (or one from another version), and when a tool call shows a different catalog (apps/every-app-gateway/src/server/toolbus/catalog-sync.ts); everyapp deploy uploads none |
| Crons | On demand: POST /__everyapp/cron |
Cloudflare's scheduler |
| Email in | POST /cdn-cgi/local/email on the app's internal port |
Email Routing to the app's addresses |
| Email out | Printed, unless .dev.vars holds Email Sending credentials |
The Gateway's email capability |
send_email bindings (resources.sendEmail) |
Bound as in production, with the same addresses; Miniflare sends nothing: it checks the addresses (email from <address> not allowed, email to <address> not allowed) and logs the message: send(new EmailMessage(...)) as send_email binding called with the following message: and an .eml file under .cloudflare/tmp/, the builder form send({ from, to, subject, text, html }) as send_email binding called with MessageBuilder: with .txt and .html files |
Cloudflare sends it, checking the sender against the account's Email Sending domains |
| Members | The signed-in user alone | The organization's directory |
| Identity key | The public dev keypair, dev kid, dev issuer, aud = app id |
The Gateway's keypair, aud = Worker name |
| Vectorize | The account's own index every-<id>--<binding>, bound with dev: { remote: true }: Cloudflare has no local Vectorize, so every call throws until the Vite plugin's remoteBindings is on and cf auth login has run (Vectorize in development) |
The index the deploy provisioned |
AI Gateway (resources.aiGateway) |
The json binding is there with the production id rule's id (every-<id>-<binding>, one hyphen) and an empty accountId and url: there is no gateway in development (none is created), so an app treats an empty url as "call the provider directly" with the key in .dev.vars |
{ id, accountId, url } of the gateway the deploy provisioned, url = https://gateway.ai.cloudflare.com/v1/<account>/<id> |
Secrets Store (resources.secretsStore) |
{ type: "secrets-store-secret", storeId: "local", secretName: "every-<id>--<binding>" }, Miniflare's local store under .cloudflare/state/v3/secrets-store, empty until seeded: get() throws Secret "<name>" not found |
The secret the deploy made in the account's store from .everyapp/secrets.env |
The development build's env
everyAppWorker({ manifest, entrypoint, ctx }) in packages/sdk/src/cloudflare/worker.ts builds the Worker for ctx.mode "development" (cf dev) or undefined (cf API commands, cf workers types) with:
| Binding | Value |
|---|---|
EVERYAPP_APP_ID |
The manifest id (text) |
CF_VERSION_METADATA |
Version metadata |
EVERYAPP_DEV |
"1" (text) |
EVERYAPP_DEV_CAPABILITIES |
The manifest's capabilities as JSON (text; helper Workers get it too): what integrationFetch() reads for an OAuth client's scopes sets in local dev |
EVERYAPP_IDENTITY_PUBLIC_KEYS |
JSON.stringify([DEV_PUBLIC_KEY_PEM]) (text) |
EVERYAPP_IDENTITY_ISSUER |
https://gateway.dev.localhost (text) |
DATABASE_PROVIDER |
manifest.database.provider (text), when database is declared |
Each resources.d1 binding |
{ type: "d1", name, id }: name is every-<id> for the binding DB (or KV), otherwise every-<id>--<binding> with the binding lowercased and _ as - (AUDIT_LOG → every-todo--audit-log; resourceNameFor in worker.ts); id is localD1Id(worker, binding) |
Each resources.kv binding |
{ type: "kv" } (with helper Workers: id: <binding>-<worker> so helpers share it) |
Each resources.r2 binding |
{ type: "r2", name }: every-<id> for the binding R2, otherwise the same suffixed form (bucketNameFor) |
Each resources.hyperdrive config |
Only with a local connection string (above) |
Each resources.queues entry |
{ type: "queue", name } on every Worker, name = every-<id>--<binding> (queueNameFor); a binding name the Worker already has throws. A queue with consumer also puts { type: "queue", name, deadLetterQueue?, …settings } in the consuming Worker's triggers (the app's own, or the helper consumer.worker names), after its crons |
Each resources.ai binding |
{ type: "ai", dev: { remote: true } }, on helper Workers too (Workers AI in development, below) |
Each resources.sendEmail entry |
{ type: "send-email" } plus the entry's allowedSenderAddresses, destinationAddress or allowedDestinationAddresses, the same as in production (helper Workers get it too) |
Each resources.browser binding |
{ type: "browser" }, on helper Workers too (Browser Rendering in development, below) |
Each resources.images binding |
{ type: "images" }, on helper Workers too (Images in development, below) |
Each resources.analyticsEngine entry |
{ type: "analytics-engine-dataset", name }: the entry's dataset, else every-<id>--<binding> with the binding lowercased and _ as -; on helper Workers too (Analytics Engine in development, below) |
Each resources.durableObjects entry |
{ type: "durable-object", worker, exportName: <className> }, worker the defining Worker (the app's every-<id>, or every-<id>--<worker> for a helper's); with a module-import entrypoint, the app's own classes name the Worker by its definition so cf workers types can type the namespace. The defining Worker exports the class (storage: "sqlite") |
Each resources.workflows entry |
{ type: "workflow", name, worker, exportName: <className> }, name = every-<id>--<binding> with the binding lowercased and _ as - (workflowNameFor; over 64 characters throws), plus the class export |
Each workers[].binding |
{ type: "worker", worker: every-<id>--<name> } (helperWorkerName); a binding name the Worker already has throws |
Each manifest secrets name set locally |
{ type: "secret" } |
| Each provider or capability key set locally | { type: "secret" } (table below) |
EVERYAPP_EMAIL_SENDERS |
{ type: "secret" } when the manifest lists the email capability and it is set locally |
Each resources.vectorize binding |
{ type: "vectorize", name, dev: { remote: true } }, name = every-<id>--<binding> with the binding lowercased and _ as - (the deploy's rule, vectorizeNameFor, applied to the dev Worker; over 64 characters throws vectorize: <NAME> would be the Vectorize index "<name>", longer than Cloudflare's 64 characters; shorten the binding name.); bound on helper Workers too |
Each resources.aiGateway entry |
{ type: "json", value: { id, accountId: "", url: "" } }, id = every-<id>-<binding> with the binding lowercased and _ as - (one hyphen: aiGatewayIdFor); cf returns a json binding to the Worker as given, so env.<NAME>.url is "" |
Each resources.secretsStore binding |
{ type: "secrets-store-secret", storeId: "local", secretName }, secretName = every-<id>--<binding> with the binding lowercased and _ as - (the deploy's name), on helper Workers too (Secrets Store in development, below) |
Durable Objects, Workflows and helper Workers are built the same way as in production; only resource ids and names differ. A development build has no EVERYAPP_AUDIENCE, EVERY_APP_GATEWAY, GATEWAY_URL, EVERY_APP_ORG_ID or EVERYAPP_SHARED.
.dev.vars: which file is read, and which names count
everyAppWorker() reads local values the way the Cloudflare Vite plugin does (localEnv in worker.ts): .dev.vars.<mode> (.dev.vars.development under cf dev) if it exists, else .dev.vars; when either exists it is the only source. With neither, .env, .env.local, .env.<mode> and .env.<mode>.local are merged in that order and the process environment on top. The template's .gitignore ignores .dev.vars*, .env and .env.local.
Only three sets of names are declared, and only the ones that are set:
The manifest's
secrets. Each must be a valid binding name (/^[A-Za-z_][A-Za-z0-9_]*$/); a reserved name (GATEWAY_URL,CF_VERSION_METADATA,EVERYAPP_*,EVERY_APP_*,APP__*) or one that collides with a binding throwssecrets: <NAME> is a name the deploy sets or a binding it builds.The keys of the manifest's
providersandcapabilities, under the Gateway's own names (DEV_GATEWAY_KEYSinworker.ts, a copy of the tables inpackages/sdk/src/internal/):Manifest entry Names read from .dev.varsproviders: ["openai"]OPENAI_API_KEYproviders: ["openrouter"]OPENROUTER_API_KEYproviders: ["workers-ai"]WORKERS_AI_API_TOKEN,CLOUDFLARE_ACCOUNT_IDcapabilities.dataforseoDATAFORSEO_API_KEYcapabilities.contextCONTEXT_API_KEYcapabilities.emailEMAIL_FROM,EMAIL_FROM_NAME,EMAIL_REST_API_TOKEN,CLOUDFLARE_ACCOUNT_ID,RESEND_API_KEY,EMAIL_PROVIDERcapabilities.membersNothing: listMembers()answers from the dev identityOAuth clients ( google-workspace,microsoft-365,slack; the social networkslinkedin,facebook,instagram,threads,youtube,tiktok,tiktok-business,reddit,pinterest,discord,mastodon, …)<PROVIDER>_CLIENT_IDand<PROVIDER>_CLIENT_SECRET(GOOGLE_WORKSPACE_CLIENT_ID,MICROSOFT_365_CLIENT_ID,SLACK_CLIENT_ID;FACEBOOK_APP_ID/FACEBOOK_APP_SECRET,INSTAGRAM_APP_ID,THREADS_APP_IDfor Meta's;MASTODON_URLbesides for Mastodon)EVERYAPP_EMAIL_SENDERSwhen the manifest listsemail.
Getting each key and setting it on the Gateway for production is step by step in packages/cli/skills/everyapp/setup/<name>.md (openai.md, openrouter.md, workers-ai.md, dataforseo.md, context.md, email.md, google.md, microsoft-365.md, slack.md; index README.md; in an app .claude/skills/everyapp/setup/<name>.md); in development the same value goes in .dev.vars under the name above.
A name in .dev.vars that is in none of these sets is not declared, so the Worker does not see it (everyAppWorker() adds a { type: "secret" } binding only for these names, and, as the secrets comment in worker.ts puts it, "the plugin passes it through" because it is declared): an app secret must be listed under secrets in everyapp.config.ts locally for the same reason everyapp secret set requires it in production.
What the ready block asks for. dev --help (packages/cli/src/commands/dev/command.ts) says:
With no Gateway in local runs, the app reads its manifest's secrets and the
keys of its providers and capabilities (OPENROUTER_API_KEY, DATAFORSEO_API_KEY,
...) from .dev.vars in its directory, one NAME=value per line, gitignored. The
ready block names the ones that file is missing and the app can't run without;
until they are set, AI calls and integrations answer that they aren't set up
(email needs none: sendEmail() prints until EMAIL_FROM and a transport are set).Which names the app "can't run without" is what devVarsHint() asks of the settings model, appSettings() in packages/sdk/src/internal/appSettings.ts, following the manifest's required, optional and anyOf marks: the Dev vars fact under The ready block. The three sets above are what the Worker is given when set; the Dev vars line asks only for the subset the SDK cannot stand in for, so never an optional setting, never email's settings and transports, never members, never MASTODON_URL, never EVERYAPP_EMAIL_SENDERS. worker.ts declares its names from its own copy of the tables (DEV_GATEWAY_KEYS), which a test in packages/sdk/src/cloudflare/worker.test.ts keeps in step with the model (the install's CLOUDFLARE_ACCOUNT_ID is the one name the Worker declares that the model leaves to the install).
Typing app secrets. cf workers types writes Env to .cloudflare/types/index.d.ts from the everyAppWorker() env, so a secret is in Env only when .dev.vars (or the .env files) set it at generation time (locallySetSecrets in worker.ts). For one that is not set locally, declare it yourself as an optional property of Env in a .d.ts the template's tsconfig.json includes ("include": ["**/*.ts", "**/*.tsx", ".cloudflare/types/index.d.ts"]). The template's src/env.d.ts is that file: its comment shows the declare namespace Cloudflare { interface Env { … } } block to add (the SDK's doc comment in worker.ts names the same file).
AI, capabilities, email and members with no Gateway
Each SDK helper in packages/sdk/src/server/ checks for the EVERY_APP_GATEWAY binding first, then for EVERYAPP_DEV:
createGatewayFetch()(gatewayFetch.ts): withEVERYAPP_DEVand the provider's key in env it calls the provider directly (https://api.openai.com,https://openrouter.ai, or Workers AI's REST path underCLOUDFLARE_ACCOUNT_ID), setsAuthorization: Bearer <key>itself and applies the same response normalization the Gateway does. With no binding and no key:ConfigurationErrorAI gateway unavailable: deploy with the EVERY_APP_GATEWAY binding, or set the provider's key (…) in .dev.vars for direct local development.; a call to a provider whose key is missing:Set OPENAI_API_KEY in .dev.vars to call openai in local development.integrationFetch()(integrationFetch.ts): withEVERYAPP_DEV, the developer's own key (DATAFORSEO_API_KEY, …) or OAuth client (<PROVIDER>_CLIENT_ID/_SECRET) from.dev.varsreaches the provider directly;integrationStatus()reportsconfiguredandreadyas whether those values are set. No binding and noEVERYAPP_DEV: aConfigurationError. For an OAuth client with scope sets (google-workspace,microsoft-365,slack) dev holds the app to the manifest'sscopesas the Gateway does, read fromEVERYAPP_DEV_CAPABILITIES: the client must be listed withaccess: "write"(capability_not_requested) and usablescopes(integration_scopes_undeclared); a token request asking beyond them isintegration_scope_not_allowed, a token granted beyond themintegration_scope_exceeded, one that names no scopesintegration_scope_unverified.integrationStatus().scopeSetsandauthorizeUrl()build the right request.sendEmail()(sendEmail.ts): no binding and noEVERYAPP_DEVisConfigurationErrorEmail is unavailable: deploy with the EVERY_APP_GATEWAY binding (everyapp deploy), or run under everyapp dev.Undereveryapp devthe message is checked as the Gateway checks it (email_invalid_message), afrommust be one of the sendersEVERYAPP_EMAIL_SENDERSlists (a comma list or JSON array; unset, everyfromis refused with403 email_sender_not_allowed), then: withEMAIL_REST_API_TOKEN,CLOUDFLARE_ACCOUNT_IDandEMAIL_FROMall set, it really sends through Email Sending's REST API from the developer's account; otherwise it prints[everyapp dev] email to <n> recipients subject "<subject>" (not sent: set EMAIL_REST_API_TOKEN, CLOUDFLARE_ACCOUNT_ID and EMAIL_FROM in .dev.vars to send)and answers{ status: "logged", messageId: "dev-<uuid>" }.listMembers()(listMembers.ts): no binding and noEVERYAPP_DEVisConfigurationErrorMembers are unavailable: …. Undereveryapp devthere is no directory: a call for a request answers that request's user alone (name: "", roleowner/adminormember), background work answers{ members: [] }.
TODO.md records the planned change: the gateway-lite would serve /api/ai and /api/integrations itself and the SDK's direct fallbacks and DEV_GATEWAY_KEYS would go.
Workers AI in development
Miniflare has no Workers AI of its own. An app that declares resources.ai gets the binding under everyapp dev ({ type: "ai", dev: { remote: true } } on every Worker, addAiBindings() in packages/sdk/src/cloudflare/worker.ts), but with the starter's remoteBindings: false every call throws Binding AI needs to be run remotely. To call the model from dev, set remoteBindings: true on the Cloudflare plugin in vite.config.ts and run cf auth login once; then env.AI.run() reaches the Workers AI of the account cf is logged in to, billed to it. The SDK already marks the binding remote, so that one switch is all it takes; it moves only the bindings marked remote, and D1, KV, R2, Durable Objects and the rest stay local. Restart everyapp dev after the change. An app that would rather not touch the account in dev calls Workers AI through the Gateway's workers-ai provider instead (createGatewayFetch(), with WORKERS_AI_API_TOKEN and CLOUDFLARE_ACCOUNT_ID in .dev.vars, above), or keeps the call behind a check of its own.
Browser Rendering in development
An app that declares resources.browser gets the binding under everyapp dev in the shape production has, { type: "browser" } on every Worker (addBrowserBindings() in packages/sdk/src/cloudflare/worker.ts), and it works locally with no login: Miniflare serves a local stand-in for Browser Rendering, and env.BROWSER.fetch("http://browser.local/v1/limits") answers 200 from it. The first session the code opens (puppeteer.launch(env.BROWSER), or the same with @cloudflare/playwright) downloads Chrome 126.0.6478.182 into the global .wrangler cache and starts it on the developer's machine; later sessions reuse that copy. Nothing reaches the Cloudflare account and nothing is billed. Deployed, the same code drives Cloudflare's headless Chrome, which needs the account on Workers Paid.
Images in development
An app that declares resources.images gets the binding under everyapp dev in the shape production has, { type: "images" } on every Worker (addImagesBindings() in packages/sdk/src/cloudflare/worker.ts), and it works locally with no login: Miniflare's Images service answers it with sharp, which ships with Miniflare, so env.IMAGES.info(stream) and env.IMAGES.input(stream).transform({ width: 256 }).output({ format: "image/webp" }) run on the developer's machine. Nothing reaches the Cloudflare account and nothing is billed. On a machine where sharp can't load (a platform without its prebuilt binary), every call answers 503 with code 9523; installing sharp for the platform fixes it. Deployed, the same calls run on Cloudflare Images.
Vectorize in development
Cloudflare has no local Vectorize: Miniflare can't emulate an index, so a resources.vectorize binding only works against a real index in the account. The SDK binds each one with dev: { remote: true } to the account's index of the dev Worker's name (every-<id>--<binding>, never the deployed app's every-<orgKey>-<id>--<binding>), so the switch is the one Workers AI uses (above):
- In the app's
vite.config.ts, change the Cloudflare plugin'sremoteBindings: false(the starter's default: local dev never touches remote resources) toremoteBindings: true, and restarteveryapp dev. - Run
cf auth loginonce (the plugin reaches the account with cf's login). - Make the dev index once, as the deploy would but under the dev Worker's name:
cf vectorize create --body @index.jsonwith{ "name": "every-<id>--<binding>", "config": { "dimensions": 768, "metric": "cosine" } }(or"config": { "preset": "@cf/baai/bge-base-en-v1.5" }; a body file, since cf reads a flag value starting with@as a file).everyapp devmakes nothing in the account.
Until all three hold, the binding is there but every call on it throws (Binding VECTORS needs to be run remotely while remoteBindings is off; Cloudflare's refusal without a login or an index). The rest of the app runs as usual: keep the Vectorize calls behind a check, or turn remote bindings on while working on them. The vectors written in development are real, in the dev index, which everyapp deploy never touches; delete it with cf vectorize delete every-<id>--<binding> --force when done.
Secrets Store in development
An app that declares resources.secretsStore gets each binding under everyapp dev as { type: "secrets-store-secret", storeId: "local", secretName: "every-<id>--<binding>" } on every Worker (addSecretsStoreBindings() in packages/sdk/src/cloudflare/worker.ts). Miniflare keeps a local store per storeId under .cloudflare/state/v3/secrets-store (any id works locally), and it starts empty: await env.STRIPE_KEY.get() throws Secret "every-shop--stripe-key" not found until the secret is put there. What doesn't fill it: everyapp dev (not yet; the ready block's Secrets Store line says the binding is empty), .dev.vars (never read for this binding; a .dev.vars entry of the same name doesn't shadow it either), and cf secrets-store secrets create --local ("This command has no local equivalent" on cf 1.0.0-beta.14). What does: Miniflare's admin API, writing to the same state directory, once, with dev stopped. From the app's root, with the value on stdin:
// seed-secret.mjs: node seed-secret.mjs every-shop--stripe-key < value.txt
import { createRequire } from "node:module";
import path from "node:path";
const [secretName] = process.argv.slice(2);
const app = createRequire(path.resolve("package.json"));
const { Miniflare } = createRequire(app.resolve("cf/package.json"))(
"miniflare",
);
let value = "";
for await (const chunk of process.stdin) value += chunk;
const mf = new Miniflare({
resourcePersistencePath: path.resolve(".cloudflare/state/v3"),
workers: [
{
config: {
name: "seed",
compatibilityDate: "2026-06-01",
manifest: {
mainModule: "m.mjs",
modules: {
"m.mjs": {
type: "esm",
contents: "export default { fetch: () => new Response('') }",
},
},
},
env: {
S: { type: "secrets-store-secret", storeId: "local", secretName },
},
},
},
],
});
try {
const admin = (await mf.getSecretsStoreSecretAPI("S"))();
const existing = (await admin.list()).find(
(secret) => secret.name === secretName,
);
if (existing) await admin.update(value.trimEnd(), existing.metadata.uuid);
else await admin.create(value.trimEnd());
} finally {
await mf.dispose();
}cf resolves miniflare from its own dependencies (cf 1.0.0-beta.14 pins Miniflare 5), which is why the script loads it through cf/package.json. The seeded secret stays until .cloudflare/state is deleted. Deployed, the binding reads the secret the deploy created from .everyapp/secrets.env (/docs/agent/manifest, "Secrets Store").
Analytics Engine in development
An app that declares resources.analyticsEngine gets each binding under everyapp dev as { type: "analytics-engine-dataset", name } on every Worker (addAnalyticsEngineBindings() in packages/sdk/src/cloudflare/worker.ts), the name the entry's dataset or every-<id>--<binding>. Miniflare accepts every env.EVENTS.writeDataPoint(...) and keeps nothing: the call succeeds, nothing reaches the Cloudflare account, and there is nothing to query locally. Code that reads the dataset back calls the SQL API against the account, so test it against a deployed app. cf's shape for this binding takes no dev options (dev: { remote: true } is a type error), so there is no switch to send development writes to the account.
Data in development: the local D1 database
Source: packages/cli/src/lib/app/localD1.ts, packages/sdk/src/cloudflare/worker.ts.
- Id.
localD1Id(workerName, binding)= the first 32 hex characters ofsha256("<worker>:<binding>"), becausecf d1 … --localaddresses a database by UUID or 32-hex id, never by name.everyAppWorker()gives the binding this id and the CLI computes the same one forcf d1commands. - Files.
.cloudflare/stateunder the app root (--persist-to .cloudflare/state; the Cloudflare Vite plugin's state directory). The sqlite file is.cloudflare/state/v3/d1/miniflare-D1DatabaseObject/<hash>.sqlite, where<hash>is workerd's Durable Object id for the database id;localD1Path(root, databaseId)from@every-app/sdk/cloudflarecomputes it. Thev3/d1part is Miniflare's layout: itsgetPersistPathputs each binding kind in its own directory of one persist root, so the other local bindings sit beside it,v3/kv,v3/r2,v3/do,v3/workflows. Apps once run under Wrangler kept data in.wrangler/state; it is not copied (LEGACY_LOCAL_STATE). - Migrations. Read from
migrations.dirineveryapp.config.ts(defaultdrizzle/) for the bindingmigrations.bindingnames (default: the firstresources.d1).everyapp devapplies pending ones at every start;pnpm everyapp db migrateapplies them without a restart, printingApplied <names>.orThe local database is up to date., orMIGRATIONS_FAILEDnaming the failed file. Production migrations run only insideeveryapp deploy. Source:packages/cli/src/commands/db/index.ts. - Queries.
pnpm everyapp db query --local "<sql>"(or--file <path>,-for stdin;--binding <name>for another D1 binding, default the migrations binding or the first) runscf d1 raw <id> --local --persist-to .cloudflare/state --sql …(stepdb.query) and prints a table, or NDJSON rows with--json;--verboseshows every step. Without--localthe same command targets production and confirms writes (--yesskips the question). db migrateoutput. Flags--jsonand--verbose. Stepdb.migrate:Applying migrations to the local database...only once there is something to apply; a skip says<id> declares no D1 database, so there's nothing to migrate.(data.reasonno_d1) orNo migrations in <dir>/ yet, so there's nothing to migrate.(no_migrations_dir). It warnsLEGACY_LOCAL_STATEthe same wayeveryapp devdoes. Both capcf d1 migrations applyatMIGRATIONS_TIMEOUT_MS = 600_000(10 minutes,localD1.ts).- Dev registry. Every
cfprocesseveryappstarts (dev, migrations, queries) getsCLOUDFLARE_REGISTRY_PATHandMINIFLARE_REGISTRY_PATHset to<app>/.everyapp/registry, so local Workers find each other's service bindings per checkout; the default is one registry per user, which two worktrees of the same app would share..everyapp/is ignored by git.
Codes the local db commands raise (packages/cli/src/commands/db/index.ts):
| Code | Message | Fix or data |
|---|---|---|
NO_D1 |
<id> declares no D1 database |
add a binding to resources.d1 in everyapp.config.ts |
UNKNOWN_D1_BINDING |
No D1 binding <name> in everyapp.config.ts then It declares <a>, <b>. |
A rerun of the same command with --binding set to the first declared binding |
SQL_REQUIRED |
No SQL to run |
pass it as an argument, or with --file <path> (- for stdin) |
SQL_TWICE |
Pass the SQL or --file, not both |
drop one of them |
SQL_FILE_NOT_FOUND |
No file at <path> |
|
QUERY_FAILED |
Query failed: <problem> then <sqlite code> from the local database. (a CfError with a sqlite code; any other error is rethrown as is) |
|
MIGRATIONS_FAILED |
db migrate: Migration <file> failed (or A local migration failed when no pending migration can be named) then <problem> (<sqlite code>) |
correct <dir>/<file> (correct the migration when unnamed); data.migration names the file |
Building: development builds compile, only everyapp deploy builds for production
appWorkerName() in packages/sdk/src/cloudflare/worker.ts decides by ctx.mode:
"development"orundefined: the dev env above, Worker nameevery-<id>, local resource ids."production": only withEVERYAPP_BUILD=1in the process environment, whicheveryapp deploysets around the build. Without it, the config throws:EveryApp Workers are built for production only by `everyapp deploy` (`pnpm run deploy`), which sets EVERYAPP_BUILD=1 and installs the build: a bare `cf build` or `cf deploy` would ship a Worker whose name, identity keys, gateway binding and resources are placeholders. For a local build, run Vite in development mode (`vite build --mode development`).With it, every install-specific value is a placeholder frompackages/sdk/src/cloudflare/sentinel.ts(every-hosted--sentinel,https://every-hosted--sentinel.invalid, …) that the installer rewrites and refuses to leave in place; production builds are tenant-neutral and declare none of the manifest'ssecrets(the only secret bindings a production build declares are the deploy's ownGATEWAY_URLandEVERY_APP_ORG_ID,{ type: "secret" }, sent throughcf deploy --secrets-file).- Any other mode:
everyAppWorker() builds in the "development" or "production" mode, not "<mode>".
So in the template (starter/package.json):
| Script | Runs | Purpose |
|---|---|---|
pnpm dev |
everyapp dev |
The app behind the gateway-lite |
pnpm build |
vite build --mode development && tsc --noEmit |
A development build, to check the app compiles; not deployable |
pnpm test |
vitest run |
The server module against an in-memory SQLite with the app's migrations applied (src/server/todos.test.ts); no Worker runtime needed |
pnpm types:check |
cf workers types && tsc --noEmit |
Regenerate .cloudflare/types/index.d.ts (the Env type, included by tsconfig.json) after config changes, then type-check |
pnpm run deploy |
everyapp deploy |
The only production build (/docs/agent/deploy) |
cloudflare.config.ts must not import anything the Worker imports: in dev the Cloudflare Vite plugin hides that file's imports from the Worker (that is how the dev keypair never reaches the bundle). vite.config.ts loads the plugin with remoteBindings: false, so local dev never touches remote resources; an app with resources.ai turns it on to call Workers AI from dev (Workers AI in development, above), and an app with resources.vectorize to reach its dev index (Vectorize in development, above).
Calling the app's tools locally from Claude Code
<app-id>.localhost/mcp is served by a local ToolBus (packages/toolbus/src/dev/index.ts, wired in packages/cli/src/commands/dev/devAppGateway.ts) from the app's live defineTools() catalog, with the Gateway's policy, argument validation and identity contract: each tool call reaches the app at /__everyapp/tools/call with an identity whose chan is mcp and whose tc claim binds that one call.
claude mcp add --transport http todos http://<app-id>.localhost:8787/mcp \
--header "Authorization: Bearer dev"- Stub mode: any bearer value works. The perimeter only authenticates a non-browser
POSTthat carries a bearer (aPOSTwith no cookie, noOriginand no bearer is anonymous, so/mcpanswers401); the stub authenticator then answers the dev user for any value. The principal is{ kind: "dev" }withwriteaccess (packages/toolbus/src/principal.ts). Destructive tools stay hidden, as in production. - Mirror mode: a real personal access token. It is forwarded to the local Gateway; one without
*,mcp:writeormcp:readis403 {"error":"forbidden","reason":"token_scope"}. - Hosts and origins.
localhostOnlyis set: the Host must belocalhost,*.localhost,127.0.0.1or[::1](403 Forbidden: invalid Host header.), and a browserOriginmust be the endpoint's own or any localhost-class origin; other origins are403 Forbidden: origin not allowed. - Browser MCP clients work in
stubmode only. A CORS preflight (OPTIONS) carries no cookie and no bearer. In stub mode the authenticator still answers the dev user, so the ToolBus answers the preflight (204withaccess-control-allow-origin: http://localhost:6274for MCP Inspector). Inmirrormode the remote authenticator resolves no session for it and the perimeter answers401before the ToolBus can add CORS headers (devAppGateway.test.ts,leaves a credential-less preflight to the perimeter in mirror mode). curl and Claude Code send no preflight and work in both modes. - The catalog is re-read every 2 s (
DEFAULT_CATALOG_TTL_MS), so an edit todefineTools()shows on the nexttools/listafter the app reloaded; atools/callwhose catalog hash no longer matches drops the cache too. The caveat (TODO.md): the dev view has norefresh, so for 2026-07-28 clients (Claude Code) the server declareslistChanged: falseand a connected client does not learn of the change until it lists again or reconnects; only 2025-era clients get the session-hash notification. - An app without tools (or whose SDK predates the contract) gets
/mcpproxied to itself; the latter warnsTOOLS_CATALOG(Agent tools: …) once per problem. - Each call prints a line in the terminal (progress on step
dev.mcp), its parts joined by two spaces:MCP <tool> <outcome> <ms>ms, orMCP <tool> blocked: <reason>(destructive requires approval, …). The event'sdatacarriestool,decision,reason,outcomeanddurationMs.
With curl, the same from a script (pin the host as above):
curl --resolve todo.localhost:8787:127.0.0.1 -X POST http://todo.localhost:8787/mcp \
-H "Authorization: Bearer dev" -H "content-type: application/json" \
-H "accept: application/json, text/event-stream" -H "mcp-protocol-version: 2025-11-25" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'Pitfalls
- Port taken.
DEV_PORT_IN_USE(Port 8787 is already in use) names the next free port and the exact rerun, printed asRun: pnpm everyapp dev --port <free>. Each worktree needs its own--portor$PORT(portless assigns one). - Mirror mode without the Gateway.
MIRROR_GATEWAY_UNREACHABLEafter 5 s; startpnpm devinapps/every-app-gatewayfirst or pass--gateway. A one-label--base-hostisMIRROR_BASE_HOSTbefore anything starts. - Editing
everyapp.config.tsneeds a restart. The gateway-lite loads the manifest once at start: public routes,serverFnGuard/serverFnBase,mcp,email.inbound,resources,migrationsand thedevcommand are frozen untileveryapp devruns again. The tools catalog is the exception (re-read every 2 s from the running app). Crons are detected textually fromcloudflare.config.tsat start, too. .dev.varsnames. A value under a name that is not in the manifest'ssecrets, not one of the Gateway key names for itsproviders/capabilities, and notEVERYAPP_EMAIL_SENDERSis not declared and the Worker does not see it.EVERYAPP_*andEVERY_APP_*names cannot be app secrets. When.dev.varsexists,.envfiles are ignored entirely.- No
--json.everyapp devalways prints for a person; run it in the background and read theAppline. POSTfrom a script answers401. AddOrigin: http://<app-id>.localhost:8787(or a bearer, for/mcp); a cookie withoutOriginis403 csrf_denied. Same as production.*.localhostresolves to::1first on macOS while the gateway-lite listens on127.0.0.1; curl falls back, a client that takes the first address fails withECONNREFUSED ::1. Pin with--resolveor--connect-to.- Stopping. Ctrl+C, or a
SIGINT/SIGTERMto theeveryappprocess, stops the whole process group (exit130/143).pkill -f <path>can leave thepnpm vite devand workerd children running, since their command lines do not name the path (TODO.md); a pattern kill onviteorworkerdstops other projects' processes too. Under the template'spnpm dev, pnpm follows a clean Ctrl+C with itsELIFECYCLEline for exit code130: nothing failed. - The SDK rebuilt under a running dev.
pnpm install(rootpostinstall:pnpm -C packages/sdk build) or anypnpm everyappcommand, a deploy included, whose launcher finds the SDK's source newer than itsdist/(packages/cli/bin/everyapp.js) rewritespackages/sdk/dist, which the app loads; dev breaks for a few seconds until the build is done. cf devalone answers401on every SDK-protected route: nothing mints the identity; run the app withpnpm dev.Secret "<name>" not found. A Secrets Store binding'sget()undereveryapp dev: the local store is empty until seeded (Secrets Store in development, above).Binding AI needs to be run remotely. A Workers AI call under the starter'sremoteBindings: false; set it totrueinvite.config.tsand runcf auth login(Workers AI in development, above).Binding VECTORS needs to be run remotely. The same for a Vectorize index, which also needs the dev index made once (Vectorize in development, above).- Stale
Envtypes after adding a resource or secret:everyapp devregenerates.cloudflare/typesonly when the file is missing; runpnpm types:check. A secret that is not set locally at generation time is absent fromEnvaltogether: declare it optional in your own.d.ts(see Typing app secrets). - A different
cfin the app than the CLI pins warnsCF_VERSION_DIFFERS(Using the app's cf <version>; everyapp is tested with <pinned>) but runs; with nocfin the app the CLI's own runs. - MCP Inspector in mirror mode gets
401on its preflight; usestubmode for browser MCP clients, or curl and Claude Code in either mode. - A manifest that does not load (syntax or import error) is a raw error without a code; only a schema violation is
INVALID_MANIFEST.
Errors everyapp dev raises
| Code | When | See |
|---|---|---|
INVALID_DEV_MODE |
--mode or EVERYAPP_DEV_MODE is neither stub nor mirror |
/docs/agent/errors#invalid_dev_mode |
MIRROR_BASE_HOST |
Mirror mode with a base host of fewer than two labels | /docs/agent/errors#mirror_base_host |
MANIFEST_NOT_FOUND |
No everyapp.config.ts in the current directory |
/docs/agent/errors#manifest_not_found |
INVALID_MANIFEST |
everyapp.config.ts fails the strict schema (a file that fails to load is rethrown without a code) |
/docs/agent/errors#invalid_manifest |
MISSING_CLOUDFLARE_CONFIG |
No cloudflare.config.ts and no manifest dev command |
/docs/agent/errors#missing_cloudflare_config |
DEV_PORT_IN_USE |
The gateway port is taken (EADDRINUSE); data.free and a rerun with --port |
/docs/agent/errors#dev_port_in_use |
DEV_PORT_UNAVAILABLE |
Any other listen error on --host:--port |
/docs/agent/errors#dev_port_unavailable |
MIRROR_GATEWAY_UNREACHABLE |
No answer from --gateway within 5 s |
/docs/agent/errors#mirror_gateway_unreachable |
MIGRATIONS_FAILED |
A local migration did not apply; dev does not start | /docs/agent/errors#migrations_failed |
DEV_SERVER_FAILED |
The app's dev server exited before answering; its output is replayed | /docs/agent/errors#dev_server_failed |
DEV_SERVER_TIMEOUT |
The app's dev server did not listen on PORT within 90 s |
/docs/agent/errors#dev_server_timeout |
Warnings (not errors): CF_VERSION_DIFFERS, LEGACY_LOCAL_STATE, DEV_EXPOSED, DEV_WRONG_HOST, DEV_REQUEST_FAILED, MIRROR_SESSION, TOOLS_CATALOG.
Sources
packages/cli/src/commands/dev/command.tspackages/cli/src/commands/dev/index.tspackages/cli/src/commands/dev/mode.tspackages/cli/src/commands/dev/gatewayServer.tspackages/cli/src/commands/dev/appServer.tspackages/cli/src/commands/dev/proxy.tspackages/cli/src/commands/dev/ready.tspackages/cli/src/commands/dev/registerDevApp.tspackages/cli/src/commands/dev/remoteAuth.tspackages/cli/src/commands/dev/migrations.tspackages/cli/src/commands/dev/launcherPage.tspackages/cli/src/commands/dev/rerun.tspackages/cli/src/commands/dev/devAppGateway.tspackages/cli/src/commands/dev/index.test.tspackages/cli/src/commands/dev/mode.test.tspackages/cli/src/commands/dev/migrations.test.tspackages/cli/src/commands/dev/appServer.test.tspackages/cli/src/commands/dev/devAppGateway.test.tspackages/cli/src/commands/dev/remoteAuth.test.tspackages/cli/src/commands/dev/rerun.test.tspackages/cli/src/commands/dev/proxy.test.tspackages/cli/src/commands/localMigrations.tspackages/cli/src/commands/db/command.tspackages/cli/src/commands/db/index.tspackages/cli/src/commands/db/queryOutput.tspackages/cli/src/lib/app/localD1.tspackages/cli/src/lib/app/workerTypes.tspackages/cli/src/lib/app/cloudflareConfig.tspackages/cli/src/lib/interrupt.tspackages/cli/src/lib/output/events.tspackages/cli/src/lib/output/command.tspackages/cli/src/lib/longRunning.tspackages/cli/src/lib/manifest.tspackages/cli/src/lib/cf/bin.tspackages/cli/src/commands/deploy/deployApp.tspackages/cli/bin/everyapp.jspackages/cli/package.jsonpackage.jsonpackages/cli/skills/everyapp/SKILL.mdpackages/perimeter/src/dev/devGateway.tspackages/perimeter/src/gateway.tspackages/perimeter/src/gateway.test.tspackages/perimeter/src/publicRoutes.tspackages/perimeter/src/csrf.tspackages/perimeter/src/cookies.tspackages/perimeter/src/serverFunctions.tspackages/perimeter/src/headers.tspackages/perimeter/src/host.tspackages/perimeter/src/manifest/manifest.tspackages/sdk/src/dev/index.tspackages/sdk/src/dev/keypair.tspackages/sdk/src/dev/index.test.tspackages/sdk/src/internal/protocol.tspackages/sdk/src/internal/identity.tspackages/sdk/src/internal/aiProviders.tspackages/sdk/src/internal/appSettings.tspackages/sdk/src/internal/appSettings.test.tspackages/sdk/src/internal/gatewaySettings.tspackages/sdk/src/internal/capabilities.tspackages/sdk/src/internal/tools.tspackages/sdk/src/cloudflare/worker.tspackages/sdk/src/cloudflare/sentinel.tspackages/sdk/src/server/everyApp.tspackages/sdk/src/server/gatewayFetch.tspackages/sdk/src/server/integrationFetch.tspackages/sdk/src/server/sendEmail.tspackages/sdk/src/server/listMembers.tspackages/toolbus/src/dev/index.tspackages/toolbus/src/dev/index.test.tspackages/toolbus/src/http.tspackages/toolbus/src/principal.tsapps/every-app-gateway/src/routes/api/dev/identity.tsapps/every-app-gateway/src/server.tsapps/every-app-gateway/src/server/toolbus/catalog-sync.tsstarter/package.jsonstarter/README.mdstarter/AGENTS.mdstarter/cloudflare.config.tsstarter/vite.config.tsstarter/everyapp.config.tsstarter/drizzle.config.tsstarter/tsconfig.jsonstarter/.gitignorestarter/src/entry.worker.tsdocs/TESTING.mdTODO.md
Secrets, Gateway settings and capabilities
Where an app's keys live (a Gateway setting held once for every app, the app's own secret, or a capability the Gateway brokers), how everyapp configure sets up everything an app declares and everyapp secret set NAME one name, where each name goes and what is refused, how keys work in local development, and the Postgres secrets file.
Email: senders, inbound and the email capability
How an app sends mail through the Gateway with sendEmail(), receives mail at addresses routed to its email() handler, what everyapp email set, unset and show configure on an installation, and what the operator sets up once.