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.
The manifest is the single source of truth for an app's identity, resources and public surface. It is authored as everyapp.config.ts in the app's root, turned into the Worker definition by the app's cloudflare.config.ts (everyAppWorker() from @every-app/sdk/cloudflare), validated and snapshotted by the CLI into the Gateway's apps registry row as JSON at deploy time, and read by the Gateway's perimeter on every request. The schema lives in packages/perimeter/src/manifest/manifest.ts; everything below is derived from it and from the code that consumes each field.
Shape and loading
The file default-exports a plain object literal ending in as const. Every manifest in the repository does this; the test fixtures add satisfies EveryAppCliManifest (packages/cli/src/test/fixtures/manifests/postiz.ts). as const matters: everyAppWorker<const M>() infers the Worker's Env type from the literal (each D1, KV, R2, Durable Object, Workflow, Hyperdrive, AI Gateway and helper-Worker binding name becomes a typed key), so a widened type loses the typed bindings. defineEveryApp(manifest) exists in packages/perimeter/src/manifest/manifest.ts as an identity helper typed (EveryAppManifest) => EveryAppManifest; no app in the repository uses it, and the perimeter is not a package apps depend on.
cloudflare.config.ts consumes it like this (starter/cloudflare.config.ts):
import { defineConfig } from "cf/config";
import { everyAppWorker } from "@every-app/sdk/cloudflare";
import * as entrypoint from "./src/entry.worker.ts" with { type: "cf-worker" };
import manifest from "./everyapp.config.ts";
export default defineConfig((ctx) => ({
worker: everyAppWorker({ manifest, entrypoint, ctx }),
}));everyAppWorker() (packages/sdk/src/cloudflare/worker.ts) takes manifest, entrypoint, ctx (cf's { mode, isPreview }), and optionally root (where .dev.vars and .env live), crons (five-field UTC schedules for the scheduled handler; crons: "<s>" isn't a five-field cron schedule, e.g. "0 6 * * *". otherwise) and compatibilityFlags (appended to nodejs_compat, which every Worker has). A helper Worker (workers) is built with everyAppAuxiliaryWorker({ manifest, worker: "<name>", entrypoint, ctx }) and passed to the Vite plugin's auxiliaryWorkers. Nothing the Worker imports may be imported from cloudflare.config.ts: in dev the Cloudflare Vite plugin hides that file's imports from the Worker.
ctx.mode is development, production or unset (cf's API commands and cf workers types pass none; unset builds as development). Any other mode throws everyAppWorker() builds in the "development" or "production" mode, not "<mode>". (everyAppAuxiliaryWorker() names itself for a helper). Production without EVERYAPP_BUILD=1 in the environment throws EveryApp Workers are built for production only by `everyapp deploy` (`pnpm run deploy`), which sets EVERYAPP_BUILD=1 and installs the build: a bare `cf build` or `cf deploy` would ship a Worker whose name, identity keys, gateway binding and resources are placeholders. For a local build, run Vite in development mode (`vite build --mode development`).; a local build of production shape is vite build --mode development.
How the CLI loads it (packages/cli/src/lib/manifest.ts): loadEveryAppManifest(cwd) looks for everyapp.config.ts in the current directory, throws MANIFEST_NOT_FOUND with the message No everyapp.config.ts in this directory and the fix run this from the app's directory when there is none, evaluates it with jiti (createJiti(pathToFileURL(manifestPath).href) then jiti.import(manifestPath, { default: true }), so the file may import TypeScript), and passes the default export to validateManifestStrict(). loadEveryAppManifestIfPresent(cwd) returns null for a missing file (for commands that also run outside an app directory, like secret); a file that is present but invalid still throws. loadEveryAppManifestFile(path) loads a named file the same way, with No manifest at <path> when there is none unless the caller passes its own message (email --manifest <path>; delete).
What every Worker gets
What everyAppWorker() puts in the app's own Worker's env in each mode, before the manifest's resources are added (packages/sdk/src/cloudflare/worker.ts; the types are EveryAppDevEnv and EveryAppDeployEnv). In production every install-specific value is a placeholder from sentinelDeployTarget() that the deploy rewrites when it installs the build (packages/cli/src/lib/app/installBuildOutput.ts).
| Binding | Type | Development (cf dev, or no mode) |
Production |
|---|---|---|---|
EVERYAPP_APP_ID |
text |
the manifest's id |
the manifest's id |
CF_VERSION_METADATA |
version-metadata |
present | present |
EVERYAPP_DEV |
text |
"1" |
absent (the deploy policy refuses a Worker that has it) |
EVERYAPP_IDENTITY_PUBLIC_KEYS |
text |
JSON.stringify([DEV_PUBLIC_KEY_PEM]): the well-known dev key from @every-app/sdk/dev, built in, so nothing needs writing to .dev.vars |
the install's identity public keys as a JSON array |
EVERYAPP_IDENTITY_ISSUER |
text |
DEV_ISSUER, https://gateway.dev.localhost |
the Gateway's URL |
EVERYAPP_AUDIENCE |
text |
absent | the Worker name; the Gateway mints every identity token for it (aud) |
EVERY_APP_GATEWAY |
worker |
absent (the SDK calls providers directly with local keys) | { type: "worker", worker: "every-app-gateway", exportName: "AppGateway", props: { organizationId, appId, workerName } } (gatewayBinding()); the props are the app's identity to the Gateway |
GATEWAY_URL |
secret |
absent | sent by the deploy through cf deploy --secrets-file |
EVERY_APP_ORG_ID |
secret |
absent | sent the same way; installing a shared app, which serves every organization, drops it |
EVERYAPP_SHARED |
text |
absent | "1" on a shared app's Workers only, put in by the install in place of EVERY_APP_ORG_ID (installWorkerConfig()) |
DATABASE_PROVIDER |
text |
database.provider, when database is declared |
same |
After these come the manifest's resources (resources, workers[].binding, the classes), and in development only, secret bindings for each of the app's secrets, each DEV_GATEWAY_KEYS name of its providers and capabilities, and EVERYAPP_EMAIL_SENDERS for an app listing email, when set locally (addSecrets(); the sections below). A helper Worker gets a different set: see workers.
Strict and tolerant validation
Two schemas share one definition:
| Validator | Schema | Who runs it | Unknown keys | Error prefix |
|---|---|---|---|---|
validateManifestStrict() |
ManifestSchemaStrict (.strict() at the top level, in resources, resources.durableObjects[], resources.workflows[], resources.hyperdrive[], resources.queues[], resources.queues[].consumer, resources.vectorize[], resources.sendEmail[], workers[], workers[].limits, public[], ai, capabilities.<id>, email, database, limits and migrations; secrets is an array with its own rules, below) |
The CLI, on every load | Refused: Unrecognized key: "backgroud" |
Invalid everyapp.config: |
validateManifest() |
ManifestSchema (.passthrough() at the top level and in every sub-object but migrations, which is .strict() in both schemas) |
Registration (planRegistration(): the CLI itself in self-host, where it writes the Gateway's D1; the hosted deploy API otherwise) and the Gateway whenever it re-reads a stored row (parseStoredManifest()) |
Tolerated | Invalid manifest: |
Why: the registration rules in packages/perimeter/src/registry/registration.ts are shared code, run by whoever registers (in self-host the CLI's own process, ADR-0003; in the hosted tier the deploy API), and a stored row is later read by a Gateway that may be older than the CLI that wrote it. The tolerant schema checks every field the perimeter relies on (id, public) and passes additive fields through so a newer CLI never bricks registration until the Gateway is redeployed; parseStoredManifest() returns null for a row that no longer parses. The strict schema is the author-time linter. Registration wraps a tolerant failure as INVALID_MANIFEST (HTTP 400) and also refuses manifest.id "<id>" does not match appId "<slug>".
Both paths throw ManifestError and both reject: a reserved scope id, a public route that is not absolute, a catch-all public route, percent-encoding, backslashes, dot segments, an invalid named segment, and anything under /__everyapp (assertPublicPathIsSafe). The strict path additionally runs the secret/binding clash check, the reserved-id check, the refusal of a public route that is /mcp, under it, or a pattern that covers it (public: /:page can't be public (it covers /mcp)), assertHelperWorkersAreDeclared and assertDatabaseIsConsistent (each below). Issues are joined with ; and each is prefixed with its path (capabilities.email.access: …, or (root) at the top level).
Fields
Every top-level field. Nothing is required but id.
| Field | Type | Default | Meaning |
|---|---|---|---|
id |
string, kebab-case | required | The stable app id: the hostname's first label and the stem of the Worker name. |
name |
string, non-empty | the id |
Display name; the registry row's name. everyapp create writes the id's words capitalized (my-app is My App). |
description |
string | the id |
One line for the app library; the registry row's description. |
icon |
path of an SVG the app serves | the favicon | The launcher tile the Gateway's app library shows. |
main |
string | none | Legacy; the Worker entry now comes from cloudflare.config.ts. Accepted, read by nothing. |
compatibilityDate |
YYYY-MM-DD |
2026-06-01 |
Workers compatibility date for the app's Worker and its helpers. |
resources |
object | none | The D1, KV, R2, Durable Object, Workflow, Hyperdrive, Queue (resources.queues), Vectorize (resources.vectorize), AI Gateway (resources.aiGateway) and Secrets Store (resources.secretsStore) bindings the deploy provisions and binds, and Workers AI (resources.ai), Browser Rendering (resources.browser), Images (resources.images), Cloudflare's send_email bindings (resources.sendEmail) and Analytics Engine (resources.analyticsEngine), which need none. |
workers |
array | none | Helper Workers deployed beside the app's Worker as <app worker>--<name>. |
database |
{ provider, region? } |
none | The database the app's code talks to (DATABASE_PROVIDER), and the region its Workers are placed near. |
limits |
{ cpuMs? } |
Cloudflare's default | CPU time per invocation for the app's own Worker. |
public |
array of { path, methods? } |
none (default-private) | Routes reachable without an authenticated session. |
providers |
array of provider ids, or { id, why?, docs?, optional?, anyOf? } entries |
none | AI providers the app may call through the Gateway at /api/ai/<provider>. |
capabilities |
record of { access, why?, docs?, optional?, anyOf?, scopes? } by capability id |
none | Third-party APIs and Gateway services the Gateway performs for the app with keys it holds. |
secrets |
array of names, or { name, why?, docs?, optional?, anyOf?, setup? } entries |
none | The app's own secrets, set on its Workers by everyapp configure <app> (everything the manifest declares) or everyapp secret set <NAME> (one name). |
scopes |
record of scope id to description | none | Opaque app-domain scopes personal access tokens may request. |
ai |
{ models?, routes? } |
any model, inference only | Limits on what the app may do through the Gateway's AI proxy. |
email |
{ inbound? } |
none | Declares the Worker's email() handler. |
access |
"everyone" or "admins" |
"admins" |
Who can open the app when it is first registered. |
serverFnGuard |
boolean | true |
Whether the perimeter guards TanStack Start server functions. |
serverFnBase |
path | /_serverFn |
Where the server functions live when not the default. |
build |
shell command | cf build |
Builds the app for production. |
dev |
shell command | cf dev |
Starts the app's dev server for everyapp dev. |
devPort |
positive integer | none | Legacy; the dev port now travels in PORT. Accepted, read by nothing. |
install |
false or shell command |
auto-detected | Installs dependencies before a build. |
migrations |
object | drizzle, first D1 binding, drizzle dir |
How the app's database is migrated; engine is required once the object is given. |
mcp |
{ by, why } |
none | Whether the app's own host answers /mcp, and who does. Left out, it doesn't: the Gateway's /mcp is the one connector. |
id
- Regex
^[a-z]([a-z0-9-]*[a-z0-9])?$, messageid must be kebab-case (lowercase, digits, hyphens). The SDK checks the same regex when building the Worker (everyapp.config.ts id must be kebab-case (lowercase letters, digits, hyphens); got "<id>".). - Reserved:
everyapp(it prefixes the Gateway's own MCP tools,everyapp_<tool>). The strict validator throwsInvalid everyapp.config: id "everyapp" is reserved for the platform.;isReservedAppId()is the check. Registration refuses the same id with its own code,RESERVED_APP_ID(400):The app id "everyapp" is reserved for the platform.An id whose Worker name would be the Gateway's own (app-gateway, sinceevery-app-gatewayisGATEWAY_WORKER_NAME) is refused by the SDK (App id "app-gateway" is reserved: its Worker would be the gateway's own (every-app-gateway).) and byeveryapp create("app-gateway" is reserved for the platform). - Registration refuses an id containing
--(INVALID_APP_ID:App id "<id>" can't contain "--": it separates an app's Worker name from its resources' names. Choose another id.). The regex alone allows it:everyapp createrefuses such a name up front with its ownINVALID_APP_ID("<name>" isn't a valid app name, fixdon't put two dashes in a row, e.g. <name with single dashes>;packages/cli/src/commands/create/steps/appId.ts, which also caps a name at 64 characters), and a manifest written by hand is caught at registration.
What the id names:
| Name | Rule | Source |
|---|---|---|
| Hostname | <id>.<APP_BASE_DOMAIN> for the install's own organization; <id>-<orgKey>.<APP_BASE_DOMAIN> for any other organization. The label must match ^(?=.{1,63}$)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$ or registration fails INVALID_APP_HOSTNAME. |
appNames() in packages/perimeter/src/registry/registration.ts |
| Worker name | every-<orgKey>-<id> (registryWorkerNameFor()), assigned at registration so two organizations never share a Worker, D1 database or KV namespace. At most 63 characters (MAX_WORKER_NAME_LENGTH), else INVALID_WORKER_NAME (400): App id "<id>" is too long: its Worker name "<name>" would exceed 63 characters. Never every-app-gateway or a name starting every-app-gateway- (isReservedName()); that, or a stored Worker name that is not the app's own, is INVALID_WORKER_NAME with App id "<id>" can't be registered: its Worker would be "<name>", which is reserved for the gateway or isn't the app's own. |
packages/perimeter/src/manifest/manifest.ts |
| Legacy Worker name | every-<id> (workerNameFor(); an id already starting every- is used as is). This is the dev Worker name (everyapp dev, local D1 ids) and the name apps registered before org-scoped names keep. |
same |
EVERYAPP_APP_ID |
A text binding with the id on every one of the app's Workers. | packages/sdk/src/cloudflare/worker.ts |
EVERYAPP_AUDIENCE |
A text binding with the Worker name; the Gateway mints every identity token for that aud. |
same |
Every account-wide resource is named after the Worker name with the separator -- (RESOURCE_SEPARATOR): org keys have no hyphens and no org-scoped app id may contain --, so every-acme-notes--archive can only ever be app notes's ARCHIVE database, never app notes-archive's own.
name, description and icon
name:z.string().min(1).description: any string. Registration writes both to the registry row asmanifest.name ?? manifest.idandmanifest.description ?? manifest.id(packages/cli/src/commands/appGateway.ts), a redeploy updates them (packages/cli/src/lib/gateway/registry.ts), and the Gateway's admin UI can edit them afterwards (AppRepository.ts).everyapp createwritesnameas the id's dash-separated words with their first letter capitalized (appNameFor()inpackages/cli/src/commands/create/steps/updateConfiguration.ts:my-appisMy App,crm2isCrm2) and keeps the template's description.icon: regex^(\/[A-Za-z0-9_~-][A-Za-z0-9._~-]*)*\/[A-Za-z0-9_~-][A-Za-z0-9._~-]*\.svg$, messageicon must be the path of an SVG the app serves, such as "/everyapp-icon.svg". The Gateway reads it from the stored manifest (json_extract(manifest, '$.icon')) and the app library loads//<hostname><icon>, falls back to//<hostname>/favicon.ico, then to the first letter of the name; only an active app with a hostname gets an image (apps/every-app-gateway/src/client/components/AppIcon.tsx). The app draws the whole tile; the library only rounds its corners.
main, compatibilityDate and devPort
main:z.string().optional(). Legacy. Accepted so existing manifests validate; nothing in the CLI, SDK or Gateway reads it.compatibilityDate: regex^\d{4}-\d{2}-\d{2}$, messagemust be YYYY-MM-DD. Default2026-06-01(DEFAULT_COMPATIBILITY_DATEinpackages/sdk/src/cloudflare/worker.ts); applied to the app's Worker and every helper Worker. Compatibility flags are not a manifest field: every Worker getsnodejs_compat, andeveryAppWorker({ compatibilityFlags })appends more; the Postiz fixture (packages/cli/src/test/fixtures/manifests/postiz.ts) addsglobal_fetch_strictly_publicto both of its Workers.devPort:z.number().int().positive(). Legacy.everyapp devchooses the port and passes it asPORTto the dev command (below); nothing readsdevPort.
resources
Binding names are what the Worker reads from env. In the strict schema, resources.d1, resources.kv, resources.r2, resources.ai, resources.browser, resources.images, resources.analyticsEngine[].name, resources.workflows[].name, resources.hyperdrive[].name, resources.queues[].name, resources.vectorize[].name and resources.secretsStore[].name must match ^[A-Za-z_][A-Za-z0-9_]*$ (binding names must be JavaScript identifiers; a queue's binding also can't end with _); resources.durableObjects[].name is any string; resources.aiGateway[].name is stricter, below. Unknown keys inside resources are refused in the strict schema. One name may be used once across d1, kv, r2, hyperdrive, queues, ai, browser, images, vectorize, aiGateway, sendEmail, secretsStore and analyticsEngine (the binding <NAME> is declared twice in resources.). The SDK refuses a binding that reuses a name the Worker already has (r2: <NAME> reuses a binding name the Worker already has., hyperdrive: …, queues: …, ai: …, browser: …, images: …, vectorize: …, aiGateway: …, sendEmail: …, secretsStore: …, analyticsEngine: …, workflows: <NAME> (<Class>) reuses a binding or class name the Worker already has.).
How each resource is named in the Cloudflare account, from the Worker name (W, e.g. every-acme-notes) and the binding:
| Field | Account name | Rule |
|---|---|---|
resources.d1 |
W for DB; W--<binding> otherwise |
resourceNameFor(): the suffix is the binding lowercased with _ turned into - |
resources.kv |
W for KV; W--<binding> otherwise |
same |
resources.r2 |
W for R2; W--<binding> otherwise |
bucketNameFor(); MAX_BUCKET_NAME_LENGTH is 63. R2 is the bucket apps bound by hand before resources.r2 existed |
resources.workflows |
W--<binding> always |
workflowNameFor(); at most 64 characters (workflows: <NAME> would be the Workflow "<name>", longer than Cloudflare's 64 characters; shorten the binding name.) |
resources.hyperdrive |
W--<binding> always |
hyperdriveNameFor() in packages/cli/src/lib/app/resources.ts (the same suffix rule as D1 and KV); the config is created and reconciled by the deploy through packages/cli/src/lib/cf/hyperdrive.ts |
resources.queues |
W--<binding> always |
queueNameFor(); at most 63 lowercase letters, digits and hyphens (MAX_QUEUE_NAME_LENGTH, isValidQueueName()), checked by the deploy once the Worker's name is known (QUEUE_NAME_INVALID); the queue is created by the deploy through packages/cli/src/lib/cf/queues.ts |
resources.ai |
none | Workers AI is account-wide: nothing is created, recorded or deleted (### Workers AI below) |
resources.browser |
none | Browser Rendering is account-wide: nothing is created, recorded or deleted (### Browser Rendering below) |
resources.images |
none | Images is account-wide: nothing is created, recorded or deleted (### Images below) |
resources.vectorize |
W--<binding> always |
vectorizeNameFor(); MAX_VECTORIZE_NAME_LENGTH is 64 (the deploy refuses a longer one with VECTORIZE_NAME_TOO_LONG); lowercase letters, digits and hyphens, which the rule always gives |
resources.aiGateway |
W-<binding> always (one hyphen) |
aiGatewayIdFor(): the gateway's id, which Cloudflare allows only lowercase letters and digits joined by single hyphens, at most 64 characters (isAiGatewayId()), so it can't carry the -- separator |
resources.sendEmail |
none | Cloudflare's send_email binding is account-wide: nothing is created, recorded or deleted (### Email sending below) |
resources.secretsStore |
W--<binding> always, in the account's store |
storeSecretNameFor(): the same suffix rule; a secret in the Secrets Store the account already has (### Secrets Store below) |
resources.analyticsEngine |
dataset as written; W--<binding> otherwise |
analyticsDatasetFor(); letters, digits, - and _, at most 128 characters (Cloudflare's rule). Never created, recorded or deleted (### Analytics Engine below) |
workers |
W--<name> |
helperWorkerName(); at most 63 characters (Helper Worker <name> is longer than Cloudflare's 63 characters; shorten its name in everyapp.config.ts.) |
Every D1 database, KV namespace, R2 bucket, queue (its producer binding) and Vectorize index is bound on all of the app's Workers (the app's own and each helper). In production the binding carries the ids and names the deploy provisioned (placeholders from sentinelDeployTarget() until everyapp deploy or a hosted deployer rewrites them; a bare cf build or cf deploy is refused unless EVERYAPP_BUILD=1). In development (cf dev, or no mode): a D1 binding gets the fixed local id localD1Id(W, binding) (the first 32 hex characters of sha256("W:binding"), which cf d1 … --local and everyapp db both address); a KV binding gets id <binding>-W when the manifest has helper Workers (so helpers share the namespace in dev) and cf's default otherwise; an R2 binding and a queue binding are named by the production rule.
Durable Objects and Workflows
resources.durableObjects and resources.workflows are arrays of { name, className, worker? }:
resources.durableObjects[].name/resources.workflows[].name: the binding.resources.durableObjects[].className/resources.workflows[].className: the exported class.resources.durableObjects[].worker/resources.workflows[].worker: the helper Worker (fromworkers[].name) that defines the class; the app's own Worker when unset. Naming an undeclared helper fails:<NAME> is defined by Worker "<worker>", which workers doesn't declare.
The app's Worker binds every class (each pointing at the Worker that defines it); a helper Worker binds only the classes it defines. Each Worker exports the classes it defines: Durable Objects as { type: "durable-object", storage: "sqlite" }, Workflows as { type: "workflow", name }. Every Worker that binds a Workflow can start it and look up its instances. When the app's entrypoint is a cf-worker module import, the app's own Durable Object bindings reference the Worker definition itself, so cf types the namespace with the class (DurableObjectNamespace<MailboxDO>) and RPC calls type-check; a path entrypoint or a helper's class is bound by name and untyped.
Hyperdrive
resources.hyperdrive[] takes { name, worker?, caching? }:
resources.hyperdrive[].name: the binding; the config is namedW--<binding>.resources.hyperdrive[].worker: a helper Worker that alone binds it; every Worker binds it when unset. An undeclared helper fails:Hyperdrive <NAME> is bound by Worker "<worker>", which workers doesn't declare.resources.hyperdrive[].caching:off(the default) ordefault. Off because Hyperdrive never invalidates a cached read when the app writes, and app code reads its own writes;defaultturns on Cloudflare's own caching (60 s, stale for 15 s more). The deploy setscaching.disabledon the config accordingly and corrects a config that caches otherwise (cachesAs()inpackages/cli/src/lib/cf/hyperdrive.ts).
The origin is the binding's required setting, and it never leaves the machine that deploys: the deploy creates the config from the origin URL in the app's secrets file .everyapp/secrets.env, <BINDING>_ORIGIN_URL, or POSTGRES_DATABASE_URL when the app has one config. Without it the deploy stops with HYPERDRIVE_ORIGIN_MISSING, naming the variable for this app (POSTGRES_DATABASE_URL (or HYPERDRIVE_ORIGIN_URL) for one config, the binding's own for several) and the file, and everyapp status names the same under the app while its first deploy hasn't finished. Once the config exists a redeploy needs no origin (with one, it repoints the config). The password goes to cf in a private body file, never on the command line, and nothing from that file reaches the Worker. everyapp configure doesn't write the file: it sets what the Gateway and the app's Worker hold. In development a binding exists only when CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_<binding> is set in .dev.vars or .env. Hyperdrive is not available on the dispatch tier (below). Which databases it reaches, the steps and the code: Postgres.
Queues
resources.queues[] takes { name, consumer? }: a Cloudflare Queue every Worker of the app sends to, and the Worker that consumes it.
export default defineEveryApp({
id: "crawl",
resources: {
d1: ["DB"],
queues: [
{ name: "EMAILS" }, // a producer only
{ name: "JOBS", consumer: true }, // the app's own Worker consumes
{
name: "CRAWLS",
consumer: {
worker: "crawler",
maxBatchSize: 5,
maxRetries: 2,
deadLetterQueue: "FAILED_CRAWLS",
},
},
{ name: "FAILED_CRAWLS" },
],
},
workers: [{ name: "crawler" }],
});resources.queues[].name: the binding; the queue is namedW--<binding>(every-acme-crawl--failed-crawls).resources.queues[].consumer:truefor the app's own Worker, or an object of Cloudflare's consumer settings (@cloudflare/config'sQueueConsumerTriggerOptionsbut the name, which is the queue's); absent, nothing in the app consumes it. In the strict schema the object is.strict().resources.queues[].consumer.worker: a helper Worker (fromworkers[].name) that consumes; the app's own Worker when unset. An undeclared helper fails:Queue <NAME> is consumed by Worker "<worker>", which workers doesn't declare.resources.queues[].consumer.maxBatchSize: the most messages a batch holds (a positive integer).resources.queues[].consumer.maxBatchTimeout: the most seconds to wait to fill a batch (Cloudflare keeps it asmax_wait_time_ms).resources.queues[].consumer.maxRetries: how many times a message is retried before it goes to the dead-letter queue, or is dropped without one.resources.queues[].consumer.maxConcurrency: the most consumer invocations at once (a positive integer), ornull(the same as unset) to scale with the backlog.resources.queues[].consumer.retryDelay: the seconds before a message is retried.resources.queues[].consumer.visibilityTimeoutMs: Cloudflare's setting for pulled messages, passed through as given.resources.queues[].consumer.deadLetterQueue: another of the app's queue bindings, which takes the messages that ran out of retries; the trigger carries that queue's name. Anything else fails (Queue <NAME>'s deadLetterQueue "<value>" isn't one of resources.queues; declare it there and name its binding.), and so does the queue itself (Queue <NAME> can't be its own dead-letter queue; name another of resources.queues.): Cloudflare creates a dead-letter queue that doesn't exist, even under--no-provision, and no deploy would record it.
The bindings. Every Worker of the app gets a producer binding { type: "queue", name } for each queue (QueueBinding in @every-app/sdk/cloudflare; cf workers types types it as Queue), and the consuming Worker gets a trigger { type: "queue", name, deadLetterQueue?, maxBatchSize?, maxBatchTimeout?, maxRetries?, maxConcurrency?, retryDelay?, visibilityTimeoutMs? } (QueueConsumerTrigger) in its triggers, after its crons. In production both names (and deadLetterQueue) are resourceSentinel(binding) until the deploy rewrites them to the queue's name; in development they are queueNameFor(<dev worker name>, binding) (every-crawl--jobs), which Miniflare creates and emulates locally, producers and consumers both (nothing to set; dev).
The handler. everyApp() returns only { fetch }, so a consuming Worker exports its queue() handler beside it:
const app = everyApp<Env>(handler, { tools });
export default {
...app,
async queue(batch: MessageBatch<{ url: string }>, env: Env) {
for (const message of batch.messages) {
await crawl(message.body.url, env);
message.ack();
}
},
} satisfies ExportedHandler<Env, { url: string }>;Without one, the upload goes live and Cloudflare then refuses the consumer: QUEUE_HANDLER_MISSING (export it and deploy again). A helper Worker that consumes exports queue() from its own entry. Send from any Worker of the app with await env.JOBS.send({ … }) (or sendBatch).
What the deploy does. In app.resources, before the build is installed: every queue's name is checked (QUEUE_NAME_INVALID), then each queue is looked up by exact queue_name in one cf queues list and created with cf queues create --queue-name <name> when missing, recorded first in the ledger as kind queue with its queue_id. A queue of the name that no deploy of this app recorded is refused (RESOURCE_NAME_TAKEN): a queue holds messages, so an existing one is never adopted. The install rewrites the placeholders; the policy allows a queue binding or trigger only for a queue this deploy provisioned, its dead-letter queue too (/docs/agent/deploy); cf deploy --prebuilt --no-provision uploads each Worker and publishes its consumers from its triggers (one per queue, updated in place on a redeploy). A redeploy creates nothing.
What delete --purge does. It detaches the consumers that are the app's Workers (cf queues consumers delete; Cloudflare won't delete a Worker that consumes a queue), deletes the Workers, then each recorded queue with its messages (cf queues delete <id> --force); a queue the app's last manifest names that no deploy recorded is kept (RESOURCES_KEPT), and so is one a Worker outside the app still binds. everyapp teardown lists queues whose queue_name starts with every- and the consumers that are its Workers, and deletes them in that order around the Workers.
Error codes: QUEUE_NAME_INVALID, RESOURCE_NAME_TAKEN, RESOURCE_REPLACED, RESOURCE_OWNED, QUEUE_HANDLER_MISSING, APP_POLICY, CF_QUEUE_CONSUMER_WORKER, CF_QUEUE_IN_USE. Queues are not available on the dispatch tier, nor to the hosted deployer (below).
Workers AI
resources.ai is an array of binding names, one as a rule (["AI"]), several allowed. Each is the Workers AI binding the code calls as env.AI.run():
// everyapp.config.ts
export default {
id: "assistant",
resources: { d1: ["DB"], ai: ["AI"] },
} as const;
// in the Worker
const { response } = await env.AI.run("@cf/meta/llama-3.1-8b-instruct", {
prompt: "Summarize this ticket in one sentence.",
});- Nothing to make. Workers AI is account-wide: the deploy creates, records and deletes nothing for it (
delete --purgeandteardownhave nothing of it to remove), and every call is billed to the Cloudflare account the app is deployed to, with no Gateway budget, cap or usage record. - Production. Every one of the app's Workers (its own and each helper) gets
{ type: "ai" }(addAiBindings()inpackages/sdk/src/cloudflare/worker.ts;cf workers typestypes it asAi). There is no placeholder to install. The deploy's policy lets anaibinding through only under a nameresources.aideclares, socloudflare.config.tscan't add account-wide inference on its own (/docs/agent/deploy). - Development. Every Worker gets
{ type: "ai", dev: { remote: true } }. Miniflare has no Workers AI of its own: undereveryapp devthe binding exists, but every call throwsBinding AI needs to be run remotelyuntil the app'svite.config.tssetsremoteBindings: trueon the Cloudflare plugin (the starter setsremoteBindings: false) andcf auth loginhas run. Then calls reach the account's Workers AI, billed to it. The SDK already marks the binding remote, soremoteBindings: trueis the one switch; it moves only the bindings marked remote, and D1, KV, R2 and the rest stay local. Details on/docs/agent/dev. - Dispatch tier. Refused (Tier limits, below): the binding would bill the platform's account.
- The binding or the
workers-aiprovider. Both reach Workers AI. The Gateway'sworkers-aiprovider (providers: ["workers-ai"], the section above) is the brokered path: the app calls the Gateway's OpenAI-compatible routes throughcreateGatewayFetch(), the Gateway calls Workers AI with its own token (WORKERS_AI_API_TOKEN), holds the app toai.models, and counts the calls against the Gateway's budgets and caps. Pick it when the app talks chat or embeddings through an OpenAI-compatible client and the organization wants the calls capped and counted. The binding is the native path, billed to the account directly: pick it when the code wantsenv.AI.run()itself, e.g. with the Agents SDK, for a model or task the OpenAI-compatible routes don't carry, or through an AI Gateway by id (env.AI.run(model, input, { gateway: { id } })). The top-levelaifield limits the Gateway's proxy only; it doesn't touch the binding.
Browser Rendering
resources.browser is an array of binding names, one as a rule (["BROWSER"]). Each is a Browser Rendering binding: a headless Chrome the code drives with @cloudflare/puppeteer or @cloudflare/playwright:
// everyapp.config.ts
export default {
id: "crawler",
resources: { d1: ["DB"], browser: ["BROWSER"] },
} as const;
// in the Worker
import puppeteer from "@cloudflare/puppeteer";
const browser = await puppeteer.launch(env.BROWSER);
const page = await browser.newPage();
await page.goto("https://example.com");
const screenshot = await page.screenshot();
await browser.close();- Nothing to make. Browser Rendering is account-wide: the deploy creates, records and deletes nothing for it (
delete --purgeandteardownhave nothing of it to remove). Browser time is billed to the Cloudflare account the app is deployed to, and the binding needs the account on Workers Paid. - Production. Every one of the app's Workers (its own and each helper) gets
{ type: "browser" }(addBrowserBindings()inpackages/sdk/src/cloudflare/worker.ts;cf workers typestypes it asBrowserRun). There is no placeholder to install. The deploy's policy lets abrowserbinding through only under a nameresources.browserdeclares, socloudflare.config.tscan't add account-wide browser time on its own (/docs/agent/deploy). - Development. The same
{ type: "browser" }, and it works locally: Miniflare serves a local stand-in for Browser Rendering, and the first browser session downloads Chrome126.0.6478.182into the global.wranglercache and starts it on the developer's machine. No login, no account, nothing billed. Details on/docs/agent/dev. - Dispatch tier. Refused (Tier limits, below): the browser time would bill the platform's account.
Images
resources.images is an array of binding names, one as a rule (["IMAGES"]). Each is a Cloudflare Images binding: resizing, cropping, re-encoding and reading images inside the Worker, from any stream (an upload, an R2 object, a fetch() body):
// everyapp.config.ts
export default {
id: "gallery",
resources: { r2: ["R2"], images: ["IMAGES"] },
} as const;
// in the Worker
const original = await env.R2.get("photos/cat.jpg");
const thumbnail = await env.IMAGES.input(original!.body)
.transform({ width: 256 })
.output({ format: "image/webp" });
return thumbnail.response();- Nothing to make. Images is account-wide: the deploy creates, records and deletes nothing for it (
delete --purgeandteardownhave nothing of it to remove). Transformations are billed to the Cloudflare account the app is deployed to, under Cloudflare Images' pricing. - Production. Every one of the app's Workers (its own and each helper) gets
{ type: "images" }(addImagesBindings()inpackages/sdk/src/cloudflare/worker.ts;cf workers typestypes it as the runtime'sImagesBinding). There is no placeholder to install. The deploy's policy lets animagesbinding through only under a nameresources.imagesdeclares, socloudflare.config.tscan't add account-wide transformations on its own (/docs/agent/deploy). - Development. The same
{ type: "images" }, and it works locally: Miniflare answers it with sharp, which ships with Miniflare, soinfo()andinput().transform().output()run on the developer's machine with no login and nothing billed. Where sharp can't load, every call answers503with code9523. Details on/docs/agent/dev. - Dispatch tier. Refused (Tier limits, below): the transformations would bill the platform's account.
Vectorize
resources.vectorize[] takes one of two forms, never both:
resources: {
vectorize: [
// Your own embeddings: their dimensions (32 to 1536) and the distance metric.
{ name: "VECTORS", dimensions: 768, metric: "cosine" },
// Or a model Cloudflare knows: it sets the dimensions and metric.
{ name: "DOC_CHUNKS", preset: "@cf/baai/bge-base-en-v1.5" },
],
},resources.vectorize[].name: the binding; the index is namedW--<binding>(every-acme-notes--doc-chunks), at most 64 characters.resources.vectorize[].dimensions: a whole number from 32 to 1536 (a Vectorize index has 32 to 1536 dimensions), the length of the vectors the app inserts; withmetric.resources.vectorize[].metric:cosine,euclideanordot-product; withdimensions.resources.vectorize[].preset:@cf/baai/bge-small-en-v1.5(384 dimensions),@cf/baai/bge-base-en-v1.5(768),@cf/baai/bge-large-en-v1.5(1024),openai/text-embedding-ada-002(1536) orcohere/embed-multilingual-v2.0(768), all cosine: Cloudflare sets the shape from the model, so the vectors that model embeds fit.
Both or neither is refused: <NAME>: a preset sets the index's dimensions and metric itself; give either preset, or dimensions and metric, or <NAME> needs dimensions and metric (e.g. 768 and "cosine"), or a preset (e.g. "@cf/baai/bge-base-en-v1.5"). The tolerant schema takes any numbers and strings here, so a newer CLI's preset never bricks registration.
The binding. In production { type: "vectorize", name } on every Worker of the app (the app's own and each helper), name the index the deploy provisioned (resourceSentinel(<binding>) until the install rewrites it). In development { type: "vectorize", name: "every-<id>--<binding>", dev: { remote: true } }: the account's index of the dev Worker's name. Cloudflare has no local Vectorize, so everyapp dev binds nothing local; calls reach that index only once the app's vite.config.ts turns the Cloudflare plugin's remoteBindings on (the starter has it off) and cf auth login has run, and until then every call throws (Binding VECTORS needs to be run remotely; /docs/agent/dev). The typed Env has VectorizeBinding under each binding name, which cf workers types turns into VectorizeIndex.
What the deploy does (packages/cli/src/lib/app/resources.ts, through packages/cli/src/lib/cf/vectorize.ts): checks every index name against the 64 characters first (VECTORIZE_NAME_TOO_LONG), then for each index cf vectorize get <name>; one the app recorded (_everyapp_app_resources, kind vectorize, the name as its id) is used when it has the declared shape and refused with VECTORIZE_CONFIG_MISMATCH when it doesn't (an index can't be reshaped: rename the binding to make a new one, or change the manifest back); one nobody recorded is RESOURCE_NAME_TAKEN (an index holds whoever wrote it's vectors); none is recorded as the app's, then created with cf vectorize create --body @<file> ({ name, config: { dimensions, metric } } or { name, config: { preset } }; a body file because cf reads a flag value starting with @ as a file). A recorded index that was deleted is made again, empty, after a RESOURCE_RECREATED warning. cf's own deploy never makes an index, so the index exists before the upload. delete --purge deletes every recorded index (cf vectorize delete <name> --force, its vectors with it) and names any index of the app's names it never recorded in RESOURCES_KEPT; everyapp teardown deletes every index named every-*. Vectorize is not available on the dispatch tier (below).
AI Gateway
resources.aiGateway[] takes { name, collectLogs?, cacheTtl? }: an AI Gateway of the app's own in the Cloudflare account, for the model calls the app makes itself.
resources: {
aiGateway: [{ name: "AI_GATEWAY", collectLogs: true, cacheTtl: 0 }],
},resources.aiGateway[].name: the binding, letters and digits joined by single underscores (^[A-Za-z][A-Za-z0-9]*(?:_[A-Za-z0-9]+)*$; otherwisean AI Gateway's binding is letters and digits joined by single underscores (it ends the gateway's id, which takes single hyphens only)), since it ends the gateway's id.resources.aiGateway[].collectLogs: whether the gateway keeps a log of each request (prompt, answer, tokens, cost) for its dashboard;trueby default.resources.aiGateway[].cacheTtl: how many seconds the gateway serves a repeated request from its cache, a whole number from 0;0(the default) caches nothing.
The id. W-<binding> with the binding lowercased and _ as -, joined by one hyphen (aiGatewayIdFor()): every-acme-notes and AI_GATEWAY give every-acme-notes-ai-gateway. Cloudflare refuses --, _ and uppercase in a gateway id ([7001] not valid ID format) and more than 64 characters, so the -- separator the other kinds carry can't be used; the id alone doesn't say whose it is (app notes-ai's GATEWAY would be the same id), and the resource ledger does instead (below). The strict validator refuses a binding whose id could pass 64 characters on any Gateway, counting the longest organization key (12 characters): resources.aiGateway: <NAME> would make an AI Gateway id of up to <n> characters (every-<orgKey>-<id>-<suffix>), longer than Cloudflare's 64; shorten the binding name.
The binding is a json binding on every Worker of the app (the app's own and each helper), typed { type: "json"; value: { id: string; accountId: string; url: string } }, so the Worker reads env.AI_GATEWAY.id, env.AI_GATEWAY.accountId and env.AI_GATEWAY.url:
| Mode | id |
accountId |
url |
|---|---|---|---|
| Production | the gateway's id | the account | https://gateway.ai.cloudflare.com/v1/<accountId>/<id> |
| Development | the id the deploy would use (every-<id>-<binding>) |
"" |
"" |
In production the build holds placeholders (value.id = resourceSentinel(<binding>), value.accountId = BUILD_SENTINEL.accountId), which the install rewrites; it rewrites only a json binding whose value.id is its own binding's placeholder, never any other json binding. In development there is no gateway: an app treats an empty url as "call the provider directly".
Using it. With Workers AI, pass the id; with a provider's own client and the app's own key (one of its secrets), put the client's base URL under the gateway (Anthropic's path is /anthropic, the OpenAI-compatible endpoint /compat):
await env.AI.run(model, input, { gateway: { id: env.AI_GATEWAY.id } });
const openai = new OpenAI({
apiKey: env.OPENAI_API_KEY,
// Empty in development: straight to the provider.
baseURL: env.AI_GATEWAY.url ? `${env.AI_GATEWAY.url}/openai` : undefined,
});The gateway is unauthenticated (authentication: false): the provider key in the request is what authorizes the call, and the gateway adds logs, caching and analytics.
The Gateway's AI proxy is separate. An app that lists providers calls models through the Gateway with the Gateway's keys (createGatewayFetch(), providers); whatever account-level AI Gateway the Gateway routes those calls through is the Gateway's setting, shared by every app. resources.aiGateway is the app's alone: its logs, cache and analytics hold only the app's own calls.
What the deploy does (packages/cli/src/lib/app/resources.ts, packages/cli/src/lib/cf/aiGateway.ts): looks the id up (cf ai-gateway gateways get <id>); one the resource ledger records as the app's is used, and set back to the manifest's collectLogs and cacheTtl when they differ (cf ai-gateway gateways update, said as a progress line); one that exists unrecorded is refused, RESOURCE_NAME_TAKEN (a gateway holds logs of prompts and answers, so it is never adopted); none is recorded as the app's (ledger kind ai-gateway, the id as resource_id), then created (cf ai-gateway gateways create --body @<file> with collect_logs, cache_ttl, cache_invalidate_on_update: false, no rate limit and authentication: false). everyapp delete --purge deletes a recorded gateway and its stored logs (cf ai-gateway gateways delete <id> --force); everyapp teardown finds the account's every-* gateways (cf ai-gateway gateways list --search every-) and deletes them. AI Gateways are not available on the dispatch tier (below), and the hosted deployer refuses the json binding.
Email sending
resources.sendEmail[] binds Cloudflare's own send_email binding on every one of the app's Workers. The Gateway's email capability (capabilities: { email }, sendEmail() from @every-app/sdk/server, the senders everyapp email set --sender allows; the email page) stays the way to send mail: it keeps daily budgets, checks every from against the installation's sender list, logs each message, and works for an app whose zone has no Email Sending of its own. Declare the binding when the app wants Cloudflare's native API: code written against env.EMAIL.send() that you'd rather not port (Agentic Inbox's upstream is), or mail an inbound email() handler (email: { inbound: true }) sends beyond what message.reply() can (reply() needs no binding, but answers only the sender, once, while the event runs).
// everyapp.config.ts
export default defineEveryApp({
id: "notifier",
resources: {
sendEmail: [
{
name: "EMAIL",
allowedSenderAddresses: ["noreply@example.com"],
allowedDestinationAddresses: ["ops@example.com"],
},
],
},
});Each entry takes { name, allowedSenderAddresses, destinationAddress?, allowedDestinationAddresses? }, strict:
resources.sendEmail[].name: the binding (env.EMAIL), an identifier.resources.sendEmail[].allowedSenderAddresses: required, at least one: the onlyfromaddresses the binding sends as. Cloudflare's binding without the list sends as any address on the account's Email Sending domains, the Gateway's own sender (invitations, resets) included, so every entry, a third-party release's too, names its senders; missing or empty is refused withlist the addresses the binding may send as: without allowedSenderAddresses it could send as any address on the account's Email Sending domains, the Gateway's own sender included.resources.sendEmail[].destinationAddress: the one address it sends to.resources.sendEmail[].allowedDestinationAddresses: the only addresses it sends to.
Every address must be an address (must be an email address such as noreply@example.com, not a pattern; a sender pattern such as *@example.com, which everyapp email set --sender takes, is not one), a destination list holds at least one (list at least one address, or leave the list out), and destinationAddress and allowedDestinationAddresses can't both be set (destinationAddress and allowedDestinationAddresses can't both be set: one destination, or a list of them; Cloudflare's own rule). The account has no resource to name, so the deploy makes nothing, records nothing, and delete --purge has nothing to delete.
The binding is the same in both modes, on the app's Worker and every helper: { type: "send-email" } plus the lists the entry gives (addSendEmailBindings() in packages/sdk/src/cloudflare/worker.ts; cf workers types types it as SendEmail). The deploy's policy accepts a send-email binding only under a name resources.sendEmail declares and with exactly its addresses (in any order), so cloudflare.config.ts can't add one or widen one (/docs/agent/deploy).
In development (everyapp dev, cf dev) nothing is sent: Miniflare checks the addresses (email from <address> not allowed, email to <address> not allowed) and logs the message. send(new EmailMessage(from, to, raw)) (from cloudflare:email) logs send_email binding called with the following message: and the path of an .eml file under .cloudflare/tmp/; the builder form send({ from, to, subject, text, html }) logs send_email binding called with MessageBuilder:, the message, and .txt and .html files.
Deployed, the upload checks no address: Cloudflare checks each message when the Worker sends it. The from must be on a domain onboarded to Email Sending in the app's Cloudflare account and verified (E_SENDER_DOMAIN_NOT_AVAILABLE, E_SENDER_NOT_VERIFIED), the step everyapp email set --sender documents for the Gateway's senders (the email page's operator setup); a recipient outside the binding's lists is E_RECIPIENT_NOT_ALLOWED; and sending to arbitrary recipients needs the Workers Paid plan. Email sending bindings are not available on the dispatch tier (below).
Secrets Store
resources.secretsStore[] takes { name }: the binding of a Cloudflare Secrets Store secret, which the code reads as await env.<NAME>.get():
// everyapp.config.ts
export default {
id: "shop",
resources: { d1: ["DB"], secretsStore: [{ name: "STRIPE_KEY" }] },
} as const;
// in the Worker
const key = await env.STRIPE_KEY.get();- The name. The secret is
W--<binding>(the binding lowercased,_turned into-:every-acme-shop--stripe-key;storeSecretNameFor()inpackages/perimeter/src/manifest/manifest.ts), in the account's store, so the teardown'severy-scan finds it. The name may not be one of the manifest'ssecretsor another binding (the strict schema's checks above). - The store, made once by a person. cf 1.0.0-beta.14 can list stores but has no command to create one, so the account needs a store before the first deploy: make one once in the Cloudflare dashboard (Secrets Store → Create store). The deploy uses the account's only store; with several, the one named
everyapp, else the first Cloudflare lists; an app keeps using the store it recorded a secret in. With none, the deploy stops before anything is created withSECRETS_STORE_MISSING. - The value. The deploy creates the secret (scope
workers) from<BINDING>in the app's secrets file.everyapp/secrets.env(STRIPE_KEY=sk_live_…, beside Hyperdrive's origins). The value reaches cf only in a private body file (--body @<file>), never the command line, the output or the ledger. Without it, the deploy stops before anything is created withSECRETS_STORE_VALUE_MISSING, naming the variable and the file. Once the secret exists, the value is no longer needed: a redeploy binds the secret as it is and never rewrites its value, so changing the value is the dashboard's (Secrets Store → the secret → Edit) untileveryapp secret setlearns to route there. - Ownership. The secret goes through the deploy's ledger as a D1 database does (kind
secrets-store-secret, recorded as<store id>/<secret id>): a secret of the name that no deploy of this app recorded is refused (RESOURCE_NAME_TAKEN: it may hold someone's credential); a recorded one that was deleted is created again withRESOURCE_RECREATED. - Production. Every one of the app's Workers (its own and each helper) gets
{ type: "secrets-store-secret", storeId, secretName }with the store and secret the deploy provisioned (placeholders fromsentinelDeployTarget()until installed;cf workers typestypes the binding asSecretsStoreSecret). The deploy's policy allows the binding only as the provisioned store and secret of a bindingresources.secretsStoredeclares (/docs/agent/deploy). - Development. Every Worker gets
{ type: "secrets-store-secret", storeId: "local", secretName: "every-<id>--<binding>" }, which reads Miniflare's local store under.cloudflare/state.everyapp devdoesn't seed it yet,.dev.varsdoesn't reach it and cf has no localsecrets create, soget()throws until the secret is put there; the ready block says so, and/docs/agent/dev("Secrets Store") says how to seed it. delete --purgedeletes the recorded secret (cf secrets-store secrets delete <id> --store-id <store> --force); the store stays.teardowndeletes every secret namedevery-*in every store, and keeps the stores.- Dispatch tier. Refused (Tier limits, below): the store is the platform account's.
- A Secrets Store secret or an app secret. An app secret (
secrets, set witheveryapp configure) is a value on the app's Workers and is the default; a Secrets Store secret lives once in the account's store, where the dashboard manages and audits it, and the Worker fetches it withget()./docs/agent/secretscompares them.
Analytics Engine
resources.analyticsEngine[] takes { name, dataset? }, one entry per dataset the app writes to:
resources.analyticsEngine[].name: the binding, a JavaScript identifier of at most 63 characters (so the dataset named after the Worker fits Cloudflare's 128).resources.analyticsEngine[].dataset: the dataset's name, used as written: letters, digits,-and_, at most 128 characters (dataset names are letters, digits, dashes and underscores, at most 128 characters (Cloudflare's rule); Cloudflare refuses anything else at upload with code100144). It may not be named like another app's: one that starts withevery-must beW--<name>, the app's own (the deploy's policy, below). Left out, the dataset is named after the app's Worker,W--<binding>(analyticsDatasetFor()inpackages/perimeter/src/manifest/manifest.ts, e.g.every-acme-shop--events).
// everyapp.config.ts
export default {
id: "shop",
resources: { d1: ["DB"], analyticsEngine: [{ name: "EVENTS" }] },
} as const;
// in the Worker
env.EVENTS.writeDataPoint({
blobs: [request.url, country],
doubles: [durationMs],
indexes: [customerId],
});- Nothing to make, nothing to delete. A dataset exists once something is written to it: the deploy creates and records nothing for it. Cloudflare has no way to delete a dataset; its data expires after 90 days. So
delete --purgeandteardownleave every dataset as it is, including the ones named after the app's Worker. Writes are billed to the Cloudflare account the app is deployed to. - Production. Every one of the app's Workers (its own and each helper) gets
{ type: "analytics-engine-dataset", name }(addAnalyticsEngineBindings()inpackages/sdk/src/cloudflare/worker.ts;cf workers typestypes it asAnalyticsEngineDataset). In the tenant-neutral build the default name holds the placeholder Worker name, and the install rewrites it with every other placeholder; adatasetthe manifest names stays as written. The deploy's policy lets the binding through only under a nameresources.analyticsEnginedeclares and only to the dataset it declares, socloudflare.config.tscan't add a dataset or point one elsewhere on its own, and a named dataset never takes another app'severy-…name (/docs/agent/deploy). - Reading it. Query the dataset through Cloudflare's Analytics Engine SQL API (
cf analytics_engine sql query --body '<SQL>', orPOST /accounts/<id>/analytics_engine/sqlwith a token that may read account analytics). The default name holds-, so double-quote it:SELECT count() FROM "every-acme-shop--events". Unquoted, the parser reads the dashes as minus, and backticks are refused. - Development. The binding is
{ type: "analytics-engine-dataset", name }with the dataset named after the dev Worker (every-<id>--<binding>), and Miniflare accepts everywriteDataPoint()and keeps nothing: nothing reaches the account and there is nothing to query locally. cf's shape for this binding takes nodevoptions. Details on/docs/agent/dev. - Dispatch tier. Refused (Tier limits, below): a dataset's name is account-wide, and the writes would bill the platform's account.
workers
Helper Workers run beside the app's Worker, for work that should not share its memory or CPU budget (the Postiz test fixture's NestJS backend api; a crawler). Each entry:
| Field | Rule |
|---|---|
workers[].name |
Regex ^[a-z]([a-z0-9]|-(?!-))*[a-z0-9]$|^[a-z]$, at most 20 characters; message helper Worker names are kebab-case (lowercase letters, digits, single hyphens). Listed twice: workers lists "<name>" twice. |
workers[].binding |
Optional identifier (strict). A service binding { type: "worker", worker: "W--<name>" } on the app's Worker to the helper's default export. Reusing a binding name: workers: <name>'s binding <BINDING> reuses a binding name the Worker already has. |
workers[].limits |
{ cpuMs } for that Worker (strict, no other keys). |
workers[].limits.cpuMs |
Integer 1 to 300000 (Cloudflare's 5 minutes). |
A helper is built with everyAppAuxiliaryWorker() and gets: EVERYAPP_APP_ID; every D1, KV, R2 and send_email binding; DATABASE_PROVIDER; the Hyperdrive configs shared by all Workers plus those naming it; the classes it defines; in production EVERY_APP_GATEWAY (the AppGateway service binding with the app's identity, so the Gateway treats it as the app), GATEWAY_URL and EVERY_APP_ORG_ID as secrets; in development EVERYAPP_DEV=1. Unknown helper: everyapp.config.ts declares no helper Worker "<name>" in workers. It may take its own crons.
Who may reach it: only the app's own Worker, through binding or the Durable Objects and Workflows the helper defines. The deploy's policy (packages/cli/src/lib/app/policy.ts) refuses a build whose helper is public (workersDev, previewUrls, domains, routes), binds any Worker but the Gateway's AppGateway, or is not named after the app; a built Worker the manifest does not declare (the build has a Worker "<name>" that everyapp.config.ts doesn't declare in workers); and a declared helper missing from the build (the build has no Worker <name>, which everyapp.config.ts declares; add it to the Vite plugin's auxiliaryWorkers with everyAppAuxiliaryWorker()). Helper Workers are not available to hosted third-party apps.
database
| Field | Rule |
|---|---|
database.provider |
d1 or postgres: the database the app's code talks to. Every Worker gets it as the text binding DATABASE_PROVIDER. |
database.region |
Regex ^[a-z0-9]+:[a-z0-9-]+$, message region must be a cloud region such as "aws:us-east-1". Deployed Workers get placement: { region } (cf's targeted placement) so they run near the database; not applied in development. |
Strict: no other keys. migrations.dialect: "postgresql" requires database.provider: "postgres": Postgres migrations (migrations.dialect "postgresql") need database: { provider: "postgres" }. Declaring database reserves the binding name DATABASE_PROVIDER (a secret may not take it). An app on Postgres (Neon, Supabase, any URL) declares it beside a Hyperdrive binding: Postgres.
limits
limits.cpuMs: integer 1 to 300000, the CPU time an invocation of the app's own Worker may use (a Workflow step that crawls for minutes, say). Passed through as the Worker's limits: { cpuMs }. Helper Workers set theirs in workers[].limits. Strict: no other keys.
public
The app is default-private: every route requires an authenticated session unless it matches a declared public route. Each entry is { path, methods? } (strict: nothing else).
public[].path: an absolute glob.*matches within one segment,**across segments,:nameafter a/matches exactly one non-empty segment.public[].methods: HTTP methods, compared case-insensitively. Default["GET"].
What a declaration may not be
assertPublicPathIsSafe() runs in both validators; these fail the deploy rather than silently narrowing:
| Declaration | Message |
|---|---|
Not starting with / |
Public route path must be absolute (start with "/"): "<path>" |
/*, /**, * or / |
Public route "<path>" exposes the entire app. A catch-all public route is not allowed. |
Contains % |
Public route "<path>" must not contain percent-encoding; declare the decoded path. |
Contains \ |
Public route "<path>" must not contain backslashes. |
A . or .. segment |
Public route "<path>" must not contain "." or ".." segments. |
A segment starting : not matching ^:[A-Za-z_][A-Za-z0-9_]*$ |
Public route "<path>" has an invalid named segment "<segment>". |
/__everyapp, anything under it, or a first segment __everyapp |
Public route "<path>" targets the reserved /__everyapp namespace, which can never be public. |
How a request matches
matchPublicRoute(routes, method, rawPathname) in packages/perimeter/src/publicRoutes.ts is default-deny:
- No routes: not public (
no public routes declared). normalizePath(url.pathname)canonicalizes the raw, percent-encoded path. It is ambiguous, and never public, when it is empty, not absolute, has control characters (\x00to\x1f, or\x7f), a backslash, an encoded separator (%2for%5c, any case), invalid percent-encoding, a%surviving one decode (double-encoded), decodes to a control character or backslash, resolves..above the root, or used an encoded dot (%2e) in a..that traversed. Otherwise it drops empty and.segments, resolves.., and yields/a/b/cwith no trailing slash. So/blog/post/and/blog/./postmatch/blog/*;/a/%2fbmatches nothing.- The canonical path inside
/__everyappis refused after normalization (internal namespace is never public), so/p/%2e%2e/__everyapp/tools/callcannot ride a/p/**route./__everyapp/tools/*is refused earlier still, under any spelling, before public matching runs (isReservedToolsPath()). - The first route whose methods include the request's (uppercased) and whose glob matches wins.
globToRegExp()compiles*to[^/]*,**to.*,/:nameto/[^/]+, escapes the rest and anchors with^…$.
From the tests: { path: "/blog/*" } matches /blog/post-1 and not /blog/a/b; { path: "/assets/**" } matches /assets/css/app.css; { path: "/api/webhook", methods: ["POST"] } matches POST and not GET; { path: "/:user/:type", methods: ["GET", "POST"] } matches /alice/intro and not /alice/intro/extra.
What the perimeter does on a public route
In packages/perimeter/src/gateway.ts, after the app is found and active:
- Every inbound
cookie,authorization,cloudflare-workers-version-overrides,x-everyapp-*andcf-access-*header is stripped (stripInboundHeaders()inpackages/perimeter/src/headers.ts).authorizationis put back only on a public route, and only when the Gateway did not consume it: a personal access token (epat_…) or a Gateway OAuth token is never forwarded, and such a bearer without app access fails closed (401unauthenticatedor 403forbidden,reason: "no_app_access") rather than falling to anonymous access. Any other bearer (an app's own API key, a webhook signature scheme) reaches the app on public routes. - CSRF is evaluated on every request, but on a public route a failure only downgrades the request to anonymous (no cookie identity is forwarded); on a private route a cookie-carrying failure is 403
csrf_denied. - Public means auth-optional, not auth-stripped: a signed-in member with access still gets their identity JWT in
x-everyapp-identityon a public route (so/:userbooking pages next to a dashboard serve members signed in). Anonymous visitors and signed-in non-members get the public marker instead: a Gateway-signed, app-scoped JWT withpub: trueandtyp: "public"inx-everyapp-public(PUBLIC_HEADER), cached per app for half the identity TTL (60 of 120 seconds). /mcp, anything under it, and a pattern that covers/mcpunder any of the route's methods (/:page) can't be declared public: the strict schema refuses them (public: /mcp can't be public; an app that runs its own MCP server sets mcp: { by: "app", why: "..." }; for a pattern,public: /:page can't be public (it covers /mcp); …). A stored manifest that still has one is proxied to the app rather than served by the Gateway, and an MCP-only token is403 token_scopethere (seemcp).
In the app, getIdentityFromRequest() (packages/sdk/src/internal/identity.ts) verifies the marker and resolves to { user: null, isPublic: true }; a bare or forged header throws, so a re-exposed Worker fails closed. Everything else needs the identity JWT.
Why a route is public
Declare a route public when something other than a signed-in member of the organization has to reach it: a provider's webhook (declare methods: ["POST"]; the Gateway forwards the body and the provider's authorization header untouched, and the app verifies the provider's signature itself, as it would anywhere), a tracking script and its collector that run on other sites, an OAuth callback the provider redirects the person's browser to (their session cookie still travels, so a member keeps their identity), or a page meant for the public. A health check is not one of them: an uptime monitor reaches an app's private /health with a personal access token, and the Gateway's own GET https://<gateway>/health is public (Cloudflare Access, "Uptime monitoring"). Keep it to exact paths or one named segment; the catch-alls are refused, /mcp can't be public, and /__everyapp can never be public. The invariants behind this section are perimeter.public-default-private, perimeter.authorization-forwarding and perimeter.public-auth-optional in docs/threat-model.md (/docs/agent/repo/threat-model).
providers
providers: an array of ids matching ^[a-z][a-z0-9-]*$, or of { id, why, docs, optional, anyOf } entries (the section on needs, below); ids (provider must start with a lowercase letter and contain only lowercase letters, digits, and hyphens), each once (duplicate provider). The Gateway's AI providers (apps/every-app-gateway/src/server/ai-proxy.ts) are openai (key OPENAI_API_KEY), openrouter (OPENROUTER_API_KEY) and workers-ai (WORKERS_AI_API_TOKEN plus the install's CLOUDFLARE_ACCOUNT_ID). The app calls /api/ai/<provider>/<path> on the EVERY_APP_GATEWAY binding through the SDK's createGatewayFetch(); the Gateway puts the key on the upstream request, so no app holds it.
| Refusal | Code | Status |
|---|---|---|
Provider not in the manifest's providers |
provider_not_allowed, The app manifest does not allow this provider. |
403 |
| Not a provider the Gateway has | ai_provider_not_supported |
404 |
| Key (or account) not set on the Gateway | ai_provider_not_configured, Provider is not configured: the gateway needs <settings> for <provider>. |
503 |
In development there is no Gateway: the SDK calls the provider directly with the developer's own key set locally under the same name (OPENAI_API_KEY in .dev.vars), and everyAppWorker() declares each such key as a secret binding when it is set (DEV_GATEWAY_KEYS in packages/sdk/src/cloudflare/worker.ts). The keys of listed providers are Gateway settings the settings model marks required unless the entry says otherwise (appSettings() in packages/sdk/src/internal/appSettings.ts); see /docs/agent/secrets. Setting a provider's key on the Gateway, step by step: packages/cli/skills/everyapp/setup/openai.md, setup/openrouter.md and setup/workers-ai.md (in an app, .claude/skills/everyapp/setup/<name>.md).
capabilities
capabilities is a record keyed by capability id; each value is { access, why?, docs?, optional?, anyOf?, scopes? } (the need fields are under Required, optional, or one of a group). Strict entries refuse any other key.
| Field | Rule |
|---|---|
capabilities.<id>.access |
"read" or "write", and one the capability offers: <Label> offers <read or write> access otherwise (for example capabilities.email.access: Email offers write access). |
capabilities.<id>.why |
1 to 200 characters, optional: why the app needs it, for whoever approves it. |
capabilities.<id>.scopes |
Scope-set names, for an OAuth client with a scope policy only (google-workspace: identity, gmail.read, calendar.read, search-console.read, analytics.read; microsoft-365: identity, mail.read, calendar.read; slack: channels.read, users.read, users.email, messages.write, channels.join, channels.manage, files.write, user-channels.manage). Required there, one or more, each once: <Label> needs scopes: one or more of <sets> when missing or empty, unknown scope set (<Label> offers: <sets>) at capabilities.<id>.scopes.<n>, a scope set is listed twice. Refused on every other capability: <Label> takes no scopes; list it as { access: ..., why }. The Gateway hands the app the provider scopes behind the sets in integrationStatus(), and holds the app to them at every token mint and refresh (integration_scope_not_allowed, integration_scope_exceeded: /docs/agent/identity; design: docs/design/2026-10-oauth-scopes.md). |
An unknown id fails with unknown capability (the gateway offers: dataforseo, context, email, members, linkedin, facebook, instagram, threads, youtube, tiktok, tiktok-business, reddit, pinterest, discord, slack, mastodon, kick, twitch, tumblr, dribbble, google-workspace, microsoft-365): the catalog's ids. A deploy that has reached the Gateway checks against its registry instead, the catalog and the providers registered on it (/docs/agent/providers), so a registered id passes (checked for the access its spec offers) and one nobody has is CAPABILITY_UNKNOWN. An AI provider fails with <id> is an AI provider: list it under providers, not capabilities. email.senders in the manifest fails with senders moved out of the manifest: set them with `everyapp email set --sender <pattern>` in the app's directory after its first deploy: an installation's senders are its own, not the manifest's. The tolerant schema accepts any { access: "read" | "write", … } entry under any well-formed id.
There is no separate approval step: the deployed manifest's entry is the grant, for calls with a person behind them and background work alike (apps/every-app-gateway/src/server/integration-proxy.ts). Approval is of the app: once the stored manifest lists a capability, every call the app makes may use it, attributed to the signed-in person whose identity it forwards or to the app. The Gateway checks the stored entry at call time (email needs access: "write", members needs "read", every OAuth client "write") and refuses with a JSON body { error, code, message }, the code repeated in the x-everyapp-error header (GATEWAY_ERROR_HEADER; capabilityRefusal() in apps/every-app-gateway/src/server/capabilities/refusal.ts), which is what the app's code sees:
| Code | Status | When | Message |
|---|---|---|---|
capability_not_requested |
403 | The stored manifest does not list the capability | api_key: This app doesn't list <Label> in its capabilities. Add it to everyapp.config.ts (capabilities.<id>) and deploy again.; members: This app doesn't list members in its capabilities. Add capabilities: { members: { access: "read" } } to everyapp.config.ts and deploy again.; email: the same with { email: { access: "write" } }; an OAuth client: the same with { "<id>": { access: "write" } } |
integration_method_not_allowed |
405 | Not a method the capability takes | api_key: <Label> calls may use <methods>., its readMethods joined with "or" (GET or POST for both api_key capabilities); members: Members are listed with GET /api/integrations/members/list (listMembers() in @every-app/sdk/server).; email: Email is sent with POST /api/integrations/email/send (sendEmail() in @every-app/sdk/server). |
integration_path_not_allowed |
403 | api_key: an account-wide path, however spelled; oauth_client: anything but a token endpoint |
<Label>'s <path> describes the whole account the gateway's key belongs to, which every app shares.; Through the gateway, <Label> answers its token endpoints only: <endpoints>. Call its API with the user's token directly. |
integration_not_configured |
503 | The Gateway lacks the key or client | api_key: <Label> isn't set up on this gateway. Its operator sets <SECRET> with: everyapp secret set <SECRET>; an OAuth client: <Label> isn't set up on this gateway. Its operator registers an OAuth client (docs/capabilities.md, "OAuth clients") and gives it to the gateway with: pnpm everyapp secret set <CLIENT ID SETTING> <CLIENT SECRET SETTING>, with the instance setting appended for a capability that has one (oauthClientSecretSetCommand() in packages/sdk/src/internal/capabilities.ts) |
integration_agent_not_allowed |
403 | members called on behalf of an MCP agent (user.channel === "mcp") |
An agent's tool call can't list the organization's members. |
integration_read_only |
403 | email sent on behalf of an MCP agent whose connection lacks mcp:write (or *) |
This agent connection is read-only, and sending email writes. Reconnect the agent with write access. |
integration_scopes_undeclared |
403 | An OAuth client with scope sets listed without scopes |
This app lists <Label> without the scope sets it uses. Add scopes: [...] (one or more of: <sets>) to its "<id>" entry in everyapp.config.ts and deploy again. |
integration_scope_not_allowed, integration_scope_exceeded |
403 | A token request asking, or a token granted, beyond the declared sets | The message names the sets, the excess, and how to reconnect |
integration_scope_unverified |
502 | The provider's token answer names no scopes | <Label> issued a token without saying which scopes it carries, so the gateway can't hold it to this app's scope sets; nothing was passed on. Ask the person to connect again. |
app_monthly_cap_exceeded |
429 | The organization's monthly cap on this app's provider calls (Manage workspace → Usage) is reached: the call cap, or the dollar cap on provider-reported cost | <app> has reached its monthly cap of <n> provider calls for this organization[ (the workspace default)]. Its AI and provider calls are refused until the month ends (UTC) or an owner or admin raises the cap under Manage workspace → Usage. with retry-after; for the dollar cap … its monthly cap of $<dollars> of provider-reported cost for this organization … |
The registry (CAPABILITIES in packages/sdk/src/internal/capabilities.ts):
| Id | Mode | Access | What it is |
|---|---|---|---|
dataforseo |
api_key |
read |
DataForSEO (api.dataforseo.com, sandbox.dataforseo.com); GET and POST count as reads; account-wide paths (tasks_ready, id_list, errors, appendix/user_data, appendix/webhook_resend) are refused. Key DATAFORSEO_API_KEY. |
context |
api_key |
read |
Context.dev (api.context.dev). Key CONTEXT_API_KEY. |
email |
service |
write |
Outbound mail through the Gateway's sender: route send (POST) under /api/integrations/email/, the SDK's sendEmail(). Settings EMAIL_FROM and EMAIL_FROM_NAME; read when set: EMAIL_REST_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, RESEND_API_KEY, EMAIL_PROVIDER. |
members |
service |
read |
The organization's members who can open the app: route list (GET), the SDK's listMembers(). No settings. |
linkedin |
oauth_client |
write |
Client LINKEDIN_CLIENT_ID / LINKEDIN_CLIENT_SECRET. |
facebook |
oauth_client |
write |
FACEBOOK_APP_ID / FACEBOOK_APP_SECRET. |
instagram |
oauth_client |
write |
INSTAGRAM_APP_ID / INSTAGRAM_APP_SECRET. |
threads |
oauth_client |
write |
THREADS_APP_ID / THREADS_APP_SECRET. |
youtube |
oauth_client |
write |
YOUTUBE_CLIENT_ID / YOUTUBE_CLIENT_SECRET. |
tiktok |
oauth_client |
write |
TIKTOK_CLIENT_ID / TIKTOK_CLIENT_SECRET. |
tiktok-business |
oauth_client |
write |
TIKTOK_BUSINESS_CLIENT_ID / TIKTOK_BUSINESS_CLIENT_SECRET. |
reddit |
oauth_client |
write |
REDDIT_CLIENT_ID / REDDIT_CLIENT_SECRET. |
pinterest |
oauth_client |
write |
PINTEREST_CLIENT_ID / PINTEREST_CLIENT_SECRET. |
discord |
oauth_client |
write |
DISCORD_CLIENT_ID / DISCORD_CLIENT_SECRET. |
slack |
oauth_client |
write |
SLACK_CLIENT_ID / SLACK_CLIENT_SECRET. |
mastodon |
oauth_client |
write |
MASTODON_CLIENT_ID / MASTODON_CLIENT_SECRET; instance from MASTODON_URL, default https://mastodon.social. |
kick |
oauth_client |
write |
KICK_CLIENT_ID / KICK_CLIENT_SECRET. |
twitch |
oauth_client |
write |
TWITCH_CLIENT_ID / TWITCH_CLIENT_SECRET. |
tumblr |
oauth_client |
write |
TUMBLR_CLIENT_ID / TUMBLR_CLIENT_SECRET. |
dribbble |
oauth_client |
write |
DRIBBBLE_CLIENT_ID / DRIBBBLE_CLIENT_SECRET. |
google-workspace |
oauth_client |
write |
Gmail, Calendar, Search Console and Analytics, read-only scopes: GOOGLE_WORKSPACE_CLIENT_ID / GOOGLE_WORKSPACE_CLIENT_SECRET. |
microsoft-365 |
oauth_client |
write |
Outlook: MICROSOFT_365_CLIENT_ID / MICROSOFT_365_CLIENT_SECRET. |
Modes: api_key calls go through integrationFetch() to /api/integrations/<id>/<host><path> and the Gateway adds the key; oauth_client means the Gateway completes the provider's token endpoints only, putting the client on them, while the app builds the authorize URL from the public client id (integrationStatus()) and keeps the user tokens; service is something the Gateway does itself at /api/integrations/<id>/<route>. The settings named above are what the settings model (appSettings()) reports the manifest needs on the Gateway. In development, everyAppWorker() declares the matching DEV_GATEWAY_KEYS names that are set locally, plus EVERYAPP_EMAIL_SENDERS for an app listing email. The full design is /docs/agent/repo/capabilities. Setting a capability up on the Gateway, step by step: packages/cli/skills/everyapp/setup/dataforseo.md, setup/context.md, setup/email.md, setup/google.md (google-workspace), setup/microsoft-365.md and setup/slack.md, indexed by setup/README.md; an app carries copies under .claude/skills/everyapp/setup/ and .agents/skills/everyapp/setup/.
secrets
secrets names the app's own secrets: an API key it calls out with, a webhook signing secret. everyapp configure <app> sets up everything the manifest declares, each where it is held, and everyapp secret set <NAME> one name; an app's own secret lands on the app's Workers (every helper too), later deploys keep them, so production builds declare none. In development each one set locally is declared as a secret binding, read as the Vite plugin reads secrets: .dev.vars.<mode> or .dev.vars alone when present, otherwise .env, .env.local, .env.<mode>, .env.<mode>.local and the process environment. A secret is bound only when it is set locally, so treat each as optional in the app's Env type (the template generates it with cf workers types, its types:check script).
Declaring a setting never fails a deploy: the deploy reads secrets only to build and check the Worker. It ends with the APP_NEEDS_SETUP warning when a required one is unset, and until it is set the app doesn't open (below). An optional one fails only where it is used: a never-set secret is absent from env; an unset provider key answers ai_provider_not_configured, an unset capability integration_not_configured, both 503. everyapp secret list [<app>] shows every setting and its state, and everyapp configure [<app>] asks for what is unmet and sets it (packages/cli/src/commands/secret/index.ts, packages/cli/src/commands/configure/index.ts, packages/cli/src/lib/app/settings.ts).
Rules in the strict schema (AppSecretsSchemaStrict). The reserved-name and Gateway-setting rules read the name without regard to case (both uppercase it first); the others compare it as written, so FOO and foo are not duplicates of each other and a secret clashes with a binding only in the binding's exact spelling:
| Rule | Message |
|---|---|
An identifier: ^[A-Za-z_][A-Za-z0-9_]*$ |
binding names must be JavaScript identifiers |
| Each once | duplicate secret <NAME> |
Not GATEWAY_URL, CF_VERSION_METADATA, or a name starting EVERYAPP_, EVERY_APP_ or APP__ |
<NAME> is set by everyapp itself (GATEWAY_URL, CF_VERSION_METADATA and the EVERYAPP_*, EVERY_APP_* and APP__* names) |
Not a settable Gateway setting (SETTABLE_GATEWAY_SETTINGS in packages/sdk/src/internal/gatewaySettings.ts: every provider key, every capability's client ids and secrets, AI_PROXY_EXTRA_ROUTES, the email, sign-in, MCP, Cloudflare Access and hosted settings) |
<name> is a Gateway setting, which `everyapp secret set <NAME>` sets on the Gateway for every app; list `<id>` under providers instead (or under capabilities instead), or ; give the app's secret another name when no provider or capability owns it |
Not a remote MCP app's credential name: nothing ending in _MCP_TOKEN (REMOTE_MCP_SECRET_PATTERN, any case; /docs/agent/secrets) |
<NAME> is a remote MCP app's credential name (every name ending in _MCP_TOKEN is the Gateway's, set with `everyapp secret set`); give the app's secret another name |
Not one of the app's bindings: its resources names, workers[].binding, DATABASE_PROVIDER when database is set, and ASSETS (bindingNamesOf()) |
Invalid everyapp.config: secrets: <NAME> is one of the app's bindings; pick another name for the secret |
Examples: secrets: ["openai_api_key"] fails with secrets.0: openai_api_key is a Gateway setting, which `everyapp secret set OPENAI_API_KEY` sets on the Gateway for every app; list `openai` under providers instead; secrets: ["EMAIL_FROM"] says list `email` under capabilities instead. Install-owned Gateway settings (BETTER_AUTH_SECRET, JWT_PRIVATE_KEY, …) are not in the settable list, so an app may have a secret of that name; the two never meet.
The tolerant schema has its own limits: at most 100 names, each at most 64 characters, matching the identifier regex (a secret's name is an identifier). The strict schema replaces the field with the rules above and carries no count or length cap, so only registration enforces 100 and 64. The SDK repeats the reserved-name rule (RESERVED_SECRET_NAME in packages/sdk/src/cloudflare/worker.ts: secrets: <NAME> is a name the deploy sets or a binding it builds.). Hosted third-party apps may declare none (below).
Required, optional, or one of a group
Every providers, capabilities and secrets entry carries a need, read by the settings model (appSettings() in packages/sdk/src/internal/appSettings.ts; the maintainer reference is docs/manifest-settings.md). A bare string in providers or secrets, or a capability entry with neither flag, is required: the app does not open until the setting is set (the Gateway's home and Admin → Apps show "Needs setup", everyapp status and everyapp secret list show the SETTINGS table, and the app's host is gated (apps/every-app-gateway/src/server/app-needs-setup.ts): a private HTML GET navigation gets 302 to <GATEWAY_URL>/?setup=<appId>, the launcher's setup dialog; everything else, public routes included, gets 503 { "error": "app_needs_setup", "app" }, never naming a setting. Its tools stay out of /mcp meanwhile, and everyapp_list_apps lists it available: false). The author's rule: required means the app is useless without it; a setting only one feature needs is optional, and that feature says what is missing where it is used.
An entry may be written as an object to say more:
| Path | Meaning |
|---|---|
providers[].id |
The provider, as the bare string form names it (openai, openrouter, workers-ai); each once (duplicate provider, whichever form). |
secrets[].name |
The app's own secret, as the bare string form names it; the same name rules as a bare name (identifier, each once, no reserved or Gateway name, no binding name). |
providers[].why, capabilities.<id>.why, secrets[].why |
Why the app needs it, 1 to 200 characters, shown to whoever sets it. |
providers[].docs, capabilities.<id>.docs, secrets[].docs |
Where the key or client comes from: an https URL (must be an https URL). Providers and capabilities get a default from the SDK registries (the provider's key page, an api_key capability's docs, an OAuth client's console); the manifest may override it. |
providers[].optional, capabilities.<id>.optional, secrets[].optional |
true: the app opens without it; the feature that uses it says what is missing (integrationStatus().configured, or the ai_provider_not_configured / integration_not_configured refusal). |
providers[].anyOf, capabilities.<id>.anyOf, secrets[].anyOf |
The name of a group (1 to 40 characters, trimmed) of which one set member is enough; more set is fine. Only for an app that picks among the set members at run time: the Gateway refuses a call to a provider whose key is unset whichever others are set. An app that picks at build time declares that one required and the others optional. |
secrets[].setup |
For an app's own secret, its setup guide in the everyapp skill, setup/<name>.md (must be a guide in the everyapp skill, such as "setup/main-secret.md"). Providers and capabilities take theirs from the registries (setup/openai.md, setup/dataforseo.md, …) and refuse the field (Unrecognized key: "setup"). |
The strict schema refuses an entry that is both optional and in an anyOf group (<path> is both optional and in the any-of group "<group>"; a group's member is optional by itself, so drop one of them.) and a group with one member counted across providers, capabilities and secrets (the any-of group "<group>" has one member (<path>); an any-of group needs two or more. Drop anyOf, or mark it optional.). The tolerant schema accepts both forms and passes an entry's unknown fields through, so a manifest from a newer CLI still registers.
providers: ["openrouter"],
capabilities: {
dataforseo: { access: "read", why: "Keyword, SERP and backlink data." },
context: { access: "read", optional: true, why: "Renders JavaScript-heavy pages in site audits." },
},
secrets: [
{ name: "MAIN_SECRET", why: "Signs visitor salts and collect tokens; 32 or more random characters, e.g. openssl rand -base64 32." },
{ name: "GITHUB_TOKEN", optional: true, why: "Raises GitHub's rate limit.", docs: "https://github.com/settings/tokens" },
],What is set is read per holder: a provider's or capability's settings on the Gateway Worker, an app's own secrets as the CLI last recorded them on the app's registry row (apps.secret_names; never recorded counts as unknown and never blocks). everyapp status --json carries the result as data.apps[].setup, everyapp secret list [<app>] --json as data.app.setup, and the one command that opens an app is pnpm everyapp configure <app> (setup.command), which asks for each unmet setting, which member of an any-of group, then the optional ones, and sets each where it is held; pnpm everyapp secret set <NAME> [--app <id>] sets one name. See /docs/agent/secrets and /docs/agent/json.
scopes
scopes is a record of scope id to description. Ids match ^[a-z0-9][a-z0-9:_-]{0,63}$ (invalid scope id); descriptions are 1 to 200 characters. * and any id starting provider: are refused in both schemas (reserved scope id): provider:* scopes belonged to the retired egress credential plane and must not be registrable from any path. The schema describes them as opaque app-domain scopes that personal access tokens may request; the identity JWT carries a token's scopes claim to the app. No Gateway code reads manifest.scopes beyond validating it: a personal access token's scopes are whatever the person typed, trimmed, deduplicated and capped in number without consulting any manifest (apps/every-app-gateway/src/server/services/UserPatService.ts), and the app checks them itself with hasScope(user, scope) (packages/sdk/src/server/everyApp.ts; * grants every scope).
ai
ai limits what the app may do through the AI proxy (/api/ai/<provider>); strict: no other keys. It is not resources.ai, the Workers AI binding (### Workers AI above), which the proxy never sees.
| Field | Rule |
|---|---|
ai.models |
Model ids (1 to 200 characters each) the app may request from inference endpoints. Omitted: any model. |
ai.routes |
OpenAI API paths beyond inference, each matching ^\/v1(\/[a-z0-9_-]+)+$ (must be an OpenAI API path such as "/v1/files"); each covers its sub-paths. Omitted: inference only. |
ai.chat |
The app needs a chat model and takes whichever the Gateway's operator made the default: true, or an entry with the need fields of the settings model, ai.chat.why, ai.chat.docs, ai.chat.optional and ai.chat.anyOf (strict: nothing else). The app calls /api/ai/default/v1/chat/completions (and /v1/embeddings) with the SDK's ai(); ai.models doesn't apply there (the operator chose the model). An app that needs one provider lists it under providers instead, or as well. See /docs/agent/ai. |
Enforcement (apps/every-app-gateway/src/server/ai-proxy.ts):
- Inference routes are always allowed as POST to the exact path.
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. ai.modelsis checked on inference routes, where the Gateway reads the model from the body:model_not_allowed(403),The app manifest does not allow this model (<model>); list it in ai.models.A request naming no model is refused too. Routes opted into withai.routesstream through unread.- Any other path needs both the app's
ai.routesand the operator's allowlistAI_PROXY_EXTRA_ROUTES(a Gateway setting, comma or space separated, same pattern, each covering its sub-paths) to cover it, and the provider must support extra routes (onlyopenaidoes): otherwiseai_route_not_allowed(403),This provider endpoint is not available to apps. Why: the one key is shared by every app in every organization, and providers keep files, batches and fine-tunes per account, so an app's ownai.routesnever widens what the shared key can do by itself. The settings model marksAI_PROXY_EXTRA_ROUTESrequired whenai.routesis non-empty.
email.inbound: true when the Worker exports an email() handler beside everyApp()'s fetch. The addresses are not in the manifest: everyapp email set --inbound <address> writes them to the installation's row (apps.email_inbound) and the deploy's email routing step routes them (packages/cli/src/lib/app/emailRoutes.ts reads receives: manifest.email?.inbound === true). Senders for the email capability are set the same way with --sender <pattern>. Design: /docs/agent/email.
Strict schema: { inbound?: boolean } and nothing else; a non-boolean (an address list from before the move) fails with inbound is true (the Worker exports an email() handler) or left out; the addresses moved out of the manifest: set them with `everyapp email set --inbound <address>` in the app's directory after its first deploy. Tolerant schema: inbound: z.boolean().optional().catch(undefined) with passthrough, so a row written before the move reads as undeclared and the Gateway keeps routing the app until its next deploy. Dispatch-tier apps may not declare it (below).
access
access is everyone or admins and decides who can open the app when it is first registered (initialAccess() in packages/perimeter/src/registry/registration.ts):
admins(the default): the deployer and the organization's owners and admins get grants; nobody added later.everyone: every member, and the app becomes the organization's default (is_default), so future members get it too.
A redeploy of an existing app writes no grants (kind: "existing"); admins manage access in the Gateway UI afterwards. A deleted app registered again (kind: "revived") is granted anew. A shared-scope app grants nobody: membership of any active organization opens it. Before the Gateway has an owner there is nobody to grant; the setup claim gives the first owner every app nobody has access to.
serverFnGuard and serverFnBase
TanStack Start server functions are cookie-authenticated RPC endpoints under one prefix, and the session cookie spans every app subdomain. Without a guard, a link, redirect or <img> on any sibling app could invoke one with the member's identity, and a navigation could render its response as a page (CVE-2026-102989). So the perimeter lets only same-origin fetch() calls from TanStack's client through, on every method, before any auth: real clients always send x-tsr-serverFn: true (1.168+) or x-tsr-redirect: manual (1.140-era), which a cross-origin page cannot add without a CORS preflight the app never grants. The check runs in packages/perimeter/src/gateway.ts once the app is found and active, before the session cookie is read or any auth runs; a refusal is 403 server_fn_denied with a reason: server function navigation (sec-fetch-mode navigate or nested-navigate, a sec-fetch-dest of document, iframe or frame, or, without Fetch Metadata, an accept that includes text/html), sec-fetch-site <value> for a sec-fetch-site other than same-origin, or missing server function client header (evaluateServerFnRequest() in packages/perimeter/src/serverFunctions.ts).
serverFnGuard:falseturns the guard off, for an app whose server functions must be reachable some other way. Defaulttrue.serverFnBase: regex^(\/[A-Za-z0-9_~-][A-Za-z0-9._~-]*)+\/?$(must be a path such as "/app/_serverFn"). Default/_serverFn. An app with a router basepath or its ownserverFns.basesets it so the guard covers that path. Matching decodes and resolves the path as Start does (/%5FserverFn/…and/x/%2e%2e/_serverFn/…both reach it), case-insensitively.
build, dev and install
build: a shell command run withEVERYAPP_BUILD=1in the app root (packages/cli/src/lib/app/buildApp.ts). Default:cf buildwith no--mode(cf refuses--modefor TanStack Start projects, and Vite builds in production mode by default), 10-minute timeout, failureBUILD_FAILED. The result must be a production Build Output in.cloudflare/output/v0, or the deploy's policy failsAPP_POLICY:The app's Build Output isn't a production build. The manifest's build command must run `cf build` (production by default).dev: a shell commandeveryapp devruns withPORTset to the internal loopback port the gateway-lite proxies to; it must listen there (packages/cli/src/commands/dev/appServer.ts). Default:cf dev, withPORTand the dev registry environment. An app with neitherdevnor acloudflare.config.tsfailsMISSING_CLOUDFLARE_CONFIG.install:falseskips dependency installation; a string runs through the shell; omitted auto-detects (packages/cli/src/lib/package-manager.ts): thepackageManagerfield ofpackage.json(pnpm,yarnornpm), elsepnpm-lock.yaml,yarn.lockorpackage-lock.json, elsepnpm. FailureINSTALL_FAILED. A release deploy (install <url>) skips it.
migrations
migrations is strict (no other keys) in both schemas. migrations.engine is required whenever migrations is declared (Invalid everyapp.config: migrations.engine: Invalid option: expected one of "drizzle"|"d1-sql"|"prisma" otherwise, so migrations: { dir: "migrations" } alone fails); every other key is optional. An absent migrations field means the drizzle engine against the first D1 binding, on both the deploy and the dev-preflight paths (packages/cli/src/lib/manifest.ts); diverging would silently skip the preflight for every app that does not spell the field out.
| Field | Values | Default | Meaning |
|---|---|---|---|
migrations.engine |
drizzle, d1-sql, prisma |
required | What writes and applies the migrations: drizzle-kit; SQL files written some other way (Prisma's generated SQL for D1, with --> statement-breakpoint between statements); or prisma migrate deploy (Postgres only). |
migrations.dialect |
sqlite, postgresql |
sqlite |
sqlite: the D1 database binding names. postgresql: the Postgres database at POSTGRES_DATABASE_URL in .everyapp/secrets.env, migrated before the upload. |
migrations.dir |
relative path | drizzle (migrationsDir()) |
The SQL migration files, relative to the app root. |
migrations.config |
relative path | the tool's default | The drizzle config (or prisma.config.ts on the prisma engine); the schema check and the Postgres migration run the tool with it. |
migrations.binding |
a D1 binding | the first of resources.d1 |
The D1 database to migrate. Must be declared: Manifest migrations binding "<NAME>" is not declared in resources.d1. |
Refinements, each with its message:
| Rule | Message |
|---|---|
engine: "prisma" needs dialect: "postgresql" |
migrations.engine: Prisma migrations run on Postgres (dialect "postgresql"); D1 takes the generated SQL through "d1-sql" |
dialect: "postgresql" refuses engine: "d1-sql" |
migrations.engine: Postgres migrations run on the "drizzle" or "prisma" engine |
dialect: "postgresql" refuses binding |
migrations.binding: binding names a D1 database; Postgres migrations have none |
config is relative, without .. |
migrations.config: config must be a relative path without .. segments |
engine: "d1-sql" needs dir |
migrations.dir: dir is required when migrations.engine is "d1-sql" |
dir is relative, without .. |
migrations.dir: dir must be a relative path without .. segments |
dialect: "postgresql" needs database.provider: "postgres" |
Postgres migrations (migrations.dialect "postgresql") need database: { provider: "postgres" }. |
What runs (packages/cli/src/commands/deploy/deployApp.ts): on D1, cf d1 migrations apply against the provisioned database from dir, with a restore point recorded first; skipped when the app has no D1 binding or the directory is missing. In dev, cf d1 migrations apply --local against the same local id, for the drizzle and d1-sql engines alike. On Postgres, drizzle-kit migrate or prisma migrate deploy runs against POSTGRES_DATABASE_URL after the resources are provisioned, the app built and its Build Output checked, and before the upload; the URL itself is checked before anything is created (POSTGRES_URL_MISSING when unset: <id> needs a Postgres URL to migrate its database, fix set POSTGRES_DATABASE_URL in .everyapp/secrets.env, then deploy again). Prisma's run is skipped only when a dir the manifest names is missing, since Prisma finds its migrations through its config; there is no restore point (its own backups are the way back). The schema drift check (packages/cli/src/commands/deploy/steps/checkSchemaDrift.ts) runs only on the drizzle engine: d1-sql is skipped silently, prisma with the step message Schema not checked against migrations (Prisma needs a shadow database; --verbose shows how). On drizzle it warns SCHEMA_CHECK_SKIPPED (Can't check the schema against the migrations: <why>) when drizzle-kit is not installed (drizzle-kit isn't installed, fix add drizzle-kit to devDependencies), when there is no drizzle.config.ts (with the detail An app that writes its SQL by hand sets migrations.engine to "d1-sql".), or when the migrations.config it names is missing (no <config> (migrations.config)). Releases: packing one refuses the prisma engine (RELEASE_MIGRATIONS_UNSUPPORTED: <id> migrates with Prisma, which a release can't carry, rerun <cli> deploy from the app's checkout; packages/cli/src/lib/app/release.ts), and install <url> refuses any release whose manifest has migrations.dialect: "postgresql", drizzle included (the same code: <id> can't deploy from a release: its Postgres migrations need the source, fix clone it, then run pnpm install && <cli> deploy in it, where <cli> is how everyapp was run (invocation() in packages/cli/src/lib/output/command.ts: pnpm everyapp under pnpm, npx everyapp under npm, yarn everyapp under yarn, everyapp when run directly)), because a release carries SQL files that only the D1 migration applies by itself. Browser installs of third-party apps accept only D1 SQL migrations (sqlite dialect, drizzle engine).
Postgres
EveryApp reaches Postgres through Cloudflare Hyperdrive, which the deploy provisions with cf: any Postgres with a URL works, Neon, Supabase, PlanetScale Postgres, Xata, Crunchy Bridge or a server of your own, and a MySQL URL (mysql://) makes a MySQL config the same way. There is no Postgres resource to create in Cloudflare and no driver to proxy: the database stays where it is, and the app's Workers bind a Hyperdrive config that holds its credentials and pools connections to it.
What a person does, three steps:
Make the database at the provider and copy its URL (for Neon and Supabase the direct one, below).
Put it in the app's
.everyapp/secrets.env, which stays on the machine that deploys (.everyapp/is gitignored; never paste it into a chat):POSTGRES_DATABASE_URL=postgres://user:password@host:5432/dbDeclare it in
everyapp.config.ts:resources: { hyperdrive: [{ name: "HYPERDRIVE" }] }, database: { provider: "postgres" }, migrations: { engine: "drizzle", dialect: "postgresql" },
Then pnpm everyapp deploy creates the Hyperdrive config every-<orgKey>-<appId>--hyperdrive pointed at that URL with query caching off (Hyperdrive; caching: "default" turns Cloudflare's on), binds it as HYPERDRIVE on every Worker of the app with DATABASE_PROVIDER=postgres, and runs drizzle-kit migrate (or prisma migrate deploy, migrations.engine: "prisma") against the URL before the upload (migrations). Leave out migrations and nothing is migrated: the app migrates its database itself. database.region ("aws:us-east-1") places its Workers near the database. Without the URL the deploy stops with HYPERDRIVE_ORIGIN_MISSING (or, with Postgres migrations, POSTGRES_URL_MISSING before anything is created); a URL it can't read is DATABASE_URL_INVALID. Every variable of the file: /docs/agent/secrets.
In code, open a client on the binding's connectionString inside each request (Cloudflare refuses a socket one request opened to another), with postgres (postgres.js) or pg:
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
// In the handler, per request: Hyperdrive pools the connections at the edge.
// Env types HYPERDRIVE optional: under everyapp dev it exists only with a local URL (below).
const url = env.HYPERDRIVE?.connectionString;
if (!url) throw new Error("No HYPERDRIVE binding");
const sql = postgres(url, { max: 1, fetch_types: false });
const db = drizzle(sql);OpenSEO does this (open-seo/src/db/pg/client.ts: withPgClient() keeps the request's client in AsyncLocalStorage; open-seo/src/db/provider.ts reads DATABASE_PROVIDER and the binding; open-seo/drizzle/pg.config.ts is the drizzle-kit config that reads process.env.POSTGRES_DATABASE_URL). With pg, new Client({ connectionString: env.HYPERDRIVE.connectionString }) and await client.connect() per request. A MySQL config has no database.provider or migration dialect of its own: set HYPERDRIVE_ORIGIN_URL=mysql://…, leave database and migrations out, and connect with mysql2 on the binding's host, user, password, database and port.
In development (everyapp dev) Hyperdrive isn't contacted: set CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE=postgres://… in .dev.vars (a local Postgres, or a development branch of the database) and env.HYPERDRIVE.connectionString is that URL; without it the binding is absent (/docs/agent/dev).
Neon and Supabase each offer a pooled URL and a direct one. Hyperdrive pools connections itself and wants the direct one: on Neon the host without -pooler (connection pooling unchecked in the dashboard's connection details), on Supabase the "Direct connection" string. drizzle-kit migrate works over either, so one direct POSTGRES_DATABASE_URL serves both; to give the config another URL than the migrations, set HYPERDRIVE_ORIGIN_URL beside it (a binding's own variable wins). Neon's API key in the providers catalog (NEON_API_KEY) manages projects and branches through the Gateway; it is not how an app reaches its database.
Turso is libSQL (SQLite), not Postgres, so it isn't a Hyperdrive origin. Its URL and token are the app's own secrets, declared under secrets (TURSO_DATABASE_URL, TURSO_AUTH_TOKEN) and set with pnpm everyapp configure <app> after the first deploy (.dev.vars in development); the app connects over HTTP with createClient({ url: env.TURSO_DATABASE_URL, authToken: env.TURSO_AUTH_TOKEN }) from @libsql/client/web (drizzle-orm/libsql on top). database.provider stays d1 or is left out, and the deploy runs no Turso migrations: run drizzle-kit migrate (dialect turso) yourself.
mcp
mcp is an object, { by, why }, and is left out by default: then the app's host serves no MCP, https://<app-host>/mcp is a private route like any other (the app answers it, usually 404), and https://<gateway>/mcp is the one connector, serving this app's tools with every other the caller can use. An app that needs a connector of its own opts in with a reason:
mcp.byisgatewayorapp.gateway: once the perimeter has authenticated the caller (a personal access token, or a same-origin browser session that passed CSRF) and confirmed access, the Gateway's ToolBus serves/mcpfrom the app's catalog snapshot, the tools the app defines withdefineTools()(apps/every-app-gateway/src/server/toolbus/app-host-mcp.ts); with no snapshot to serve (nodefineTools(), or the catalog not readable), the answer is 503{ "error": "mcp_unavailable", "app" }, never the app.app: the app runs its own MCP server at/mcpand the perimeter proxies the path to it with the identity header; that identity carries the token's scopes for the app to read (chan: "api", nevermcp), so the Gateway's tool policy, per-calltcclaim and audit row do not apply there, and a read-only token is read-only only if the app's server honoursscopes.mcp.whyis a non-empty string of at most 200 characters, plain text (no control characters), shown byeveryapp deploy(Serves /mcp on its own host: <why>) andeveryapp status.
The old string form (mcp: "gateway") is refused by both schemas with the message mcp is { by: "gateway" | "app", why: "..." }: …. Whatever the field says, an MCP-only token (every scope an mcp: scope) answers at /mcp and nowhere else, and only where MCP is served there: on a host whose manifest has no mcp it opens nothing at all (403 forbidden, reason: "token_scope" on every route, /mcp included), and the same at a /mcp a stored row's public route still covers on a by: "gateway" host: apps do not scope-check their own routes. everyapp dev serves http://<app-id>.localhost:8787/mcp from its local ToolBus whatever the manifest says (an app with by: "app" keeps its own server there too). Cloudflare Access lets /mcp through on an app's host only when its manifest has mcp (Cloudflare Access). See /docs/agent/mcp.
remote
remote is not a field of everyapp.config.ts. It is a field of the manifest the Gateway's registry stores on a remote MCP app's row: a provider-tier row (PROVIDER_APP_TIER, no Worker, no hostname) that pnpm everyapp mcp add <url>, Admin → Apps → Add app → Add an MCP server or planRemoteMcpAppAdd (packages/perimeter/src/registry/remoteMcpApps.ts) writes. Nobody authors it: the strict schema refuses it in an app's file (Invalid everyapp.config: (root): Unrecognized key: "remote"), and the tolerant schema passes it through on a stored row like any field it doesn't know. The block (url, auth, risk, tools, rev) is read by parseRemoteMcpBlock and remoteMcpOf in packages/sdk/src/internal/remoteMcp.ts, field by field, wherever the Gateway serves the row, so a row edited by hand can't widen what the rules allow. The settings model reads remote.auth.secret as one required Gateway setting of kind remote-mcp. Every field and rule: /docs/agent/mcp.
Tier limits
manifestTierProblems(manifest, tier) returns sentences for what an app on a tier cannot declare; everyapp deploy runs it against the registered app's tier and fails with <id> can't be deployed as configured followed by them.
| Tier | Result |
|---|---|
service_binding (self-hosted: the app's Worker is bound to the Gateway) |
Nothing refused; every field above is available. |
dispatch (hosted third-party: customer code in a Workers for Platforms namespace) |
resources.hyperdrive: Hyperdrive isn't available to apps in a dispatch namespace (an account holds at most 25 configs); resources.r2: R2 buckets aren't available to apps in a dispatch namespace yet; resources.ai: the Workers AI binding isn't available to apps in a dispatch namespace (it bills the platform's account); list the workers-ai provider instead; resources.browser: Browser Rendering isn't available to apps in a dispatch namespace (it bills the platform's account); resources.images: the Images binding isn't available to apps in a dispatch namespace (it bills the platform's account); resources.queues: Queues aren't available to apps in a dispatch namespace yet; resources.vectorize: Vectorize indexes aren't available to apps in a dispatch namespace yet; resources.aiGateway: AI Gateways aren't available to apps in a dispatch namespace yet; resources.sendEmail: Email sending bindings aren't available to apps in a dispatch namespace (they send through the Gateway's email capability); resources.secretsStore: Secrets Store secrets aren't available to apps in a dispatch namespace (the store is the platform account's); resources.analyticsEngine: Analytics Engine datasets aren't available to apps in a dispatch namespace (their names are account-wide and their writes bill the platform's account); email.inbound: apps in a dispatch namespace don't receive mail (DISPATCH_INBOUND_PROBLEM; the platform's zones aren't an organization's to route); secrets: third-party apps can't have secrets of their own yet (a third-party Worker gets GATEWAY_URL and EVERY_APP_ORG_ID only). |
| Anything else | apps on tier "<tier>" can't be deployed |
The hosted Gateway's deployer (apps/every-app-gateway/src/server/deploy/metadata.ts, manifestProblems()) refuses more for a third-party bundle: workers, resources.workflows, resources.kv (use D1), R2 or Hyperdrive (which the browser deployer can't provision; use the CLI), resources.sendEmail (send through the Gateway's email capability), Queues (it declares Queues, which the browser deployer can't provision; use the CLI), migrations other than sqlite/drizzle, secrets, email.inbound, a Durable Object class naming a helper Worker (a Durable Object class names a helper Worker), and more than 2 Durable Object classes (it declares <n> Durable Object classes; a third-party app may have 2; durableObjectClasses: 2 in THIRD_PARTY_LIMITS, apps/every-app-gateway/src/server/platform-policy.ts, and the same in BROWSER_INSTALL_LIMITS). Its bundle check (bundleProblems(), the same file) refuses a Workers AI, Browser Rendering, Images or Analytics Engine binding as a type third-party apps can't have (AI is a ai binding, which third-party apps can't have, BROWSER is a browser binding, which third-party apps can't have, IMAGES is a images binding, which third-party apps can't have, EVENTS is a analytics-engine-dataset binding, which third-party apps can't have); the manifest check doesn't name resources.ai, resources.browser, resources.images or resources.analyticsEngine yet. The same for resources.secretsStore: the bundle check refuses a secrets-store-secret binding as a type third-party apps can't have. The same holds for resources.vectorize: its manifest check passes, and the bundle check refuses the binding it builds (is a vectorize binding, which third-party apps can't have).
What the built Worker must be
The deploy checks the Build Output against a policy before cf deploy --prebuilt uploads it (packages/cli/src/lib/app/policy.ts, error APP_POLICY; the full list is on /docs/agent/deploy). In short: the Worker is named as the registry says; workersDev and previewUrls are false; no domains, routes or triggers other than cron schedules and consumers of the app's own queues (their dead-letter queues too); no EVERYAPP_DEV; EVERYAPP_IDENTITY_PUBLIC_KEYS and EVERYAPP_IDENTITY_ISSUER built in as text; EVERYAPP_AUDIENCE equal to the Worker name; only text, json, secret, version-metadata, assets, d1, kv, r2, durable-object, workflow, worker, hyperdrive, queue, ai, browser, images, vectorize, send-email, secrets-store-secret and analytics-engine-dataset bindings; service bindings only to the Gateway's AppGateway and, from the app's own Worker, its helpers; D1, KV, Hyperdrive, queues, Vectorize indexes and AI Gateways only as the deploy provisioned; buckets only from resources.r2 (or, without any, named after the Worker); Workers AI only under a name resources.ai declares; Browser Rendering only under a name resources.browser declares; Images only under a name resources.images declares; send-email only as resources.sendEmail declares it, by name and addresses; a Secrets Store secret only as the deploy provisioned it for a binding resources.secretsStore declares; Analytics Engine only under a binding resources.analyticsEngine declares, and only to the dataset it declares; Workflows and Durable Objects only the app's own; no Durable Object class deleted, transferred or container-backed. Build from everyAppWorker() and the policy holds by construction.
Examples
The starter (starter/everyapp.config.ts): one D1 database named after the Worker, one public route and one capability; everything else is private and refused.
export default {
id: "simple-todo-template",
name: "Simple Todo",
description: "A todo list that shows who you are and what it was allowed.",
resources: {
d1: ["DB"],
},
// Every route needs a member the Gateway signed in. A route something else
// must reach (a provider's webhook, an OAuth callback) is listed here:
// public: [{ path: "/webhooks/github", methods: ["POST"] }]
// What the Gateway does for the app with keys it holds. A call for anything not listed is refused.
capabilities: {
members: { access: "read", why: "Shows who can open this app." },
},
} as const;A Postgres app with a helper Worker and a Workflow. Deployed for organization acme it is Worker every-acme-crawler with helper every-acme-crawler--crawl, Hyperdrive config every-acme-crawler--hyperdrive and Workflow every-acme-crawler--crawl-site, defined and exported by the helper; both Workers read DATABASE_PROVIDER=postgres and run near aws:us-east-1; drizzle-kit migrate runs against POSTGRES_DATABASE_URL from .everyapp/secrets.env before the upload. (This one validates against the strict schema; it is not a manifest in the repository.)
export default {
id: "crawler",
name: "Crawler",
description:
"Crawls the sites the team tracks and keeps their pages in Postgres.",
resources: {
hyperdrive: [{ name: "HYPERDRIVE", caching: "off" }],
workflows: [
{ name: "CRAWL_SITE", className: "CrawlSiteWorkflow", worker: "crawl" },
],
},
workers: [{ name: "crawl", binding: "CRAWL", limits: { cpuMs: 300_000 } }],
database: { provider: "postgres", region: "aws:us-east-1" },
migrations: { engine: "drizzle", dialect: "postgresql", dir: "drizzle" },
public: [],
} as const;An app with public webhook routes, capabilities and its own secrets. /webhooks/stripe takes POSTs whose authorization header the Gateway forwards and whose signature the app checks with STRIPE_WEBHOOK_SECRET (set with everyapp secret set STRIPE_WEBHOOK_SECRET); /booking/:uid is one booking page per id, where a signed-in member still gets their identity; the Gateway sends mail for the app through its own sender and lists the organization's members for it. (Validates against the strict schema; not a manifest in the repository. On the dispatch tier it would be refused for its secrets.)
export default {
id: "inbox-relay",
name: "Inbox Relay",
description: "Takes payment webhooks and emails the people on the account.",
resources: { d1: ["DB"] },
public: [
{ path: "/webhooks/stripe", methods: ["POST"] },
{ path: "/booking/:uid", methods: ["GET", "POST"] },
],
capabilities: {
email: {
access: "write",
why: "Emails the account owners when a payment fails.",
},
members: { access: "read", why: "Finds the owners to email." },
},
secrets: ["STRIPE_WEBHOOK_SECRET"],
access: "everyone",
} as const;Real manifests in the repository: starter/everyapp.config.ts (the starter above: D1 DB, no public route (its /health is a private JSON endpoint; the comment shows a webhook as the public example) and the members capability; everyapp create copies it and renames its id and name), packages/cli/src/test/fixtures/manifests/postiz.ts (database: { provider: "d1" }, a d1-sql migration from migrations against DB (nine statements of our own separated by --> statement-breakpoint), a helper Worker api bound as API with cpuMs: 300_000 defining PUBLISH_POST / PublishPostWorkflow, providers: ["openai"] with ai.models, own secrets JWT_SECRET, X_API_KEY, X_API_SECRET, access: "everyone", custom install, build and dev commands) and packages/cli/src/test/fixtures/manifests/agentic-inbox.ts (two SQLite Durable Objects, MAILBOX / MailboxDO and EMAIL_AGENT / EmailAgent, providers: ["workers-ai", "openrouter"] with ai.models, access: "everyone"). Both fixtures end in as const satisfies EveryAppCliManifest, and each carries the cloudflare.config.ts equivalent that builds its Workers for production (postizConfig, agenticInboxConfig).
Sources
packages/perimeter/src/manifest/manifest.tspackages/perimeter/src/publicRoutes.tspackages/perimeter/src/publicRoutes.test.tspackages/perimeter/src/gateway.tspackages/perimeter/src/headers.tspackages/perimeter/src/serverFunctions.tspackages/perimeter/src/registry/registration.tspackages/sdk/src/cloudflare/worker.tspackages/sdk/src/cloudflare/sentinel.tspackages/sdk/src/internal/capabilities.tspackages/sdk/src/internal/gatewaySettings.tspackages/sdk/src/internal/aiProviders.tspackages/sdk/src/internal/protocol.tspackages/sdk/src/internal/identity.tspackages/sdk/src/server/everyApp.tspackages/sdk/src/server/gatewayFetch.tspackages/cli/src/lib/manifest.tspackages/cli/src/lib/app/policy.tspackages/cli/src/lib/app/buildApp.tspackages/cli/src/lib/app/release.tspackages/cli/src/lib/app/localD1.tspackages/cli/src/lib/app/deploySecrets.tspackages/cli/src/lib/app/resources.tspackages/cli/src/lib/app/installBuildOutput.tspackages/cli/src/lib/cf/hyperdrive.tspackages/cli/src/lib/cf/secretsStore.tspackages/cli/src/lib/cf/queues.tspackages/cli/src/lib/cf/vectorize.tspackages/cli/src/lib/cf/aiGateway.tspackages/cli/src/lib/gateway/registry.tspackages/cli/src/lib/output/command.tspackages/cli/src/lib/package-manager.tspackages/cli/src/commands/create/steps/appId.tspackages/cli/src/commands/create/steps/updateConfiguration.tspackages/cli/src/commands/appGateway.tspackages/cli/src/commands/secret/index.tspackages/cli/src/commands/deploy/deployApp.tspackages/cli/src/commands/deploy/steps/checkSchemaDrift.tspackages/cli/src/commands/dev/appServer.tspackages/cli/src/commands/dev/index.tsapps/every-app-gateway/src/server/ai-proxy.tsapps/every-app-gateway/src/server/app-gateway-entrypoint.tsapps/every-app-gateway/src/server/integration-proxy.tsapps/every-app-gateway/src/server/capabilities/refusal.tsapps/every-app-gateway/src/server/capabilities/members.tsapps/every-app-gateway/src/server/capabilities/email.tsapps/every-app-gateway/src/server/capabilities/oauthClient.tsapps/every-app-gateway/src/server/platform-policy.tsapps/every-app-gateway/src/server/deploy/metadata.tsapps/every-app-gateway/src/server/deploy/browser-install.tsapps/every-app-gateway/src/server/deploy/run.tsapps/every-app-gateway/src/server/toolbus/app-host-mcp.tsapps/every-app-gateway/src/server/repositories/AppRepository.tsapps/every-app-gateway/src/server/services/UserPatService.tsapps/every-app-gateway/src/client/components/AppIcon.tsxstarter/everyapp.config.tsstarter/cloudflare.config.tspackages/cli/src/test/fixtures/manifests/postiz.tspackages/cli/src/test/fixtures/manifests/agentic-inbox.ts
Example patterns
How the apps on EveryApp do each thing and the file to copy from, in the starter or one of the four open apps: identity, public routes, every manifest field, email, AI, agents and Workflows, tools, needs setup, cookies and the bundle, tests, dev vs production, release and install.
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.