Sky Yoo

Agent infrastructure · 2026 · completed

Escapement

A cloud-resident agent that keeps a Zettelkasten on schedule — and never writes in it.

Escapement — main view
ESCAPEMENT · MAIN VIEW

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

Escapement — It reads the week back to you and asks one thing
PLATE I — IT READS THE WEEK BACK TO YOU AND ASKS ONE THING

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.

Escapement — Six jobs, one cron expression, no DST in the config
PLATE II — SIX JOBS, ONE CRON EXPRESSION, NO DST IN THE CONFIG

Six jobs, one cron expression, no DST in the config

Cloudflare cron is UTC-only, so each job used to be registered twice — once at its CDT hour, once at its CST hour — with the handler discarding the wrong half. That costs an expression per job against a free-plan cap of five. Now a single expression ticks every 15 minutes and the handler matches the local wall clock within ±7 minutes: a tolerance under half the spacing, so a late fire still counts and no two jobs can claim the same tick. Daylight saving stopped being represented in the config at all.

Escapement — Three writers, one repo, one guard
PLATE III — THREE WRITERS, ONE REPO, ONE GUARD

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.

Escapement — The agent routes and prunes; the writing stays yours
PLATE IV — THE AGENT ROUTES AND PRUNES; THE WRITING STAYS YOURS

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.

The gate is a wall clock, not a cron field

The Worker wakes ninety-six times a day and does nothing on ninety of them. One expression ticks every fifteen minutes; the handler converts to local time and compares against minutes-of-day with a ±7 window. The tolerance has to stay under half the tick spacing — wide enough to absorb a trigger that fires a minute or two late, narrow enough that two jobs on adjacent ticks can never both match and let the if/else silently drop the second. Sunday 19:30 is deliberately both the reflection nudge and the evening slipbox reminder; the reflection branch comes first and wins, because two nudges stacked in one channel at one minute is how a channel gets muted.

index.ts
/** Cron. Wakes every 15 minutes; gates on local wall clock. */
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))) <= 7;

  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.DISCORD_WEBHOOK_LEARNING, REFLECT_NUDGE));
  } else if (near(env.GRATITUDE_HOUR, env.GRATITUDE_MINUTE)) {
    ctx.waitUntil(dc.send(env.DISCORD_WEBHOOK_LEARNING, GRATITUDE_NUDGE));
  } else if (
    near(env.ZETTEL_HOUR_1, env.ZETTEL_MINUTE_1) ||
    near(env.ZETTEL_HOUR_2, env.ZETTEL_MINUTE_2)
  ) {
    ctx.waitUntil(dc.send(env.DISCORD_WEBHOOK_LEARNING, ZETTEL_NUDGE));
  } else if (near(env.NEWS_HOUR, env.NEWS_MINUTE)) {
    ctx.waitUntil(postNewsDigest(env, localDate(env)));
  }
  // Otherwise this tick isn't a job time — do nothing. Most of them aren't.
}

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.

vault.ts
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.

vault.ts
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.

index.ts
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.

index.ts
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.

Specification
Runtime
Cloudflare Worker · ~1,150 lines of TypeScript
Source of truth
GitHub — every local clone is a cache
Interface
Discord — /zettel, /gratitude, /reflect inbound; channel webhooks 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
Six jobs on one 15-minute tick, matched against the local wall clock
Running cost
Roughly $3–5/month at current vault size
Source
Public — every credential is a Workers secret, never in the repo