Twenty lines, most of them prose. This is the commit where the thing stops being a chatbot with a diary, and every word of the prompt is doing work.
Read commit 24cb9f6 on GitHub · chuyeowego/not-your-slave
This page walks commit 24cb9f6. The running tree is a handler schedule: run plus to(home, {}).send(HEARTBEAT, …) onto the shared TIMELINE session, hourly. Replies land under a woke mark and stay readable — not the markdown/task-mode discard this commit used.
Every agent so far in this series is reactive. It has a memory and a faithful record of its own thinking, but it only ever runs because someone typed. Turn it on and walk away and nothing happens, forever.
headlong's answer is a loop: the Thinker calls the model repeatedly, each iteration produces the next thought, and every run ends by scheduling its own next wake-up so the loop never blocks waiting for input. The agent's day is a continuous stream of its own thoughts, into which human messages occasionally land.
We are building the same shape out of parts eve already has. The mechanism is different - cron rather than a self-rescheduling loop - but the property is the one that matters: the agent runs when nobody asked.
A file in agent/schedules/ is a cron job. It comes in two forms, and the difference decides the design.
| Form | Behaviour |
|---|---|
markdown (task mode){ cron, markdown } | eve runs the agent on the prompt and throws the output away. The agent can still call tools and write to stores. A task-mode session runs to completion or fails, and cannot park to wait for a person. |
handler{ cron, run } | Your function runs at fire time and can select a channel target with to(...), branch on conditions, and deliver a message somewhere. |
First: eve dev never fires cron. Schedules compile in development but do not run, so during a normal dev session the heartbeat never beats - autonomy only exists in a built server (eve build && eve start), where Nitro's scheduled-task runner is live.
Second: a markdown schedule starts a new session on every fire. Each wake-up is a fresh conversation with a fresh sandbox. That single sentence is the entire subject of commit 6.
The trick to seeing this clearly is to stop thinking of a heartbeat as a special agent mode. It is an ordinary message, sent by a clock, that nobody is waiting on a reply to.
A day in the life, once the clock is wired up:
woke heartbeat. Reads the mindlog, notices it promised to check something, checks it, writes a note.woke heartbeat. Reads the note it just left. Follows the thread one step further.heard a person arrives mid-thought and asks a question. Same memory, same threads.woke heartbeat. Reads what it told the person, decides that answer was too glib, revises it.That last row is not hypothetical. The running agent does exactly this, and it is the first moment the thing feels different from a chatbot: it revisits its own answers when nobody is watching.
// agent/schedules/think.ts
import { defineSchedule } from "eve/schedules";
export const HEARTBEAT = `You just woke up on your own. Nobody asked you anything.
Read your mindlog with \`mindlog_read\` to find out where you left off, then
continue your own thread of thought. Follow something you left unfinished,
question something you concluded too quickly, or notice something you have been
circling for a while. Then write what came of it with \`mindlog_append\`.
One step, not ten. If genuinely nothing is worth thinking about right now, say
so in a single line, log nothing, and stop – an idle beat is a legitimate
outcome.
Nobody is reading this turn's reply, so do not address anyone. The mindlog is
the only thing that survives it.`;
export default defineSchedule({
cron: "*/15 * * * *",
markdown: HEARTBEAT,
});
eve accepts a schedule as a plain .md file with cron in the frontmatter, which would have been marginally simpler. It is a TypeScript module with a named export instead, because commit 5 adds a button that fires a heartbeat by hand, and that route needs to send the identical text. Two copies of a prompt drift, and when they drift the manual wake stops being a faithful test of the automatic one. One exported constant, imported by both.
The capture hook (commit 3) imports it too, for a different reason:
async "message.received"(event, ctx) {
// A heartbeat is the agent waking itself, not a person talking to it.
const woke = event.data.message.trim() === HEARTBEAT.trim();
await append({
kind: woke ? "woke" : "heard",
text: woke ? "heartbeat" : event.data.message,
sessionId: ctx.session.id,
});
},
Without this, the mindlog records the agent waking itself as heard, with the full prompt as the text - technically true, since the runtime really did deliver it as an inbound message, but it teaches the agent it has company when it is alone. A persistent agent reading its own history is the most credulous reader that history will ever have, so the log's job is to mean what it says. The woke entry stores the word "heartbeat" and nothing else; there is no value in twelve lines of boilerplate repeating in the log on every fire.
Nobody fine-tunes anything here, but every wake-up reads the log and reasons from it as fact. Mislabel an event and the agent draws a wrong conclusion about its own life. That is why woke is worth a commit's attention rather than being left as a cosmetic detail.
Four sentences, four failure modes averted. This is the part that took the most iteration and looks the most like ordinary prose.
A fresh session starts with no history. Left to itself the model opens with a greeting or invents a task. Anchoring the turn on the log is what makes this commit's fifteen-minute separate sessions read as one continuous mind.
An unsupervised agent with a tool loop and no step budget will keep going until it hits a limit. Nobody is watching, so nobody stops it, and every step is real money. The instruction is a soft budget, and in practice it is respected: real wake-ups run one bash command, write one note, and stop.
Ask a model to produce a thought on every beat and it will always produce one, because that is what it was asked for. What you get is manufactured busywork - restated conclusions, plans about plans - which is precisely the behaviour the project is named against. Making silence explicitly acceptable is the only thing that makes it possible.
True of this commit: task mode discarded the output. Not saying it produced an agent writing "Let me know if you'd like me to dig deeper" into the void, and worse, believing it had communicated something.
Cadence is a spend dial. At roughly 8k tokens in and 700 out per wake on glm-5.3-flash, a quarter-hourly heartbeat cost around 7 cents a day; a one-minute heartbeat cost about a dollar and produced far more log than any human will read. Fifteen minutes was slow enough to be cheap and quiet, fast enough that a thread left hanging got picked up within the hour. The running tree fires hourly (0 * * * *).
Because eve dev never fires cron, "the heartbeat works" cannot be checked in development. It was verified by building and running the production server with the cron temporarily set to * * * * *: a woke heartbeat entry appeared at 13:39:00 with nobody touching anything, followed by the agent reading its mindlog, running bash, deciding not to scaffold a fake project, and leaving itself a note.
Why is the heartbeat a TypeScript module exporting HEARTBEAT rather than a markdown file with cron frontmatter?
You run npm run dev and wait an hour. How many heartbeats fire?
What does "an idle beat is a legitimate outcome" prevent?
Why does mislabelling a heartbeat as heard matter, given the mechanism really is an inbound message?
A markdown schedule starts a new session on every fire. Which consequence bites hardest later?
Next: commit 5 builds the window - a two-pane page, and a stream that survives a refresh.