EveryAppEveryApp

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:

  1. Resolves the app from the Host header. 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, or binding until the first request reaches the Worker and activates it): 503 app_deploying with retry-after: 5.
  2. 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 a 302 to ${GATEWAY_URL}/?setup=<appId>, the launcher's setup dialog; everything else is 503 { "error": "app_needs_setup", "app" }, uncacheable, never naming a setting.
  3. Refuses the internal tools contract from outside: any spelling of /__everyapp/tools/* (dot segments, encoded separators, double encoding, case) is 404 not_found. /.well-known/oauth-protected-resource and anything under it are 404 not_found too (an app host is no OAuth resource).
  4. Guards TanStack Start server functions: a request to /_serverFn/* (or the manifest's serverFnBase) must be a same-origin fetch() from TanStack's client, on every method, before any authentication. Navigations, any Sec-Fetch-Site other than same-origin, and requests without x-tsr-serverFn: true or x-tsr-redirect: manual are 403 server_fn_denied. The manifest's serverFnGuard: false turns this off.
  5. Refuses a request naming the session cookie more than once (cookie tossing): 401 unauthenticated with reason: "duplicate_session_cookie".
  6. Evaluates CSRF (packages/perimeter/src/csrf.ts): GET, HEAD and OPTIONS always pass; otherwise Sec-Fetch-Site: same-origin passes, any other Sec-Fetch-Site fails, and without it the Origin host must equal the app's host. A cookie-carrying WebSocket upgrade must carry an Origin on the app's host. A failing check on a private route with a cookie and no bearer is 403 csrf_denied; on a public route it only downgrades the request to anonymous (below).
  7. Authenticates: the Better Auth session cookie (or, on a third-party host, the per-host __Host-everyapp_s cookie), or a personal access token Authorization: Bearer epat_…. A reserved bearer (an epat_ token or a token shaped like the Gateway's OAuth server's) that does not authenticate fails closed with 401 unauthenticated, never anonymous access. OAuth access tokens are for the Gateway's own /mcp only: an app host accepts none. Any Authorization: Bearer, whatever its shape, is never cookie-authenticated: once a bearer is present the authenticator (createProdAuthenticator in apps/every-app-gateway/src/perimeter/betterAuthAuthenticator.ts) ignores the session cookie and returns no session unless the bearer is a valid epat_ token, and the request is exempt from the CSRF check in step 6. A personal access token whose scopes include a provider: scope or the deploy scope (deploy, DEPLOY_SCOPE) opens no app: the authenticator returns no session and the request is 401 unauthenticated.
  8. Checks app access (organization membership, the app installed for the person, the organization active). A credential whose scopes are all mcp: scopes reaches /mcp alone, and only where the host's manifest has mcp and MCP is served there (not at a /mcp a public route covers on a by: "gateway" host); elsewhere it is 403 forbidden with reason: "token_scope". No access is 403 forbidden with reason: "no_app_access". A personal access token acts only in the organization it was created in: on another organization's app it is 403 forbidden with reason: "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).
  9. Serves /mcp from the Gateway's ToolBus when the manifest says mcp: { by: "gateway" } (503 mcp_unavailable when there is no catalog to serve); proxies it to the app's own MCP server with the identity when it says by: "app"; without the field, /mcp is a private route like any other, and a credential whose scopes are all mcp: scopes opens nothing on that host (see Agent tools).
  10. Strips the inbound trust headers (stripInboundHeaders in packages/perimeter/src/headers.ts): cookie, authorization, cloudflare-workers-version-overrides, every header starting x-everyapp-, and every header starting cf-access-. On a public route the inbound authorization is put back unless the Gateway consumed it (a personal access token or an OAuth-shaped token is never forwarded).
  11. Mints a fresh identity JWT and sets x-everyapp-identity (an authenticated caller with access), or sets the signed public marker in x-everyapp-public (an anonymous or non-member caller on a public route), or answers 401 unauthenticated (a 302 to the login page for an HTML navigation; on a third-party host, the session handoff) or 403 forbidden.
  12. Builds the outbound Request with the same URL, method and body, redirect: "manual", and the visitor's cf object (country, ASN and the rest) so the app sees what Cloudflare saw, and calls the fetcher. An app that throws is 502 app_error.
  13. 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 every Set-Cookie; HTML responses get the CSP floor frame-ancestors 'none'; base-uri 'self'; object-src 'none' merged over the app's own CSP (an app may keep frame-ancestors 'self', which turns X-Frame-Options into SAMEORIGIN, and base-uri 'none'). A 101 WebSocket response is passed through untouched. On a third-party host the response also loses Access-Control-Allow-Credentials and any Set-Cookie named __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 as gatewayMs and ms.
  • 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 default Cache-Control: public, max-age=0, must-revalidate is answered with private, 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 own Cache-Control on an asset keeps it.
  • The two reads an app-access check needs (the organization's status and the user_app_access row) are asked together; a verified Access assertion is kept per isolate for a minute at most and never past its exp, 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.ts pins 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 sees Sec-Purpose: prefetch on 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:

  1. With the dev opt-in on, the issuer must be https://gateway.dev.localhost; anything else is a ConfigurationError (500), whatever the env says, because the dev keypair is public (packages/sdk/src/dev/keypair.ts).
  2. An empty header is no identity token; an undecodable one malformed identity token.
  3. The protected header is pinned before any signature work: alg must be RS256 (so none and HS256 confusion fail here), and kid must be everyapp-identity, or also everyapp-identity-dev under the dev opt-in.
  4. A user or system token needs a configured issuer (EVERYAPP_IDENTITY_ISSUER or the issuer option); without one the error is EVERYAPP_IDENTITY_ISSUER not configured — deploy with the EveryApp CLI or provide an explicit issuer.
  5. Each key in the set is tried with jwtVerify (algorithms: ["RS256"], the expected audience, the issuer when configured, requiredClaims ["exp"], or ["exp", "iat"] for a system token). The payload's typ must be the expected one.
  6. The claims are checked: sub, email, org_id and org_role are 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-identity on a public route, after the usual session, access and CSRF checks. The handler's user is set.
  • An anonymous caller, or a signed-in person without access, gets x-everyapp-public instead. The handler's user is null, getEveryAppUser() returns null, 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 Authorization header is forwarded on a public route (unless it held an epat_ 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 inbound Request: applyAttribution copies its x-everyapp-identity onto 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, an email() handler). The SDK sends x-everyapp-org, no identity; the call is the app's.
  • for omitted, 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.ts
  • packages/sdk/src/internal/identity.ts
  • packages/sdk/src/internal/tools.ts
  • packages/sdk/src/internal/index.ts
  • packages/sdk/src/internal/aiProviders.ts
  • packages/sdk/src/internal/capabilities.ts
  • packages/sdk/src/internal/email.ts
  • packages/sdk/src/internal/members.ts
  • packages/sdk/src/internal/gatewaySettings.ts
  • packages/sdk/src/internal/oauthClient.ts
  • packages/sdk/README.md
  • packages/sdk/src/server/index.ts
  • packages/sdk/src/server/everyApp.ts
  • packages/sdk/src/server/toolsEndpoint.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/sdk/src/server/emit.ts
  • packages/sdk/src/server/tools.ts
  • packages/sdk/src/server/nodeServer.ts
  • packages/sdk/src/server/attribution.ts
  • packages/sdk/src/server/server-exports.test.ts
  • packages/sdk/src/cloudflare/worker.ts
  • packages/sdk/src/dev/index.ts
  • packages/sdk/src/dev/keypair.ts
  • packages/sdk/package.json
  • packages/perimeter/src/headers.ts
  • packages/perimeter/src/gateway.ts
  • packages/perimeter/src/publicRoutes.ts
  • packages/perimeter/src/csrf.ts
  • packages/perimeter/src/serverFunctions.ts
  • packages/perimeter/src/hostSession.ts
  • packages/perimeter/src/cookies.ts
  • packages/perimeter/src/session.ts
  • packages/perimeter/src/getAppFetcher.ts
  • packages/perimeter/src/manifest/manifest.ts
  • packages/perimeter/src/dev/devGateway.ts
  • packages/toolbus/src/execute.ts
  • packages/toolbus/src/catalog.ts
  • packages/cli/src/lib/app/installBuildOutput.ts
  • packages/cli/src/lib/app/policy.ts
  • packages/cli/src/lib/app/secrets.ts
  • packages/cli/src/lib/gateway/secretsFile.ts
  • packages/cli/src/commands/deploy/deployApp.ts
  • packages/cli/src/commands/appGateway.ts
  • packages/cli/src/lib/gateway/registry.ts
  • packages/perimeter/src/registry/registration.ts
  • apps/every-app-gateway/src/server/app-gateway-entrypoint.ts
  • apps/every-app-gateway/src/server/deploy/metadata.ts
  • apps/every-app-gateway/src/server/ai-proxy.ts
  • apps/every-app-gateway/src/server/integration-proxy.ts
  • apps/every-app-gateway/src/server/capabilities/refusal.ts
  • apps/every-app-gateway/src/server/capabilities/email.ts
  • apps/every-app-gateway/src/server/capabilities/members.ts
  • apps/every-app-gateway/src/server/capabilities/oauthClient.ts
  • apps/every-app-gateway/src/server/capabilities/forward.ts
  • apps/every-app-gateway/src/server/user-pat-scopes.ts
  • apps/every-app-gateway/src/perimeter/betterAuthAuthenticator.ts
  • apps/every-app-gateway/src/auth/cloudflare-access/jwt.ts
  • apps/every-app-gateway/src/server/mcp-auth/access-caller.ts
  • packages/cli/src/commands/access/index.ts
  • packages/cli/src/test/fixtures/manifests/postiz.ts
  • apps/every-app-gateway/src/routes/api/dev/identity.ts
  • starter/src/entry.worker.ts
  • starter/src/middleware/ensureUser.ts
  • starter/src/tools.ts
  • starter/everyapp.config.ts
  • starter/cloudflare.config.ts
  • docs/security-model.md
  • docs/capabilities.md
  • packages/cli/skills/everyapp/mcp.md

On this page