diff --git a/.claude/skills/ci-deploy/SKILL.md b/.agents/skills/ci-deploy/SKILL.md
similarity index 100%
rename from .claude/skills/ci-deploy/SKILL.md
rename to .agents/skills/ci-deploy/SKILL.md
diff --git a/.claude/skills/content-editing/SKILL.md b/.agents/skills/content-editing/SKILL.md
similarity index 100%
rename from .claude/skills/content-editing/SKILL.md
rename to .agents/skills/content-editing/SKILL.md
diff --git a/.claude/skills/design-system/SKILL.md b/.agents/skills/design-system/SKILL.md
similarity index 100%
rename from .claude/skills/design-system/SKILL.md
rename to .agents/skills/design-system/SKILL.md
diff --git a/.claude/skills/git-hooks/SKILL.md b/.agents/skills/git-hooks/SKILL.md
similarity index 100%
rename from .claude/skills/git-hooks/SKILL.md
rename to .agents/skills/git-hooks/SKILL.md
diff --git a/.claude/skills/mdx-thread/SKILL.md b/.agents/skills/mdx-thread/SKILL.md
similarity index 100%
rename from .claude/skills/mdx-thread/SKILL.md
rename to .agents/skills/mdx-thread/SKILL.md
diff --git a/.claude/skills/seo-metadata/SKILL.md b/.agents/skills/seo-metadata/SKILL.md
similarity index 100%
rename from .claude/skills/seo-metadata/SKILL.md
rename to .agents/skills/seo-metadata/SKILL.md
diff --git a/.claude/skills/ci-deploy b/.claude/skills/ci-deploy
new file mode 120000
index 0000000..1d2e5af
--- /dev/null
+++ b/.claude/skills/ci-deploy
@@ -0,0 +1 @@
+../../.agents/skills/ci-deploy
\ No newline at end of file
diff --git a/.claude/skills/content-editing b/.claude/skills/content-editing
new file mode 120000
index 0000000..c09701d
--- /dev/null
+++ b/.claude/skills/content-editing
@@ -0,0 +1 @@
+../../.agents/skills/content-editing
\ No newline at end of file
diff --git a/.claude/skills/design-system b/.claude/skills/design-system
new file mode 120000
index 0000000..ed518de
--- /dev/null
+++ b/.claude/skills/design-system
@@ -0,0 +1 @@
+../../.agents/skills/design-system
\ No newline at end of file
diff --git a/.claude/skills/git-hooks b/.claude/skills/git-hooks
new file mode 120000
index 0000000..a8ae83e
--- /dev/null
+++ b/.claude/skills/git-hooks
@@ -0,0 +1 @@
+../../.agents/skills/git-hooks
\ No newline at end of file
diff --git a/.claude/skills/mdx-thread b/.claude/skills/mdx-thread
new file mode 120000
index 0000000..47a2285
--- /dev/null
+++ b/.claude/skills/mdx-thread
@@ -0,0 +1 @@
+../../.agents/skills/mdx-thread
\ No newline at end of file
diff --git a/.claude/skills/seo-metadata b/.claude/skills/seo-metadata
new file mode 120000
index 0000000..47a0c47
--- /dev/null
+++ b/.claude/skills/seo-metadata
@@ -0,0 +1 @@
+../../.agents/skills/seo-metadata
\ No newline at end of file
diff --git a/src/app/threads/[slug]/page.tsx b/src/app/threads/[slug]/page.tsx
index d635851..8d61496 100644
--- a/src/app/threads/[slug]/page.tsx
+++ b/src/app/threads/[slug]/page.tsx
@@ -11,6 +11,7 @@ import { threadCovers } from "@/lib/thread-covers";
import { Photo } from "@/components/photo";
import { Header } from "@/components/header";
import { Footer } from "@/components/footer";
+import { TableOfContents } from "@/components/table-of-contents";
type Params = { slug: string };
@@ -67,6 +68,7 @@ export default async function ThreadPage({
+
+
+
+
diff --git a/src/components/annotate.tsx b/src/components/annotate.tsx
new file mode 100644
index 0000000..540a754
--- /dev/null
+++ b/src/components/annotate.tsx
@@ -0,0 +1,189 @@
+"use client";
+
+/**
+ * Hand-drawn "pen" annotations for long-form thread prose. Wrap any inline text
+ * to circle / underline / box / strike / highlight it, the way you'd mark up a
+ * printed page. Each mark draws itself when it scrolls into view (respecting
+ * `prefers-reduced-motion`, which shows it instantly).
+ *
+ * Wired into MDX in `src/mdx-components.tsx`, so threads can write:
+ * Cron was easy because the job was dumb.
+ *
+ * Stroke colour comes from `currentColor`; set it with a text utility via
+ * `className` (default is the brand accent, i.e. a red-pen look).
+ */
+
+import { useEffect, useRef, useState, type ReactNode } from "react";
+
+type MarkProps = {
+ children: ReactNode;
+ /** Tailwind colour utility for the stroke, e.g. "text-accent-strong". */
+ className?: string;
+};
+
+/** Reveal once, when the element scrolls into view. Reduced-motion => instant. */
+function useDrawn() {
+ const ref = useRef(null);
+ const [drawn, setDrawn] = useState(false);
+
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+
+ const reduce =
+ typeof window !== "undefined" &&
+ window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
+ if (reduce) {
+ const raf = requestAnimationFrame(() => setDrawn(true));
+ return () => cancelAnimationFrame(raf);
+ }
+
+ const io = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ if (entry.isIntersecting) {
+ setDrawn(true);
+ io.disconnect();
+ }
+ }
+ },
+ { threshold: 0.85, rootMargin: "0px 0px -8% 0px" },
+ );
+ io.observe(el);
+ return () => io.disconnect();
+ }, []);
+
+ return { ref, drawn };
+}
+
+/** Common style for a self-drawing stroke path. */
+function strokeStyle(drawn: boolean, ms: number) {
+ return {
+ strokeDasharray: 1,
+ strokeDashoffset: drawn ? 0 : 1,
+ transition: `stroke-dashoffset ${ms}ms cubic-bezier(0.65, 0, 0.35, 1)`,
+ } as const;
+}
+
+/** Slightly wobbly underline that sweeps in under the text. */
+export function Underline({ children, className = "text-blue/80" }: MarkProps) {
+ const { ref, drawn } = useDrawn();
+ return (
+
+ {children}
+
+
+ );
+}
+
+/** Open, hand-drawn ellipse looped around the text. */
+export function Circle({ children, className = "text-violet/80" }: MarkProps) {
+ const { ref, drawn } = useDrawn();
+ return (
+
+ {children}
+
+
+ );
+}
+
+/** Rough rectangle drawn around the text. */
+export function Box({ children, className = "text-lime" }: MarkProps) {
+ const { ref, drawn } = useDrawn();
+ return (
+
+ {children}
+
+
+ );
+}
+
+/** A line struck through the text. */
+export function Strike({ children, className = "text-subtle" }: MarkProps) {
+ const { ref, drawn } = useDrawn();
+ return (
+
+ {children}
+
+
+ );
+}
+
+/** Marker-style highlight that wipes in behind the text. */
+export function Highlight({ children, className = "bg-sun/40" }: MarkProps) {
+ const { ref, drawn } = useDrawn();
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/src/components/code-block.tsx b/src/components/code-block.tsx
new file mode 100644
index 0000000..a02a8a2
--- /dev/null
+++ b/src/components/code-block.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+/**
+ * Thread code blocks with a copy-to-clipboard button.
+ *
+ * Registered as the `pre` element in `src/mdx-components.tsx`. The incoming
+ * props (including rehype-pretty-code's `data-*` attributes and classes) are
+ * spread straight onto the real
, so the highlighted output is preserved
+ * exactly; we only wrap it to position the button.
+ */
+
+import { useRef, useState, type ComponentPropsWithoutRef } from "react";
+
+export function Pre(props: ComponentPropsWithoutRef<"pre">) {
+ const ref = useRef(null);
+ const [copied, setCopied] = useState(false);
+ const timer = useRef | null>(null);
+
+ async function copy() {
+ const text = ref.current?.textContent ?? "";
+ if (!text) return;
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopied(true);
+ if (timer.current) clearTimeout(timer.current);
+ timer.current = setTimeout(() => setCopied(false), 2000);
+ } catch {
+ // Clipboard unavailable (insecure context, denied permission): leave the
+ // button idle rather than pretending it worked.
+ }
+ }
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/components/cron-diagram.tsx b/src/components/cron-diagram.tsx
new file mode 100644
index 0000000..766de0e
--- /dev/null
+++ b/src/components/cron-diagram.tsx
@@ -0,0 +1,64 @@
+/**
+ * A clean cron-expression breakdown for use in threads: each field sits above
+ * the name and range it maps to, replacing fragile ASCII box-art. Pure server
+ * component (no client JS). Wildcards render in the accent colour.
+ *
+ * Usage in MDX:
+ */
+
+const FIELDS = [
+ { name: "minute", range: "0–59" },
+ { name: "hour", range: "0–23" },
+ { name: "day-of-month", range: "1–31" },
+ { name: "month", range: "1–12" },
+ { name: "day-of-week", range: "0–7" },
+] as const;
+
+export function CronDiagram({
+ expr = "0 8 * * *",
+ note,
+}: {
+ expr?: string;
+ note?: string;
+}) {
+ const tokens = expr.trim().split(/\s+/).slice(0, 5);
+
+ return (
+
+
+ {note ? (
+
+ → {note}
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/table-of-contents.tsx b/src/components/table-of-contents.tsx
new file mode 100644
index 0000000..d646531
--- /dev/null
+++ b/src/components/table-of-contents.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+/**
+ * "On this page" navigator for a thread. Reads the rendered article's h2/h3
+ * headings (which already carry ids from rehype-slug), lists them, and
+ * highlights the section you're currently reading via IntersectionObserver.
+ * Clicking a heading jumps to it (native anchor + the headings' scroll-mt).
+ *
+ * Renders nothing when there are fewer than two headings, and is hidden on
+ * narrow screens by its parent (scrolling is fine there).
+ */
+
+import { useEffect, useState } from "react";
+
+type Item = { id: string; text: string; level: 2 | 3 };
+
+export function TableOfContents() {
+ const [items, setItems] = useState([]);
+ const [activeId, setActiveId] = useState("");
+
+ useEffect(() => {
+ let io: IntersectionObserver | undefined;
+
+ // Read the rendered headings after paint so we're not calling setState
+ // synchronously in the effect body, and so ids from rehype-slug exist.
+ const raf = requestAnimationFrame(() => {
+ const headings = Array.from(
+ document.querySelectorAll(".thread h2, .thread h3"),
+ ).filter((h) => h.id);
+
+ setItems(
+ headings.map((h) => ({
+ id: h.id,
+ text: h.textContent ?? "",
+ level: h.tagName === "H3" ? 3 : 2,
+ })),
+ );
+
+ if (headings.length < 2) return;
+
+ // Track which heading is nearest the top of the viewport.
+ const visible = new Map();
+ io = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ const id = (entry.target as HTMLElement).id;
+ if (entry.isIntersecting) visible.set(id, entry.boundingClientRect.top);
+ else visible.delete(id);
+ }
+ if (visible.size > 0) {
+ const top = [...visible.entries()].sort((a, b) => a[1] - b[1])[0][0];
+ setActiveId(top);
+ }
+ },
+ { rootMargin: "-80px 0px -68% 0px", threshold: 0 },
+ );
+
+ headings.forEach((h) => io!.observe(h));
+ });
+
+ return () => {
+ cancelAnimationFrame(raf);
+ io?.disconnect();
+ };
+ }, []);
+
+ if (items.length < 2) return null;
+
+ return (
+
+ );
+}
diff --git a/src/content/threads/claude-routines-and-cron.mdx b/src/content/threads/claude-routines-and-cron.mdx
new file mode 100644
index 0000000..52b1b99
--- /dev/null
+++ b/src/content/threads/claude-routines-and-cron.mdx
@@ -0,0 +1,274 @@
+---
+title: "Claude routines are cron jobs that can think"
+description: "A deep, practical guide to Claude routines: cron jobs whose payload is an agent, not a script. What that breaks (determinism, idempotency, retries, overlap), a gallery of routines worth building, and how to harden one so it behaves when nobody is watching."
+date: 2026-07-17
+tags: ["ai", "agents", "claude", "cron", "distributed-systems"]
+---
+
+Cron is one of the most durable pieces of software ever written. You give it five fields and
+a command, and for forty years it has run that command on time and gotten out of the way. It
+works because it makes almost no promises. It fires a process; it
+does not care what the process does, whether it succeeded, or whether the
+last one is still running.
+
+Claude *routines* — scheduled agents that run on a cron expression — look like the same idea
+with a smarter payload. Point one at "every morning at 8, review yesterday's failed CI runs
+and open an issue for anything that looks like a real regression," pick a schedule, and it
+runs on its own. Underneath, the scheduling primitive really is just cron.
+
+But the payload is no longer a script. It's a
+nondeterministic agent that reads, decides, and writes to the outside world.
+That single swap invalidates most of the assumptions cron got to be simple by ignoring. This is
+a long, practical note about which assumptions break, the routines actually worth building, and
+how to design one so it behaves when nobody is watching.
+
+> **Prefer to watch?** There's a video on this same topic on YouTube:
+> [youtube.com/watch?v=eSP7PLTXNy8](https://www.youtube.com/watch?v=eSP7PLTXNy8).
+
+## The anatomy of a routine
+
+Strip away the framing and a routine has exactly three parts:
+
+1. **A schedule** — a cron expression (`0 8 * * *`) that decides *when* it wakes up.
+2. **A prompt** — the self-contained instruction that *is* the job.
+3. **A grant** — the tools and permissions the run is allowed to use (a repo, an MCP server,
+ a Slack channel), which decides *what it can touch*.
+
+A plain cron job has the first and third of these; the second is a shell command. The whole
+shift is that the second is now a paragraph of natural language handed to a model. Everything
+strange about routines falls out of that one substitution.
+
+### A routine is a cold start every time
+
+The first thing to internalize: a scheduled run does **not** inherit the conversation you were
+in when you created it. It starts cold. No memory of "the above," no working
+directory you had open, no half-finished reasoning. The prompt you save is
+the entire context the next run gets.
+
+This is the right design — a job that only works because of state trapped in one person's
+terminal session is not a job, it's a demo — but it flips how you write the instruction. You're
+not talking to an assistant that already knows what you meant. You're writing a spec for
+a stranger who will execute it once, alone, months from now:
+
+```text
+Bad: "do that check we talked about and let me know"
+Good: "Fetch the last 24h of runs from the `deploy-production` workflow in
+ BiSemaphore/binarysemaphore. For each failure, read the logs. If the
+ failure is a real regression (not a flaky timeout or a cancelled run),
+ open a GitHub issue titled `CI regression: ` with the failing step
+ and a link. If everything passed, do nothing and post no message."
+```
+
+Everything the run needs — repo, tool, definition of "real regression," what to do on the happy
+path — is in the text, because there is nothing else. The discipline this forces is the same
+discipline behind a good `README` or a good on-call runbook:
+assume the reader has none of your context,
+because they don't.
+
+## A gallery of routines worth building
+
+The abstract argument lands better against concrete jobs. Here are routines that earn their
+keep, each with a schedule and the shape of its prompt. Notice how every one ends with an
+explicit otherwise, do nothing — that clause is load-bearing, and we'll come
+back to why.
+
+**Nightly CI regression triage** — turn a wall of red into a short list of real bugs.
+
+```text
+schedule: 0 7 * * 1-5 # 07:00 on weekdays
+prompt: Read failed runs from the last 24h in . Cluster them.
+ Open one issue per distinct real regression (skip flaky timeouts and
+ cancelled runs), each with the failing step and a permalink. If an
+ open issue with that title already exists, comment instead. Otherwise
+ do nothing.
+```
+
+**Morning dependency & advisory digest** — the security email you'd never read, summarized.
+
+```text
+schedule: 30 8 * * 1 # 08:30 every Monday
+prompt: List dependencies with a new release or CVE since last Monday. For
+ each, one line: package, old -> new, why it matters, breaking? Post a
+ single message to #eng. If nothing changed, say "no updates" once.
+```
+
+**Stale issue & PR sweep** — the janitorial pass nobody volunteers for.
+
+```text
+schedule: 0 16 * * 5 # 16:00 every Friday
+prompt: Find issues with no activity in 30 days and PRs green but unmerged for
+ 7. Add a "stale" comment with a specific next step, not a generic ping.
+ Never close anything. Summarize what you touched in one message.
+```
+
+**On-call handoff** — a written summary at the shift boundary.
+
+```text
+schedule: 0 9,21 * * * # 09:00 and 21:00 daily
+prompt: Summarize alerts, deploys, and incident-channel activity since the
+ last handoff. What's still open, what to watch. Post to #on-call.
+```
+
+**Docs freshness check** — catch the doc that now lies.
+
+```text
+schedule: 0 3 * * 2 # 03:00 every Tuesday
+prompt: Find docs referencing code paths, flags, or env vars that no longer
+ exist in the repo. Open one issue listing them with file:line. If the
+ list is empty, do nothing.
+```
+
+Two patterns run through the whole gallery. First, **narrow verbs**: "cluster," "comment,"
+"summarize," never "manage". Second, **a defined empty case**: the boring
+night, where the right output is nothing at all. A routine that can't stay quiet becomes noise
+you learn to ignore, and an ignored routine is worse than no routine.
+
+## Cron's promises, and which ones the agent revokes
+
+Cron's simplicity came from a short list of things it refused to guarantee. Each one was fine
+when the payload was `backup.sh`. Each one is now yours to think about.
+
+| Cron quietly assumes | Fine for a script because | Now that the payload is an agent |
+| --- | --- | --- |
+| The job is deterministic | Same input, same output | Same prompt can produce different actions each run |
+| Retrying is safe | Re-running `rsync` is idempotent | A retry might file the issue *again* |
+| Overlap is your problem | Two `backup.sh` rarely race | Two runs can both act on the same event |
+| Exit code 0 means success | The script knows if it failed | "Success" is a judgement the model is making |
+| Missed runs just… don't happen | You'll notice a stale backup | A skipped morning review is silent |
+
+None of these are reasons not to use routines. They're the checklist you'd apply to any
+unattended distributed job, surfaced by the fact that the payload can now think. The good news
+is the fixes are old and well understood.
+
+### Make the action idempotent, not the agent
+
+You cannot make the model deterministic, and you shouldn't try. What you *can* do is
+make the side effect safe to repeat. This is the same move you make with
+any at-least-once system: push idempotency into
+the write, not the caller. A few patterns cover almost everything:
+
+- **Check before write.** "Open an issue *if one with this title doesn't already exist*." The
+ title becomes a natural idempotency key.
+- **Scope by period.** "Post the digest *for this ISO week*, and check the channel for one
+ already posted this week first." The time window is the key.
+- **Prefer upsert over insert.** Update-or-create against a stable identifier instead of
+ blindly appending, so the second run converges instead of duplicating.
+- **Make "nothing to do" a first-class outcome.** The happy path for most runs is a no-op, and
+ the prompt should say so explicitly, or the model will invent activity to look useful.
+
+Here's the same routine written the naive way and the safe way. The diff is small and it is the
+whole ballgame:
+
+```diff
+- Open a GitHub issue for the regression.
+- Post a summary to #eng.
++ If an open issue titled "CI regression: " exists, add a comment; else open it.
++ Post the summary tagged with today's date; if today's is already in #eng, skip.
+```
+
+The routine is allowed to run twice — because eventually it will — and the second run is a
+no-op. A cron job for a dumb script gets this for free. A routine gets it because you designed
+the *target* of the action to tolerate a duplicate.
+
+### Decide what overlap means before it happens
+
+If your review job takes eleven minutes and you schedule it every five, cron will happily start
+a second one while the first is still going. For `backup.sh` that's an annoyance. For an agent
+with write access it's two runs reasoning about the same
+world and possibly both acting on it — the classic double-file, now with extra
+steps.
+
+Pick a schedule with generous headroom over the worst-case runtime, and lean on the idempotent
+writes above as the real backstop, since headroom is a guess and
+idempotency is a guarantee.
+
+## A cron refresher, because the boring part still bites
+
+The agent is the interesting part, but the cron expression itself is still where a lot of
+routines quietly go wrong. Worth having the five fields cold:
+
+
+
+Each field takes more than a single number:
+
+- `*` — every value. `*/15` — every 15th (a **step**): `*/15 * * * *` is every quarter hour.
+- `1-5` — a **range** (Monday to Friday in the weekday field).
+- `1,15` — a **list** (the 1st and the 15th).
+- `@daily`, `@hourly`, `@weekly`, `@reboot` — shorthands some schedulers accept for the common
+ cases.
+
+Three failure modes survive intact into the AI era:
+
+- **Time zones.** `0 8 * * *` is 8am in *some* zone. If you don't know which, you don't know
+ when your routine runs. State the zone explicitly; don't let a server's
+ `UTC` default decide your "every morning" is happening at midnight local.
+- **Daylight saving.** Twice a year a wall-clock time either doesn't exist or happens twice. A
+ job at `2:30` on the spring-forward night can be skipped entirely; on the fall-back night it
+ can fire twice. If the run matters, keep it out of the 1am–3am window, and — again — make it
+ idempotent so a double-fire is harmless.
+- **The day-of-month / day-of-week trap.** When you set *both* of those fields, cron treats
+ them as OR, not AND. `0 9 13 * 5` is not "the 13th if it's a Friday." It's
+ "the 13th, *and also* every Friday." This has been surprising people since the 1970s and it
+ will surprise you too. Want "the 13th only when it's Friday"? You can't say it in one
+ expression; schedule the 13th and let the job check the weekday itself.
+
+The routine wrapper doesn't rescue you from any of this. Cron semantics are cron semantics; the
+agent just does something more expensive than usual when the expression is wrong.
+
+## Routines vs. loops: schedule vs. session
+
+There's a neighbouring feature worth drawing a line against, because they solve different
+problems. A **loop** re-runs a prompt on an interval *inside the current session* — it keeps
+your context and is meant for "watch this thing until it's done": babysit a deploy, poll a build,
+iterate on a task while you're around. A **routine** is a scheduled *cloud* run with no session
+and no you: it wakes up cold, does one self-contained job, and exits.
+
+The rule of thumb: if the work needs the conversation you're in right now,
+it's a loop. If the work should happen whether or not you're at your
+machine, it's a routine. Reaching for a
+routine to babysit something you're actively watching gives you a cold agent fumbling for
+context it would have had for free in-session. Reaching for a loop to run a nightly job means it
+only runs on the nights you happened to leave the session open.
+
+## Knowing it ran: observability for something you never see
+
+A cron job mailed you its stdout. A routine, left alone, is a black box that may or may not be
+doing its work at 3am. Before you trust one, wire up three things:
+
+- **A heartbeat.** Have it leave a trace even on the empty case — a one-line "checked CI, all
+ green" — at least until you trust it.
+ A silent routine is indistinguishable from a broken one, and "it hasn't
+ posted in a week" should read as *alarming*, not *calm*.
+- **A dry run.** For anything with a write, run it once in a mode where it reports what it
+ *would* do instead of doing it. You are
+ reviewing the model's judgement before you let it act unattended; do
+ that while you're watching.
+- **A bounded blast radius.** Same instinct as [starting an MCP server
+ read-only](/threads/an-mcp-server-for-your-notes): give the routine exactly the reach its job
+ needs and nothing more. An unattended agent with broad write access is a
+ standing risk that fires on a timer.
+
+## Design a routine like an unattended job, because it is one
+
+Strip away the novelty and a routine is a cron-scheduled batch job whose worker happens to be a
+language model. The engineering that makes it trustworthy is the same engineering that makes any
+unattended job trustworthy, and none of it is new:
+
+1. **Keep the scope narrow.** One routine, one job. "Review CI failures"
+ is a routine. "Manage the repo" is a wish. A tight objective is easier to write, cheaper to
+ run, and far easier to trust when nobody's watching.
+2. **Make success and failure observable.** Leave a trace even on the "nothing to do" path.
+ Cron mailed you the job's stdout for exactly this reason.
+3. **Make the writes idempotent.** The single highest-leverage habit.
+ Assume at-least-once, design for it, stop worrying about double-fires.
+4. **Bound the blast radius.** Give the routine exactly the reach its job needs and nothing
+ more.
+5. **Define the empty case.** Say what "nothing happened" looks like, or the model will fill the
+ silence.
+
+Cron got to be simple because it never had to understand its payload. Routines hand the payload
+a mind, which is genuinely useful — a nightly job that can *read logs and judge* is worth a
+lot — but the price is that all the guarantees cron waved off are now yours to provide. Provide
+them, and a routine is a tireless teammate that shows up on
+schedule. Skip them, and it's a very articulate way to do the wrong thing at 8am
+every day.
diff --git a/src/content/threads/upgrading-to-typescript-7.mdx b/src/content/threads/upgrading-to-typescript-7.mdx
new file mode 100644
index 0000000..e6f7e99
--- /dev/null
+++ b/src/content/threads/upgrading-to-typescript-7.mdx
@@ -0,0 +1,127 @@
+---
+title: "Upgrading to TypeScript 7: what actually breaks"
+description: "A field guide to the TypeScript 7 migration: the compiler options that are now hard errors, the missing programmatic API, and why a Next.js + MDX site like this one still needs TypeScript 6 in the editor."
+date: 2026-07-12
+tags: ["typescript", "tooling", "migration"]
+---
+
+[TypeScript 7 is 8-12x faster](/threads/why-typescript-7-is-written-in-go), which makes the
+upgrade tempting to do on a Friday afternoon. Don't. The speed is real, but 7.0 is the
+release where the team cashed in every deprecation they've been sitting on *and* shipped
+without a programmatic API. Whether you can move depends less on your code than on your
+toolchain.
+
+Here's the honest checklist, in the order the breakage actually hits you.
+
+## 1. Options that are now hard errors
+
+TypeScript 6 made these warnings. TypeScript 7 makes them errors, so `tsc` refuses to start
+rather than silently doing something else. Removed outright:
+
+| Removed | What to do instead |
+| --- | --- |
+| `target: es5` | Target a modern runtime; downlevel in your bundler if you truly must |
+| `downlevelIteration` | Gone with ES5 |
+| `moduleResolution: node` / `node10` | `nodenext` (Node) or `bundler` (Vite, webpack, Next) |
+| `module: amd` / `umd` / `systemjs` / `none` | `esnext` |
+| `baseUrl` | `paths`, resolved relative to the project root |
+| `esModuleInterop: false`, `allowSyntheticDefaultImports: false` | Cannot be disabled; delete them |
+
+If you're on a modern setup, most of this is a no-op. If you're carrying a `tsconfig.json`
+that has been copy-pasted since 2019, this is where your afternoon goes.
+
+## 2. Defaults that changed under you
+
+More dangerous than the removals, because these don't error, they just change what your
+build means.
+
+- **`strict: true` is the default.** If you were implicitly relying on `strict` being off,
+ you now have every error `strict` catches, all at once. Set `"strict": false` explicitly
+ if you're not ready — but note you're now opting *out*, in writing, which is the point.
+- **`module: esnext` is the default.**
+- **`noUncheckedSideEffectImports: true`** — a bare `import "./thing"` that resolves to
+ nothing is now an error. This catches real bugs and a few false ones (side-effect imports
+ of CSS or assets need the right ambient declarations).
+- **`stableTypeOrdering: true`, and it cannot be disabled.** The checker is parallel now, so
+ deterministic type ordering has to be enforced rather than assumed.
+- **`rootDir` now defaults to `./`.** If your `tsconfig.json` lives above your sources, your
+ output layout just changed. Say what you mean:
+
+ ```json
+ {
+ "compilerOptions": {
+ "rootDir": "./src"
+ }
+ }
+ ```
+
+- **`types` defaults to `[]`** instead of auto-discovering everything in
+ `node_modules/@types`. This is a genuinely good change (auto-discovery was a common source
+ of "why is `describe` a global in my production build") and a genuinely annoying one,
+ because you must now list what you use:
+
+ ```json
+ {
+ "compilerOptions": {
+ "types": ["node", "vitest/globals"]
+ }
+ }
+ ```
+
+ Missing globals after upgrading — `process`, `describe`, `expect` — is almost always this.
+
+## 3. Checked JavaScript got stricter
+
+If you run `checkJs` over a JS codebase with JSDoc types, several patterns are gone: values
+can no longer stand in for types (use `typeof x`), `@enum` is no longer recognized (use
+`@typedef`), the postfix `!` non-null operator is out, and Closure-style function syntax
+isn't supported. Small surface, but if you're a big JSDoc shop it's the whole migration.
+
+## 4. The one that actually decides your timeline: no API
+
+TypeScript 7.0 ships with **no programmatic API**. Anything that imports `typescript` and
+walks the AST or asks the checker questions does not work on 7 yet. That includes:
+
+- typescript-eslint (so: most people's lint setup)
+- Vue, Svelte, Astro, **MDX** — anything that embeds or templates TypeScript
+- Angular's template type-checking
+- ts-morph, most codemods, most custom transformers
+
+The supported path is to run both compilers side by side: TypeScript 7 for the fast CLI
+builds, TypeScript 6 via `@typescript/typescript6` (an npm alias) for the tools that still
+need the API. A real API is expected in 7.1.
+
+So the practical question is not "does my code compile under 7" but "which of my tools reach
+into the compiler". Most teams will find the answer is *at least one*.
+
+## What this means for a site like this one
+
+This site is Next.js App Router plus MDX (that's how these threads are written). MDX needs
+the TypeScript API to type-check inside `.mdx`, so per the release notes, MDX projects stay
+on TypeScript 6 for now. The realistic setup is split:
+
+- **CLI type-check and CI:** TypeScript 7. `npm run typecheck` is the single slowest thing
+ in our pipeline, and it's the thing every push waits on.
+- **Editor and lint:** TypeScript 6 until 7.1 lands the API, because typescript-eslint and
+ the MDX toolchain need it.
+
+That's not a clean win, and it's worth being clear-eyed about it rather than performing an
+upgrade for the changelog entry. A codebase this size type-checks in a couple of seconds
+either way. The 8-12x is transformative for a 130-second build and a rounding error for a
+2-second one, so we'll take the CI half now and move the editor when the API is real.
+
+## A migration order that works
+
+1. Upgrade to the **latest TypeScript 6** first, and fix every deprecation warning. This is
+ the actual work, and you can do it incrementally while your tooling still functions.
+2. Modernize `tsconfig.json`: `moduleResolution` to `bundler` or `nodenext`, drop `baseUrl`,
+ drop `esModuleInterop: false`, make `rootDir` and `types` explicit.
+3. Turn `strict` on, or explicitly turn it off, so nothing changes silently under you.
+4. Inventory what touches the compiler API: lint, test transforms, codemods, framework
+ plugins. That list is your gate.
+5. Run TypeScript 7 for CLI builds and CI, keep TypeScript 6 wherever the API is required.
+6. Revisit when 7.1 ships an API and the ecosystem catches up.
+
+Steps 1-3 are worth doing even if you never adopt 7. That's usually the sign of a
+well-designed breaking release: the migration path improves your codebase whether or not you
+arrive at the destination.
diff --git a/src/content/threads/why-typescript-7-is-written-in-go.mdx b/src/content/threads/why-typescript-7-is-written-in-go.mdx
new file mode 100644
index 0000000..b823ee1
--- /dev/null
+++ b/src/content/threads/why-typescript-7-is-written-in-go.mdx
@@ -0,0 +1,156 @@
+---
+title: "Why TypeScript 7 is written in Go"
+description: "TypeScript 7 shipped on July 8, 2026 with an 8-12x faster compiler. The speedup isn't a clever optimization, it's the consequence of leaving a runtime that couldn't express the thing the checker wanted to do all along."
+date: 2026-07-12
+tags: ["typescript", "compilers", "go", "concurrency"]
+---
+
+TypeScript 7.0 [shipped on July 8, 2026](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/).
+The headline is the number: vscode's build went from 125.7s to 10.6s, sentry from 139.8s
+to 15.7s, playwright from 12.8s to 1.47s. Roughly 8-12x, plus 6-26% less memory. Editors
+got the bigger win in practice — opening a file with errors in VS Code fell from 17.5
+seconds to 1.3.
+
+The interesting part isn't the number. It's that the number came from a *port*, not a
+redesign. Same architecture, same algorithms, same type system, largely the same code
+structure, moved from TypeScript to Go. When a straight port makes something an order of
+magnitude faster, the old implementation wasn't slow because it was badly written. It was
+slow because the runtime under it couldn't express what the program actually wanted to do.
+
+## Where the time was going
+
+`tsc` was a TypeScript program compiled to JavaScript, running on Node. That gives you
+three costs that no amount of micro-optimization removes.
+
+**You pay for the JIT, every time.** A compiler run is short and cold. V8 has to parse,
+interpret, profile and eventually optimize hot functions — but batch `tsc` often exits
+before the JIT has finished paying itself back. The checker is a mess of megamorphic call
+sites (a `Type` can be one of dozens of shapes), which is exactly the pattern inline caches
+handle worst.
+
+**Every node is a heap object.** ASTs, symbols, types: millions of small objects, each a
+V8 object with a hidden class and a pointer chase to reach any field. Type checking is
+pointer-chasing all the way down, so cache behavior *is* the performance story, and
+JavaScript gives you no control over layout.
+
+**And the hard one: you cannot share memory.** Node's concurrency story is worker threads
+with structured-cloned messages or `SharedArrayBuffer` (raw bytes, no objects). The type
+checker's core structure is a giant cyclic graph — symbol tables pointing at declarations,
+declarations at AST nodes, AST nodes back at symbols, types recursing into themselves. To
+check two files in parallel, both workers need to *read that same graph*. Serializing it
+per worker costs more than the checking does. So `tsc` stayed single-threaded, on machines
+where 8 or 16 idle cores were watching it work.
+
+That last constraint is the whole story. The checker is embarrassingly parallel by nature —
+files are mostly independent, and the shared graph is read-mostly once it's built. The
+algorithm wanted threads. The runtime had none to give.
+
+## Why Go, and not Rust
+
+This is where most of the noise was, and the reasoning is more interesting than the tribal
+answer.
+
+Go's memory model is the one the compiler already assumed: a garbage-collected heap where
+any goroutine can hold a pointer to any object, and goroutines share one address space. You
+can spawn workers that all read the same symbol tables with no copying, no `Arc`, no
+serialization. That is precisely the capability JavaScript couldn't offer, and it's where
+the 10x lives.
+
+Rust would have fought the data model. The compiler's graph is *deliberately* cyclic: a
+node points at its parent and its children; a symbol points at the declarations that create
+it, which point back at the symbol; recursive types loop by construction. That's ownership's
+worst case. You'd end up with `Rc>` or arena indices everywhere — either
+paying the cost you were trying to avoid, or rewriting the type checker's data flow from
+scratch. And a rewrite is a different, much riskier project than a port: you have to
+re-derive twenty years of accumulated behavior that only exists as code.
+
+The port mattered for a second reason. The existing compiler is written in a function-heavy,
+closure-and-data style rather than a deeply object-oriented one, which maps onto Go almost
+one-to-one. That kept the port mostly mechanical, and mechanical means the type system's
+thousand undocumented edge cases survive the move.
+
+Go isn't "faster than Rust" here. It's the language whose defaults match the shape of this
+specific program, at a level of effort that made the project finishable.
+
+## What native + threads actually buys
+
+Three separate wins, worth keeping apart:
+
+1. **Native code.** No JIT warmup, no deopt cliffs. Structs are laid out flat, so walking
+ an AST touches fewer cache lines. Call this a solid constant factor.
+2. **Shared-memory parallelism.** Parsing, checking and emitting run concurrently across
+ goroutines over one graph. This is the multiplier, and it's the one that was previously
+ impossible rather than merely slow.
+3. **Control over allocation.** A compiler allocates in bulk and frees in bulk. In Go you
+ can shape that; in JavaScript you get whatever V8 decides.
+
+The parallelism is exposed, not hidden. TypeScript 7 adds `--checkers` (default 4) for
+type-checking workers, `--builders` for parallelizing project references, and
+`--singleThreaded` when you need deterministic behavior for debugging:
+
+```bash
+# More checkers: faster on big projects, more memory.
+tsc --checkers 8
+
+# Reproducible ordering while chasing a compiler bug.
+tsc --singleThreaded
+```
+
+The team reports up to 16.7x with `--checkers 8` on large projects, trading memory for it.
+That knob existing at all is the tell: concurrency is now a first-class part of the
+compiler's design, not an afterthought bolted onto a single-threaded core.
+
+There's a subtle consequence of going parallel, and TypeScript 7 handles it rather than
+ignoring it. If workers check files in nondeterministic order, the order that types get
+*created* in can vary, and anything that prints types — declaration emit, error messages,
+union ordering — could shift between runs. So `stableTypeOrdering` is on and cannot be
+turned off. Determinism is now a property the compiler has to maintain deliberately, because
+the machine underneath it no longer provides it for free.
+
+## The editor was always the real workload
+
+Batch builds are the number people quote, but the language service is where developers
+actually feel a compiler. It's a long-lived process holding the same graph, answering
+latency-sensitive questions: hover, completions, go-to-definition, find-all-references.
+
+TypeScript 7 rebuilt this on the Language Server Protocol with multithreading, and the
+numbers that matter aren't speed at all: an 80% reduction in failing language server
+commands and 60% fewer server crashes. A single-threaded server that blocks for 17 seconds
+on a big file isn't slow, it's *broken* — it drops requests, times out, and gets killed. A
+lot of what everyone experienced as TypeScript being flaky in large repos was a
+concurrency-starved process failing under load.
+
+They also rewrote the file watcher, porting Parcel's C++ watcher to Go. Watching is one of
+those problems that looks trivial and is not: it's where "my editor didn't notice the file
+changed" bugs are born.
+
+## What it cost
+
+TypeScript 7.0 ships **without a programmatic API**. Not a smaller one — none. Everything
+that imports `typescript` and pokes at the AST (typescript-eslint, ts-morph, Vue, Svelte,
+Astro, MDX, Angular's template checker, most codemods) cannot run on TypeScript 7 today. The
+compatibility answer is to keep TypeScript 6 side by side via `@typescript/typescript6`,
+with an API promised in 7.1.
+
+That's a big bill, and it's honest about the tradeoff being made. A JavaScript API handing
+out object references into a graph is a very different thing to design when that graph is
+being read by a pool of goroutines and owned by a Go garbage collector. They shipped the
+compiler and deferred the boundary rather than shipping a bad boundary and living with it
+for a decade. Worth respecting, and worth knowing before you plan an upgrade — we wrote up
+[what the migration actually involves](/threads/upgrading-to-typescript-7) separately.
+
+## The part worth stealing
+
+Nobody found a magic algorithm. They took a program whose *natural* structure was a
+shared, read-mostly graph traversed in parallel, and moved it to a runtime that can
+represent that structure. The 10x wasn't created; it was already there, sitting behind an
+abstraction that couldn't reach it.
+
+Which is the useful question to carry back to your own systems: not "how do I make this
+loop faster", but "what is this program obviously trying to do, and what is stopping it?"
+Sometimes the answer is a better data structure. Sometimes the honest answer is that the
+platform you chose cannot express your problem, and every optimization from here is
+interest payments on that decision.
+
+The TypeScript team spent a year answering that question with a full port. The rest of us
+usually get to answer it for the price of a weekend spike.
diff --git a/src/images/cron-routine-notes.jpg b/src/images/cron-routine-notes.jpg
new file mode 100644
index 0000000..05edf59
Binary files /dev/null and b/src/images/cron-routine-notes.jpg differ
diff --git a/src/lib/thread-covers.ts b/src/lib/thread-covers.ts
index 6695532..d4ab0f4 100644
--- a/src/lib/thread-covers.ts
+++ b/src/lib/thread-covers.ts
@@ -3,6 +3,9 @@ import notesWindow from "@/images/notes-window.jpg";
import wireframes from "@/images/wireframes.jpg";
import tornPaper from "@/images/torn-paper.jpg";
import forceNote from "@/images/the-force-note.jpg";
+import colorStacks from "@/images/color-stacks.jpg";
+import planningNotes from "@/images/planning-notes.jpg";
+import cronRoutineNotes from "@/images/cron-routine-notes.jpg";
/**
* Maps a thread slug to its cover photo. Threads without an entry simply render
@@ -14,4 +17,7 @@ export const threadCovers: Record = {
"how-semantic-search-works": wireframes,
"an-mcp-server-for-your-notes": tornPaper,
"what-local-first-buys-you": forceNote,
+ "why-typescript-7-is-written-in-go": colorStacks,
+ "upgrading-to-typescript-7": planningNotes,
+ "claude-routines-and-cron": cronRoutineNotes,
};
diff --git a/src/mdx-components.tsx b/src/mdx-components.tsx
index 0662326..7eb3950 100644
--- a/src/mdx-components.tsx
+++ b/src/mdx-components.tsx
@@ -1,6 +1,9 @@
import type { MDXComponents } from "mdx/types";
import Link from "next/link";
import type { AnchorHTMLAttributes } from "react";
+import { Underline, Circle, Box, Strike, Highlight } from "@/components/annotate";
+import { CronDiagram } from "@/components/cron-diagram";
+import { Pre } from "@/components/code-block";
/**
* Global MDX element styling for thread content. Required by @next/mdx in the
@@ -84,6 +87,16 @@ const components: MDXComponents = {
td: (props) => (
),
+ // Hand-drawn pen annotations, usable inline in any thread.
+ Underline,
+ Circle,
+ Box,
+ Strike,
+ Highlight,
+ // Cron expression breakdown.
+ CronDiagram,
+ // Code blocks, with a copy-to-clipboard button.
+ pre: Pre,
};
export function useMDXComponents(): MDXComponents {