Identity, headers and the SDK
What an app Worker receives from the Gateway on every request (headers, the identity token and its claims, env and bindings), what @every-app/sdk/server exports, and how the app calls the Gateway back for AI, capabilities, email and members.
Everything on this page is what app code behind the Gateway sees and can rely on. The user-facing summary is How it works; the trust model in full is the security model.
The request path
A request to an app host travels: browser or API client, then the Gateway Worker (the only public Worker), then the perimeter (handleGatewayRequest in packages/perimeter/src/gateway.ts), then the app Worker through getAppFetcher (packages/perimeter/src/getAppFetcher.ts), then everyApp() in the app (packages/sdk/src/server/everyApp.ts), then the app's own handler.
getAppFetcher is the one seam. An app on the service_binding tier (every self-hosted app, and a hosted Gateway's first-party apps) is reached over the Gateway's service binding named APP__<worker_name>. An app on the dispatch tier (third-party apps on a hosted Gateway) is reached through the Gateway's DISPATCHER Workers for Platforms binding, with per-invocation limits (5,000 ms CPU, 200 subrequests by default) and the app's egress allowlist handed to the Gateway's outbound Worker. Neither tier falls back to the other; an unreachable app is 503 app_unreachable.
The perimeter does the following, in this order, before the app sees anything:
- Resolves the app from the
Hostheader. No app:404 no_app_for_host. Disabled, or a registry row with a tier or scope the Gateway does not know:503 app_unavailable(two different bodies, below). Not yet active (pending, orbindinguntil the first request reaches the Worker and activates it):503 app_deployingwithretry-after: 5. - Closes an app that needs setup (
deps.needsSetup,apps/every-app-gateway/src/server/app-needs-setup.ts): a required provider, capability or secret unset, and no path opens, public routes included. A private HTML GET navigation is a302to${GATEWAY_URL}/?setup=<appId>, the launcher's setup dialog; everything else is503 { "error": "app_needs_setup", "app" }, uncacheable, never naming a setting. - Refuses the internal tools contract from outside: any spelling of
/__everyapp/tools/*(dot segments, encoded separators, double encoding, case) is404 not_found./.well-known/oauth-protected-resourceand anything under it are404 not_foundtoo (an app host is no OAuth resource). - Guards TanStack Start server functions: a request to
/_serverFn/*(or the manifest'sserverFnBase) must be a same-originfetch()from TanStack's client, on every method, before any authentication. Navigations, anySec-Fetch-Siteother thansame-origin, and requests withoutx-tsr-serverFn: trueorx-tsr-redirect: manualare403 server_fn_denied. The manifest'sserverFnGuard: falseturns this off. - Refuses a request naming the session cookie more than once (cookie tossing):
401 unauthenticatedwithreason: "duplicate_session_cookie". - Evaluates CSRF (
packages/perimeter/src/csrf.ts):GET,HEADandOPTIONSalways pass; otherwiseSec-Fetch-Site: same-originpasses, any otherSec-Fetch-Sitefails, and without it theOriginhost must equal the app's host. A cookie-carrying WebSocket upgrade must carry anOriginon the app's host. A failing check on a private route with a cookie and no bearer is403 csrf_denied; on a public route it only downgrades the request to anonymous (below). - Authenticates: the Better Auth session cookie (or, on a third-party host, the per-host
__Host-everyapp_scookie), or a personal access tokenAuthorization: Bearer epat_…. A reserved bearer (anepat_token or a token shaped like the Gateway's OAuth server's) that does not authenticate fails closed with401 unauthenticated, never anonymous access. OAuth access tokens are for the Gateway's own/mcponly: an app host accepts none. AnyAuthorization: Bearer, whatever its shape, is never cookie-authenticated: once a bearer is present the authenticator (createProdAuthenticatorinapps/every-app-gateway/src/perimeter/betterAuthAuthenticator.ts) ignores the session cookie and returns no session unless the bearer is a validepat_token, and the request is exempt from the CSRF check in step 6. A personal access token whose scopes include aprovider:scope or the deploy scope (deploy,DEPLOY_SCOPE) opens no app: the authenticator returns no session and the request is401 unauthenticated. - Checks app access (organization membership, the app installed for the person, the organization active). A credential whose scopes are all
mcp:scopes reaches/mcpalone, and only where the host's manifest hasmcpand MCP is served there (not at a/mcpa public route covers on aby: "gateway"host); elsewhere it is403 forbiddenwithreason: "token_scope". No access is403 forbiddenwithreason: "no_app_access". A personal access token acts only in the organization it was created in: on another organization's app it is403 forbiddenwithreason: "no_app_access"(hasAppAccess:if (session.credential) return false), where the same person's browser session is let in through their membership there. A token bound to one app (credentialAppRowId) opens that app alone, on a shared app too (boundToAnotherApp). - Serves
/mcpfrom the Gateway's ToolBus when the manifest saysmcp: { by: "gateway" }(503mcp_unavailablewhen there is no catalog to serve); proxies it to the app's own MCP server with the identity when it saysby: "app"; without the field,/mcpis a private route like any other, and a credential whose scopes are allmcp:scopes opens nothing on that host (see Agent tools). - Strips the inbound trust headers (
stripInboundHeadersinpackages/perimeter/src/headers.ts):cookie,authorization,cloudflare-workers-version-overrides, every header startingx-everyapp-, and every header startingcf-access-. On a public route the inboundauthorizationis put back unless the Gateway consumed it (a personal access token or an OAuth-shaped token is never forwarded). - Mints a fresh identity JWT and sets
x-everyapp-identity(an authenticated caller with access), or sets the signed public marker inx-everyapp-public(an anonymous or non-member caller on a public route), or answers401 unauthenticated(a302to the login page for an HTML navigation; on a third-party host, the session handoff) or403 forbidden. - Builds the outbound
Requestwith the same URL, method and body,redirect: "manual", and the visitor'scfobject (country, ASN and the rest) so the app sees what Cloudflare saw, and calls the fetcher. An app that throws is502 app_error. - Stamps security headers on the way back (
withSecurityHeaders):Strict-Transport-Security: max-age=63072000; includeSubDomains; preload,X-Content-Type-Options: nosniff,X-Frame-Options: DENY,X-Robots-Tag: noindex, nofollow,Referrer-Policy: strict-origin-when-cross-origin;Domain=is removed from everySet-Cookie; HTML responses get the CSP floorframe-ancestors 'none'; base-uri 'self'; object-src 'none'merged over the app's own CSP (an app may keepframe-ancestors 'self', which turnsX-Frame-OptionsintoSAMEORIGIN, andbase-uri 'none'). A101WebSocket response is passed through untouched. On a third-party host the response also losesAccess-Control-Allow-Credentialsand anySet-Cookienamed__Host-everyapp_*.
So an app never receives the session cookie, the personal access token, an Access assertion, or any x-everyapp-* header a client typed. The one credential it receives is the token the Gateway minted for this request, and that token lives 120 seconds (IDENTITY_TTL_SECONDS in packages/sdk/src/internal/protocol.ts).
Responses the Gateway sends without calling the app are JSON with an error field:
| Status | Body | When |
|---|---|---|
| 400 | { "error": "missing_host" } |
no Host |
| 404 | { "error": "no_app_for_host", "host" } |
the host maps to no app |
| 404 | { "error": "not_found" } |
/__everyapp/tools/*, /.well-known/oauth-protected-resource |
| 503 | { "error": "app_unavailable", "status": "disabled" } |
the app is disabled |
| 503 | { "error": "app_unavailable", "app" } |
the registry row has a tier or scope the Gateway does not know (unroutableAppResponse; no status field) |
| 503 | { "error": "app_deploying", "status" } |
pending or binding, with retry-after: 5 |
| 302 | Location: <GATEWAY_URL>/?setup=<appId> |
the app needs setup, a private HTML GET navigation |
| 503 | { "error": "app_needs_setup", "app" } |
the app needs setup, anything else (public routes included) |
| 403 | { "error": "server_fn_denied", "reason" } |
the server-function guard |
| 401 | { "error": "unauthenticated", "reason": "duplicate_session_cookie", "message" } |
the session cookie twice |
| 403 | { "error": "csrf_denied", "reason" } |
a cookie-carrying state change from another origin, private route |
| 401 | { "error": "unauthenticated" } with WWW-Authenticate: Bearer realm="every-app" (plus error="invalid_token" and a pointer to the Gateway's /mcp when a bearer was presented) |
no session on a private route (API clients) |
| 403 | { "error": "forbidden", "reason": "token_scope" } |
an MCP-only token off /mcp, anywhere on a host whose manifest has no mcp, or at a /mcp a public route covers on a by: "gateway" host |
| 403 | { "error": "forbidden", "reason": "no_app_access" } |
signed in, no access to this app |
| 503 | { "error": "mcp_unavailable", "app" } |
/mcp on a host whose manifest says mcp: { by: "gateway" } with no catalog to serve (no defineTools(), or none readable yet: a POST syncs it); never the app (packages/perimeter/src/gateway.ts) |
| 503 | { "error": "app_unreachable", "app" } |
no service binding, no dispatcher, or the Worker is not in the namespace |
| 502 | { "error": "app_error", "app" } |
the app threw |
What a request costs
Measured on everyapp.team on 2026-10-24 from Workers Logs and the perimeter's own proxy lines (the gateway Worker, its apps, one person in SJC, D1 in WNAM). Cloudflare Access costs nothing per navigation: the browser's CF_Authorization cookie for an app host lives as long as the Access application's session (720 hours there), and the edge check is 3 to 22 ms. The gateway Worker adds 35 to 70 ms to a request when its isolate is warm (the session and the user read in turn, the user_app_access row, the identity token) and 170 to 370 ms, with 60 to 140 ms of CPU, on a fresh isolate (its lazy initialisation, the three cache-miss reads, the Access JWKS fetch). An app's own cold start is the largest single number: 400 to 1200 ms for a large app Worker on a trivial request, a few milliseconds warm. A page is a document, several script chunks and the app's own fetches, each one of these requests.
| Hop, one request to an app host | Measured |
|---|---|
| TLS to a new app host | 25 to 45 ms |
| Cloudflare Access, outer ring | 3 to 22 ms at the edge; a login once per host per Access session |
| Gateway Worker, warm isolate | 35 to 70 ms on top of the app |
| Gateway Worker, fresh isolate | 170 to 370 ms on top of the app |
| App Worker, warm | a few ms to ~80 ms for a page or a fetch |
| App Worker, cold | 400 to 1200 ms (CRM), before its own work |
What the Gateway does about it, since 2026-10-24:
- Every proxied response carries
Server-Timing: gw;dur=<ms>, app;dur=<ms>: the perimeter's own time and the app's (the fetch over the binding), readable in a browser's network panel. The proxy log line in Workers Logs carries the same two asgatewayMsandms. - A fingerprinted asset (
/assets/<name>-<8 character hash>.<ext>, as Vite names every built file) that the app left to the Workers asset server's defaultCache-Control: public, max-age=0, must-revalidateis answered withprivate, max-age=31536000, immutable(packages/perimeter/src/assetCache.ts): the browser keeps it for a year instead of asking, authenticated, for a 304 on every page load. An app that sets its ownCache-Controlon an asset keeps it. - The two reads an app-access check needs (the organization's status and the
user_app_accessrow) are asked together; a verified Access assertion is kept per isolate for a minute at most and never past itsexp, and its verification starts beside the session's reads rather than after them. The decisions are unchanged; the request waits on D1 three times when the isolate's caches are warm and five when they have expired (apps/every-app-gateway/src/perimeter/appHostCost.sqlite.test.tspins both). - The launcher warms an app while the pointer rests on its card: Chrome prefetches the app's document (speculation rules,
eagerness: "moderate", a credentialed same-site GET of/, the one the click makes) and every browser opens the connection (preconnect). The app seesSec-Purpose: prefetchon such a request.
What an app owns: its Worker's cold start (module-scope imports), how many requests its first paint needs, and what each of them does. A client-only shell that then runs two server functions in a row pays the gateway three times before any content.
Headers
| Header | Who sets it | What it holds | How the SDK treats it |
|---|---|---|---|
x-everyapp-identity |
The perimeter, on every request from an authenticated caller with access (private routes, and public routes when a member is signed in). The ToolBus, on POST /__everyapp/tools/call. The SDK's applyAttribution, copying the request's token onto a call to the EVERY_APP_GATEWAY binding when for: request is passed. |
The identity JWT (below), 120 s. | Verified fail-closed by everyApp() before the handler runs; the result is EveryAppUser. The AppGateway entrypoint verifies a forwarded one against the calling Worker's name and the Gateway's JWT_PUBLIC_KEY. Stripped from what clients send; stripped again by the AI and capability proxies before a provider sees the request. |
x-everyapp-public |
The perimeter, on a declared public route when the caller is anonymous or is signed in without access to the app. | The public marker: a JWT with typ: "public", pub: true, sub: "public", the same aud, iss, kid and 120 s lifetime as an identity. Cached per audience and issuer for 60 s. |
Verified like an identity (verifyPublicMarkerJwt); on success the handler's user is null and getEveryAppUser() returns null. A bare value (1, anything unsigned) is a 401. Checked before x-everyapp-identity: a request carrying both is public. |
x-everyapp-system |
The ToolBus (packages/toolbus/src/catalog.ts), on POST /__everyapp/tools/list. |
A system token: typ: "system", sub: "system", purpose: "tools.list", jti, aud, iss, iat, exp at most 60 s after iat. |
Accepted only on /__everyapp/tools/list, only for its purpose; never an identity. The app's handler never sees the request. |
x-everyapp-org |
The SDK's applyAttribution, on a call to the EVERY_APP_GATEWAY binding with for: { background: true, organizationId }. |
The organization id background work is for. | Read by the AppGateway entrypoint (apps/every-app-gateway/src/server/app-gateway-entrypoint.ts). An organization's own app may only name its own (else 403 caller_org_mismatch). A shared app's call without an identity must name one (else 403 caller_identity_required), and it must be an active organization with a live member (else 403 caller_org_not_attributable). The perimeter strips it from what clients send. |
x-everyapp-error |
The AppGateway entrypoint, the AI proxy (ai-proxy.ts) and the capability proxy (integration-proxy.ts, capabilities/refusal.ts), on a refusal the Gateway makes itself. |
The refusal's code (integration_not_configured, ai_provider_not_configured, caller_identity_invalid, …), also in the JSON body's code. |
gatewayRefusal(response) reads it and returns { status, code, message }; absent, the response is the provider's own answer. sendEmail() and listMembers() throw GatewayRefusalError on it. |
The identity token
Minted by mintIdentityJwt (packages/sdk/src/internal/protocol.ts), verified by verifyIdentityJwt (packages/sdk/src/internal/identity.ts).
Header: alg is RS256; kid is everyapp-identity in production and everyapp-identity-dev for tokens everyapp dev mints.
| Claim | Value |
|---|---|
typ |
user. (public on a marker, system on a system token; each is verified only where it is expected.) |
sub |
The user id. |
email |
The user's email. |
org_id |
The organization the request acts under: for an organization's own app, the app's organization (the person's role there); for a shared app, the session's organization. |
org_role |
The person's role in org_id (owner, admin, member). |
chan |
The channel: web, api, mcp or agent (below). Absent is read as web. |
act |
{ "sub": "<actor>" }: the principal acting for the user. Absent is read as { sub }. |
scopes |
A string array. ["*"] when the credential carries no scopes (a browser session, a personal access token created without scopes). |
jti |
A UUID per token. |
tc |
Only on an MCP tool call: { "id": "<call id>", "name": "<tool>" }, the one call the token was minted for. A malformed tc is a 401. |
aud |
The audience: the app's Worker name (every-<orgKey>-<appId>), the value in EVERYAPP_AUDIENCE. everyapp dev mints for the app id instead. |
iss |
The Gateway's URL (GATEWAY_URL on the Gateway, EVERYAPP_IDENTITY_ISSUER on the app). everyapp dev mints with https://gateway.dev.localhost. |
iat |
Seconds since the epoch. |
exp |
iat + 120 (IDENTITY_TTL_SECONDS). Required. |
Keys arrive in EVERYAPP_IDENTITY_PUBLIC_KEYS: a JSON array of SPKI PEM strings (a single PEM string is also accepted), the current key and the next, so a Gateway can rotate without a window where apps reject it. \n escape sequences are unescaped. Nothing is fetched at runtime: there is no JWKS endpoint.
Verification is fail-closed (verifyPinnedJwt), in this order:
- With the dev opt-in on, the issuer must be
https://gateway.dev.localhost; anything else is aConfigurationError(500), whatever the env says, because the dev keypair is public (packages/sdk/src/dev/keypair.ts). - An empty header is
no identity token; an undecodable onemalformed identity token. - The protected header is pinned before any signature work:
algmust beRS256(sononeand HS256 confusion fail here), andkidmust beeveryapp-identity, or alsoeveryapp-identity-devunder the dev opt-in. - A user or system token needs a configured issuer (
EVERYAPP_IDENTITY_ISSUERor theissueroption); without one the error isEVERYAPP_IDENTITY_ISSUER not configured — deploy with the EveryApp CLI or provide an explicit issuer. - Each key in the set is tried with
jwtVerify(algorithms: ["RS256"], the expectedaudience, the issuer when configured,requiredClaims["exp"], or["exp", "iat"]for a system token). The payload'stypmust be the expected one. - The claims are checked:
sub,email,org_idandorg_roleare required (identity token missing required claims).
Every failure is an IdentityError (status 401, name "IdentityError"), which everyApp() answers as 401 { "error": "unauthenticated", "message" }. A missing key set, an unset audience, a mismatched app id or the dev opt-in with the wrong issuer is a ConfigurationError (status 500), answered as 500 { "error": "misconfigured", "message" } and logged with console.error.
The audience is resolved by audienceFor in everyApp.ts: EVERYAPP_AUDIENCE when set; otherwise, only under the dev opt-in, the app id from EVERYAPP_APP_ID; otherwise the 500 EVERYAPP_AUDIENCE is not set — the app's cloudflare.config.ts must declare it (everyAppWorker() does at deploy). The dev opt-in is EVERYAPP_DEV equal to 1 or true (any case), or the allowDevIdentities option. A token minted for one organization's copy of an app never verifies in another's: their Worker names differ.
The EveryAppUser object
What the handler's fourth argument, getEveryAppUser() and requireEveryAppUser() return (identityClaimsToEveryAppUser in protocol.ts):
| Field | From | Notes |
|---|---|---|
id |
sub |
The user id. Scope every database row by it. |
email |
email |
|
orgId |
org_id |
The organization the request acts under. |
orgRole |
org_role |
|
channel |
chan |
"web" when absent. |
actor |
act |
{ sub }: the user id, or the credential acting for them (pat:<id>, oauth:<id>, access:<email>). |
scopes |
scopes |
["*"] when absent or not an array; non-string entries are dropped. |
jti |
jti |
"" when absent. |
toolCall |
tc |
Only on channel: "mcp": { id, name }. |
Channels, actors and scopes
chan says which door the request came through; act.sub says what acted for the person; sub is always the person.
chan |
Who mints it | act.sub |
scopes |
|---|---|---|---|
web |
The perimeter, for a browser session (a session carries no credential, so channel defaults to web). The dev gateway's seeded user. The value read when chan is absent. |
The user id. | ["*"]. |
api |
The perimeter, for a personal access token (Authorization: Bearer epat_…) on an app host (apps/every-app-gateway/src/perimeter/betterAuthAuthenticator.ts, credential kind: "pat"). The token acts in its own organization only and, bound to an app, in that app only; one carrying a provider: scope or the deploy scope opens no app (401 unauthenticated). |
pat:<token id>. |
The token's scopes, or ["*"] for a token created without any. A token whose scopes are all mcp:* reaches /mcp only, on a host whose manifest has mcp. |
mcp |
The ToolBus (packages/toolbus/src/execute.ts), one token per tool call, sent to POST /__everyapp/tools/call. |
pat:<id>, oauth:<id> or access:<email> (an AI client Cloudflare Access authenticated; actorFor) for the credential behind the MCP connection, else the user id. |
Explicit, never *: ["mcp:read"] for a read-only connection, ["mcp:read", "mcp:write"] for read and write. mcp:destructive is never minted. |
agent |
Declared in IdentityChannel (protocol.ts); nothing in the repository mints it today. |
hasScope(user, scope) is user.scopes.includes("*") || user.scopes.includes(scope). The tools endpoint does not use it: a tool call needs the exact scope its risk requires (mcp:read for read, mcp:write for write, mcp:destructive for destructive), and * does not count there.
Apps do not check scopes on their own routes; the perimeter keeps an MCP-only token off them.
How a handler reads the user
everyApp() verifies the token before the handler runs, stores the result in a WeakMap keyed by the Request object, and passes user (an EveryAppUser, or null on a public route) as the handler's fourth argument. Deeper in the app, getEveryAppUser(request, env) returns the cached result for that same Request object (a different object, such as a clone, is verified again from the env). requireEveryAppUser(request, env) returns the user or throws the same 401 JSON Response that everyApp() would send (it throws a Response, not an Error), with the message this request does not have an authenticated user on a public route.
A Worker export, with the handler reading the user directly:
// src/entry.worker.ts
import { everyApp } from "@every-app/sdk/server";
import { tools } from "./tools";
export default everyApp<Env>(
async (request, env, ctx, user) => {
// user is an EveryAppUser on a private route, null on a public one.
if (!user) return new Response("not signed in", { status: 401 });
return Response.json({ id: user.id, email: user.email, org: user.orgId });
},
{ tools },
);A TanStack Start app hands the request to Start's server entry and reads the user in a server-function middleware (starter/src/entry.worker.ts, starter/src/middleware/ensureUser.ts):
// src/entry.worker.ts
import tanstackEntry from "@tanstack/react-start/server-entry";
import { everyApp } from "@every-app/sdk/server";
import { tools } from "./tools";
export default everyApp<Env>(async (request) => tanstackEntry.fetch(request), {
tools,
});// src/middleware/ensureUser.ts
import { createMiddleware } from "@tanstack/react-start";
import { getRequest } from "@tanstack/react-start/server";
import { requireEveryAppUser } from "@every-app/sdk/server";
import { env } from "cloudflare:workers";
export const ensureUserMiddleware = createMiddleware({
type: "function",
}).server(async ({ next }) => {
const user = await requireEveryAppUser(getRequest(), env);
return next({ context: { user } });
});everyApp() takes an options object: issuer (overrides EVERYAPP_IDENTITY_ISSUER), keysEnvVar (another env name than EVERYAPP_IDENTITY_PUBLIC_KEYS), publicKeys (keys passed directly, for tests), allowDevIdentities (overrides EVERYAPP_DEV), tools (a defineTools() result) and now (tests). A manifest object as the second argument is deprecated: when passed, its id must equal EVERYAPP_APP_ID, else a 500.
everyApp() returns { fetch } only (EveryAppExport). A Worker with crons or inbound mail spreads it beside the other handlers: export default { ...everyApp(handler, { tools }), scheduled, email } (packages/sdk/README.md). Those handlers run with no identity: there is no user, getEveryAppUser() has no request to read, and a Gateway call from them passes for: { background: true, organizationId } in a shared app or omits for in an organization's own app (Attribution).
Public routes at runtime
The manifest's public lists routes reachable without a session: [{ path, methods }], methods defaulting to ["GET"], path a glob where * is one segment, ** one or more, and /:name exactly one non-empty segment (packages/perimeter/src/publicRoutes.ts; the schema is in packages/perimeter/src/manifest/manifest.ts, see the manifest reference). Matching is default-deny: a path that does not canonicalize unambiguously (normalizePath: an empty or non-absolute path, control characters, a backslash, an encoded separator %2f or %5c, invalid or double percent-encoding, a decoded backslash or control byte, traversal above the root, or traversal spelled with encoded dots such as %2e%2e) is never public, and /__everyapp and everything under it is never public after normalization, whatever the globs say. Plain . and .. segments that stay inside the root are resolved, and the canonical path is matched normally.
Public means authentication is optional, not stripped:
- A signed-in member with access to the app still gets
x-everyapp-identityon a public route, after the usual session, access and CSRF checks. The handler'suseris set. - An anonymous caller, or a signed-in person without access, gets
x-everyapp-publicinstead. The handler'suserisnull,getEveryAppUser()returnsnull,requireEveryAppUser()throws the 401. - A failing CSRF check on a public route does not deny: the request is handled as anonymous, so a cross-site POST can never ride a member's cookie into the app.
- The inbound
Authorizationheader is forwarded on a public route (unless it held anepat_token or an OAuth-shaped token, which the Gateway consumes or refuses and never forwards), so a webhook or an API the app exposes can check its own credential. The Gateway does not interpret it. - The server-function guard still applies to
/_serverFn/*on public routes, since it runs before anything else.
The marker is signed because a bare header is never a trust source: a Worker that is somehow reached without the Gateway cannot be put into public mode by a client typing x-everyapp-public: 1. The perimeter mints one marker per audience and issuer and reuses it for 60 s (half its 120 s lifetime, PUBLIC_MARKER_REUSE_MS in gateway.ts).
A public webhook route. The manifest:
public: [{ path: "/webhooks/stripe", methods: ["POST"] }],The handler verifies the provider's own signature; user is null for the provider's call:
export default everyApp<Env>(async (request, env, ctx, user) => {
const { pathname } = new URL(request.url);
if (pathname === "/webhooks/stripe") {
const body = await request.text();
const signature = request.headers.get("stripe-signature");
// The app's own check, with its own secret (manifest `secrets`,
// set with `pnpm everyapp secret set STRIPE_WEBHOOK_SECRET`).
if (!verifyStripeSignature(body, signature, env.STRIPE_WEBHOOK_SECRET)) {
return new Response("bad signature", { status: 400 });
}
return new Response(null, { status: 204 });
}
if (!user) return new Response("not signed in", { status: 401 });
// ...
});System tokens
A system token (mintSystemJwt in protocol.ts) opens exactly one endpoint for one purpose and nothing else. The only purpose is tools.list. The token carries typ: "system", sub: "system", purpose, jti, aud (the Worker name), iss, iat and exp; exp - iat must be at most 60 s (SYSTEM_TOKEN_TTL_SECONDS), and the minter refuses a longer ttlSeconds. It arrives in x-everyapp-system on POST /__everyapp/tools/list, which everyApp() answers itself (packages/sdk/src/server/toolsEndpoint.ts) with the app's tool catalog, whether or not the app defines tools (an app without tools answers an empty catalog, so the Gateway can tell "no tools" from "no contract"). verifySystemJwt rejects a user identity or a public marker here, and verifyIdentityJwt rejects a system token everywhere else: a leaked system token is short-lived and useless.
The other reserved path, POST /__everyapp/tools/call, takes an identity in x-everyapp-identity with chan: "mcp" and a tc claim; the body's name and callId must equal tc.name and tc.id, and the token's scopes must include the scope the tool's risk requires. Both paths answer 405 with allow: POST to other methods, cache-control: no-store on every answer, and never reach the app's handler. The perimeter refuses them from outside (404 not_found), so only the Gateway's ToolBus, over the service binding, ever reaches them. The request bodies ({ v: 1 }; { v: 1, name, arguments, callId }), the success shapes ({ v: 1, sdk, hash, instructions?, tools, warnings? }; { v: 1, catalogHash, result }), the error envelope { v: 1, error: { code, message, issues? } } and its codes (ToolsErrorCodeV1 in packages/sdk/src/internal/tools.ts: unknown_tool 404, invalid_arguments 400, invalid_request 400, forbidden 403, unauthenticated 401, method_not_allowed 405, misconfigured 500) are documented with when each is answered in MCP.
Env and bindings
everyAppWorker() (packages/sdk/src/cloudflare/worker.ts) builds the app Worker's definition from everyapp.config.ts. In development mode (and with no mode) it declares the dev identity; in production mode it is tenant-neutral, with placeholders from sentinelDeployTarget that the install rewrites (packages/cli/src/lib/app/installBuildOutput.ts; for a browser install, the Gateway's own deployer, apps/every-app-gateway/src/server/deploy/metadata.ts). A production build runs only with the process environment variable EVERYAPP_BUILD set to 1, which everyapp deploy sets around the build; a bare cf build or cf deploy throws, since it would ship the placeholders. EVERYAPP_BUILD is a build-time variable, never a Worker binding.
| Name | Production | Development (everyapp dev) |
Set by |
|---|---|---|---|
EVERYAPP_APP_ID |
text: the manifest's id |
same | everyAppWorker() |
EVERYAPP_AUDIENCE |
text: the Worker's name (every-<orgKey>-<appId>), the aud every token for this app carries; a placeholder until the install rewrites it |
absent (the SDK falls back to the app id under EVERYAPP_DEV) |
everyAppWorker() + the install |
EVERYAPP_IDENTITY_PUBLIC_KEYS |
text: JSON array of the Gateway's SPKI PEM public keys (current and next) | text: JSON array holding the well-known dev public key | everyAppWorker() + the install |
EVERYAPP_IDENTITY_ISSUER |
text: the Gateway's URL | text: https://gateway.dev.localhost |
everyAppWorker() + the install |
EVERYAPP_DEV |
absent (a deploy never sets it) | text: 1; the SDK accepts the dev kid, the app id as audience, and uses local provider keys |
everyAppWorker() |
EVERY_APP_GATEWAY |
service binding: { type: "worker", worker: "every-app-gateway", exportName: "AppGateway", props: { organizationId, appId, workerName } } |
absent (the SDK's local fallbacks apply) | everyAppWorker() + the install |
GATEWAY_URL |
secret: the Gateway's URL, sent with the upload (cf deploy --secrets-file) |
absent | everyapp deploy |
EVERY_APP_ORG_ID |
secret: the owning organization's id, sent with the upload; absent on a shared app's Workers | absent | everyapp deploy |
EVERYAPP_SHARED |
text: 1 on a shared app's Workers only (SHARED_ENV in packages/sdk/src/internal/protocol.ts; installWorkerConfig swaps EVERY_APP_ORG_ID for it when its target is shared, which no command sets since PR #441 removed deploy --shared); the SDK then requires for on every Gateway call |
absent | nothing in this checkout (installWorkerConfig with target.shared) |
DATABASE_PROVIDER |
text: d1 or postgres, the manifest's database.provider; absent without database |
same | everyAppWorker() |
EVERYAPP_EMAIL_SENDERS |
absent: an installation's senders live on the Gateway's apps row (everyapp email set --sender) |
secret, when the manifest lists the email capability and it is set locally (the reading order below): the stand-in senders list |
everyAppWorker() |
CF_VERSION_METADATA |
version-metadata binding | same | everyAppWorker() |
| manifest resources | D1 (resources.d1), KV, R2, Durable Objects and Workflows bindings by their manifest names, with the names and ids the deploy provisioned; a service binding per helper Worker binding |
the same bindings with local ids (D1 ids from localD1Id, buckets named like deployed ones; a KV namespace gets the id <binding>-<app worker> only when the manifest declares helper Workers, so they share it in dev, else the Vite plugin's default) |
everyAppWorker() + the install |
resources.hyperdrive |
a hyperdrive binding per config, with the id the deploy provisioned; a config whose worker names a helper is bound on that helper alone, never on the app's Worker |
declared only when CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_<binding> is set locally (the reading order below); otherwise the binding is absent from env (addDatabaseBindings) |
everyAppWorker() + the install |
manifest secrets |
set with pnpm everyapp secret set <NAME> after the first deploy and kept across deploys; not declared in the build |
declared as a secret binding for each one set locally (the reading order below) | everyapp secret set / everyAppWorker() |
| Gateway keys in dev | absent: the Gateway holds OPENAI_API_KEY, DATAFORSEO_API_KEY, the OAuth clients, and the rest |
declared as secrets for the manifest's providers and capabilities when set locally (DEV_GATEWAY_KEYS) |
everyAppWorker() |
| static assets | an assets binding passes the install policy (ALLOWED_BINDINGS in packages/cli/src/lib/app/policy.ts); everyAppWorker() declares none, so an app that serves static files adds its own in cloudflare.config.ts beside everyAppWorker()'s env (the Postiz fixture packages/cli/src/test/fixtures/manifests/postiz.ts adds ASSETS: bindings.assets()) |
same | the app's cloudflare.config.ts |
"Set locally" means read as the Vite plugin reads them (localEnv in worker.ts, used for the Hyperdrive connection strings, the manifest secrets, the dev Gateway keys and EVERYAPP_EMAIL_SENDERS): .dev.vars.<mode> when it exists, else .dev.vars, and that file alone; with neither, .env, .env.local, .env.<mode> and .env.<mode>.local merged in that order under the process environment, which wins. <mode> is the build's mode, development when none is given.
GATEWAY_URL is also where an app's UI goes back to. The convention every app on a Gateway follows: the first row of the sidebar (or of the page header, when a page has no sidebar) is a muted "‹ EveryApp" link to GATEWAY_URL with the accessible name "Back to EveryApp", above the app's own name; the footer holds the app's own settings and help, nothing about the Gateway. Under everyapp dev the variable is absent (the app may fall back to EVERYAPP_IDENTITY_ISSUER or hide the row).
Names the deploy owns are refused as an app's own secret, by everyapp secret set and by the manifest's secrets, case-insensitively: GATEWAY_URL, CF_VERSION_METADATA and anything starting EVERYAPP_, EVERY_APP_ or APP__ (packages/sdk/src/internal/gatewaySettings.ts). See secrets.
Every app Worker gets the compatibility flag nodejs_compat, plus the compatibilityFlags option (each once), the manifest's compatibilityDate or 2026-06-01, workersDev: false, previewUrls: false, observability on (logs with invocation logs, traces sampled at 10%, Workers Issues, query strings redacted), placement: { region } from the manifest's database.region once deployed, limits.cpuMs from the manifest's limits, and a scheduled trigger per entry in the crons option (five-field UTC schedules; an app can declare no routes). A scheduled handler is exported beside everyApp()'s fetch and runs with no identity (see How a handler reads the user). Helper Workers (everyAppAuxiliaryWorker(), the manifest's workers, deployed as <app worker>--<name>) get EVERYAPP_APP_ID, the storage bindings (D1, KV, R2), DATABASE_PROVIDER when the manifest has database, the Hyperdrive configs whose worker names them plus those with none, bindings and exports for the Durable Object and Workflow classes they define (only those), the manifest secrets and dev Gateway keys as the app Worker gets them, EVERY_APP_GATEWAY with the app's own props, GATEWAY_URL and EVERY_APP_ORG_ID, and in dev EVERYAPP_DEV; no audience, keys or issuer, because the Gateway never calls a helper and a helper verifies no identity, and no CF_VERSION_METADATA. The install rewrites every built Worker config (installAppBuildOutput), so the shared branch of installWorkerConfig gives a helper EVERYAPP_SHARED=1 in place of EVERY_APP_ORG_ID too, and its Gateway calls must then say whom they are for, else AttributionError; no command in this checkout takes that branch (see Shared and organization-scoped apps).
The server API
@every-app/sdk/server (packages/sdk/src/server/index.ts, SDK 0.3.0) has no client-side export. Protocol constants (header names, the tools paths, the minters) stay in @every-app/sdk/internal, which is for the platform, not apps.
| Export | Kind | What it does |
|---|---|---|
everyApp |
function | everyApp(handler, options?): wraps a Worker fetch handler (request, env, ctx, user); verifies the identity or public marker, answers the /__everyapp/tools/* contract, returns { fetch } as the Worker's default export. |
getEveryAppUser |
function | (request, env) => Promise<EveryAppUser | null>: the verified user, from the per-request cache or by verifying the headers; null on a public route. Throws IdentityError or ConfigurationError. |
requireEveryAppUser |
function | (request, env) => Promise<EveryAppUser>: the user, or throws the 401 JSON Response. |
hasScope |
function | (user, scope) => boolean: * or the exact scope. |
createGatewayFetch |
function | ({ env, for? }) => fetch: a fetch for the Gateway's AI providers at /api/ai/<provider>/…, over EVERY_APP_GATEWAY; in dev, the developer's own provider key. |
ai |
function | ({ env, for? }) => AiClient: the Gateway's default chat model as a client: baseURL (AI_DEFAULT_BASE_URL, http://every-app-gateway.internal/api/ai/default/v1), fetch (the same as createGatewayFetch), model (default, which the Gateway replaces with the operator's model), chatCompletionsUrl and embeddingsUrl. The manifest says ai: { chat: true }. See /docs/agent/ai. |
integrationFetch |
function | ({ env, provider, for? }) => fetch: a fetch for an api_key capability's real URLs or an oauth_client capability's token endpoints, over the binding; in dev, the developer's own key or client. |
integrationStatus |
function | ({ env, provider }) => Promise<IntegrationStatus>: { requested, configured, ready } without calling the provider; email adds sender, senders, provider; an OAuth client adds clientId and, for Mastodon, origin; a client with scope sets adds scopeSets (the manifest's names) and scopes (the exact provider strings to put on the authorize URL), and a void grant answers ready: false with scopeSets: [] and no clientId. |
connectUrl |
function | ({ env, provider, returnTo?, account?, sets? }) => string: where an app sends a person to connect an OAuth provider the Gateway runs the flow for (google-workspace, microsoft-365, slack, discord, and every registered OAuth provider such as github-oauth): <GATEWAY_URL>/connect/<id>?return=…&account=user|organization&sets=a,b. The Gateway asks the provider, finishes on its own callback, stores the tokens and sends the browser back to returnTo (the app's URL, or a path on the Gateway). Throws ConfigurationError without GATEWAY_URL in the env. |
connectPath |
function | (provider) => string: the path part, /connect/<id>, as integrationStatus().connectPath reports it. |
devAccessTokenName |
function | (provider) => string: the .dev.vars name of the held token the SDK uses for the provider in local dev (GOOGLE_WORKSPACE_ACCESS_TOKEN, GITHUB_OAUTH_ACCESS_TOKEN): a token the developer obtained themselves, standing in for the connection the Gateway holds. Dev-only; the Gateway never reads it. |
authorizeUrl |
function | ({ provider, status, redirectUri, state, codeChallenge?, params? }) => URL: the provider's authorize URL for a client with scope sets, from integrationStatus()'s clientId and scopes (response_type=code, Google's access_type=offline, Microsoft's response_mode=query, S256 when a challenge is given). Throws TypeError when the status isn't ready or carries no scopes, for a client without scope sets, or when params names scope, user_scope, client_id, redirect_uri, state, response_type, code_challenge, code_challenge_method, access_type, response_mode or include_granted_scopes. |
gatewayRefusal |
function | (response) => Promise<GatewayRefusal | null>: { status, code, message } when x-everyapp-error is set, else null. Reads the body only for a refusal; call it before reading the body yourself. |
GatewayRefusalError |
class | A thrown GatewayRefusal (status, code, message, name "GatewayRefusalError"): what sendEmail(), listMembers() and emit() throw. |
sendEmail |
function | ({ env, for?, message }) => Promise<SendEmailResult>: POST /api/integrations/email/send through the Gateway's email capability. |
listMembers |
function | ({ env, for? }) => Promise<ListMembersResult>: GET /api/integrations/members/list through the members capability. |
emit |
function | ({ env, for?, event, data }) => Promise<EmitEventResult>: POST /api/events/emit, an app's own event on the Gateway's event bus, delivered as app.<app id>.<event>. |
defineTools |
function | (tools, { instructions? }): the app's agent tools, passed to everyApp(handler, { tools }). Names are lowercase snake_case, at most 48 characters; each tool has description, risk (read, write, destructive), input (a Standard Schema with JSON Schema support such as zod 4.2+, or { jsonSchema, validate }), optional output, title, idempotent, openWorld, and a handler(input, { user, env, ctx, request, callId }). |
toolResult |
function | ({ text, structured }): a tool result with text for the model and structured content checked against output. |
ToolError |
class | Throw from a tool handler for a message the model may read; any other error reaches the client only as Internal error in <app> (ref <call id>). |
nodeServerHandler |
function | ({ boot }): a handler for everyApp() that serves a Node HTTP framework (Express, NestJS) through workerd's cloudflare:node bridge; boot(env) returns { port } once per isolate. The identity is already verified; the Node app still sees x-everyapp-identity. Inside the Node app, read the user with await getEveryAppUser(new Request(url, { headers: req.headers }), process.env): nodejs_compat puts the Worker's text bindings (EVERYAPP_AUDIENCE, EVERYAPP_IDENTITY_PUBLIC_KEYS, EVERYAPP_IDENTITY_ISSUER, EVERYAPP_DEV) on process.env, and a new Request object is verified from env rather than served from the per-request cache (packages/sdk/README.md). Drops set-cookie from the Node response and passes a content-encoding body through unencoded. |
ConfigurationError |
class | status 500, name "ConfigurationError": a missing or empty key set, a dev opt-in with a non-dev issuer, a binding that is not a service binding, a Gateway call with no binding and no local key. |
IdentityError |
class | status 401, name "IdentityError": any verification failure. |
AttributionError |
class | code "attribution_required", name "AttributionError": a Gateway call whose for is malformed, or a shared app's Worker calling with neither the request nor an organization. |
Types exported beside them: EveryAppOptions, EveryAppManifestLike, EveryAppHandler, EveryAppUser, IdentityResult, GatewayCallFor, AuthorizeUrlOptions, CreateGatewayFetchOptions, GatewayFetchEnv, GatewayRefusal, IntegrationFetchOptions, IntegrationStatus, IntegrationStatusOf, EmailIntegrationStatus, OAuthClientIntegrationStatus, NodeServer, NodeServerHandlerOptions, SendEmailOptions, SendEmailMessage, SendEmailResult, EmailAddressInput, EmailAttachmentInput, ListMembersOptions, ListMembersResult, OrganizationMember, OrganizationMemberRole, CapabilityId, ApiKeyCapabilityId, OAuthClientCapabilityId, DefineToolsOptions, ToolDefinition, ToolContext, ToolResult, ToolSchema, ToolSet, ToolRisk, PlainToolSchema, StandardSchemaWithJSON.
Calling the Gateway from the app
EVERY_APP_GATEWAY is a service binding to the Gateway Worker's AppGateway entrypoint, with upload-time props { organizationId, appId, workerName } that name the calling app; the Gateway trusts the props, not anything in the request. The SDK sends every call to the origin http://every-app-gateway.internal (BINDING_ORIGIN in packages/sdk/src/server/gatewayFetch.ts); only the path and query matter. The entrypoint (apps/every-app-gateway/src/server/app-gateway-entrypoint.ts) serves two route families, /api/ai/<provider>/… and /api/integrations/…, and answers anything else 404 invalid_gateway_route.
Before any route is served the entrypoint checks the caller (cached per isolate: 30 s for a hit, 5 s for a miss, 500 entries):
| Code | Status | When |
|---|---|---|
missing_caller_identity |
401 | the props are missing or malformed |
caller_registry_unavailable |
500 | the registry (or, for background work, the organization directory) could not be read |
caller_not_registered |
403 | no registry row for the props' organization and app |
caller_not_active |
403 | the app's row is not active, or it is a shared app on a Gateway without shared apps |
caller_tier_refused |
403 | the app's row is on the dispatch tier (a hosted third-party Worker): the Gateway's providers, capabilities and event emit are for first-party Workers only, whatever the manifest declares; refused before the route is read |
caller_identity_mismatch |
403 | the row's organization, app id or Worker name differs from the props |
invalid_gateway_route |
404 | neither /api/ai/<provider> nor /api/integrations/… |
provider_not_allowed |
403 | the manifest's providers does not list the AI provider |
caller_identity_required |
403 | a shared app's call with neither x-everyapp-identity nor x-everyapp-org |
caller_identity_invalid |
401 | a shared app forwarded an identity that does not verify (the Worker name as audience, GATEWAY_URL as issuer, JWT_PUBLIC_KEY) |
caller_org_mismatch |
403 | x-everyapp-org names another organization than the props (own app) or than the forwarded identity's org_id (shared app) |
caller_org_not_attributable |
403 | the organization in x-everyapp-org is unknown, suspended, or has no active member |
internal_error |
500 | anything else |
On /api/ai/* these come back in OpenAI's error shape, { "error": { "message", "code", "type": "gateway_refusal" }, "code", "message" }; elsewhere as { "error": "<status label>", "code", "message" }. Both carry x-everyapp-error.
Attribution
Every helper takes for (GatewayCallFor in packages/sdk/src/server/attribution.ts):
for: request, the inboundRequest:applyAttributioncopies itsx-everyapp-identityonto the call, so the Gateway attributes it to that person and their organization. The copied token is the request's own and expires with it (120 s).for: { background: true, organizationId }: work no request started (a cron, a Workflow step, a queue consumer, anemail()handler). The SDK sendsx-everyapp-org, no identity; the call is the app's.foromitted, or{ background: true }without an organization: allowed only in an organization's own app, whose organization the Gateway knows from the props.
In a shared app's Worker (EVERYAPP_SHARED is the string 1 or true, or boolean true), checkAttribution throws AttributionError before any call is made when for is missing or names no organization. A for that is neither a Request nor { background: true, … }, or whose organizationId is empty, throws AttributionError everywhere. The SDK deletes any x-everyapp-identity or x-everyapp-org the app put on the headers itself, and always deletes authorization.
How the Gateway attributes (attributeCall): an organization's own app calls for the organization in its props; a forwarded identity of that organization names the user, and one that does not verify or names another organization is ignored (the call is the app's). A shared app's forwarded identity decides the organization and must verify; without one, x-everyapp-org must name an active organization with a live member; with both, they must agree. Every AI provider and capability proxy call is logged with the organization and orgSource (props, jwt or header); AI and api_key/oauth_client calls are not metered or refused on volume. Email has a daily budget (email_budget_exhausted, below under sendEmail()).
AI: createGatewayFetch()
createGatewayFetch({ env, for }) returns a fetch for /api/ai/<provider>/<path>. The manifest lists the provider in providers (openai, openrouter, workers-ai), and ai.models limits the models. The proxy (apps/every-app-gateway/src/server/ai-proxy.ts) joins <path> onto the provider's base URL, forwards POST to inference routes only (openai: /v1/responses, /v1/chat/completions, /v1/embeddings, /v1/audio/speech, /v1/audio/transcriptions, /v1/audio/translations, /v1/images/generations, /v1/images/edits, /v1/images/variations; openrouter: /api/v1/chat/completions, /api/v1/completions, /api/v1/responses, /api/v1/embeddings; workers-ai: /v1/chat/completions, /v1/embeddings, /v1/responses under the Gateway account's /client/v4/accounts/<id>/ai), plus OpenAI paths both the manifest's ai.routes and the operator's AI_PROXY_EXTRA_ROUTES allow. It rewrites store to false on OpenAI's /v1/responses and /v1/chat/completions, reads model for the allowlist and the usage log, strips authorization, x-api-key, openai-organization, openai-project, cookie, forwarding and hop-by-hop headers, every x-everyapp-* and cf-aig-* header, and puts the Gateway's key on. Workers AI chat completions are normalized so all-digit tokens come back as strings. On an inference route a multipart/form-data body (the audio and image endpoints) is read as a form and re-encoded, so the Gateway sets content-type (a new boundary) and content-length and reads model from the form field; any other body there must be a JSON object. A path admitted through ai.routes streams through unread (prepareBody): no model check, no store rewrite.
| Code | Status | When |
|---|---|---|
ai_provider_not_supported |
404 | no such provider on the Gateway |
ai_provider_not_configured |
503 | the Gateway lacks the provider's key (OPENAI_API_KEY, OPENROUTER_API_KEY, or WORKERS_AI_API_TOKEN and CLOUDFLARE_ACCOUNT_ID) |
ai_request_invalid |
400 | a path that could leave the provider's base, or a non-object JSON body on an inference route |
ai_route_not_allowed |
403 | not an inference route, and not allowed by both ai.routes and AI_PROXY_EXTRA_ROUTES |
model_not_allowed |
403 | on an inference route only: ai.models is set and the body's model is not in it (or absent); a path allowed by ai.routes is never checked |
ai_upstream_failed |
502 | the provider could not be reached |
The provider's own errors pass through without x-everyapp-error.
Putting a provider's key on the Gateway, step by step: packages/cli/skills/everyapp/setup/openai.md, setup/openrouter.md, setup/workers-ai.md (in an app .claude/skills/everyapp/setup/<file>).
import { createGatewayFetch, gatewayRefusal } from "@every-app/sdk/server";
const ai = createGatewayFetch({ env, for: request });
const response = await ai(
"http://every-app-gateway.internal/api/ai/openai/v1/chat/completions",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: "gpt-4.1-mini", // one the manifest's ai.models allows
messages: [{ role: "user", content: "Summarize this week's todos." }],
}),
},
);
const refused = await gatewayRefusal(response);
if (refused) throw new Error(`${refused.code}: ${refused.message}`);
const completion = await response.json();An OpenAI client works unchanged with fetch: createGatewayFetch({ env, for: request }) and baseURL http://every-app-gateway.internal/api/ai/openai/v1 (/api/ai/workers-ai/v1 for Workers AI; /api/ai/openrouter/api/v1 for OpenRouter, whose routes live under /api/v1).
Local dev. With no binding and EVERYAPP_DEV set, createGatewayFetch calls the provider directly with the developer's own settings from .dev.vars under the Gateway's names: OPENAI_API_KEY for /api/ai/openai, OPENROUTER_API_KEY for /api/ai/openrouter, WORKERS_AI_API_TOKEN with CLOUDFLARE_ACCOUNT_ID for /api/ai/workers-ai. A provider is reachable only with every setting it needs; with none set, createGatewayFetch throws ConfigurationError (AI gateway unavailable: deploy with the EVERY_APP_GATEWAY binding, or set the provider's key … in .dev.vars for direct local development.), and a call to an unset provider throws Set <KEY> in .dev.vars to call <provider> in local development. See local development.
Capabilities: integrationFetch() and integrationStatus()
A capability is listed in the manifest as capabilities: { <id>: { access, why } } (the registry, defined in packages/sdk/src/internal/capabilities.ts). integrationFetch({ env, provider, for }) returns a fetch for the provider's real URLs: for an api_key capability (dataforseo, context) only the provider's own https hosts, sent to the binding as /api/integrations/<id>/<host><path>?<query>; for an oauth_client capability (linkedin, facebook, mastodon, google-workspace, microsoft-365, and the other social networks) only the provider's token endpoints, with the Gateway's client injected (send the code or refresh token without client_id or client_secret; build the authorize URL from integrationStatus().clientId; call the provider's API with the user's token directly). A service capability (email, members) has no fetch: integrationFetch throws ConfigurationError naming sendEmail() or listMembers(). A host or endpoint outside the capability's is a TypeError before anything is sent.
The capability proxy (apps/every-app-gateway/src/server/integration-proxy.ts) checks an api_key call in this order, then forwards with the Gateway's key, redirect: "manual", no authorization, x-api-key, cookie, x-everyapp-* or forwarding headers, and drops the provider's set-cookie. An OAuth client call (capabilities/oauthClient.ts) checks the client's host setting, then the endpoint and method, the manifest, the agent rule, the client, then the body:
| Code | Status | When |
|---|---|---|
invalid_integration_route |
400 | not /api/integrations/<id>/<host>/<path> on a host the provider uses, nor a service route |
capability_not_requested |
403 | the deployed manifest does not list the capability |
integration_path_not_allowed |
403 | an account-wide path (DataForSEO's tasks_ready, id_list, errors, appendix/user_data, appendix/webhook_resend), an OAuth call that is not a token endpoint, or a token endpoint called with a method it does not take (an OAuth client call never answers 405) |
integration_method_not_allowed |
405 | a method the provider does not read with (api_key calls and the service routes) |
integration_not_configured |
503 | the Gateway holds no key (or client), or the client's host setting (Mastodon's MASTODON_URL) is not an https origin; the message names everyapp secret set <SECRET> |
integration_agent_not_allowed |
403 | an agent's identity (chan: "mcp") on an OAuth client call or a members list |
integration_body_too_large |
413 | an OAuth token request body over 64 KiB (OAUTH_TOKEN_BODY_LIMIT in packages/sdk/src/internal/oauthClient.ts) |
integration_body_invalid |
400 | an OAuth token request body that is not the form-encoded or JSON shape the endpoint takes, or not a JSON object where JSON is taken (injectClientCredentials); for a client with scope sets, grant_type, scope or user_scope in the query string, or grant_type named twice in the body |
integration_scopes_undeclared |
403 | an OAuth client with scope sets listed without a valid scopes list (missing, empty, a name listed twice, or a set this Gateway doesn't offer): the whole grant is void; the message names the sets offered |
integration_grant_not_allowed |
403 | a grant_type that isn't a user's grant (client_credentials), or isn't one the client's scope policy completes (authorization_code, refresh_token): it would mint a token for the operator's own client |
integration_scope_not_allowed |
403 | the token request's scope (or user_scope) asks for a provider scope outside the app's sets; the message names the first ten in excess |
integration_scope_exceeded |
403 | the provider's answer grants scopes outside the app's sets (a wider consent, Google's include_granted_scopes, a refresh of a token minted under a wider manifest): the tokens are never forwarded, stored or revoked; the message names the excess and how to reconnect |
integration_scope_unverified |
502 | a 2xx answer carries a token but names no scopes, so the Gateway can't hold it to the sets; nothing is forwarded |
integration_response_invalid |
502 | a 2xx answer from a client with scope sets that isn't a JSON object or passes 64 KiB; nothing is forwarded |
integration_upstream_failed |
502 | the provider could not be reached, or its answer broke off while the Gateway read it |
integrationStatus({ env, provider }) sends GET /api/integrations/<id> and returns { requested, configured, ready } (the Gateway's answer also carries capability); for email, sender, senders and provider; for an OAuth client, clientId and origin. configured is false for an api_key, oauth_client or email capability the app does not list (configured = requested && …), so no app learns what the operator holds; members, with nothing to hold, always reports configured: true, and only its requested and ready follow the manifest (capabilities/members.ts). A non-OK answer returns every field false.
Held tokens. For an OAuth provider the Gateway runs the flow for (integrationStatus().flow is gateway or both: google-workspace, microsoft-365, slack, discord, and every registered OAuth provider), integrationFetch also reaches the provider's API hosts: the Gateway puts the connected person's token on the call and the app never sees it (docs/design/2026-10-oauth-providers.md). integrationFetch({ env, provider, for, account }) picks whose connection: user (the default when a person is behind the call) or organization (the default for background work), sent as x-everyapp-account. The status adds connectPath and connection ({ account, label, scopeSets, ready, reconnect? } or null); a call with no usable connection is integration_not_connected (409) carrying connectPath and reconnect, and a path outside the app's declared scope sets is integration_scope_not_allowed (403). Send people to connectUrl() to connect.
Local dev. With no binding and EVERYAPP_DEV set, everyAppWorker() and everyAppAuxiliaryWorker() also declare EVERYAPP_DEV_CAPABILITIES (the manifest's capabilities as JSON), from which the SDK holds a client with scope sets to the same rules as the Gateway and answers the same x-everyapp-error codes; integrationFetch calls the provider directly with the developer's own key or client from .dev.vars under the Gateway's names (DATAFORSEO_API_KEY, CONTEXT_API_KEY, LINKEDIN_CLIENT_ID and LINKEDIN_CLIENT_SECRET, MASTODON_URL, …); without them it throws ConfigurationError. integrationStatus answers from the same settings.
Putting a capability's key or OAuth client on the Gateway, step by step: packages/cli/skills/everyapp/setup/dataforseo.md, setup/context.md, setup/google.md (google-workspace), setup/microsoft-365.md, setup/slack.md (in an app .claude/skills/everyapp/setup/<file>).
Email: sendEmail()
sendEmail({ env, for, message }) posts the message to /api/integrations/email/send. The manifest lists capabilities: { email: { access: "write" } }. The message (SendEmailMessage in packages/sdk/src/internal/email.ts): to (with cc and bcc, at most 50 recipients), subject, text and/or html, optional from (must match one of the installation's senders set with everyapp email set --sender; absent, the Gateway's EMAIL_FROM), replyTo, headers (at most 20; the reserved names are refused with email_invalid_message, case-insensitively: From, Sender, To, Cc, Bcc, Reply-To, Subject, Return-Path, Date, MIME-Version, Content-Type, Content-Transfer-Encoding, DKIM-Signature), attachments (at most 32). subject is at most 998 characters; the request body, attachments included, at most 5 MiB (EMAIL_LIMITS). The answer is { messageId?, status } with status delivered, queued or, in dev, logged. Refusals throw GatewayRefusalError:
| Code | Status | When |
|---|---|---|
integration_method_not_allowed |
405 | not POST |
capability_not_requested |
403 | the manifest does not list email with write |
integration_read_only |
403 | an agent's identity without mcp:write |
email_message_too_large |
413 | over the 5 MiB body cap, attachments included |
email_invalid_message |
400 | the message breaks a rule, or the provider refuses it as malformed |
email_sender_not_allowed |
403 | from matches no installation sender, or the Gateway is hosted (own sender only) |
integration_not_configured |
503 | the Gateway has no sender or provider |
email_budget_exhausted |
429 | the daily app budget is spent; retry-after says when |
email_budget_unavailable |
503 | the budget counters could not be read or written |
email_sender_not_verified |
422 | the sender's domain is not set up at the provider |
email_provider_limited |
429 | the provider's account reached its sending limit (E_RATE_LIMIT_EXCEEDED, E_DAILY_LIMIT_EXCEEDED, REST 10004); try later |
email_send_failed |
502 | anything else the provider answered, or (SDK-side) a non-refusal non-2xx from the Gateway |
The full rules and budgets are in email and the capabilities registry; setting the Gateway's sender and provider up, step by step: packages/cli/skills/everyapp/setup/email.md (in an app .claude/skills/everyapp/setup/email.md). Local dev: with EVERYAPP_DEV and no binding the message is checked the same way (from against EVERYAPP_EMAIL_SENDERS in .dev.vars), then printed and answered { status: "logged", messageId: "dev-<uuid>" }; with EMAIL_REST_API_TOKEN, CLOUDFLARE_ACCOUNT_ID and EMAIL_FROM set it really sends through Email Sending's REST API from the developer's account, and a refused send throws GatewayRefusalError with the same provider-mapped codes (email_sender_not_verified, email_provider_limited, email_invalid_message, email_send_failed). Without EVERYAPP_DEV and without the binding, ConfigurationError.
Members: listMembers()
listMembers({ env, for }) gets /api/integrations/members/list and returns { members: [{ id, email, name, role }] } (packages/sdk/src/internal/members.ts): the organization's members who can open the app now (not deactivated, the organization active, and for an organization's own app granted the app). id is the identity's sub. The manifest lists capabilities: { members: { access: "read" } }. Refusals (GatewayRefusalError): integration_method_not_allowed 405 (not GET), capability_not_requested 403, integration_agent_not_allowed 403 (an agent's tool call cannot page through the directory), and SDK-side members_list_failed for a non-refusal non-2xx. Local dev: with EVERYAPP_DEV and no binding there is no directory: for: request answers that request's user alone (name empty, role from orgRole, member unless owner or admin), background work answers { members: [] }.
Events: emit()
emit({ env, for, event, data }) posts { event, data } to /api/events/emit and returns { id, type } (EmitEventResult), the event's evt_… id and its delivered type app.<app id>.<event>; owners and admins subscribe to app.<app id>.* (or one name) in Manage workspace, Events, and the Gateway delivers it, signed, to their webhooks and email. No manifest entry is needed. event is lowercase dotted segments (invoice.paid), at most 64 characters; data is a JSON object, at most 8 KiB, 8 levels deep, and never a credential: a key named like one at any depth (secret, token, password, apiKey, session, cookie, …) is refused. Refusals (GatewayRefusalError): event_name_invalid 400, event_data_invalid 400, event_data_refused 400, event_data_too_large 413, event_rate_limited 429 (600 an hour per app), and SDK-side event_emit_failed for a non-refusal non-2xx. Local dev: with EVERYAPP_DEV and no binding the event is checked the same way, printed as [everyapp dev] event app.<id>.<event> (not delivered: no Gateway) and answered { id: "dev-<uuid>", type }.
Refusals
GatewayRefusal is { status: number, code: string, message: string }. gatewayRefusal(response) returns one when the response carries x-everyapp-error (the message from the JSON body, or The EveryApp gateway refused the call (<code>).), else null: the response is then the provider's own, errors included. GatewayRefusalError is the thrown form.
Agent tools
defineTools() describes the app's tools; everyApp(handler, { tools }) serves them to the Gateway over the internal contract, and the Gateway's ToolBus serves them to MCP clients at the app host's /mcp and at the Gateway's own /mcp (as <app>_<tool>), snapshotting the catalog with the one-minute system token and calling each tool with a chan: "mcp" identity bound to that call. The app never speaks MCP. The protocol, the catalog contract, scopes and the audit log are in MCP; the how-to every new app ships with is packages/cli/skills/everyapp/mcp.md (in an app .agents/skills/everyapp/mcp.md); how a person connects a client is Use your apps from Claude or ChatGPT.
Shared and organization-scoped apps
An organization's own app (scope = 'org': every self-hosted app, and third-party apps on a hosted Gateway) is one Worker per organization: its EVERY_APP_GATEWAY props name that organization, its Workers carry EVERY_APP_ORG_ID, and every identity it receives has that organization's org_id (a member signed in under another of their organizations is let in as their membership in the app's). A shared app (scope = 'shared', a hosted Gateway's first-party apps) is one Worker for every organization: its Workers are expected to carry EVERYAPP_SHARED=1 instead of EVERY_APP_ORG_ID (the SDK's SHARED_ENV, packages/sdk/src/internal/protocol.ts, read by packages/sdk/src/server/attribution.ts), each identity carries the session's organization in org_id, the app scopes its data by it, and every Gateway call must say whom it is for (for: request or for: { background: true, organizationId }), else AttributionError. No command in this checkout installs one: the only writer of EVERYAPP_SHARED is installWorkerConfig with InstallTarget.shared (packages/cli/src/lib/app/installBuildOutput.ts), which everyapp deploy never sets (PR #441 removed deploy --shared), and the Gateway's own deployer (apps/every-app-gateway/src/server/deploy/metadata.ts) always sends EVERY_APP_ORG_ID and never EVERYAPP_SHARED. The registry keeps a row's scope when a deploy names none (planRegistration in packages/perimeter/src/registry/registration.ts; everyapp deploy passes no scope, registerFromManifest in packages/cli/src/commands/appGateway.ts), and only the platform organization's owners and admins may redeploy a shared row (assertPlatformAdmin in packages/cli/src/lib/gateway/registry.ts), so a redeploy of an existing shared row keeps the scope but installs Workers carrying EVERY_APP_ORG_ID (the install organization's id) and no EVERYAPP_SHARED. The SDK never compares org_id with EVERY_APP_ORG_ID; the audience (the Worker name) is what keeps one organization's tokens out of another's Worker. The rules in full are under "Organizations and Shared Apps" in the security model.
Trust zones
Three zones (docs/security-model.md): the client zone (browsers, API clients, webhook senders: untrusted), the Gateway zone (the one public Worker: terminates sessions, enforces access, mints identity), and the app zone (private Workers reached only through getAppFetcher, plus their helper Workers, which only the app's own Worker may bind). An app trusts exactly one thing from a request: the token the Gateway signed. The Gateway trusts an app's calls by the binding's props, never by a header. Cloudflare Access, when a self-hosted Gateway uses it (everyapp access on puts the Gateway and its apps behind it, packages/cli/src/commands/access/index.ts; a hosted Gateway ignores the Access settings, and the command refuses one with ACCESS_HOSTED), is a ring outside the Gateway: the Gateway verifies its Cf-Access-Jwt-Assertion against the team's JWKS (apps/every-app-gateway/src/auth/cloudflare-access/jwt.ts) and strips every cf-access-* header before an app, so an app never sees them (Access). The threat model behind these zones is docs/threat-model.md (/docs/agent/repo/threat-model), with its register docs/threat-model-invariants.md (on GitHub only). The full contract, the response contract, the dispatch tier's egress rules and the per-host sessions of third-party hosts are in the security model.
Sources
packages/sdk/src/internal/protocol.tspackages/sdk/src/internal/identity.tspackages/sdk/src/internal/tools.tspackages/sdk/src/internal/index.tspackages/sdk/src/internal/aiProviders.tspackages/sdk/src/internal/capabilities.tspackages/sdk/src/internal/email.tspackages/sdk/src/internal/members.tspackages/sdk/src/internal/gatewaySettings.tspackages/sdk/src/internal/oauthClient.tspackages/sdk/README.mdpackages/sdk/src/server/index.tspackages/sdk/src/server/everyApp.tspackages/sdk/src/server/toolsEndpoint.tspackages/sdk/src/server/gatewayFetch.tspackages/sdk/src/server/integrationFetch.tspackages/sdk/src/server/sendEmail.tspackages/sdk/src/server/listMembers.tspackages/sdk/src/server/emit.tspackages/sdk/src/server/tools.tspackages/sdk/src/server/nodeServer.tspackages/sdk/src/server/attribution.tspackages/sdk/src/server/server-exports.test.tspackages/sdk/src/cloudflare/worker.tspackages/sdk/src/dev/index.tspackages/sdk/src/dev/keypair.tspackages/sdk/package.jsonpackages/perimeter/src/headers.tspackages/perimeter/src/gateway.tspackages/perimeter/src/publicRoutes.tspackages/perimeter/src/csrf.tspackages/perimeter/src/serverFunctions.tspackages/perimeter/src/hostSession.tspackages/perimeter/src/cookies.tspackages/perimeter/src/session.tspackages/perimeter/src/getAppFetcher.tspackages/perimeter/src/manifest/manifest.tspackages/perimeter/src/dev/devGateway.tspackages/toolbus/src/execute.tspackages/toolbus/src/catalog.tspackages/cli/src/lib/app/installBuildOutput.tspackages/cli/src/lib/app/policy.tspackages/cli/src/lib/app/secrets.tspackages/cli/src/lib/gateway/secretsFile.tspackages/cli/src/commands/deploy/deployApp.tspackages/cli/src/commands/appGateway.tspackages/cli/src/lib/gateway/registry.tspackages/perimeter/src/registry/registration.tsapps/every-app-gateway/src/server/app-gateway-entrypoint.tsapps/every-app-gateway/src/server/deploy/metadata.tsapps/every-app-gateway/src/server/ai-proxy.tsapps/every-app-gateway/src/server/integration-proxy.tsapps/every-app-gateway/src/server/capabilities/refusal.tsapps/every-app-gateway/src/server/capabilities/email.tsapps/every-app-gateway/src/server/capabilities/members.tsapps/every-app-gateway/src/server/capabilities/oauthClient.tsapps/every-app-gateway/src/server/capabilities/forward.tsapps/every-app-gateway/src/server/user-pat-scopes.tsapps/every-app-gateway/src/perimeter/betterAuthAuthenticator.tsapps/every-app-gateway/src/auth/cloudflare-access/jwt.tsapps/every-app-gateway/src/server/mcp-auth/access-caller.tspackages/cli/src/commands/access/index.tspackages/cli/src/test/fixtures/manifests/postiz.tsapps/every-app-gateway/src/routes/api/dev/identity.tsstarter/src/entry.worker.tsstarter/src/middleware/ensureUser.tsstarter/src/tools.tsstarter/everyapp.config.tsstarter/cloudflare.config.tsdocs/security-model.mddocs/capabilities.mdpackages/cli/skills/everyapp/mcp.md
everyapp.config.ts reference
Every field of the app manifest (everyapp.config.ts), with its type, default, validation rule and exact error message, how the deploy turns it into the Worker, and what the Gateway does with it at request time.
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.