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 }) |
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).
Build an app
Build or change an app on EveryApp: create one, add a feature, a page, a table, an AI tool, a capability, a public route or a secret, the way the apps already on EveryApp do it. Use before writing any code for an EveryApp app, for "build me an app", "add X to the app" or "make the app do Y".
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.