~/lodehed/blog/winston-agentic-os.md · 2026-07-31 · 11 min
all writing

Winston: an agentic OS where the model never routes.

For the past month I've been building Winston, a personal agentic OS that runs 24/7 on an always-on Mac: a conversational orchestrator I talk to over Telegram and a local web panel, backed by scheduled sub-agents that triage my email, work my task board, tend their own memory, and extend the system by writing new plugins. It uses no agent framework. The only agent dependency is the Claude Agent SDK, used as a primitive — and everything LangGraph or CrewAI would call 'the framework' is thirteen thousand lines of plain TypeScript around SQLite and the filesystem. This post is about why that shape, and what it buys.

Winston is the assistant I've been circling for years: one persona, reachable from my phone and from a localhost panel, that remembers what I tell it, runs the recurring work of my apps — Lovio's weekly newsletter, Lykka's Apple Search Ads report, email triage three times a day — and does the right amount of things on its own without ever surprising me. It went from empty repo to the system described here in one month: 502 commits, roughly 36,800 lines of TypeScript, 823 test cases, sixteen production dependencies. No LangChain, no CrewAI, no AutoGen, no agent graph. The Claude Agent SDK provides exactly one thing — a query() call that runs an agentic session — and the rest is a daemon I can read end to end.

Three parts, deliberately separate

The architecture is three layers that refuse to blur. First, a deterministic kernel: a Node daemon under launchd that owns a SQLite job queue, a cron materializer, a dispatcher, the Telegram long-poller, an embedded web control plane, and a watchdog. There is no LLM anywhere in its hot loop — the scheduler tick, the task-board sweep, the health checks are all pure SQLite and filesystem work, so the system idles at zero tokens. Second, Winston himself: a chat-triggered session claimed from a warm pool of exactly one, so the subprocess and tool servers are already up and the first reply lands in a couple of seconds — a warm spare that has run no turn costs nothing. Third, the workers: short-lived sub-agent jobs dispatched from the queue with a fresh context, a turn limit, a wall-clock timeout, and a model tier chosen by task class — haiku for triage, sonnet for standard work, opus reserved for chat and the genuinely hard reasoning.

The separation is the design. The kernel decides when things run and what they may do. The model decides only what to say and do inside a bounded job. In every popular framework I've read, the model participates in routing — an LLM node decides which edge of the graph fires next. In Winston the model never routes. Control flow is rows in a table.

The queue is the framework

One SQLite table is both the durable queue and the scheduler. Scheduled agents are defined by a cron line in their own definition file; every fifteen seconds the kernel materializes each agent's next occurrence as a job row with a deduplication key, so restarts and repeated ticks are idempotent and the in-memory cron library is never the source of truth. Claiming is a single immediate transaction:

SELECT * FROM jobs WHERE status = 'pending' AND run_at <= ?
  ORDER BY run_at, id LIMIT 1;
UPDATE jobs SET status = 'running', attempts = attempts + 1,
  lease_until = ? WHERE id = ?;

Jobs hold leases that are extended while they run; a reaper requeues anything orphaned by a crash. Failures back off exponentially and dead-letter after three attempts. My favorite small mechanism is retry context: a retried job gets the previous attempt's error prepended to its prompt with the instruction to diagnose and take a different approach — because queue-level retries reuse the original input, and without this, attempt two is just attempt one again. Around the whole dispatch loop sits a circuit breaker, so an API outage pauses the system instead of burning every job's attempts against a wall.

architecture diagram
The job lifecycle. Gold on the left is a file, violet is the only place a model runs — everything in between is deterministic kernel. Orange is the happy path from a cron line in an agent file to a report on a desk; the dashed loop is failure: expired leases reaped, retries carrying the previous error back into the next prompt.

Files are the source of truth

The rule that shapes everything: knowledge lives in git-tracked files, machine state lives in SQLite, and the SQLite side is rebuildable — I could delete the database and lose queue positions, never knowledge. An agent is a markdown file: YAML frontmatter for schedule, model, turn limit, tool list and task class, then the system prompt as prose. The registry re-reads the directory on every dispatch, so saying 'create an agent that checks X daily at seven' in chat, filling a form in the panel, and hand-editing the file are the identical write, live on the next dispatch with no restart. Standing goals are files whose 'next step' line is injected into every session and rewritten each night by a reflection agent. Work domains are files too — Winston calls them desks.

A desk is the concept I'd defend hardest. Each desk — lovio, lykka, email, jesper — is a markdown file holding a standing brief, a memory scope, and a roster of which agents can be mentioned there. Filing a conversation on a desk injects the brief into the first turn, makes that project's memory rank first in recall, and stamps provenance on anything written. What it deliberately is not is a permission: the desk README states it as doctrine, and it's the line I'd put on the wall.

Filing a thread changes what a session knows, never what it may do.

Knowledge, capability, and machine state are three separate systems: files that are hot-loaded and hand-editable, a per-tool-call gate the model cannot reach, and a database I could delete. Every framework I've used blends at least two of those three, and most of the failure modes I've read about live in the blend.

Agents never talk to each other

The multi-agent pattern in most frameworks is agent-to-agent message passing, and its famous failure mode is two agents politely ping-ponging forever. Winston bans the conversation instead of capping it. When a sub-agent finishes, its report is not injected into Winston's session as a user message — the transcript has a third role, agent, precisely because injecting a report as 'user' would launder 'the agent said this' into 'Jesper said this' everywhere downstream. Reports land on the desk as bus frames costing zero tokens. The kernel — not the agent — lifts the report's first line as its summary, which is why the operating principles injected into every session say to lead with the outcome: that line is all I see until I expand, and it is the only part that can ever enter Winston's context.

When I next say something in a thread, Winston's context gets at most six one-line summaries of what arrived while I was away — bodies never — and 'unread' needs no state table, because it's simply every agent post since my last message. If agents somehow start filling a thread anyway, a structural breaker diverts posts to the desk feed after six consecutive reports between two things I've said. That breaker shipped in the same commit as the mention feature that made loops reachable, which I've decided is the only acceptable time to ship one.

architecture diagram
The report path. An agent's report never enters a session as conversation: it lands on the desk as a zero-token bus frame, the kernel — not the agent — lifts its first line, and at most six such lines ride in with my next message. Bodies never travel, and there is deliberately no agent-to-agent edge anywhere in this graph.
Jesper starts threads; agents finish them. No agent ever writes a post whose purpose is to answer another agent.

The autonomy line

Winston sends real email and touches real money, so the interesting question is never 'can it act autonomously' but 'where exactly is the line'. The line is a pattern table over tool calls, each pattern assigned one of three tiers. Reads, analysis, and memory writes run silently. Notify-class actions — cloud writes, enabling a plugin — run immediately but leave an audit row and a Telegram notice after the fact. Gate-class actions — sending email, changing an Apple Search Ads bid — block the tool call on a one-tap approval on my phone, and an unanswered approval expires into a deny with instructions to report it rather than retry. The config file carries the rule that decides which tier a new capability starts in: loosening later is a config change; tightening after an incident is not.

Enforcement is doubled on purpose. A PreToolUse hook is the hard boundary — it runs before everything and blocks even under the SDK's bypassPermissions mode, so agent-authored code can never route around it — and a canUseTool callback re-gates anything that somehow reaches it. Sessions also load nothing from my personal Claude configuration, so no convenience default from my own laptop setup leaks into the daemon. And for actions Winston wants to take unprompted, there's one more layer I've grown fond of: a council. Three parallel councilors with fixed charters — an advocate, a skeptic who can only hard-block for irreversible harm or real money, and a 'Jesper model' that reads my memory files and must cite the specific lines it relies on — each return a structured verdict, and the resolution is a pure function that fails safe: two agreeing votes and no hard blocker means proceed with the union of their modifications; anything else, including a councilor timing out, becomes a question to me.

Memory that maintains itself

Memory is a git-tracked markdown tree with an index file as router, a pinned frontmatter schema, and kernel-stamped provenance — the kernel, never the agent, records whether a fact came from chat, a distillation, or a named job. Corrections don't rewrite history: a new line carries a supersession marker pointing at the old line by a content hash, the old document is never mutated, and superseded lines are demoted in ranking rather than deleted — still findable, no longer first. Retrieval started as plain ripgrep, and a full-text index was added only after two consecutive weekly reviews of logged zero-hit queries showed real misses accumulating. Every piece of derived indexing is a rebuildable cache added on measured pain.

Measured is the operative word, because retrieval has its own regression harness: thirty-six hand-labelled golden queries run on every npm test, in three arms — the real ranked search, a dumb-grep control, and a full-context upper bound that scores as if the entire corpus fit in the window. The baseline as I write this is 0.556 for ranked against 0.222 for grep and 1.0 for full context. Green means no regression, not all targets met; known-failing rows are recorded as targets, and when one flips to passing, the harness reports the improvement and a human re-records the baseline deliberately. If the ranked arm ever stops beating grep, the ranking is not earning its complexity and should be deleted. I have not seen an artifact like this in any agent framework, and I now think it's the first thing a long-lived memory system should grow.

Two agents keep the tree alive. A weekly gardener never deletes anything itself — it repairs the router, mines my approval history for patterns ('Jesper denied this twice; stop proposing it'), and files proposals as task cards. A nightly reflection agent has the opposite constitution — its job is to act, not advise: it reads the standing goals, takes each one's next step if the step is reversible, and routes anything past the autonomy line through the council or a one-tap question. The clearest sign this loop works is in the git history: 319 of the repo's 502 commits are memory commits written by the system itself. Winston has authored most of its own repository's history.

Self-extension without self-escalation

Winston can extend himself, on two lanes with very different blast radii. The light lane writes a new skill — a validated markdown instruction file — into an already-enabled plugin, live the next session. Only two paths hold that tool: chat, where I'm present, and the nightly reflection agent; the email-triage agent will never hold it, because an agent that processes external text is a prompt-injection path, and what contains the grant is the autonomy line — every authored skill leaves an audit row and a Telegram notice naming the exact directory to delete to undo it. The heavy lane dispatches a plugin-author agent that builds a full plugin in a git worktree, validates it, and must end its report with an explicit ready marker. No marker, and the plugin stays dark. Even then, new plugins ship disabled by default, and enabling one never widens the per-call gates: the Apple Search Ads tools only register once I've enabled that plugin, and every money-affecting write still stops at my thumb.

What this is instead of a framework

Put next to LangGraph, CrewAI, AutoGen, or the OpenAI Agents SDK, Winston inverts a few defaults that I think are worth stating plainly. The model never routes: control flow is a durable queue and a cron materializer, so 'what runs next' is a query I can run, not a decision a model made. Durability is not a checkpointer bolted on; the queue with leases, dedupe keys, and a crash reaper is the core, and the drama of a kill -9 is that a heartbeat watchdog restarts the daemon and the reaper requeues the orphans. Agents are files, not objects constructed in code, which is what makes 'the system edits its own agents' a file write instead of a metaprogramming project. And structured output is used exactly once — the council's verdicts — because everywhere else the agents return prose and the kernel derives structure deterministically, rather than trusting a report to summarize itself.

The economics push the same direction. Winston bills a flat Claude subscription rather than per-token API, which sounds like a reason to relax — and is actually the opposite. The comment at the top of the config file calls the concurrency cap of two, the model tiering, and the per-class daily caps mandatory protections. Every idle-cost decision compounds: the token-free kernel, the warm spare that has run no turn, reports as bus frames, the six-line inbox note, a memory librarian sub-agent that dies holding the forty search hits so only the cited answer travels back, and a kernel rule that an agent with nothing to report produces silence instead of a Telegram buzz. A system that runs around the clock earns its keep in what it refuses to spend.

The part I did not expect

Winston was built the way everything I ship is now built — agents doing the overwhelming majority of the typing against a binding design document, me supplying judgment and the constraints no model could know. But something new happened here: the system participates in its own construction. The reflection agent files engineering cards against its own kernel; an engineer agent implements them; a reviewer agent gates the merge; the memory commits pile up underneath. The code comments have taken on a particular texture because of it — nearly every non-obvious decision is dated and cites its incident, a maintained postmortem log embedded in the source, because comments are how the system explains itself to its own future sessions.

A month in, the honest status: email triage runs three times a day, the newsletters draft themselves on schedule, the task board moves while I sleep, and the failure modes so far have been the mundane kind the watchdog was built for. The bet underneath it all is the same one running through everything I've written this year — that the durable value isn't in any model, it's in the harness: the queue, the gates, the memory discipline, the evals. Models will keep improving underneath Winston, and the kernel won't need to care. That's what an OS is for.