{"id":81701,"plugin_id":"plugin_asdk_app_6abebbc07294819182ee61dfb4b9f56f","kind":"skill","collection_source":"plugin_package","comparison_source":null,"observed_at":"2026-11-05T01:16:26.751Z","digest":"0ef962927adf21b98388d39320c3b620bb6ab20bb0f27ccf21afdbe2f06830a9","against":null,"payload":{"description":"Author a Clawnify app end-to-end — the canonical Hono + React + Vite + @hono/zod-openapi + @clawnify/db (Drizzle) stack, schema.ts as the single schema source of truth (DDL generated via pnpm db:generate), OpenAPIHono routes (createRoute) that auto-generate an OpenAPI spec, and platform-injected identity via user()/orgId()/caller(). Use when building or modifying an app that deploys to the Clawnify platform.","included_files":[],"name":"build-a-clawnify-app","skill_md_contents":"---\nname: build-a-clawnify-app\ndescription: Author a Clawnify app end-to-end — the canonical Hono + React + Vite + @hono/zod-openapi + @clawnify/db (Drizzle) stack, schema.ts as the single schema source of truth (DDL generated via pnpm db:generate), OpenAPIHono routes (createRoute) that auto-generate an OpenAPI spec, and platform-injected identity via user()/orgId()/caller(). Use when building or modifying an app that deploys to the Clawnify platform.\n---\n\n# Build a Clawnify app\n\nCanonical skill: how to author a Clawnify app end-to-end. Same content\nfor humans reading the docs and for AI coding agents injecting this as\ninstructions during an app build.\n\nIf you're an agent: this document is enough context to produce a\nworking app from scratch. Follow it literally. The patterns here\nare exactly what `clawnify init` scaffolds and what the platform\nexpects.\n\n## TL;DR\n\n- **One stack.** Hono + React + Vite + `@hono/zod-openapi` +\n  `@clawnify/db` (Drizzle) + `@clawnify/app` (entry, routing,\n  identity) + Tailwind v4. Nothing else.\n- **Schema lives in `schema.ts` alone** — the Drizzle DSL your queries\n  compile against. The DDL is generated from it (`pnpm db:generate` →\n  `.clawnify/drizzle/*.sql`); never hand-write a `schema.sql` in a\n  scaffolded app.\n- **Queries through `getDB`** — `import { getDB, eq } from\n  \"@clawnify/db\"`. JSON columns auto-serialize.\n- **API routes are `OpenAPIHono` + `createRoute`** — Zod-validated\n  request/response.\n- **`createApp()`** (from `@clawnify/app`) builds the entry — it returns an\n  `OpenAPIHono` with API discovery already mounted (`/api/openapi.json` +\n  `/llms.txt`, generated from your live routes). That descriptor *is* the\n  tool surface agents discover. Never hand-write those routes.\n- **One import.** `@clawnify/app` re-exports `createRoute`, `z` and\n  `OpenAPIHono`, and adds `user` / `orgId` / `caller`. Import from it, not\n  from `@clawnify/routes` directly.\n- **`clawnify.json`** declares framework, env, and public routes.\n  There is no `api.tools[]` — the agent discovers and calls your\n  endpoints via `/llms.txt` (and the OpenAPI spec).\n- **Identity is injected by the platform** — read it with `user(c)` /\n  `orgId(c)` / `caller(c)` from `@clawnify/app`. Your app never\n  authenticates anyone.\n\n## Stack rationale\n\nOne template, one set of decisions. Don't deviate unless the user has\nexplicitly asked. `clawnify init` (type `app`) scaffolds either a\n`blank` template (one GET route + a page) or a `crud` template (a\ntable with list/create/update/delete + a form). Both share the exact\nsame stack below.\n\n## File layout\n\n```\nmy-app/\n  clawnify.json              ← Manifest (validated against /schema/v1/clawnify.json)\n  package.json\n  tsconfig.json\n  vite.config.ts             ← @vitejs/plugin-react + @tailwindcss/vite; proxies /api → :8787\n  index.html\n  .gitignore\n  .clawnify/                 ← Managed by the CLI (gitignored) — never edit\n    wrangler.toml            ← Generated wrangler config\n    drizzle.config.ts        ← Generated drizzle config (from clawnify.json + schema.ts)\n    drizzle/                 ← Generated migration SQL (applied by dev + deploy)\n    applied-migrations.json  ← Local-apply tracker\n  src/\n    server/\n      index.ts               ← OpenAPIHono entry — mounts routes, serves /api/openapi.json\n      routes.ts              ← OpenAPIHono + createRoute definitions — the API surface\n      schema.ts              ← Drizzle table definitions — the single source of truth\n      worker-env.d.ts        ← Cloudflare Workers types reference\n      uploads.ts             ← R2 helpers (only when storage is enabled)\n    client/\n      main.tsx               ← React mount point\n      app.tsx                ← Root component (plain fetch against /api/*)\n      index.css              ← @import \"tailwindcss\"\n```\n\n## Schema — `schema.ts` is the single source of truth\n\n`schema.ts` (Drizzle DSL) defines the tables. Do **not** hand-write a\n`schema.sql` in a scaffolded app, and there is no toolchain config to\nmaintain: the drizzle config is a managed artifact the CLI materializes\ninto `.clawnify/` (from `clawnify.json` + the schema path), and the DDL\nis generated from `schema.ts` into `.clawnify/drizzle/*.sql` — which is\nwhat `clawnify dev` applies locally and the deploy applies to your D1.\n\nThe scaffold's starter table (blank template):\n\n```ts\n// src/server/schema.ts\nimport { sqliteTable, text, integer } from \"@clawnify/db\";\n\nexport const items = sqliteTable(\"items\", {\n  id: integer(\"id\").primaryKey({ autoIncrement: true }),\n  name: text(\"name\").notNull(),\n  createdAt: text(\"created_at\").notNull().$default(() => new Date().toISOString()),\n});\n```\n\nReal apps are multi-tenant. Every user-facing table carries an\n`org_id`, every query filters by it. JSON columns and indexes use the\nDrizzle DSL re-exported from `@clawnify/db`:\n\n```ts\nimport { sqliteTable, text, integer, index } from \"@clawnify/db\";\n\nexport const notes = sqliteTable(\n  \"notes\",\n  {\n    id: integer(\"id\").primaryKey({ autoIncrement: true }),\n    orgId: text(\"org_id\").notNull(),       // ALWAYS filter by this in queries\n    title: text(\"title\").notNull(),\n    body: text(\"body\").notNull().default(\"\"),\n    tags: text(\"tags\", { mode: \"json\" }).$type<string[]>().notNull().default([]),\n    createdAt: text(\"created_at\").notNull().$default(() => new Date().toISOString()),\n  },\n  (t) => ({\n    byOrg: index(\"notes_by_org\").on(t.orgId, t.createdAt),\n  }),\n);\n```\n\nRules:\n- JSON columns: `text(col, { mode: \"json\" }).$type<T>()` — Drizzle\n  serializes on write and parses on read.\n- Multi-tenancy: every user-facing table has `org_id`. Every query\n  filters by it. No exceptions.\n- Identifiers: snake_case in SQL, camelCase in TS (Drizzle bridges).\n\n## Generating the DDL — automatic, or `clawnify db generate`\n\n`clawnify dev` and `clawnify deploy` (local-directory deploys) generate\nthe migration SQL themselves — editing `schema.ts` and running dev or\ndeploy is the whole workflow. To refresh the SQL explicitly without\neither, run `npx clawnify@latest db generate` (the scaffold's\n`pnpm db:generate` is an alias for it). Always invoke the CLI as\n`npx clawnify@latest` — never a global or pinned install. Deploy refuses a database-enabled app with no DDL,\nso a broken generate fails loudly, not silently.\n\nOne artifact to leave alone: after a successful signed-in deploy the CLI\nsyncs a read-only `schema.sql` snapshot of the DEPLOYED schema into the\napp root. It is generated output for reference/diffing — never edit it,\nand never treat it as the source of truth (`schema.ts` is).\n\nRules:\n- Deploys are **additive**: new tables and columns are added\n  automatically. Destructive changes (dropping or retyping a column) are\n  not applied automatically — do those deliberately, and never drop a\n  table with user data unless explicitly asked.\n- Never edit the generated `.clawnify/drizzle/*.sql` by hand — change\n  `schema.ts` and regenerate.\n- A hand-written `schema.sql` at the app root is accepted **only** for\n  apps that don't use Drizzle at all (e.g. some template repos). A\n  scaffolded app never needs one.\n\n## `src/server/index.ts` — the entry\n\n```ts\nimport { createApp } from \"@clawnify/app\";\nimport api from \"./routes\";\n\n// db:false — this app reads the database per request via getDB(c.env),\n// which needs no init middleware.\nconst app = createApp({ title: \"My app\", version: \"1.0.0\", db: false });\n\napp.route(\"/\", api);\n\nexport default app;\n```\n\nThe entry is thin: `createApp()` then mount your routes. `createApp`\nreturns an `OpenAPIHono` with **API discovery already mounted** — it\nserves both `/api/openapi.json` (generated from your `createRoute`\ndefinitions, with schemas) **and** `/llms.txt` (the same, agent-readable).\nAny `/api/*` route written as a plain `app.get` with no `createRoute` is\nstill listed from the live route table, so the endpoint inventory is\nalways complete. **Never hand-write a `/openapi.json` or `/llms.txt`\nhandler** — and don't call `mountDiscovery` yourself; `createApp` did it.\n\nWhen storage (file uploads) is enabled, the entry also wires the R2\nbucket in a middleware before mounting routes:\n\n```ts\nimport { createApp, type AppEnv } from \"@clawnify/app\";\nimport { initUploads } from \"./uploads\";\nimport api from \"./routes\";\n\ntype Env = AppEnv & { Bindings: { UPLOADS: R2Bucket } };\n\nconst app = createApp<Env>({ title: \"My app\", version: \"1.0.0\", db: false });\n\napp.use(\"*\", async (c, next) => {\n  initUploads(c.env.UPLOADS);\n  await next();\n});\n\napp.route(\"/\", api);\n\nexport default app;\n```\n\n## `src/server/routes.ts` — routes with `createRoute`\n\nEach endpoint is a `createRoute({ method, path, summary, request, responses })`\ndefinition paired with a handler via `api.openapi(route, handler)`.\nRequest bodies and params are Zod-validated (`c.req.valid(\"json\")`,\n`c.req.valid(\"param\")`); response shapes are declared with\n`.openapi(\"Name\")` Zod schemas so they land in the generated spec.\n\nBlank template — one read route:\n\n```ts\nimport { OpenAPIHono, createRoute, z, PageQuery, pageParams, pagedResponse, type AppEnv } from \"@clawnify/app\";\nimport { getDB, desc, like } from \"@clawnify/db\";\nimport * as schema from \"./schema\";\n\nconst api = new OpenAPIHono<AppEnv>();\n\nconst ItemSchema = z.object({\n  id: z.number(),\n  name: z.string(),\n  createdAt: z.string(),\n}).openapi(\"Item\");\n\napi.openapi(\n  createRoute({\n    method: \"get\",\n    path: \"/api/items\",\n    summary: \"List items\",\n    // Every list endpoint pages: an agent reads this API through one tool,\n    // and an unbounded list would land in its context whole.\n    request: { query: PageQuery },\n    responses: { 200: pagedResponse(\"items\", ItemSchema) },\n  }),\n  async (c) => {\n    const { page, limit, offset, search } = pageParams(c.req.valid(\"query\"));\n    const db = getDB(c.env, { schema });\n    const where = search ? like(schema.items.name, \"%\" + search + \"%\") : undefined;\n    const total = await db.$count(schema.items, where);\n    const items = await db\n      .select()\n      .from(schema.items)\n      .where(where)\n      .orderBy(desc(schema.items.createdAt))\n      .limit(limit)\n      .offset(offset);\n    return c.json({ items, total, page, limit }, 200);\n  },\n);\n\nexport default api;\n```\n\nCRUD template — the full list/create/update/delete surface:\n\n```ts\nimport { OpenAPIHono, createRoute, z, PageQuery, pageParams, pagedResponse, type AppEnv } from \"@clawnify/app\";\nimport { getDB, eq, desc, like } from \"@clawnify/db\";\nimport * as schema from \"./schema\";\n\nconst api = new OpenAPIHono<AppEnv>();\n\nconst ItemSchema = z.object({\n  id: z.number(),\n  title: z.string(),\n  status: z.string(),\n  createdAt: z.string(),\n}).openapi(\"Item\");\n\nconst CreateItemSchema = z.object({ title: z.string().min(1) }).openapi(\"CreateItem\");\nconst UpdateItemSchema = z.object({\n  title: z.string().min(1).optional(),\n  status: z.string().optional(),\n}).openapi(\"UpdateItem\");\nconst IdParamSchema = z.object({ id: z.string() });\nconst OkSchema = z.object({ ok: z.boolean() }).openapi(\"Ok\");\n\napi.openapi(\n  createRoute({\n    method: \"get\",\n    path: \"/api/items\",\n    summary: \"List items\",\n    // Every list endpoint pages: an agent reads this API through one tool,\n    // and an unbounded list would land in its context whole.\n    request: { query: PageQuery },\n    responses: { 200: pagedResponse(\"items\", ItemSchema) },\n  }),\n  async (c) => {\n    const { page, limit, offset, search } = pageParams(c.req.valid(\"query\"));\n    const db = getDB(c.env, { schema });\n    const where = search ? like(schema.items.title, \"%\" + search + \"%\") : undefined;\n    const total = await db.$count(schema.items, where);\n    const items = await db\n      .select()\n      .from(schema.items)\n      .where(where)\n      .orderBy(desc(schema.items.createdAt))\n      .limit(limit)\n      .offset(offset);\n    return c.json({ items, total, page, limit }, 200);\n  },\n);\n\napi.openapi(\n  createRoute({\n    method: \"post\",\n    path: \"/api/items\",\n    summary: \"Create item\",\n    request: { body: { content: { \"application/json\": { schema: CreateItemSchema } } } },\n    responses: {\n      201: {\n        description: \"Created item\",\n        content: { \"application/json\": { schema: ItemSchema } },\n      },\n    },\n  }),\n  async (c) => {\n    const { title } = c.req.valid(\"json\");\n    const db = getDB(c.env, { schema });\n    const [row] = await db\n      .insert(schema.items)\n      .values({ title: title.trim() })\n      .returning();\n    return c.json(row, 201);\n  },\n);\n\napi.openapi(\n  createRoute({\n    method: \"patch\",\n    path: \"/api/items/:id\",\n    summary: \"Update item\",\n    request: {\n      params: IdParamSchema,\n      body: { content: { \"application/json\": { schema: UpdateItemSchema } } },\n    },\n    responses: {\n      200: { description: \"Updated\", content: { \"application/json\": { schema: OkSchema } } },\n      400: { description: \"Nothing to update\", content: { \"application/json\": { schema: OkSchema } } },\n    },\n  }),\n  async (c) => {\n    const { id } = c.req.valid(\"param\");\n    const body = c.req.valid(\"json\");\n    const patch: Partial<typeof schema.items.$inferInsert> = {};\n    if (body.title !== undefined) patch.title = body.title;\n    if (body.status !== undefined) patch.status = body.status;\n    if (Object.keys(patch).length === 0) return c.json({ ok: false }, 400);\n    const db = getDB(c.env, { schema });\n    await db.update(schema.items).set(patch).where(eq(schema.items.id, Number(id)));\n    return c.json({ ok: true });\n  },\n);\n\napi.openapi(\n  createRoute({\n    method: \"delete\",\n    path: \"/api/items/:id\",\n    summary: \"Delete item\",\n    request: { params: IdParamSchema },\n    responses: {\n      200: { description: \"Deleted\", content: { \"application/json\": { schema: OkSchema } } },\n    },\n  }),\n  async (c) => {\n    const { id } = c.req.valid(\"param\");\n    const db = getDB(c.env, { schema });\n    await db.delete(schema.items).where(eq(schema.items.id, Number(id)));\n    return c.json({ ok: true });\n  },\n);\n\nexport default api;\n```\n\nPatterns to follow:\n- **Give every route a clear `summary`/`description`.** The generated\n  OpenAPI spec is what the agent reads to decide which endpoint to\n  call — a vague summary makes your endpoint invisible in practice.\n- **Validate input with Zod** via `request.body` / `request.params`\n  and read it with `c.req.valid(\"json\")` / `c.req.valid(\"param\")`.\n  Never parse `c.req.raw` by hand.\n- **Declare response shapes** with `.openapi(\"Name\")` schemas — they\n  become the documented return type in the spec.\n- **Filter by the org** on every user-facing query (see \"who is\n  calling\" below). No exceptions.\n\n## `src/server` — who is calling\n\nThe platform authenticates every caller at its perimeter and injects a\nverified identity onto the request (it strips any client-supplied\nversion first, so it can't be spoofed). Your app never authenticates\nanyone: no `Authorization`, no cookies, no JWT, no login screen.\n\nRead it with the helpers from `@clawnify/app` — don't parse headers\nyourself:\n\n```ts\nimport { user, orgId, caller } from \"@clawnify/app\";\n\napp.get(\"/api/notes\", async (c) => {\n  const org = orgId(c);\n  if (!org) return c.json({ error: \"unauthorized\" }, 401);\n\n  const db = getDB(c.env, { schema });\n  return c.json(\n    await db.select().from(schema.notes).where(eq(schema.notes.orgId, org)),\n  );\n});\n```\n\n| Helper | Returns |\n|--------|---------|\n| `orgId(c)` | Organization UUID — the tenant key. Filter every user-facing query by it. |\n| `user(c)` | `{ id, email, name, avatarUrl, firstName, lastName }` — or **`null`** when the caller isn't a person. |\n| `caller(c)` | `user`, `api`, `agent`, `agent-browser`, `public`, `bypass`, `system`, or `app`. |\n\nTwo rules that catch people out:\n\n- **`user(c)` is null for five of the eight caller kinds**\n  (`agent-browser`, `public`, `bypass`, `system`, `app`). Always handle\n  the null case — don't assume a person is present just because a\n  request arrived. For per-user rows, store `user(c).id` in a `user_id`\n  column and filter on it, exactly as you filter on `org_id`.\n- **`firstName` / `lastName` are a heuristic** split of a single stored\n  full name (first word / the rest). Fine for a greeting; never persist\n  them or treat them as someone's legal name — store `name`.\n\nDon't build app-level users, roles, or permissions. Authorization\nalready happened at the perimeter.\n\n## `src/client` — React + plain fetch\n\nThe client mounts React and talks to the API with plain `fetch`\nagainst the `/api/*` routes. No data-layer library is scaffolded.\n\n```tsx\n// src/client/main.tsx\nimport { StrictMode } from \"react\";\nimport { createRoot } from \"react-dom/client\";\nimport { App } from \"./app\";\nimport \"./index.css\";\n\ncreateRoot(document.getElementById(\"app\")!).render(\n  <StrictMode>\n    <App />\n  </StrictMode>\n);\n```\n\n```tsx\n// src/client/app.tsx (CRUD template shape)\nimport { useState, useEffect } from \"react\";\n\ninterface Item {\n  id: number;\n  title: string;\n  status: string;\n  createdAt: string;\n}\n\nexport function App() {\n  const [items, setItems] = useState<Item[]>([]);\n  const [title, setTitle] = useState(\"\");\n\n  async function load() {\n    const res = await fetch(\"/api/items\");\n    setItems(((await res.json()) as { items: Item[] }).items);\n  }\n\n  useEffect(() => { load(); }, []);\n\n  async function addItem(e: React.FormEvent) {\n    e.preventDefault();\n    if (!title.trim()) return;\n    await fetch(\"/api/items\", {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify({ title }),\n    });\n    setTitle(\"\");\n    load();\n  }\n\n  // ...toggleStatus / deleteItem call PATCH/DELETE the same way.\n\n  return (\n    <div className=\"max-w-xl mx-auto mt-10 px-4\">\n      {/* form + list */}\n    </div>\n  );\n}\n```\n\nIn local dev, Vite proxies `/api` to the Worker on `:8787` (see\n`vite.config.ts`), so the same relative fetch paths work in dev and\nin production. The API is the one contract: the UI calls it over\n`fetch`, and the agent calls it over the OpenAPI spec.\n\n## Design\n\nIf the project contains a `DESIGN.md`, it is the single source of\ntruth for visuals — follow its tokens, don't restyle around it. The\nscaffold ships Tailwind v4 (via `@tailwindcss/vite`, imported with\n`@import \"tailwindcss\";` in `index.css`) and nothing else — no\ncomponent library. The rules every Clawnify app follows:\n\n- **Neutral chrome.** White canvas and surfaces, one text ramp\n  (foreground → muted → faint), hairline borders. Color is for\n  meaning, not decoration.\n- **One primary CTA per screen.** Blue is reserved for links and\n  focus rings; never a second accent.\n- **Status colors are muted pairs** — never pure-saturated fills.\n- **Style with Tailwind utilities.** Don't install another component\n  library or write a bespoke CSS system unless the user asks.\n- **Sizes in rem**, so browser zoom and font settings are respected.\n\n## `clawnify.json` — the manifest\n\n```json\n{\n  \"$schema\": \"https://app.clawnify.com/schema/v1/clawnify.json\",\n  \"version\": 1,\n  \"name\": \"Notes\",\n  \"description\": \"Take and organize notes. Searchable by tag.\",\n  \"app\": {\n    \"framework\": \"react+hono\",\n    \"database\": true,\n    \"storage\": false\n  },\n  \"env\": {\n    \"optional\": [\"OPENROUTER_API_KEY\"]\n  },\n  \"api\": {\n    \"public_routes\": [\n      { \"path\": \"/api/feed\", \"methods\": [\"GET\"] }\n    ]\n  }\n}\n```\n\n`clawnify init` writes the minimal form:\n`{ app: { framework: \"react+hono\", database: true, storage } }`. Add\nthe rest as needed.\n\nIf the app talks to third-party services the user has connected\n(ads platforms, CRMs, messaging), declare them:\n\n```json\n{ \"app\": { \"credentials\": [\"metaads\", \"googleads\"] } }\n```\n\nGet the ids and names from `clawnify connections list` — see the\nnaming rule below.\n\n`env` supports `required` (deploy warns if missing), `optional`\n(injected when available), and `oneOf` (at least one per group).\n\nRules:\n- **There is no `api.tools[]`.** The manifest's `api` object only\n  accepts `public_routes` and `automation_bypass`. Your API surface\n  is discovered from the generated OpenAPI spec, not a hand-authored\n  tools list (see the next section).\n- **Public routes are the exception**, not the default. Every route\n  is behind the Clawnify perimeter (auth-required) unless you\n  explicitly add a matching path to `api.public_routes`. Use only for\n  webhooks, RSS, embedded feeds.\n- **`framework: \"react+hono\"`** for new apps. Never `preact+hono` or\n  `vite-preact` — those are legacy.\n- **`database: true`** if the app uses a DB. `schema.ts` defines the\n  tables; run `pnpm db:generate` after changing it.\n\n## How the agent reaches your API (no `api.tools[]`)\n\nThe old tRPC stack emitted an `api.tools[]` array from procedure meta.\nThat mechanism is gone. Today the flow is:\n\n1. Your `createRoute` definitions auto-generate a live OpenAPI 3.0 spec\n   plus a lean `/llms.txt` index, both served for you — never hand-write\n   or route them yourself. **These are the agent's discovery channel:**\n   it reads `GET /llms.txt` (index of every endpoint) or\n   `GET /api/openapi.json` (full request/response schemas) on the live\n   app. Because both are generated from the live route table, they\n   cannot drift from the code.\n2. The agent then drives your endpoints on the live app. A Clawnify\n   agent uses `clawnify_call_app_api`; Claude, ChatGPT and other MCP\n   clients use `clawnify_app_api_read` (GET), `clawnify_app_api_write`\n   (POST, PUT, PATCH) and `clawnify_app_api_delete`. No per-app MCP\n   registration and no manifest tools array. Use source reading to\n   *modify* code, not to discover endpoints.\n\nSo you make an endpoint \"agent-callable\" by defining it with\n`createRoute()` + `app.openapi()` and giving it a clear\n`summary`/`description` with Zod-typed request/response. The clearer\nthose are, the more reliably the agent uses it.\n\n### `agent.md` — lead with the procedure, not the endpoint list\n\n`agent.md` is extracted into `agent_instructions` on every build. Since\ndiscovery is already covered by the generated surfaces above, **`agent.md`\nis not an API reference** — re-listing every payload there just creates a\nsecond source of truth that goes stale the first time a route changes.\nIt is the one place for what generation can't produce: **judgment**.\n\nWrite it in this order:\n\n1. **Division of labour, first and short.** What the agent should do\n   itself vs. what it must delegate to the app — and say the wrong move\n   out loud (*\"never look up contact details yourself; post the records\n   and let the app resolve them\"*). This one block prevents more misuse\n   than the rest of the file combined.\n2. **The procedure** — numbered steps for the app's main job, written so\n   the agent can follow them on a request it has never seen.\n3. **Pages**, flagging the screenshot-friendly ones.\n4. **API anchors, not API docs** — method, path, and *when to reach for\n   it*, plus the one or two request shapes the agent writes most often.\n   For anything else, let it read `/llms.txt`.\n5. **How to read failures**, and any **cost discipline** — which calls\n   spend the user's money and which are free.\n\n**Don't ship a per-app skill.** A skill's frontmatter stays resident in\nagent context, so one skill per app grows the agent's context floor as\nthe app count grows; `agent_instructions` and `/llms.txt` are pulled on\ndemand instead. Skills are for **cross-app** procedures — if one earns\nreuse across more than one app, promote that single procedure\n(parameterized) to the shared skills library rather than copying it into\nevery app.\n\n## Connections & secrets — `@clawnify/connections`\n\n**The naming rule — never invent an identifier.** Before declaring\n`credentials` or env names, run `clawnify connections list`: it shows\nthe org's canonical integration ids (`googleads`, never `google_ads`),\nthe fixed provider-key names (`OPENROUTER_API_KEY`, never\n`OPENROUTER_KEY`), and custom env vars already defined — each with\nconnected/set state. Reuse those exact identifiers; a misspelled one\ndoesn't fail the build, it silently resolves to nothing at runtime. If\nyou need a custom secret that isn't listed, pick a clear name and tell\nthe user to set it in the dashboard.\n\nIf the app needs a third-party service or a provider API key, use\nthe `@clawnify/connections` SDK. Never read credential bindings\ndirectly, never paste API keys, never hardcode a token or endpoint.\n\n```ts\nimport { connect, secret, describe, searchActions } from \"@clawnify/connections\";\n\n// A connected integration:\nconst meta = connect(\"metaads\", env);\nconst accounts = await meta.adAccounts(\"name,currency\");\n\n// A provider key the user supplied (null if absent):\nconst key = secret(\"OPENROUTER_API_KEY\", env);\n\n// What's wired vs missing — drive your setup/empty state from this:\nconst status = await describe(env, undefined, REQUIRES);\n```\n\nHow to call a connected service — the mistake that silently breaks\nintegration apps, so get it right:\n\n- **Prefer the typed client's semantic methods** where they exist\n  (`meta.adAccounts(...)`, `googleads.query(gaql)`).\n- **For anything else, discover and run an action:**\n  ```ts\n  const [best] = await searchActions(\"list google ads campaigns\", env);\n  const data = await connect(best.service, env).run(best.slug, body);\n  ```\n- Some integrations also expose a raw REST client\n  (`connect(\"bird\", env).get(\"/path\")`) — but if `.get()`/`.post()`\n  throws for a service, that service only supports semantic methods\n  and `run()`. When unsure, use `run()`.\n- **Need exact paths/arguments for a raw-REST provider?** Some carry\n  a `spec_url` (the provider's published OpenAPI spec) —\n  `clawnify connections list --json` shows it. Download and query it —\n  `curl -s <spec_url> -o spec.json && jq '.paths | keys' spec.json` —\n  never read a whole spec into context (they run to 10+ MB).\n- **Never fall back to raw `fetch`** against a provider API, and\n  never invent endpoints or guess tokens.\n\n**The one exception — a service we don't support at all.** If a\nprovider isn't in the connections registry (nothing to `connect()`\nto, and `clawnify connections list` doesn't show it), the sanctioned\npath *is* a raw call with a user-supplied key: declare the key(s) it\nneeds in `clawnify.json` `env` (`required`/`optional`/`oneOf`), tell\nthe user to set them in the dashboard, read them with\n`secret(\"THAT_KEY\", env)`, and `fetch` the provider's documented\nendpoints directly. This is the escape hatch for the long tail of\nintegrations we don't model yet — it does **not** apply to a service\nwe *do* support (use `connect()` / `run()` for those, always). Keep\nthe endpoint and token server-side, never in the client bundle.\n\nBuild a `describe()`-driven setup state instead of assuming\nconnections exist — show the user what's missing and where to\nconnect it.\n\n## Scheduling & deferred work — the managed queue\n\nA deployed app **cannot bind Cloudflare Queues, Durable Objects, or cron\ntriggers** (the platform builds every app with a fixed binding set: `DB` +\n`ASSETS` + `UPLOADS`). To run work later — a scheduled publish, a reminder, a\nretry, a \"do this in 10 minutes\" — use the **Clawnify managed queue service**\nover HTTP. It owns the clock, retries, and at-least-once delivery, then calls an\nendpoint on *your own app* back at the scheduled time.\n\nUse the published `@clawnify/queue` package — `enqueueJob`, `cancelJob`,\n`verifyDelivery` — same shape as `@clawnify/db` / `@clawnify/connections`.\nAuth is the build-injected `env.CLAWNIFY_TOKEN`; with no token (local dev)\nit degrades to a no-op.\n\n**Enqueue** a deferred call to your own endpoint:\n\n```ts\n// POST https://services.clawnify.com/queue/enqueue  (Bearer CLAWNIFY_TOKEN)\nconst res = await fetch(\"https://services.clawnify.com/queue/enqueue\", {\n  method: \"POST\",\n  headers: { Authorization: `Bearer ${env.CLAWNIFY_TOKEN}`, \"Content-Type\": \"application/json\" },\n  body: JSON.stringify({\n    target_url: `${origin}/api/internal/publish`, // an endpoint on THIS app\n    payload: { post_id: id },                     // echoed back to you on fire\n    run_at: new Date(whenMs).toISOString(),\n    idempotency_key: `post:${id}:${whenMs}`,       // same key dedupes; new time = new job\n  }),\n});\nconst { job_id } = await res.json();  // store it to cancel/reschedule\n```\n\nPersist `job_id` on the row (e.g. `posts.queue_job_id`). **Cancel / reschedule**:\n`DELETE https://services.clawnify.com/queue/jobs/{job_id}` (Bearer token);\nreschedule = cancel + a fresh enqueue.\n\n**The callback endpoint is a public, signature-verified route.** The queue calls\n`target_url` from outside the perimeter, so the endpoint must be declared in\n`clawnify.json` `public_routes` **and** verify the delivery signature itself —\nthe signature *is* its auth (never trust an unsigned call):\n\n```jsonc\n// clawnify.json\n\"api\": { \"public_routes\": [ { \"path\": \"/api/internal/publish\", \"methods\": [\"POST\"] } ] }\n```\n\nDeliveries are signed with the platform's **ECDSA P-256 (ES256)** key,\nStripe-style over `${timestamp}.${rawBody}`, verified against\n`https://services.clawnify.com/.well-known/jwks.json` (public key — nothing\nbreaks on token rotation). Read the raw body, check the signature + a timestamp\ntolerance (~5 min) before doing anything:\n\n```ts\napp.post(\"/api/internal/publish\", async (c) => {\n  const raw = await c.req.text();\n  const ok = await verifyDelivery(raw, {\n    signature: c.req.header(\"x-queue-signature\"),\n    timestamp: c.req.header(\"x-queue-timestamp\"),\n    keyId: c.req.header(\"x-queue-key-id\"),\n  });\n  if (!ok) return c.json({ error: \"bad signature\" }, 401);\n  const { post_id } = JSON.parse(raw);\n  // …do the deferred work (short: it's one normal Worker invocation)…\n  return c.json({ ok: true });\n});\n```\n\nEach fire is a normal Worker request — size the work to one invocation (publish a\npost, send a digest). For long compute *now* (not deferred), hold the request\nopen and return when done; don't fire-and-forget past the response.\n\n**When the queue says no.** A refused enqueue answers `{ \"error\", \"detail\" }`\n(`enqueueJob` throws `ClawnifyQueueError` with `code`, `status` and `message`).\nPast the plan's monthly included jobs each job costs credits, so only an empty\nbalance (`402 insufficient_credits`) or no active plan (`403 quota_exceeded`)\nrefuses one; show its `detail` to the owner as it is, since it says where to fix\nit. A job that books the next one (a daily sync) must never swallow that\nrefusal: keep it, show it as a banner on the app's main page, and return it from\nthe app's status endpoint. Every service error code:\nhttps://docs.clawnify.com/concepts/service-errors\n\n## Migrations\n\nThe workflow is:\n\n1. Edit `src/server/schema.ts` for the new shape (types + queries).\n2. Run `pnpm db:generate` — emits the migration SQL under\n   `.clawnify/drizzle/`.\n3. Run `npx clawnify@latest dev` locally, then `npx clawnify@latest deploy`.\n\nOn deploy your schema changes are applied to the app's database — new\ntables and columns are added automatically.\n\nDestructive changes (drop column, rename) are not applied automatically;\ndo them deliberately, and only when the user explicitly asks. Renames\nare risky on production data — prefer \"add new column, backfill, switch\nqueries, drop old column\" over a single-step rename.\n\n## What the agent must NOT do\n\n- Author a `/openapi.json` or `/llms.txt` handler by hand. `createApp()`\n  mounts both, generated from your `createRoute` definitions.\n- Hand-write a `schema.sql`, edit anything under `.clawnify/`, or add a\n  root `drizzle.config.ts`. Schema changes happen in `schema.ts`; the\n  CLI generates and applies the SQL.\n- Invent an `api.tools[]` array in the manifest. It isn't a valid\n  field — the OpenAPI spec is the tool surface.\n- Use `JSON.stringify` on query params. JSON columns auto-serialize\n  via Drizzle. The raw SQL API has `json()` as a helper.\n- Skip the `org_id` filter on a user-facing query. Multi-tenant\n  leak is the worst-class bug we can ship.\n- Return a whole table from a list endpoint, or a bare array. Every list\n  pages: `PageQuery`, `pageParams` and `pagedResponse` from `@clawnify/app`.\n- Read auth from `Authorization` / cookies / `Bearer` tokens, or\n  hand-parse `X-Clawnify-*` headers. Use `user(c)` / `orgId(c)` /\n  `caller(c)` from `@clawnify/app`.\n- Call third-party APIs with raw `fetch` or hardcoded tokens. Go\n  through `@clawnify/connections` (`connect` / `secret` / `run`).\n- Install `drizzle-orm` as a separate top-level dependency.\n  `@clawnify/db@^0.4.1` pulls it in transitively; only add it if you\n  need to pin a specific version (rare).\n\n## What the agent SHOULD do\n\n- Import everything from `@clawnify/db` where possible\n  (`getDB`, `eq`, `and`, `desc`, `sql`, `sqliteTable`, `text`,\n  `integer`, `index`, etc. are all re-exported there). Drop to\n  `drizzle-orm` directly only for things not re-exported.\n- Give every route a clear `summary`/`description` and Zod-typed\n  request/response so the agent can discover and call it via the\n  OpenAPI spec.\n- Page every list endpoint with the helpers `@clawnify/app` exports:\n  `request: { query: PageQuery.extend({ /* filters */ }) }`,\n  `const { page, limit, offset, search } = pageParams(c.req.valid(\"query\"))`\n  (25 per page by default, capped at 100, bad input falls back), and\n  `responses: { 200: pagedResponse(\"items\", ItemSchema) }` for\n  `{ items, total, page, limit }`. Count with `db.$count(table, where)`.\n- Keep a long text field an agent edits again and again (HTML, a\n  document body, a template) as one top-level string of the record,\n  with GET and a *partial* update (`PATCH`, or a PUT that keeps the\n  fields it is not sent) on the same path, returning the updated\n  record. Agents then read it in parts and change it with literal\n  `old_text` → `new_text` edits instead of sending all of it back; the\n  platform provides that, so don't build a find/replace endpoint.\n- Type a helper that takes the database client as `DB<typeof schema>`\n  (from `@clawnify/db`), and a separate routes file's env as `AppEnv`\n  (from `@clawnify/app`). Never name the backend (`D1Database`,\n  `DrizzleD1Database`) or cast `getDB`'s result: the same app code is\n  served by more than one database backend.\n- Test locally with `npx clawnify@latest dev` before deploying; deploys go live at\n  `https://<slug>.apps.clawnify.com` (the dashboard's preview pane shows\n  the draft tier).\n- When unsure about a Drizzle pattern, check the `@clawnify/db`\n  README for the current API + version notes.\n\n## Schema-as-data exception\n\nApps that let END users define data shapes at runtime (CMS, no-code\ntable editor) can't model their schema in `schema.ts` — the table\ndefinitions live in rows. Those apps fall back to the raw SQL API\nfrom `@clawnify/db` (`query`, `get`, `run`, `json`). Open-cms is\nthe canonical example.\n\nIf you're building this kind of app, you still use Drizzle's\n`schema.ts` for the **registry** tables (content types, field\ndefinitions). Only the dynamically-spawned per-collection tables\nneed raw SQL. See open-cms's `src/server/schema-sync.ts` for the\nruntime DDL pattern.\n\n## When you're stuck: compare with a working app\n\nEvery `Open*` repo under [github.com/clawnify](https://github.com/clawnify)\nis a complete, open-source app running on this platform. When your\napp hits an error you can't resolve from this document, find one that\ndoes the same thing and compare how it does it.\n\n- **Pick the repo by what it uses.** Each repo's `clawnify.json`\n  declares it: `app.credentials` lists its integrations (`metaads`,\n  `googleads`, ...), `app.database` and `app.storage` say whether it\n  uses D1 and R2. The README says what the app does.\n- **Read it without signing in.** List the repos at\n  `https://api.github.com/orgs/clawnify/repos?per_page=100`, and fetch\n  any file at `https://raw.githubusercontent.com/clawnify/<repo>/HEAD/<path>`\n  (`HEAD` resolves the default branch, which is not always `main`).\n- **Copy the solution, not the layout.** This document wins on\n  conventions. Many templates predate parts of it: most hand-write\n  `src/server/schema.sql` instead of `schema.ts`, some use Preact, and\n  older ones don't page their lists with the `@clawnify/app` helpers.\n- **If the error comes from a `@clawnify/*` package**, read what you\n  installed: `node_modules/@clawnify/<pkg>/README.md` and\n  `dist/index.d.ts`, which match your version exactly.\n"},"changes":[],"summary":"First saved snapshot. No earlier version is available for comparison.","summary_kind":"deterministic","summary_metadata":{}}