not-your-slave · commit 4 of 6 · 24cb9f6

Giving it a clock

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.

After these six commits

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.

Background

Deep background: what "persistent" actually requires

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.

Narrow background: eve schedules

A file in agent/schedules/ is a cron job. It comes in two forms, and the difference decides the design.

FormBehaviour
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.
Two facts that shape everything downstream

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.

Intuition

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 person"what's up?"
waits for the reply
→same door
one agentreads its mindlog
thinks, acts, logs
←same door
the clockthe heartbeat prompt
this commit: reply discarded
One agent, one memory, two callers. In this commit, the clock's reply was discarded.

A day in the life, once the clock is wired up:

12:00woke heartbeat. Reads the mindlog, notices it promised to check something, checks it, writes a note.
12:15woke heartbeat. Reads the note it just left. Follows the thread one step further.
12:22heard a person arrives mid-thought and asks a question. Same memory, same threads.
12:30woke 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.

The code, and the prompt

// 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,
});

Why the prompt is exported

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.

Key concept: the log is training data for tomorrow's judgement

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.

Reading the prompt as engineering

Four sentences, four failure modes averted. This is the part that took the most iteration and looks the most like ordinary prose.

"Read your mindlog … to find out where you left off"

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.

"One step, not ten"

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.

"An idle beat is a legitimate outcome"

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.

"Nobody is reading this turn's reply"

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.

Why 15 minutes (this commit)

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 * * * *).

Verified, not assumed

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.

Quiz

Question 1

Why is the heartbeat a TypeScript module exporting HEARTBEAT rather than a markdown file with cron frontmatter?

Question 2

You run npm run dev and wait an hour. How many heartbeats fire?

Question 3

What does "an idle beat is a legitimate outcome" prevent?

Question 4

Why does mislabelling a heartbeat as heard matter, given the mechanism really is an inbound message?

Question 5

A markdown schedule starts a new session on every fire. Which consequence bites hardest later?

Answer a question to start scoring.

Next: commit 5 builds the window - a two-pane page, and a stream that survives a refresh.