EveryAppEveryApp

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.

This page is patterns.md of the build-every-app skill (packages/cli/skills/build-every-app/patterns.md in the checkout; in an app .agents/skills/build-every-app/patterns.md), the index Build an app reads before the first edit. Each app's repository is https://github.com/every-app/<name>; clone it beside the app to read a file.

How the apps on EveryApp do each thing, and the file to copy from. starter/ is what pnpm everyapp create copies (in the EveryApp checkout); in an app everyapp create made, a starter/ path is that path in the app. The four apps are open, at https://github.com/every-app/<name>, cited by their repository names: open-crm/ (OpenCRM), open-analytics/ (OpenAnalytics), open-seo/ (OpenSEO) and agentic-inbox/ (Agentic Inbox); a path is relative to that repository's root. Read a file before copying from it: each is short where it matters, and its comments say why. When a cited file is out of reach, the "What it is" and "Copy this" columns are the pattern; start from the starter's file when the row names one.

Identity

Pattern What it is Where Copy this
Identity in a server function requireEveryAppUser(getRequest(), env) in a TanStack Start middleware; every server function lists it and reads context.user starter/src/middleware/ensureUser.ts, starter/src/serverFunctions/todos.ts .middleware([ensureUserMiddleware]) on every server function, and context.user.id as the first argument of every query
Identity resolved once, with the organization getEveryAppUser() over the request's headers; the deploy's EVERY_APP_ORG_ID pin; the role from the token, never from a member row; one context { userId, userEmail, organizationId, role } every repository filters by open-crm/src/middleware/ensure-user/everyApp.ts, open-crm/src/middleware/ensureUser.ts; the same shape in open-seo/src/middleware/ensure-user/everyApp.ts resolveEveryAppUserContext(user): the pin check, ROLES, and that a server function accepts only a credential holding every scope (hasScope(user, "*"))
Identity at the Worker entry everyApp()'s handler gets user as its fourth argument; the app refuses another organization, keeps the user in a WeakMap by request, and its routes read it from there agentic-inbox/workers/app.ts, agentic-inbox/workers/everyapp/identity.ts requireOrganization(env, user) before routing; roleOf(); the requireAdmin middleware on the routes only owners and admins may use
Identity mirrored into the app's tables A Viewer from getEveryAppUser(); the person, their organization and their membership are mirrored into rows so other rows can join on them, and access still comes from the signed identity alone open-analytics/src/lib/edge/auth/viewer.ts mirror() writes at most once an hour per person and is never consulted for access
Identity in a tool handler The handler's second argument carries the verified caller (user.id, user.orgId, user.orgRole, user.scopes); the starter passes user.id to the same queries the page uses starter/src/tools.ts; open-crm/src/server/everyapp/crm-tool.ts crmTool(): one wrapper that resolves the context, scopes the handler to the organization, and turns an AppError into a ToolError
Identity on a public route A public route is open to anyone, so it never assumes a member: getEveryAppUser(request, env) is the member when the Gateway signed one in (a member's session still travels on a public route) and null otherwise. The four apps' public routes read no identity at all: OpenAnalytics' collector checks the token its tracker was issued, OpenCRM's tracker takes the site from the id in the URL open-analytics/src/lib/edge/collector/collect.ts, open-analytics/src/lib/hono/routes/collect.ts; open-crm/src/server/features/tracking/trackingRoutes.ts A public route exists only when the app needs one (a webhook, a collector, an OAuth callback); it checks what it must itself and answers nothing that is anyone's without that check
Organization-wide rows Sharing by design: every table keys on the organization in the signed identity, every member sees the organization's rows, owners and admins manage. OpenCRM hangs every table off organization_id; OpenAnalytics' teamId is the organization; Agentic Inbox is one organization per deploy, every mailbox shared open-crm/docs/maintainers/every-app.md, open-crm/src/middleware/ensure-user/everyApp.ts; open-analytics/src/lib/edge/auth/viewer.ts; agentic-inbox/workers/everyapp/identity.ts The rule in words: authorization reads the organization and the role from the token on every request, never from a row the app wrote
Roles from the Gateway owner, admin and member from user.orgRole; anything else is a member open-crm/src/server/auth/org-gate.ts; agentic-inbox/workers/everyapp/identity.ts requireOrgPermission(context, permissions) throwing FORBIDDEN; isAdmin(user)
The way back A link to the Gateway. The starter's launcher is the identity issuer (EVERYAPP_IDENTITY_ISSUER, the Gateway's URL), or the dev host one label up under everyapp dev; the apps read GATEWAY_URL (set by the deploy, absent under everyapp dev) first, then the issuer. Where: the starter's header ("All apps"), OpenCRM's and OpenSEO's sidebar footer, Agentic Inbox's signed-in line ("Back to EveryApp"). The convention the identity page states for a new app: the first sidebar row, muted "‹ EveryApp", aria-label "Back to EveryApp" starter/src/serverFunctions/home.ts, starter/src/routes/index.tsx; open-crm/src/client/components/Sidebar.tsx, open-crm/src/serverFunctions/everyApp.ts; open-seo/src/client/components/Sidebar.tsx; agentic-inbox/app/routes/home.tsx, agentic-inbox/workers/everyapp/identity.ts launcher (the starter, dev-aware); getEveryAppGatewayUrl(): GATEWAY_URL, then EVERYAPP_IDENTITY_ISSUER

Public routes with their own checks

Pattern What it is Where Copy this
A public route with its own token OpenAnalytics' tracker: /script.js (GET) issues a signed collect token (an HMAC over the site id and the client's address, 12 hours, derived from MAIN_SECRET); /collect (POST) verifies it, then runs the site's blocking rules and bot checks before anything is ingested open-analytics/everyapp.config.ts (public), open-analytics/src/lib/edge/collector/script-endpoint.ts, open-analytics/src/lib/edge/auth/collect-token.ts, open-analytics/src/lib/edge/collector/collect.ts, open-analytics/src/lib/hono/routes/collect.ts verifyCollectToken() before anything is stored; the tracker posts text/plain, so the browser sends no preflight and OPTIONS stays private
A public tracker for another website OpenCRM's /t/*: the loader, a per-site tracker with its config baked in, and a collector that always answers 204; the organization comes from the site id, the client's address only counts requests open-crm/src/server/features/tracking/trackingRoutes.ts, open-crm/everyapp.config.ts The route sets its own CORS and resource-policy headers; withinAddressLimit() before any work
A public route armed by a secret An operator endpoint that answers 404 until ERASURE_SECRET is set, and then only to a request signed with it in the last five minutes open-crm/src/server/features/erasure/erasureRoute.ts Compare signatures in constant time; keep the secret optional and unset it after use
Share links and assets Public pages, their data and the built assets they load, each listed with its method open-analytics/everyapp.config.ts /share/**, /api/public/share/**, /assets/**: list the assets a public page loads, or it renders bare
No public routes public: [] with a comment saying why nothing is open; the Gateway's own tool calls carry their own tokens open-seo/everyapp.config.ts, agentic-inbox/everyapp.config.ts Say why in the manifest comment

The manifest, field by field

Pattern What it is Where Copy this
id, name, description, icon The id names the hostname and the Worker; the description is the app library's one line; the icon an SVG the app serves open-crm/everyapp.config.ts icon: "/everyapp-icon.svg"
resources.d1 The database, bound as env.DB; the deploy creates it starter/everyapp.config.ts resources: { d1: ["DB"] }
resources.kv Per-site settings read on the hot path open-analytics/everyapp.config.ts (SITE_SETTINGS_KV), open-seo/everyapp.config.ts (KV) One namespace per purpose, named for it
resources.durableObjects { name, className }, the class exported from the Worker entry; worker when a helper Worker defines it agentic-inbox/everyapp.config.ts (MAILBOX, EMAIL_AGENT), open-crm/everyapp.config.ts (CRM_AGENT), open-seo/everyapp.config.ts (AUDIT_SCRATCHPAD on the audit Worker) The comment above each: what one instance is (a mailbox, a research session)
resources.workflows and workers Workflows by binding and class, one of them on a helper Worker; the helper declared with its service binding open-seo/everyapp.config.ts workers: [{ name: "audit", binding: "AUDIT_ENGINE" }] with SITE_AUDIT_WORKFLOW on it
An R2 bucket Added in cloudflare.config.ts beside the manifest's bindings, named after the Worker (bindings.r2({ name: worker.name })); resources.r2 in the manifest does the same. The deploy creates the bucket; an account that never enabled R2 stops with R2_NOT_ENABLED until the person enables it once in the Cloudflare dashboard open-crm/cloudflare.config.ts, open-seo/cloudflare.config.ts, agentic-inbox/cloudflare.config.ts Spread worker.env and add to it; import nothing the Worker imports there
migrations with drizzle { engine: "drizzle", dir, config, binding }, and a drizzle config that opens the local D1 pnpm dev serves open-crm/everyapp.config.ts, open-crm/drizzle/sqlite.config.ts, open-crm/drizzle/sqlite/0000_init.sql; the starter's default is starter/drizzle.config.ts with no migrations field localD1Path() from @every-app/sdk/cloudflare for drizzle-kit studio
migrations with hand-written SQL { engine: "d1-sql", dir: "migrations", binding: "DB" } and plain SQL files, one ;-terminated statement after another open-analytics/everyapp.config.ts, open-analytics/migrations/0001_baseline.sql For an app whose schema drizzle doesn't own
public { path, methods } per route, GET by default; a route is listed only when something other than a signed-in member must reach it: a webhook, a collector, an OAuth callback. The starter lists none (its manifest's comment shows a webhook); OpenAnalytics opens its tracker, collector and share pages, OpenCRM its tracker; OpenSEO and Agentic Inbox nothing starter/everyapp.config.ts (the comment), open-analytics/everyapp.config.ts, open-crm/everyapp.config.ts Comment each route with why it is open; /mcp can't be one
providers with why The AI provider the Gateway calls for the app; required unless optional: true open-crm/everyapp.config.ts (openrouter), agentic-inbox/everyapp.config.ts (openrouter required, workers-ai optional) One sentence for whoever sets the key
capabilities: an API key dataforseo and context with access: "read" and a why; optional where a feature can say what is missing open-seo/everyapp.config.ts context: { access: "read", optional: true, why }
capabilities: an OAuth client with scopes google-workspace, microsoft-365 and slack with access: "write", scopes: [...] (the least that does the job) and optional: true; the Gateway holds the client, the app keeps each member's tokens open-crm/everyapp.config.ts scopes: ["gmail.read", "calendar.read"]
capabilities: an OAuth provider the Gateway holds the tokens for github-oauth (or google-workspace, notion-oauth, any the operator added) with access: "read", scopes: [...] and optional: true; the person connects once in the Gateway, the app never sees a token the OpenSEO port's everyapp.config.ts (google-workspace with identity, search-console.read, analytics.read) scopes: ["identity", "repo.read"]
capabilities.email and capabilities.members Email as access: "write" (required for an inbox, optional for reports); members as access: "read" to know who can open the app agentic-inbox/everyapp.config.ts, open-analytics/everyapp.config.ts, starter/everyapp.config.ts The starter's members and listMembers() in starter/src/serverFunctions/home.ts
secrets, required The app's own root secret, with a why that says how to make one open-analytics/everyapp.config.ts (MAIN_SECRET) "32 or more random characters, e.g. openssl rand -base64 32"
secrets, optional Keys one feature needs, each with a why, and docs where a page exists (GITHUB_TOKEN); the feature says so where it is used open-crm/everyapp.config.ts (TOKEN_ENCRYPTION_KEY, GITHUB_TOKEN, OPENROUTER_MODEL, ERASURE_SECRET) { name, optional: true, why }, plus docs when there is a page
access: "everyone" Every member of the organization can open the app; owners and admins manage inside it. OpenSEO leaves the default open-crm/everyapp.config.ts, open-analytics/everyapp.config.ts, agentic-inbox/everyapp.config.ts The default is admins: say everyone when the app is for the team
ai.models The model ids the Gateway accepts from the app; any other is model_not_allowed agentic-inbox/everyapp.config.ts List every id the code can send, per provider
email.inbound The Worker exports an email() handler; the addresses are set after the deploy with everyapp email set --inbound agentic-inbox/everyapp.config.ts email: { inbound: true }
build and dev Commands when the app's build is more than cf build (bundles to build first, an environment variable to set) open-analytics/everyapp.config.ts, open-seo/everyapp.config.ts dev must listen on PORT
compatibilityDate The runtime date every build runs on open-analytics/everyapp.config.ts, open-seo/everyapp.config.ts Pin it when porting an app that ran under Wrangler
cloudflare.config.ts beyond the manifest Crons, compatibility flags and plain text bindings read when the Worker is built open-crm/cloudflare.config.ts (crons: ["* * * * *"], global_fetch_strictly_public), agentic-inbox/cloudflare.config.ts (AI_PROVIDER, AGENT_MODEL), open-seo/cloudflare.config.ts everyAppWorker({ manifest, entrypoint, ctx, crons }), then spread and extend

Cloudflare resources

Pattern What it is Where Copy this
resources.queues A Cloudflare Queue every Worker of the app sends to (await env.JOBS.send(body)), consumed by the app's own Worker (consumer: true) or a helper (consumer: { worker }), with Cloudflare's consumer settings and a dead-letter queue that is another of its queues. The deploy creates and records each queue, cf's upload makes the consumer, pnpm dev runs both locally in Miniflare. everyApp() returns only { fetch }, so the consuming Worker exports queue(batch, env) beside it; without it the upload stops with QUEUE_HANDLER_MISSING https://everyapp.com/docs/agent/manifest#queues (landing-page/content/agent/manifest.md in the checkout): the manifest example and the handler queues: [{ name: "JOBS", consumer: true }], then export default { ...everyApp(handler, { tools }), async queue(batch, env) { for (const message of batch.messages) { …; message.ack(); } } }
Postgres through Hyperdrive Any Postgres with a URL (Neon, Supabase, PlanetScale Postgres, a server of your own): resources.hyperdrive: [{ name: "HYPERDRIVE" }], database: { provider: "postgres" }, migrations: { engine: "drizzle", dialect: "postgresql" }, and the URL as POSTGRES_DATABASE_URL in .everyapp/secrets.env, which the person writes (Neon's and Supabase's direct URL, not the pooled one). The deploy makes the Hyperdrive config, caching off, and runs drizzle-kit migrate before the upload; the Worker opens a client per request on env.HYPERDRIVE.connectionString. OpenSEO's Postgres deployment runs this code; its everyapp.config.ts is the D1 shape its forks deploy open-seo/src/db/pg/client.ts, open-seo/src/db/provider.ts, open-seo/drizzle/pg.config.ts; the manifest and the steps: https://everyapp.com/docs/agent/manifest#postgres withPgClient(): postgres(url, { max: 1, fetch_types: false }) inside each entrypoint, kept in AsyncLocalStorage, never in global scope
Turso libSQL, not Postgres, so not Hyperdrive: the URL and token are the app's own secrets, and the client talks HTTP. No app here uses it https://everyapp.com/docs/agent/manifest#postgres (its last paragraph) secrets: ["TURSO_DATABASE_URL", "TURSO_AUTH_TOKEN"] and createClient({ url, authToken }) from @libsql/client/web; database.provider stays d1 or is left out
Workers AI as a binding resources.ai binds env.AI on every Worker of the app: env.AI.run() directly (the Agents SDK, models the OpenAI-compatible routes don't carry, an AI Gateway by id), billed to the account with no Gateway budget or cap. Under pnpm dev every call throws Binding AI needs to be run remotely until vite.config.ts sets remoteBindings: true and cf auth login has run. For calls the Gateway caps and counts, list the workers-ai provider instead packages/sdk/src/cloudflare/worker.ts (addAiBindings()); no app uses it yet: agentic-inbox/workers/everyapp/gateway.ts (gatewayAi()) is the provider path for code written against env.AI.run() resources: { ai: ["AI"] }, then await env.AI.run("@cf/meta/llama-3.1-8b-instruct", { prompt })
Browser Rendering resources.browser binds env.BROWSER on every Worker of the app: a headless Chrome for screenshots, PDFs and pages that need JavaScript, driven with @cloudflare/puppeteer or @cloudflare/playwright, billed to the account (Workers Paid). Local under pnpm dev: the first session downloads Chrome into the global .wrangler cache packages/sdk/src/cloudflare/worker.ts (addBrowserBindings()); no app uses it yet resources: { browser: ["BROWSER"] }, then const browser = await puppeteer.launch(env.BROWSER)
Images resources.images binds env.IMAGES on every Worker of the app: resize, crop, re-encode and read images from any stream (an upload, an R2 object, a fetch() body), transformations billed to the account. Local under pnpm dev through Miniflare's sharp (503, code 9523, where sharp can't load) packages/sdk/src/cloudflare/worker.ts (addImagesBindings()); no app uses it yet resources: { images: ["IMAGES"] }, then (await env.IMAGES.input(body).transform({ width: 256 }).output({ format: "image/webp" })).response()
resources.vectorize A Vectorize index for search by embedding: { name, dimensions, metric } for your own vectors, or { name, preset } for a Workers AI model's; bound as env.<NAME> (a VectorizeIndex) on every Worker of the app; the deploy creates <worker>--<binding> and its shape never changes after (rename the binding for a new one). Dev has no local Vectorize: calls throw until vite.config.ts sets remoteBindings: true and cf auth login has run packages/cli/src/commands/deploy/deployApp.vectorize.test.ts (NOTES), the manifest page resources: { vectorize: [{ name: "VECTORS", dimensions: 768, metric: "cosine" }] }, then env.VECTORS.upsert([{ id, values, metadata }]) and env.VECTORS.query(values, { topK: 5 }); keep the rows themselves in D1 and only the vectors here
resources.aiGateway An AI Gateway of the app's own (every-<orgKey>-<id>-<binding>), bound on every Worker as env.AI_GATEWAY = { id, accountId, url }, url empty in development; it logs, caches and counts the model calls the app makes itself, with its own key or Workers AI. Separate from AI through the Gateway (below), whose keys and gateway are the Gateway's /docs/agent/manifest (AI Gateway); no app uses one yet aiGateway: [{ name: "AI_GATEWAY" }]; baseURL: env.AI_GATEWAY.url ? env.AI_GATEWAY.url + "/openai" : undefined, or env.AI.run(model, input, { gateway: { id: env.AI_GATEWAY.id } })
Email sending as a binding resources.sendEmail binds Cloudflare's own send_email binding on every Worker of the app, beside the Gateway's email capability (which keeps budgets, the installation's senders and a log, and stays the default). For code written against env.EMAIL.send(), or mail an inbound email() handler sends beyond message.reply(). The deploy makes nothing and holds the built binding to the declared addresses; Cloudflare checks the sender's Email Sending domain when the Worker sends; under everyapp dev Miniflare logs the message packages/sdk/src/cloudflare/worker.ts (addSendEmailBindings()); no app uses it yet; /docs/agent/manifest sendEmail: [{ name: "EMAIL", allowedSenderAddresses: ["noreply@example.com"] }]: allowedSenderAddresses is required
A Secrets Store secret resources.secretsStore binds a secret in the account's Cloudflare Secrets Store, <worker>--<binding>, on every Worker of the app: managed, rotated and audited in the dashboard, read with await env.STRIPE_KEY.get(). The first deploy creates it from STRIPE_KEY in .everyapp/secrets.env and never rewrites it; the account needs a store made once in the dashboard (SECRETS_STORE_MISSING). Under pnpm dev the local store is empty until seeded (/docs/agent/dev). For a key only this app reads, an app secret (secrets) is simpler packages/sdk/src/cloudflare/worker.ts (addSecretsStoreBindings()), packages/cli/src/lib/app/resources.ts (provisionSecretsStore()); no app uses it yet resources: { secretsStore: [{ name: "STRIPE_KEY" }] }, then const key = await env.STRIPE_KEY.get()
Analytics Engine resources.analyticsEngine: [{ name: "EVENTS" }] binds env.EVENTS on every Worker of the app: writeDataPoint({ blobs, doubles, indexes }) for usage and event counts, read back through the SQL API with the dataset's name double-quoted (FROM "every-acme-shop--events"). The dataset is named after the Worker unless the entry names one; nothing to create or delete (data expires after 90 days); writes do nothing under pnpm dev packages/sdk/src/cloudflare/worker.ts (addAnalyticsEngineBindings()); no app uses it yet resources: { analyticsEngine: [{ name: "EVENTS" }] }, then env.EVENTS.writeDataPoint({ blobs: [path], doubles: [ms], indexes: [orgId] })

Sending and receiving email

Pattern What it is Where Copy this
Sending email sendEmail({ env, for, message }) through the Gateway's email capability; the Gateway checks the sender against what everyapp email set --sender allowed, and the budgets agentic-inbox/workers/everyapp/email.ts; open-analytics/src/lib/notifications/edge/delivery.ts gatewayEmail(): the SDK call shaped as the send_email binding upstream's code expects, every refusal an EmailSendError with the Gateway's code; OpenAnalytics skips the mail, and says so, on a Gateway without email
Receiving email An email() export beside everyApp()'s fetch; the deploy makes the Email Routing rules from email.inbound and the addresses everyapp email set --inbound names agentic-inbox/workers/app.ts, agentic-inbox/workers/everyapp/inbound.ts, agentic-inbox/test/receive-email.test.ts export default { ...everyApp(handler, { tools }), async email(event, env, ctx) { … } }; rethrow so Email Routing retries or bounces

AI through the Gateway

Pattern What it is Where Copy this
AI through the Gateway createGatewayFetch({ env, for }) as the fetch of an OpenAI-compatible client, with a base URL on the Gateway's /api/ai/<provider>/… path; the Gateway holds the key, so the client's own key is a placeholder open-crm/src/server/lib/openrouter.ts; agentic-inbox/workers/everyapp/gateway.ts OpenCRM: createOpenRouter over the Gateway fetch, the model from the optional secret OPENROUTER_MODEL; Agentic Inbox: gatewayModel() with @ai-sdk/openai-compatible, and gatewayAi() as a run() shim for code written against Workers AI
Every AI failure as a sentence ConfigurationError (no Gateway and no dev key), ai_provider_not_configured (the key isn't set on the Gateway) and every other refusal read with gatewayRefusal(), each turned into one sentence that names the setting and who sets it agentic-inbox/workers/everyapp/agent-ai.ts, agentic-inbox/workers/everyapp/gateway.ts aiSetupProblem() before the first call; aiProviderNotSetUpMessage() names the command, never a value
Whose call it is Attribution: while serving a request, for: request; for work no request started (a cron, a Workflow, an agent's turn), for: { background: true, organizationId } open-crm/src/server/lib/gateway-attribution.ts, open-seo/src/server/lib/gateway-attribution.ts; agentic-inbox/workers/everyapp/agent-ai.ts (forMailbox()) serveEveryAppRequest(request, user, fn) at the entry and in tools; forOrganization(id, fn) around background work

Agents, Workflows and a second Worker

Pattern What it is Where Copy this
A Durable Object agent One Durable Object per conversation or task, named for its organization; authorized in the Worker before the request reaches the object; the model through the Gateway open-crm/src/server/features/agent/CrmAgent.ts (on @cloudflare/think), open-crm/src/server/features/agent/agentRoutes.ts; agentic-inbox/workers/agent/index.ts (on @cloudflare/ai-chat), agentic-inbox/workers/app.ts; open-seo/src/server/features/sam/SamChatAgent.ts, open-seo/src/server.ts routeAgentRequest(request, env, { onBeforeConnect, onBeforeRequest }) with a guard that checks the session is the caller's organization's
Workflows A WorkflowEntrypoint a cron starts; the organization named once around run() and carried into every step open-seo/src/server/workflows/RankCheckWorkflow.ts, open-seo/src/server/workflows/pgStep.ts, open-seo/cloudflare.config.ts (the cron) forOrganization() around run(); carryOrganization() inside pgStep()
A second Worker A helper Worker for work that should not share the app's memory (a crawler, Lighthouse); it exports its Workflow and Durable Object classes and an RPC WorkerEntrypoint, and only the app's Worker can reach it open-seo/src/audit-worker.ts, open-seo/vite-plugin-every-app.ts, open-seo/everyapp.config.ts (workers) everyAppAuxiliaryWorker({ manifest, worker: "audit", entrypoint, ctx }) under the Cloudflare Vite plugin's auxiliaryWorkers
The lean Worker bundle A Vite plugin that stubs dead-weight packages, swaps zod's locales for English alone, and fails the build when a denylisted module reaches the Worker's eager import graph open-seo/vite-plugin-lean-worker-bundle.ts EAGER_DENYLIST with an expected note per entry; the generateBundle assertion

Tools

Pattern What it is Where Copy this
The starter's catalog Four tools over the same queries as the server functions, the zod schemas shared; instructions for the set; ToolError for a message the model may read starter/src/tools.ts, starter/src/server/todos.ts risk per tool, idempotent where true, handler: (input, { user }) => todos.listTodos(user.id)
Tools per feature, with output schemas One module per feature exporting a map, spread into one defineTools(); a crmTool() wrapper that adds output schemas, with the shapes several tools share (personOutput, recordRefOutput, pageOf()) open-crm/src/server/everyapp/tools.ts, open-crm/src/server/everyapp/tools/companies.ts, open-crm/src/server/everyapp/crm-tool.ts, open-crm/docs/maintainers/mcp.md People named by email, never by id, in what a model reads; "Never guess ids" in INSTRUCTIONS
Wrapping an existing tool registry An app that had an MCP server keeps its registry and adapts each entry: risk from the annotations and the name, objectSchema() for input and output, toolResult({ text, structured }) from the old CallToolResult open-seo/src/server/everyapp/tools.ts riskOf(); the old MCP server is off: the Gateway's /mcp serves the catalog (an app's host answers /mcp only with the manifest's mcp: { by, why }, which none of the four sets)
Tools over the private API, in-process Each tool calls the same Hono API the dashboard uses, with the call's identity, so permissions and validation live in one place open-analytics/src/lib/mcp/tools.ts resolveViewer() from the call; MAX_RESULT_BYTES asking for a narrower question
Tools loaded on first use The tools module behind a dynamic import on /__everyapp/*, so the app's eager bundle stays small open-crm/src/server.ts if (pathname.startsWith("/__everyapp/")) { const { everyAppTools } = await import(...) }
Schemas without zod 4.2 { jsonSchema, validate } from an older zod's toJSONSchema and Standard Schema agentic-inbox/workers/everyapp/tools.ts plainSchema(schema)
Testing the catalog The catalog read the way the Gateway reads it (POST /__everyapp/tools/list with a system token), the risk of every tool checked, and calls as a read-only and a read-write caller open-crm/src/server/everyapp/catalog.test.ts, open-crm/src/server/everyapp/toolTestSupport.ts; agentic-inbox/test/tools.test.ts reads equals every list_, get_ and search_ tool

Needs setup inside the app

Every app says, inside itself, what the Gateway hasn't got yet: the setting's name and who sets it, never a value, never the Gateway's own refusal sentence.

Pattern What it is Where Copy this
The starter's first screen Who the Gateway signed in and what the app was allowed, read from the inlined manifest; a GatewayRefusalError from listMembers() is a state the page shows starter/src/routes/index.tsx, starter/src/serverFunctions/home.ts whoCanOpen(): catch the refusal, return its code
A paused agent A checklist of the Gateway's keys for the agent (OPENROUTER_API_KEY, CONTEXT_API_KEY): done, checking or missing, each with what being unset costs and the command an owner or admin runs; members are told whom to ask open-crm/src/server/features/setup/services/SetupService.ts, open-crm/src/client/features/agent/AgentPaused.tsx, open-crm/src/serverFunctions/setup.ts pausedResearchMessage(canManage): two sentences, by role
A required secret Without the root secret every page redirects to one that names the secret and the command (pnpm everyapp secret set MAIN_SECRET --app analytics), or .dev.vars under everyapp dev open-analytics/src/middleware.ts, open-analytics/src/routes/$locale.runtime-config-error.tsx loadRuntimeSetupMode() asks the Worker whether it runs under everyapp dev
A capability integrationStatus({ env, provider }).ready without calling the provider, driving the "set up DataForSEO" prompt open-seo/src/server/lib/provider-access.ts, open-seo/src/serverFunctions/config.ts hasProviderAccess()
An OAuth account that isn't connected yet integrationStatus({ env, provider, for }): connection === null shows a "Connect GitHub" link to connectUrl({ env, provider, returnTo: request.url }); connection.reconnect shows "Reconnect"; a call's integration_not_connected refusal (gatewayRefusal()) carries connectPath for the same link the OpenSEO port's src/server/features/google/googleAccess.ts connectUrl({ env, provider: "google-workspace", returnTo })
Calling an OAuth provider's API with the person's token integrationFetch(url, { env, provider, for }) to the provider's real URL with no Authorization header: the Gateway puts the token on; account: "organization" for work no person started the OpenSEO port's src/server/lib/gscClient.ts integrationFetch("https://www.googleapis.com/webmasters/v3/sites", { env, provider: "google-workspace", for })
Email A banner before the person writes: no sender allowed yet (everyapp email set --sender) or no EMAIL_FROM; owners and admins get the command with the mailbox's domain filled in, members whom to ask; Send stays enabled, the server decides agentic-inbox/app/components/SendingOffBanner.tsx config.ready === false from GET /api/v1/config

Cookies, headers and the bundle

Pattern What it is Where Copy this
App-host session cookies The Gateway strips every Cookie header before a request reaches the app and refuses requests whose cookies repeat its own session cookie's name, so an app can never read back a cookie it set; withoutCookies() drops any Set-Cookie the app's framework adds (a WebSocket upgrade passes through) open-crm/src/server/everyapp/cookies.ts, open-seo/src/server/everyapp/cookies.ts; open-analytics/src/server.ts Wrap the response of everyApp()'s fetch in withoutCookies(); keep preferences in the browser
Security headers the app sets itself frame-ancestors 'self', an img-src of only what the app shows, a per-response nonce for inline scripts, on HTML documents without a policy of their own. OpenAnalytics sets one static policy on every page instead (withPageHeaders in open-analytics/src/server.ts: frame-ancestors 'none', no nonce) open-crm/src/server.ts APP_CSP

Tests

Pattern What it is Where Copy this
Tests over queries with an in-memory SQLite libsql :memory: with the app's migrations applied by drizzle's migrator, @/db mocked to it; one test that two people's rows stay apart starter/src/server/todos.test.ts vi.hoisted and vi.mock("@/db", …); migrate(db, { migrationsFolder })
A test database with every migration createSqliteTestDb() applies every production migration so repositories run their real queries; seedTwoOrganizations() and tests that one organization never sees another's rows open-crm/src/server/testing/testDb.ts, open-crm/src/server/features/agent/repositories/isolation.test.ts Mock cloudflare:workers, @/db and @/db/runBatch from the hoisted database
A fake Gateway binding A fetch that records every call's URL, body and identity header, standing in for EVERY_APP_GATEWAY agentic-inbox/test/with-gateway.test.ts, agentic-inbox/test/gateway-email.test.ts fakeGateway(reply)
Public routes under test The collect token's issue and verify, and the collector's refusals open-analytics/src/lib/edge/__tests__/collect-token.test.ts, open-analytics/src/lib/edge/__tests__/collect.test.ts Test the refusal paths first

Dev vs production

Pattern What it is Where Copy this
.dev.vars Locally there is no Gateway: the app's own secrets and the Gateway settings its manifest names (a provider key, EMAIL_FROM, EVERYAPP_EMAIL_SENDERS) come from .dev.vars, gitignored; pnpm dev names the ones missing agentic-inbox/.dev.vars.example, open-analytics/.dev.vars.example, open-crm/.dev.vars.example; starter/src/env.d.ts Ship a .dev.vars.example that names each variable and where it comes from, with no values
The manifest inlined for the page The Worker can't import everyapp.config.ts; Vite's define inlines it as __EVERYAPP_MANIFEST__ for the first screen starter/vite.config.ts, starter/src/serverFunctions/home.ts, starter/src/env.d.ts remoteBindings: false: local dev never touches remote resources
Dev or deployed, in the app env.EVERYAPP_DEV says which. The starter's launcher is EVERYAPP_IDENTITY_ISSUER (the Gateway's URL) when deployed and the dev host one label up under everyapp dev; the apps read GATEWAY_URL (set by the deploy) first, then the issuer starter/src/serverFunctions/home.ts; open-crm/src/serverFunctions/everyApp.ts, agentic-inbox/workers/everyapp/identity.ts; open-analytics/src/routes/$locale.runtime-config-error.tsx Name .dev.vars in dev and pnpm everyapp configure <app> when deployed
What a port changes What the Gateway replaces in an app that ran elsewhere (sign-in, keys, MCP, email), in one table open-crm/docs/maintainers/every-app.md, open-seo/docs/maintainers/every-app.md, agentic-inbox/docs/EVERY_APP.md Keep such a document in the app

Release and install

Pattern What it is Where Copy this
Release and install pnpm everyapp release packs the production build, the migrations and a release.json into everyapp-release.tar.gz; from a GitHub release of the app's repository, anyone installs it with pnpm everyapp install <repository url>, no clone. pnpm pack-for-app <app dir> --workflow from an EveryApp checkout vendors the CLI and the SDK into the app's repository (file: tarballs under vendor/) and adds the Release workflow that publishes the tarball on every push. The vendored CLI runs dev, db migrate and release, not deploy: deploys run from an EveryApp checkout (pnpm everyapp install <url> there, or that checkout's CLI from the app's directory). OpenCRM, OpenSEO and Agentic Inbox bind an R2 bucket, so R2 is enabled once on the account before their first install (R2_NOT_ENABLED otherwise); OpenAnalytics needs nothing docs/runbooks/app-releases.md, scripts/pack-for-app.mjs, scripts/pack-for-app/release.yml and landing-page/content/agent/deploy.md ("Release installs") in the checkout; open-crm/docs/maintainers/releases.md (tags, notes) pnpm pack-for-app <app dir> --workflow once, then commit vendor/, package.json, pnpm-lock.yaml and the workflow; after a platform change the same command without --workflow; the version in package.json names the release

A pattern missing here: read the four manifests first, then search the app for the SDK export you need (createGatewayFetch, integrationFetch, sendEmail, listMembers, defineTools).

On this page