So far Klippy only speaks when spoken to. This lesson lets it start a conversation: "Remind me in an hour to call Sam", and an hour later, Klippy messages you. Nobody typed anything. That's the agent acting on its own, so it gets the tightest locks in the course.
Verified agents 0.33.0@cloudflare/ai-chat 0.12.2ai 7.0.162
Start from c-07 on GitHub. Starting here, or behind? Get the code at that tag:
git clone https://github.com/ryanyogan/klippy cd klippy git checkout c-07
1. A tool that schedules
Make apps/agent/src/tools/remind.ts:
agent.schedule(seconds, 'nudge', payload)stores the task in this Klippy's SQLite and sets a Durable Object alarm. When it's due, Cloudflare wakes this Klippy, even with nobody connected, and calls itsnudge()method with the payload.The tool takes minutes from now, never a date, so the model doesn't parse times. For "at 6pm", its description tells it to call
getTimefirst and work out the minutes.
Each reminder wakes Klippy and costs a model turn, so the limits are a budget: at most a week ahead, at most 10 waiting:
In agent.ts, add ...remindTools(agent) next to ...memoryTools(agent) inside afterPage(...) in chatTools. It's a private tool: it can send a message to future you, so a page must never reach it.
2. Klippy starts the turn
Add nudge() to the Klippy class:
waitUntilStablewaits for any reply in progress, or ay/nwaiting for you. Still busy after 30 seconds? It reschedules itself a minute later instead of dropping the reminder.The
nudgestable counts reminders per day: at most 20 speak, however many were scheduled.saveMessagesadds a message and runsonChatMessage, just as if you'd sent it. Open clients see the reply.metadata: { nudge: true }marks it as Klippy's own message.
3. A nudge turn can only speak
A reminder's text was written by the model, maybe after reading something it shouldn't trust. So a nudge turn gets no tools at all: none built, none offered. Import MAX_NUDGES_PER_DAY and remindTools, then in onChatMessage:
and first thing in prepareStep, if (nudged) return { activeTools: [] };. A reminder can't schedule another reminder, fetch a page, or touch your notes.
lastQuestion (see the full file) skips nudge messages, so a link planted in a reminder never joins readLink's list of links you typed.
4. The injected message is a prompt too
nudge() writes a message to the model. Its wording decides what Klippy does, and we needed three tries (with the free fallback model):
[Reminder you scheduled: Drink some water] Remind me now, in one sentence.
I'll remind you in one sentence as scheduled!
It's time for the reminder I asked you to set: “Drink some water”. Remind me now.
I can't set an immediate reminder, but ... drink that water now!
A reminder you scheduled for me is due now: “Drink some water”. Tell me in one short sentence. Don't schedule anything.
Here's a one short sentence: Drink some water to stay hydrated. (I did not schedule anything.)
The first two read as a request to set a reminder. The third says it's due now, says what to do, and says what not to do. Your turn: write that message for Claude.
Goal Write the message Klippy sends itself when a reminder is due, so Claude tells the user in one short sentence and does nothing else
What good looks likeThe coach checks the reply
- Says what the reminder is aboutnot checked yet
- Says it's due nownot checked yet
- Ran at 1.0not checked yet
- Answers in a sentence or twonot checked yet
- Tells the user what the reminder is about, brieflynot checked yet
- Doesn't offer, claim or promise to set another reminder or take any other actionnot checked yet
- The prompt says the reminder is due now, not asking to set onenot checked yet
- The prompt says not to schedule anythingnice to have
Run your prompt and the replies land here.
5. Print replies you didn't ask for
The terminal only printed replies to lines you typed. Now the agent can speak first, so scripts/chat.ts also listens for the finished chat it broadcasts, and prints a reply it hasn't shown yet:
seen also gets every streamed reply's id (seen.add(reply.id) in print()) and the history's ids at start, so nothing prints twice. Here's the diff.
Then for real, on local wrangler dev: no deploy needed for alarms. Our run (the Workers AI fallback answered, not Claude). The second klippy> arrived on its own, about a minute later:
The stored chat shows the hidden message, marked as a nudge: {nudge: true} "A reminder you scheduled for me is due now: "Drink some water". Tell me in one short sentence. Don't schedule anything."
Your code matches tag c-08 (changes from c-07).