EveryAppEveryApp

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

  1. Mode. resolveDevMode(flags.mode, env.EVERYAPP_DEV_MODE). In mirror mode the base host is checked first: fewer than two DNS labels (port ignored) is MIRROR_BASE_HOST before anything else runs.
  2. Manifest. everyapp.config.ts must exist in the current directory (MANIFEST_NOT_FOUND, No everyapp.config.ts in this directory, data.fix run this from the app's directory: the same code every command raises, from loadEveryAppManifest in packages/cli/src/lib/manifest.ts) and validate (INVALID_MANIFEST, message everyapp.config.ts isn't valid plus the schema's message). Only a ManifestError from the strict schema becomes INVALID_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 no EveryAppError code (loadManifest in index.ts, loadEveryAppManifestFile in packages/cli/src/lib/manifest.ts). It is loaded once: a later edit needs a restart (see Pitfalls).
  3. Worker config. cloudflare.config.ts is read textually (packages/cli/src/lib/app/cloudflareConfig.ts: whether it contains everyAppWorker and whether /\bcrons\s*:/ matches). With neither a manifest dev command nor a cloudflare.config.ts: MISSING_CLOUDFLARE_CONFIG (fix: copy cloudflare.config.ts from starter/).
  4. Port check. The gateway port is probed before anything starts: EADDRINUSE is DEV_PORT_IN_USE (Port 8787 is already in use, with data.free the next free port above it and a rerun pnpm everyapp dev … --port <free>); any other listen error is DEV_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.
  5. cf binary (resolveCfBinary in packages/cli/src/lib/cf/bin.ts). The app's own cf (its node_modules) is used when it has one, because it is pinned next to the app's Vite plugin, so cf dev and cf d1 … --local run the same Miniflare against .cloudflare/state. With no cf in the app, the CLI's own pinned cf runs (dependencies.cf in packages/cli/package.json, 1.0.0-beta.14, resolved from the installed everyapp package; a global cf is never used). An app cf whose version differs from the pin warns CF_VERSION_DIFFERS: Using the app's cf <version>; everyapp is tested with <pinned>, and runs.
  6. Mirror probe (mirror mode only): a HEAD to the --gateway URL, up to 5000 ms, else MIRROR_GATEWAY_UNREACHABLE (No local Gateway at <url>, fix: run pnpm dev in apps/every-app-gateway, or pass --gateway <url>). Step dev.gateway.
  7. Worker types (step dev.types): when cloudflare.config.ts exists and .cloudflare/types/index.d.ts does not, cf workers types writes it. Existing types are left alone; the template's types:check script regenerates them after config changes. Source: packages/cli/src/lib/app/workerTypes.ts.
  8. Local migrations (step dev.migrations, packages/cli/src/commands/dev/migrations.ts): skipped silently with reason no_d1 (no resources.d1) or no_migrations_dir (the migrations directory is missing), with reason flag under --skip-migrations; otherwise cf 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 is MIGRATIONS_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/state directory with no .cloudflare/state yet warns LEGACY_LOCAL_STATE (Local data now lives in .cloudflare/state; old rows were not copied).
  9. App dev server (step dev.app, packages/cli/src/commands/dev/appServer.ts): a free loopback port is picked, then either the manifest's dev command runs as a shell command line with PORT=<port> in its environment, or cf dev runs with PORT=<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 dev forwards no port flag to Vite: the template's vite.config.ts reads Number(process.env.PORT ?? 3001) with strictPort: true and host: "127.0.0.1".
  10. 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 manifest dev command, make it listen on PORT).
  11. Gateway-lite listens on --host:--port, prints the ready block, then releases the app's output. Step dev.ready carries { mode, appUrl, mcpUrl, host, port, appPort }. Stub mode on a non-loopback address warns DEV_EXPOSED (anyone who can reach port <port> gets in as the dev user).
  12. 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 (sayOnInterrupt in index.ts): Stopping the local migrations while 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 step dev.stop (packages/cli/src/lib/output/events.ts) prints <id> stopped, or Stopped before <id> started. when it never became ready, and the process exits 130; SIGTERM exits 143. A second Ctrl+C exits at once. That is the way to stop it: Ctrl+C, or a SIGINT/SIGTERM to the everyapp process. The app's dev server is spawned detached, leading its own process group, and signalChild (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 on vite or workerd hits every Vite and workerd process on the machine, other projects' included (packages/cli/skills/everyapp/SKILL.md). Through the template's pnpm dev, pnpm then reports the 130 as an ELIFECYCLE failure (Command failed with exit code 130): pnpm relaying the exit code, not a dev failure. While dev runs, pnpm install (the root postinstall is pnpm -C packages/sdk build) and any pnpm everyapp command, a deploy included, whose launcher finds packages/sdk/src newer than packages/sdk/dist (packages/cli/bin/everyapp.js) rewrite packages/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):

  1. EVERYAPP_DEV in the Worker's env is 1 or true (case-insensitive). everyAppWorker() sets it in a development build; a deploy never does.
  2. The issuer is https://gateway.dev.localhost. The dev opt-in with any other issuer is a ConfigurationError: 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.
  3. The audience: EVERYAPP_AUDIENCE when 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/tools and /__everyapp/tools/* answers 404 {"error":"not_found"} from outside under any spelling (dot segments, encoded separators, case: isReservedToolsPath in packages/perimeter/src/publicRoutes.ts), because only the ToolBus calls it; so does /.well-known/oauth-protected-resource on an app host. The rest of /__everyapp/* (EVERYAPP_INTERNAL_PREFIX in packages/perimeter/src/manifest/manifest.ts) can never be public (matchPublicRoute refuses it before any public glob, internal namespace is never public) but is otherwise a private route: 401 without a session, proxied to the app with one. In stub mode every request has the dev session, so GET http://<id>.localhost:8787/__everyapp/ping reaches the app as the dev user; the exception is POST /__everyapp/cron, which the gateway-lite handles itself before the perimeter.
  • The server-function guard (packages/perimeter/src/serverFunctions.ts): unless the manifest sets serverFnGuard: false, a request under /_serverFn (or the manifest's serverFnBase) must carry x-tsr-serverFn: true or x-tsr-redirect: manual, on every method, before any auth; otherwise 403 {"error":"server_fn_denied"}.
  • The cookie-tossing check runs (hasDuplicateSessionCookie with deps.authenticator.sessionCookieNames) but is a no-op locally: both dev authenticators declare sessionCookieNames: [] (stub: No session cookie to toss; mirror: the Cookie header goes to the Gateway unchanged), so the gateway-lite never answers reason: "duplicate_session_cookie". In mirror mode a duplicated session cookie is refused by the Gateway before /api/dev/identity runs (refuseDuplicateSessionCookie in apps/every-app-gateway/src/server.ts answers 401 {"error":"duplicate_session_cookie"} on every control-plane request), which the gateway-lite sees as no session (if (!res.ok) return null in remoteAuth.ts): 401 unauthenticated, with no MIRROR_SESSION hint since no reason came back.
  • CSRF (packages/perimeter/src/csrf.ts): GET, HEAD and OPTIONS pass; other methods need Sec-Fetch-Site: same-origin or an Origin whose host is the app's. A failing request that carries a cookie and no bearer on a private route is 403 {"error":"csrf_denied"}; one with no cookie is treated as anonymous instead, so a private route answers 401 {"error":"unauthenticated"} with a WWW-Authenticate challenge. (Production also refuses a cookie-authenticated WebSocket handshake from another Origin here, evaluateWebSocketOrigin; the gateway-lite's upgrade hop does not, see below.)
  • Public routes (public in 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, cookie and authorization is stripped (prepareOutboundHeaders in packages/perimeter/src/headers.ts); authorization is restored only on a public route, and only when the Gateway did not consume it (an epat_ or Gateway OAuth token is never forwarded).
  • An MCP-only credential answers at /mcp and nowhere else (403 token_scope); /mcp goes to the local ToolBus whatever the manifest says (createDevGatewayHandler fills in mcp: { by: "gateway" } for an app without the field, since dev is local only), unless the manifest sets mcp: { by: "app" }, which proxies it to the app's own MCP server. Deployed, an app host serves /mcp only with the field (Manifest).
  • Security headers (see headers.ts) are stamped on every response: HSTS, nosniff, X-Frame-Options: DENY, X-Robots-Tag: noindex, nofollow and Referrer-Policy: strict-origin-when-cross-origin (the deployed Gateway's own pages send no-referrer); the CSP floor is on HTML responses only. A crashing app is 502 {"error":"app_error"}, an unreachable one 503 {"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:

  1. 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 throws secrets: <NAME> is a name the deploy sets or a binding it builds.

  2. The keys of the manifest's providers and capabilities, under the Gateway's own names (DEV_GATEWAY_KEYS in worker.ts, a copy of the tables in packages/sdk/src/internal/):

    Manifest entry Names read from .dev.vars
    providers: ["openai"] OPENAI_API_KEY
    providers: ["openrouter"] OPENROUTER_API_KEY
    providers: ["workers-ai"] WORKERS_AI_API_TOKEN, CLOUDFLARE_ACCOUNT_ID
    capabilities.dataforseo DATAFORSEO_API_KEY
    capabilities.context CONTEXT_API_KEY
    capabilities.email EMAIL_FROM, EMAIL_FROM_NAME, EMAIL_REST_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, RESEND_API_KEY, EMAIL_PROVIDER
    capabilities.members Nothing: listMembers() answers from the dev identity
    OAuth clients (google-workspace, microsoft-365, slack; the social networks linkedin, facebook, instagram, threads, youtube, tiktok, tiktok-business, reddit, pinterest, discord, mastodon, …) <PROVIDER>_CLIENT_ID and <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_ID for Meta's; MASTODON_URL besides for Mastodon)
  3. EVERYAPP_EMAIL_SENDERS when the manifest lists email.

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): with EVERYAPP_DEV and the provider's key in env it calls the provider directly (https://api.openai.com, https://openrouter.ai, or Workers AI's REST path under CLOUDFLARE_ACCOUNT_ID), sets Authorization: Bearer <key> itself and applies the same response normalization the Gateway does. With no binding and no key: ConfigurationError AI 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): with EVERYAPP_DEV, the developer's own key (DATAFORSEO_API_KEY, …) or OAuth client (<PROVIDER>_CLIENT_ID/_SECRET) from .dev.vars reaches the provider directly; integrationStatus() reports configured and ready as whether those values are set. No binding and no EVERYAPP_DEV: a ConfigurationError. For an OAuth client with scope sets (google-workspace, microsoft-365, slack) dev holds the app to the manifest's scopes as the Gateway does, read from EVERYAPP_DEV_CAPABILITIES: the client must be listed with access: "write" (capability_not_requested) and usable scopes (integration_scopes_undeclared); a token request asking beyond them is integration_scope_not_allowed, a token granted beyond them integration_scope_exceeded, one that names no scopes integration_scope_unverified. integrationStatus().scopeSets and authorizeUrl() build the right request.
  • sendEmail() (sendEmail.ts): no binding and no EVERYAPP_DEV is ConfigurationError Email is unavailable: deploy with the EVERY_APP_GATEWAY binding (everyapp deploy), or run under everyapp dev. Under everyapp dev the message is checked as the Gateway checks it (email_invalid_message), a from must be one of the senders EVERYAPP_EMAIL_SENDERS lists (a comma list or JSON array; unset, every from is refused with 403 email_sender_not_allowed), then: with EMAIL_REST_API_TOKEN, CLOUDFLARE_ACCOUNT_ID and EMAIL_FROM all 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 no EVERYAPP_DEV is ConfigurationError Members are unavailable: …. Under everyapp dev there is no directory: a call for a request answers that request's user alone (name: "", role owner/admin or member), 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):

  1. In the app's vite.config.ts, change the Cloudflare plugin's remoteBindings: false (the starter's default: local dev never touches remote resources) to remoteBindings: true, and restart everyapp dev.
  2. Run cf auth login once (the plugin reaches the account with cf's login).
  3. Make the dev index once, as the deploy would but under the dev Worker's name: cf vectorize create --body @index.json with { "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 dev makes 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 of sha256("<worker>:<binding>"), because cf d1 … --local addresses a database by UUID or 32-hex id, never by name. everyAppWorker() gives the binding this id and the CLI computes the same one for cf d1 commands.
  • Files. .cloudflare/state under 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/cloudflare computes it. The v3/d1 part is Miniflare's layout: its getPersistPath puts 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.dir in everyapp.config.ts (default drizzle/) for the binding migrations.binding names (default: the first resources.d1). everyapp dev applies pending ones at every start; pnpm everyapp db migrate applies them without a restart, printing Applied <names>. or The local database is up to date., or MIGRATIONS_FAILED naming the failed file. Production migrations run only inside everyapp 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) runs cf d1 raw <id> --local --persist-to .cloudflare/state --sql … (step db.query) and prints a table, or NDJSON rows with --json; --verbose shows every step. Without --local the same command targets production and confirms writes (--yes skips the question).
  • db migrate output. Flags --json and --verbose. Step db.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.reason no_d1) or No migrations in <dir>/ yet, so there's nothing to migrate. (no_migrations_dir). It warns LEGACY_LOCAL_STATE the same way everyapp dev does. Both cap cf d1 migrations apply at MIGRATIONS_TIMEOUT_MS = 600_000 (10 minutes, localD1.ts).
  • Dev registry. Every cf process everyapp starts (dev, migrations, queries) gets CLOUDFLARE_REGISTRY_PATH and MINIFLARE_REGISTRY_PATH set 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" or undefined: the dev env above, Worker name every-<id>, local resource ids.
  • "production": only with EVERYAPP_BUILD=1 in the process environment, which everyapp deploy sets 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 from packages/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's secrets (the only secret bindings a production build declares are the deploy's own GATEWAY_URL and EVERY_APP_ORG_ID, { type: "secret" }, sent through cf 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 POST that carries a bearer (a POST with no cookie, no Origin and no bearer is anonymous, so /mcp answers 401); the stub authenticator then answers the dev user for any value. The principal is { kind: "dev" } with write access (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:write or mcp:read is 403 {"error":"forbidden","reason":"token_scope"}.
  • Hosts and origins. localhostOnly is set: the Host must be localhost, *.localhost, 127.0.0.1 or [::1] (403 Forbidden: invalid Host header.), and a browser Origin must be the endpoint's own or any localhost-class origin; other origins are 403 Forbidden: origin not allowed.
  • Browser MCP clients work in stub mode 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 (204 with access-control-allow-origin: http://localhost:6274 for MCP Inspector). In mirror mode the remote authenticator resolves no session for it and the perimeter answers 401 before 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 to defineTools() shows on the next tools/list after the app reloaded; a tools/call whose catalog hash no longer matches drops the cache too. The caveat (TODO.md): the dev view has no refresh, so for 2026-07-28 clients (Claude Code) the server declares listChanged: false and 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 /mcp proxied to itself; the latter warns TOOLS_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, or MCP <tool> blocked: <reason> (destructive requires approval, …). The event's data carries tool, decision, reason, outcome and durationMs.

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 as Run: pnpm everyapp dev --port <free>. Each worktree needs its own --port or $PORT (portless assigns one).
  • Mirror mode without the Gateway. MIRROR_GATEWAY_UNREACHABLE after 5 s; start pnpm dev in apps/every-app-gateway first or pass --gateway. A one-label --base-host is MIRROR_BASE_HOST before anything starts.
  • Editing everyapp.config.ts needs a restart. The gateway-lite loads the manifest once at start: public routes, serverFnGuard/serverFnBase, mcp, email.inbound, resources, migrations and the dev command are frozen until everyapp dev runs again. The tools catalog is the exception (re-read every 2 s from the running app). Crons are detected textually from cloudflare.config.ts at start, too.
  • .dev.vars names. A value under a name that is not in the manifest's secrets, not one of the Gateway key names for its providers/capabilities, and not EVERYAPP_EMAIL_SENDERS is not declared and the Worker does not see it. EVERYAPP_* and EVERY_APP_* names cannot be app secrets. When .dev.vars exists, .env files are ignored entirely.
  • No --json. everyapp dev always prints for a person; run it in the background and read the App line.
  • POST from a script answers 401. Add Origin: http://<app-id>.localhost:8787 (or a bearer, for /mcp); a cookie without Origin is 403 csrf_denied. Same as production.
  • *.localhost resolves to ::1 first on macOS while the gateway-lite listens on 127.0.0.1; curl falls back, a client that takes the first address fails with ECONNREFUSED ::1. Pin with --resolve or --connect-to.
  • Stopping. Ctrl+C, or a SIGINT/SIGTERM to the everyapp process, stops the whole process group (exit 130/143). pkill -f <path> can leave the pnpm vite dev and workerd children running, since their command lines do not name the path (TODO.md); a pattern kill on vite or workerd stops other projects' processes too. Under the template's pnpm dev, pnpm follows a clean Ctrl+C with its ELIFECYCLE line for exit code 130: nothing failed.
  • The SDK rebuilt under a running dev. pnpm install (root postinstall: pnpm -C packages/sdk build) or any pnpm everyapp command, a deploy included, whose launcher finds the SDK's source newer than its dist/ (packages/cli/bin/everyapp.js) rewrites packages/sdk/dist, which the app loads; dev breaks for a few seconds until the build is done.
  • cf dev alone answers 401 on every SDK-protected route: nothing mints the identity; run the app with pnpm dev.
  • Secret "<name>" not found. A Secrets Store binding's get() under everyapp 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's remoteBindings: false; set it to true in vite.config.ts and run cf 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 Env types after adding a resource or secret: everyapp dev regenerates .cloudflare/types only when the file is missing; run pnpm types:check. A secret that is not set locally at generation time is absent from Env altogether: declare it optional in your own .d.ts (see Typing app secrets).
  • A different cf in the app than the CLI pins warns CF_VERSION_DIFFERS (Using the app's cf <version>; everyapp is tested with <pinned>) but runs; with no cf in the app the CLI's own runs.
  • MCP Inspector in mirror mode gets 401 on its preflight; use stub mode 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.ts
  • packages/cli/src/commands/dev/index.ts
  • packages/cli/src/commands/dev/mode.ts
  • packages/cli/src/commands/dev/gatewayServer.ts
  • packages/cli/src/commands/dev/appServer.ts
  • packages/cli/src/commands/dev/proxy.ts
  • packages/cli/src/commands/dev/ready.ts
  • packages/cli/src/commands/dev/registerDevApp.ts
  • packages/cli/src/commands/dev/remoteAuth.ts
  • packages/cli/src/commands/dev/migrations.ts
  • packages/cli/src/commands/dev/launcherPage.ts
  • packages/cli/src/commands/dev/rerun.ts
  • packages/cli/src/commands/dev/devAppGateway.ts
  • packages/cli/src/commands/dev/index.test.ts
  • packages/cli/src/commands/dev/mode.test.ts
  • packages/cli/src/commands/dev/migrations.test.ts
  • packages/cli/src/commands/dev/appServer.test.ts
  • packages/cli/src/commands/dev/devAppGateway.test.ts
  • packages/cli/src/commands/dev/remoteAuth.test.ts
  • packages/cli/src/commands/dev/rerun.test.ts
  • packages/cli/src/commands/dev/proxy.test.ts
  • packages/cli/src/commands/localMigrations.ts
  • packages/cli/src/commands/db/command.ts
  • packages/cli/src/commands/db/index.ts
  • packages/cli/src/commands/db/queryOutput.ts
  • packages/cli/src/lib/app/localD1.ts
  • packages/cli/src/lib/app/workerTypes.ts
  • packages/cli/src/lib/app/cloudflareConfig.ts
  • packages/cli/src/lib/interrupt.ts
  • packages/cli/src/lib/output/events.ts
  • packages/cli/src/lib/output/command.ts
  • packages/cli/src/lib/longRunning.ts
  • packages/cli/src/lib/manifest.ts
  • packages/cli/src/lib/cf/bin.ts
  • packages/cli/src/commands/deploy/deployApp.ts
  • packages/cli/bin/everyapp.js
  • packages/cli/package.json
  • package.json
  • packages/cli/skills/everyapp/SKILL.md
  • packages/perimeter/src/dev/devGateway.ts
  • packages/perimeter/src/gateway.ts
  • packages/perimeter/src/gateway.test.ts
  • packages/perimeter/src/publicRoutes.ts
  • packages/perimeter/src/csrf.ts
  • packages/perimeter/src/cookies.ts
  • packages/perimeter/src/serverFunctions.ts
  • packages/perimeter/src/headers.ts
  • packages/perimeter/src/host.ts
  • packages/perimeter/src/manifest/manifest.ts
  • packages/sdk/src/dev/index.ts
  • packages/sdk/src/dev/keypair.ts
  • packages/sdk/src/dev/index.test.ts
  • packages/sdk/src/internal/protocol.ts
  • packages/sdk/src/internal/identity.ts
  • packages/sdk/src/internal/aiProviders.ts
  • packages/sdk/src/internal/appSettings.ts
  • packages/sdk/src/internal/appSettings.test.ts
  • packages/sdk/src/internal/gatewaySettings.ts
  • packages/sdk/src/internal/capabilities.ts
  • packages/sdk/src/internal/tools.ts
  • packages/sdk/src/cloudflare/worker.ts
  • packages/sdk/src/cloudflare/sentinel.ts
  • packages/sdk/src/server/everyApp.ts
  • packages/sdk/src/server/gatewayFetch.ts
  • packages/sdk/src/server/integrationFetch.ts
  • packages/sdk/src/server/sendEmail.ts
  • packages/sdk/src/server/listMembers.ts
  • packages/toolbus/src/dev/index.ts
  • packages/toolbus/src/dev/index.test.ts
  • packages/toolbus/src/http.ts
  • packages/toolbus/src/principal.ts
  • apps/every-app-gateway/src/routes/api/dev/identity.ts
  • apps/every-app-gateway/src/server.ts
  • apps/every-app-gateway/src/server/toolbus/catalog-sync.ts
  • starter/package.json
  • starter/README.md
  • starter/AGENTS.md
  • starter/cloudflare.config.ts
  • starter/vite.config.ts
  • starter/everyapp.config.ts
  • starter/drizzle.config.ts
  • starter/tsconfig.json
  • starter/.gitignore
  • starter/src/entry.worker.ts
  • docs/TESTING.md
  • TODO.md

On this page