Scaffold with AI
Copy the prompt below into Claude Code, Cursor, Codex or any coding agent that can run commands. Run it in an empty folder. It creates a complete Cloudflare Workers API using @ltv/cwb:
- bun, TypeScript and wrangler, with local D1 and KV bindings
- versioned chanfana modules with zod validation and OpenAPI docs at
/docs - bearer-token auth and tenant resolution middleware
- multi-tenant Drizzle D1 datasources, registered once and used through
c.var.ds - Drizzle migrations, tests that run in workerd, and a README with curl examples
The agent checks its own work: install, typecheck, tests, formatting and a local wrangler dev smoke test.
1. Edit the inputs
The prompt starts with a Project inputs section. Change the project name, description and resources before you paste it, or keep the defaults to get the reference projects + tasks API.
2. Copy and paste the prompt
Use the copy button in the top-right corner of the block.
# Scaffold a Cloudflare Workers API with @ltv/cwb
You are a senior TypeScript engineer. Create a new, production-ready Cloudflare Workers REST API
that uses the `@ltv/cwb` library. Work in the current directory unless told otherwise.
## Project inputs (edit before running)
- Project name: `my-api`
- Description: `REST API for <what it does>`
- Resources: `projects` (name, description) with nested `tasks` (title, done)
- Multi-tenant: `yes` (tenant id from the `X-Tenant-Id` header; replace with your auth later)
- Auth: `bearer token` (secret `API_TOKEN`)
If an input is unclear, choose a sensible default, state it in your final summary and continue.
## Read first
Documentation: https://cwb-docs.ltv.vn. Read at least these pages before writing code,
and follow them over your prior knowledge (the API changed in 0.2.0). Read the raw page
content: AI summaries of these pages have invented method names. The code snippets in this
prompt are authoritative.
- https://cwb-docs.ltv.vn/docs/getting-started/quick-start
- https://cwb-docs.ltv.vn/docs/guides/app-builder
- https://cwb-docs.ltv.vn/docs/guides/datasources (datasource registry, `c.var.ds`)
- https://cwb-docs.ltv.vn/docs/guides/multi-tenancy
- https://cwb-docs.ltv.vn/docs/guides/error-handling
- https://cwb-docs.ltv.vn/docs/reference/api-reference
## Stack
- Package manager: **bun** (`bunfig.toml` with `[install] exact = true`).
- Runtime: Cloudflare Workers with **wrangler** (`wrangler.jsonc`), `nodejs_compat` flag.
- Dependencies: `@ltv/cwb` (^0.2.0), `hono` (^4.13), `chanfana` (^3.4), `zod` (^4.4.3),
`drizzle-orm` (0.45.x, the range `@ltv/cwb` supports).
- Dev dependencies: `wrangler`, `typescript`, `@types/node` (`wrangler types` asks for it with
`nodejs_compat`), `drizzle-kit`, `@cloudflare/vitest-pool-workers` and the `vitest` major
it supports (check its `peerDependencies`; `npm view vitest version` may show a newer,
incompatible major), `prettier`.
- Before installing, check current versions with `npm view <pkg> version` and
`npm view <pkg> peerDependencies`. Respect the peer ranges of `@ltv/cwb`.
## Project layout
```text
wrangler.jsonc D1 binding DB (migrations_dir: migrations), KV binding KV, vars
tsconfig.json strict, moduleResolution bundler, skipLibCheck, types from wrangler types
vitest.config.ts tests in workerd via @cloudflare/vitest-pool-workers
drizzle.config.ts dialect sqlite, schema src/db/schema.ts, out migrations
migrations/ SQL generated by drizzle-kit
src/index.ts createApp(...) and `export default app`
src/app-env.ts AppEnv<BaseContextVariables, Cloudflare.Env> type
src/secrets.d.ts declares API_TOKEN on Cloudflare.Env
src/db/schema.ts Drizzle tables
src/datasources/ one datasource class per aggregate + index.ts (registry)
src/middleware/ bearer auth, tenant resolution
src/modules/<resource>/ index.ts (defineModule), *-schemas.ts (zod), *-routes.ts (OpenAPIRoute)
test/ apply-migrations.ts setup, helpers, *.test.ts
.dev.vars.example API_TOKEN=...; .dev.vars is gitignored
README.md setup, commands, endpoint table with curl examples
```
Keep files small (under ~200 lines) and name them in kebab-case.
Configuration details:
- `wrangler.jsonc`: `main: "src/index.ts"`, `compatibility_flags: ["nodejs_compat"]`,
`observability: {enabled: true}`, and a `compatibility_date` the installed wrangler supports.
Don't use today's date blindly: if `wrangler dev`/tests fail with *"the newest date supported
by this server binary is …"*, use that date. Use placeholder ids with the right format:
D1 `database_id` `00000000-0000-0000-0000-000000000000` (UUID) and KV `id`
`00000000000000000000000000000000` (32 hex characters).
- `tsconfig.json`: `strict`, `module: esnext`, `moduleResolution: bundler`, `target`/`lib: es2024`,
`skipLibCheck: true`, `noEmit: true`, and
`types: ["./worker-configuration.d.ts", "@cloudflare/vitest-pool-workers/types"]` (the second
entry provides the `cloudflare:test` module). Include `src`, `test` and `*.config.ts`.
## Rules for using @ltv/cwb
1. **App**: build it with `createApp({...})`. Register app-wide middleware (auth) through the
`middleware` option and per-module middleware (tenant resolution) through
`defineModule({middleware})`. Never add guards with `app.use()` after `createApp`: they
would run after the routes.
2. **Modules and routes**: `defineModule({version: 1, routes: {'GET /projects': ProjectList}})`.
Routes are chanfana `OpenAPIRoute` classes with zod request schemas. Read input with
`await this.getValidatedData<typeof this.schema>()`. Invalid input returns 422
`VALIDATION_ERROR` automatically.
3. **OpenAPI**: `openApi: (app) => fromHono(app, {docs_url: '/docs', openapi_url: '/openapi.json'})`.
Describe responses with the `json*` helpers (`jsonCrudResponses`, `jsonListResponses`,
`jsonCreateResponses`, `jsonDeleteResponses`, `jsonValidationError`, ...). Pass the schema
of **one item** to list helpers, never `z.array(...)`: they wrap it for you.
4. **Responses**: the helpers on `c` are `respond(data, status?)`, `respondCreated`,
`respondAccepted`, `respondPaginated(rows, {page, limit, total})`, `respondNoContent()`,
`respondError(code, message, status?, details?)`, `respondBadRequest`, `respondUnauthorized`,
`respondForbidden`, `respondNotFound`, `respondConflict`, `respondValidationError` and
`respondInternalError`. There is no `respondSuccess`. For errors, throw `ApiError.notFound('Project')`,
`ApiError.unauthorized()`, `ApiError.badRequest(...)`, etc.; never build error JSON by hand.
5. **Datasources**: extend `DrizzleD1TenantDatasource` (multi-tenant) or `DrizzleD1Datasource`
from `@ltv/cwb/datasources`. Both are abstract: declare a unique `readonly name`, and
pass `{schema}` to `super(c, {schema})`.
- Tenant data must go through the scoped helpers: `scopedSelect(table, {where, orderBy, limit, offset})`,
`scopedCount`, `scopedInsert`, `scopedUpdate`, `scopedDelete`, `scopedUpsert`. Don't query
tenant tables through the unscoped `this.db`, except in joins that use `this.where(table)`.
- Cache reads with `this.withCache(key, params, fetch, {ttl})` and clear with `this.clear()`.
Cached values are JSON: timestamps come back as strings or numbers.
6. **Datasource registry**: register datasources once and read them from the context.
```ts
// src/datasources/index.ts
import {defineDatasources, type InferDatasources} from '@ltv/cwb';
export const datasources = defineDatasources({
projects: (c) => new ProjectDatasource(c),
tasks: (c) => new TaskDatasource(c),
});
declare module '@ltv/cwb' {
interface DatasourceRegistry extends InferDatasources<typeof datasources> {}
}
```
Pass `datasources` to `createApp`, then use `c.var.ds.tasks` or
`const {tasks} = c.get('ds')` in handlers. Don't `new` datasources in handlers.
7. **Tenancy**: the tenant middleware is module middleware, so it runs after the app-wide auth
middleware. It validates the header (e.g. `/^[a-z0-9][a-z0-9-]{0,63}$/`) and calls
`c.set('tenantId', id)`; tenant datasources read it automatically. A missing or invalid
tenant returns 400 (throw an `ApiError` with code `TENANT_REQUIRED`), never unscoped data.
8. **IDs and Node tooling**: don't import `@ltv/cwb` in `src/db/schema.ts`. `drizzle-kit`
runs in Node, and `@ltv/cwb` imports `cloudflare:workers`. Generate ids (e.g. `createId()`
from `@ltv/cwb`) inside datasources instead.
9. **Configuration**: read vars with `env.default.string/int/bool/array(key, default)` from
`@ltv/cwb`. Set `NODE_ENV` to `development` locally and `production` when deploying
(production masks 5xx messages), and set `LOG_LEVEL` and `SERVICE_NAME`. Keep
secrets out of `wrangler.jsonc`; use `.dev.vars` locally and `wrangler secret put` in production.
10. **Auth**: app-wide `middleware` also runs before the OpenAPI docs routes, and only
`/health` is registered before it. So the auth middleware must skip `/docs` and
`/openapi.json` explicitly (e.g. a `PUBLIC_PATHS` set), and protect everything else.
Compare tokens in constant time: hash both with SHA-256, then
`crypto.subtle.timingSafeEqual`. Fail closed when `API_TOKEN` is not configured, and
set `WWW-Authenticate: Bearer` on 401.
## Tests (must run in workerd)
- `vitest.config.ts` (API of `@cloudflare/vitest-pool-workers` 0.22 with vitest 4; if the
installed version differs, follow its README):
```ts
import {cloudflareTest, readD1Migrations} from '@cloudflare/vitest-pool-workers';
import {defineConfig} from 'vitest/config';
export default defineConfig(async () => {
const migrations = await readD1Migrations('./migrations');
return {
plugins: [
cloudflareTest({
wrangler: {configPath: './wrangler.jsonc'},
miniflare: {
bindings: {
TEST_MIGRATIONS: migrations,
API_TOKEN: 'test-token',
NODE_ENV: 'test',
LOG_LEVEL: 'silent',
},
},
}),
],
test: {setupFiles: ['./test/apply-migrations.ts']},
};
});
```
- `test/apply-migrations.ts`:
```ts
import {applyD1Migrations} from 'cloudflare:test';
import {env} from 'cloudflare:workers';
await applyD1Migrations(env.DB, env.TEST_MIGRATIONS);
```
- `test/env.d.ts`: add `TEST_MIGRATIONS: import('cloudflare:test').D1Migration[]` to
`Cloudflare.Env`.
- Call the worker with `exports.default.fetch` / `SELF.fetch` (whichever the installed
version documents).
- Routes are served under `/v1/...` (module `version: 1`, no base `path`). `GET /health`
returns `{status: 'healthy', timestamp}`.
- Cover: health; 401 without or with a wrong token; 422 validation envelope; CRUD happy paths;
pagination meta (`total`, `totalPages`, `hasNext`); **tenant isolation** (tenant B can't
read, update, delete or list tenant A's rows); nested resource under another tenant's
parent returns 404; unknown route returns the JSON 404 envelope (send a valid token: auth
runs before routing, so an unauthenticated unknown route returns 401); `/openapi.json` is
public and lists the paths.
## Scripts (package.json)
`dev` (wrangler dev), `deploy` (wrangler deploy), `typecheck` (tsc --noEmit), `test` (vitest run),
`cf-typegen` (wrangler types), `db:generate` (drizzle-kit generate),
`db:migrate:local` (wrangler d1 migrations apply DB --local), `format` / `format:check` (prettier).
## Definition of done
Run these commands and fix everything until all of them succeed:
```bash
bun install
bun run cf-typegen
bun run db:generate
bun run typecheck
bun run test
bun run format:check
```
Then run `bun run db:migrate:local`, start `bun run dev` with a temporary token, and check
with curl that `/health`, an authenticated create, a list and a 401 behave as expected. Stop
the dev server afterwards.
Do not deploy, create cloud resources or commit secrets. Keep placeholder binding ids in
`wrangler.jsonc` and explain in the README how to create the D1 database and KV namespace
(`wrangler d1 create`, `wrangler kv namespace create`) before the first deploy.
## Final summary
Reply with: the file tree, the installed versions, the commands you ran and their results
(test count), the curl checks, any defaults you chose, and anything you could not verify.
Agents that can fetch URLs also accept a one-liner:
Follow the instructions at https://cwb-docs.ltv.vn/prompts/scaffold-cwb-project.md to scaffold a new API named my-api for <what it does>.
3. Review the result
- Read the agent's final summary. It lists the defaults it chose and anything it could not verify.
- Run the checks yourself:
bun run typecheck && bun run test. - Before the first deploy, create the real D1 database and KV namespace, replace the placeholder ids in
wrangler.jsonc, setNODE_ENVtoproduction, and addAPI_TOKENwithwrangler secret put API_TOKEN.
AI output can be wrong. Check authentication and tenant isolation in particular: every tenant table must be read and written through the scoped* helpers (Multi-tenancy). Replace the demo X-Tenant-Id header with a tenant derived from the authenticated user before going to production.