So far Klippy's tools only read. Now it gets tools that write: say "note down: call Sam on Friday" and it saves the note; ask "what did I note?" and it lists them. Notes have to outlive a request, so they go in a database: D1, Cloudflare's SQL database.
Verified wrangler 4.157.0remix 3.1.0ai 7.0.162zod 4.6.7
Start from b-04 on GitHub. Starting here, or behind? Get the code at that tag:
git clone https://github.com/ryanyogan/klippy cd klippy git checkout b-04
1. Make the database
From apps/agent, ask Cloudflare for a database called klippy-db:
cd apps/agent pnpm wrangler d1 create klippy-db
wrangler prints a block with a database_id. Yours is your own; copy it. Then add the binding to wrangler.jsonc, after ratelimits (which now needs a comma after it). Use your database_id, not ours:
binding: "DB"makes itenv.DBin your code.migrations_dirpoints at the folder where the database's changes will live. It's in a shared package, so later apps can use the same tables.
Run pnpm run cf-typegen (you're in apps/agent) so env.DB is typed.
2. The table, as a migration
A database starts empty. You describe its tables in SQL, in numbered files called migrations. Each one runs once, in order, and is never edited after it has run: a later change is a new file, 0002_…. That way every copy of the database, yours, your tests' and the live one, goes through the same steps. Make packages/db/migrations/0001_notes.sql (from klippy/):
Three columns: an id the database counts up by itself, the note's body, and when it was made, as milliseconds since 1970.
Apply it to your local database, the copy pnpm dev uses (from apps/agent):
pnpm wrangler d1 migrations apply klippy-db --localIn your terminal, wrangler asks "continue?" and waits: type y. (Our recording ran from a script, hence the "fallback value" line.) The live database waits for the checkpoint, with --remote.
3. The same table, for TypeScript
The SQL makes the table. TypeScript also needs to know its shape, so your code can't save a note without a body. Klippy uses data-table from Remix for that. Make a package for the tables, packages/db/package.json:
and packages/db/src/index.ts, the same columns as the SQL:
data-table talks to databases through a driver, and it has no D1 driver of its own. The driver is included in the repo, as packages/data-table-d1: copy that folder from Klippy at tag b-05 into your packages/. From klippy/, clone Klippy next to your folder and copy it across:
git clone --branch b-05 https://github.com/ryanyogan/klippy ../klippy-ref cp -r ../klippy-ref/packages/data-table-d1 packages/
You don't need to read it to use it. Keep ../klippy-ref around: it's also the easiest place to copy each lesson's tests from.
Then add the three packages to the agent, from klippy/:
pnpm --filter agent add --save-exact remix@3.1.0 pnpm --filter agent add @klippy/db@workspace:* data-table-d1@workspace:*
workspace:* means "the package in this repo", not one from the internet. The driver's imports end in .ts, so apps/agent/tsconfig.json needs one line, next to moduleResolution:
4. Two tools that use the database
Make apps/agent/src/tools/notes.ts. The tools need the database, which arrives on env with each request, so they're made by a function:
createD1Database(d1)wraps your D1 binding for data-table.The description says only when the user asks to remember something. A tool that writes should be the hardest one to call by mistake.
z.string().trim().min(1).max(MAX_NOTE)refuses an empty note or one over 1,000 characters before anything is written. A tool that writes needs a limit on what it writes.db.create(notes, …, { returnRow: true })inserts a row and gives it back with its newid.
Then the second tool, and the return, before the function's last }:
listNotes takes no input (z.object({})) and returns at most 20 notes, newest first: a cap on what one call can pour into the conversation.
5. Wire them in, and keep the public door read-only
In apps/agent/src/ask.ts, gather every tool in one place, and let callers pass their own set:
(Import noteTools and type ToolSet at the top, and replace tools: { getWeather, getTime } with tools.) Why the parameter? /klippy/check is public. Anyone can call it, so it must never write a note. In apps/agent/src/check.ts, give the probe no tools at all:
The {} at the end of the ask(...) call is the whole fix. /ask still needs your token and shares the rate limit, so writes are capped at 10 a minute too.
6. Take notes
Restart pnpm dev and try it. Our run:
(We'd saved a shopping list in between.) The step log shows saveNote with {"body":"call Sam about the bike on Friday"}, then listNotes with {}. And the notes really are in the database:
The fallback model answered here, as before. If you get "no such table: notes", you skipped the migration in step 2, or ran it from the wrong folder.
Klippy's tests run on a real local D1, built from your migrations: copy test/notes.spec.ts, test/env.d.ts and the updated vitest.config.mts from the repo, then pnpm test. Ours: 22 passed, plus the driver's own 24.
Commit. Your code matches tag b-05 (changes from b-04).