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 — One local time, registered at both UTC candidates
PLATE II — ONE LOCAL TIME, REGISTERED AT BOTH UTC CANDIDATES

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.

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.

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.

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

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,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