Agent infrastructure · 2026 · completed
Escapement
A cloud-resident agent that keeps a Zettelkasten on schedule — and never writes in it.

An escapement is the part of a clock that refuses. The mainspring would unwind itself in seconds; the escapement stands in the way and lets one tooth past at a time, which is what turns stored energy into an evenly divided day. This is that part, for a Zettelkasten — a Cloudflare Worker keeping five appointments a day against a vault that lives on GitHub, so no laptop has to be awake for any of it. Captures arrive from a phone over Discord; what comes back is what recurred and how often, one structural thing about the day you didn’t notice yourself, and a single question worth carrying. It cannot write a note, which is the point.
The instrument

It reads the week back to you and asks one thing
The nightly entry names what recurred and how often, with dates; one structural thing about the day's thinking you didn't say yourself; and exactly one question. Saturday goes wider — a four-week recurrence table, and any theme circled three times with no note written yet, stated flatly as write it or drop it.

One local time, registered at both UTC candidates
Cloudflare cron is UTC-only, so every job is registered twice — once for CDT, once for CST — and the handler gates on the local wall clock, no-opping on the wrong half. Matching runs to ±10 minutes rather than an exact time, because a scheduler that fires two minutes late and an exact match together mean the run is silently skipped.

Three writers, one repo, one guard
A Worker, a Mac, and a Windows box all commit to the same vault. Only one of them runs `git add -A`, and one evening it staged 26 deletions against a working tree that was quietly missing files. The guard sits at exactly that point — trip it and the sync stalls loudly instead of losing work quietly.

The agent routes and prunes; the writing stays yours
Four layers with different owners and different lifespans, and the one that matters is written by hand, always. The model returns text and the runtime performs every file operation, so "never authors a note" is a property of the wiring rather than a line in a prompt.
The movement
The engineering underneath
An escapement doesn't drive the clock. It stands in the way of a wheel that would otherwise spin itself out in seconds, and lets exactly one tooth past at a time.
Five kinds of question, and a test for whether it earned the asking
The nightly question is drawn from whichever kind the day gives most material for — a vague term used twice and never measured; what would falsify the claim; a probe against a note older than thirty days; two dated statements that contradict each other; a topic read about all week and never written about. Open-ended reflection prompts are banned outright, and the test is stated as a rule: a question that could have been written without reading the vault is the wrong question, and gets regenerated. An unanswered one may be re-asked once, sharpened — never a third time, because a question asked three times only teaches you to skip the notification.
Recurrence is the thing a pile can't tell you
Any theme with three or more appearances and still no note gets called out by name on Saturday, with both options offered honestly — some recurring thoughts genuinely aren't worth a note. The homework is capped at three notes, each titled as a claim rather than a topic, because a topic-shaped title lets you file without thinking, and each carrying one link to a note in a different domain. Same-domain links are worthless; the folder tree already encodes those.
Both halves fire; the handler decides which one meant it
The Worker wakes ten times a day and does nothing on most of them. Each job is registered at both its CDT and CST UTC time, and the gate is a comparison against local wall-clock minutes with a ±10 window — wide enough to absorb a late trigger, narrow enough that the two DST candidates, an hour apart, can never both match.
async scheduled(_ctrl: ScheduledController, env: Env, ctx: ExecutionContext) {
const { minutes, weekday } = localParts(env);
const near = (h: string, m: string) =>
Math.abs(minutes - (Number(h) * 60 + Number(m))) <= 10;
if (
weekday === env.WEEKLY_WEEKDAY &&
near(env.WEEKLY_HOUR, env.WEEKLY_MINUTE)
) {
ctx.waitUntil(run(env, "weekly"));
} else if (near(env.DAILY_HOUR, env.DAILY_MINUTE)) {
ctx.waitUntil(run(env, "daily"));
} else if (
weekday === env.REFLECT_WEEKDAY &&
near(env.REFLECT_HOUR, env.REFLECT_MINUTE)
) {
ctx.waitUntil(dc.send(env, REFLECT_NUDGE));
} else if (near(env.GRATITUDE_HOUR, env.GRATITUDE_MINUTE)) {
ctx.waitUntil(dc.send(env, GRATITUDE_NUDGE));
} else if (near(env.NEWS_HOUR, env.NEWS_MINUTE)) {
ctx.waitUntil(postNewsDigest(env, localDate(env)));
}
}The whole vault arrives in a single request
Reading seventy-odd files one call at a time would spend the Worker's entire subrequest budget before the model was even invoked. Pulling the repo tarball and unpacking it in memory costs one fetch, and the cost stays at one as the vault grows.
export async function readVault(env: Env): Promise<Vault> {
const res = await fetch(
`${API}/repos/${env.GITHUB_REPO}/tarball/${env.GITHUB_BRANCH}`,
{ headers: headers(env) },
);
if (!res.ok) throw new Error(`tarball ${res.status}: ${await res.text()}`);
const gunzipped = res.body!.pipeThrough(new DecompressionStream("gzip"));
return untar(new Uint8Array(await new Response(gunzipped).arrayBuffer()));
}A tar path longer than 100 bytes is stored in two places
GitHub emits ustar, which splits any path past 100 bytes across a `prefix` field at offset 345 and the `name` field at offset 0. A reader that trusts `name` alone doesn't fail — it silently truncates, and the notes that vanish are the deeply nested ones, which is most of what the agent needs to see.
const prefix = str(pos + 345, 155);
const name = str(pos, 100);
const full = paxPath ?? (prefix ? `${prefix}/${name}` : name);
const size = parseInt(str(pos + 124, 12) || "0", 8);
const type = String.fromCharCode(buf[pos + 156]);Reference notes are truncated before the prompt is built
The agent needs to know a reference note exists and roughly what it covers — never its body. A handful of long ones dominate the vault by bytes and would crowd out the material actually being reasoned over. Everything in Inbox, Slipbox, Journal and Systems ships whole.
function excerpt(path: string, content: string): string {
if (!path.startsWith("Knowledge/")) return content;
const words = content.split(/\s+/);
return words.length <= 200
? content
: words.slice(0, 200).join(" ") + "\n…[reference note, truncated]";
}A model with no write capability, told so plainly
The daily pass routes a day's captures, which Sonnet handles; the Saturday pass looks for recurrence across four weeks of history, which is the hard task and gets Opus at maximum effort. Neither one can touch a file — the runtime performs every mutation, and the prompt says so, so the model doesn't narrate work it can't do.
const stream = client.messages.stream({
model: kind === "weekly" ? "claude-opus-5" : "claude-sonnet-5",
max_tokens: 32000,
system: [
{ type: "text", text: `Today is ${localDate(env)} (${env.TIMEZONE}).\n\n${corpus}` },
{ type: "text", text: prompt },
],
output_config: { effort: kind === "weekly" ? "xhigh" : "high" },
messages: [
{
role: "user",
content:
"Produce the review entry now.\n\n" +
"Output ONLY the markdown entry body, starting with its `## ` heading. " +
"The runtime prepends your output to Journal/Review.md and performs all " +
"file mutations — you cannot write files, so do not describe doing so.",
},
],
});Prompts are data, not deployment
Every scheduled job reads its instructions from a markdown file in the vault at runtime, by path. Editing a prompt in Obsidian changes that job's behaviour on its next run — no redeploy, no restart — which also means renaming one breaks the job that reads it. Voice lives in a single shared file so the two journal rituals can't drift apart.
- Runtime
- Cloudflare Worker · ~1,100 lines of TypeScript
- Source of truth
- GitHub — every local clone is a cache
- Interface
- Discord — /note, /gratitude, /reflect inbound; channel webhook outbound
- Models
- Claude Sonnet 5 (daily, high effort) · Opus 5 (weekly, xhigh)
- Vault read
- One request — repo tarball, gunzipped and untarred inside the Worker
- Schedule
- Five jobs, local times held across DST
- Running cost
- Roughly $3–5/month at current vault size
- Source
- Private — the Worker holds vault and Discord credentials