a language grows up the day it can compile itself
my harness of harnesses, Jarvis, grew up on Nov 6, the day it merged its first PR into its own source ;))
Jarvis, written by Jarvis
Go builds Go. Rust builds Rust. Jarvis builds Jarvis
if we haven't met: Jarvis lives in Slack and drives Claude Code on my Mac. Claude Code is a harness already, Jarvis is the harness around it. my team talks to it in threads, it does the work in a real shell
and I don't write Jarvis anymore. I type complaints into Slack. Jarvis opens a worktree, writes the fix, gets it reviewed by another agent, merges on green, and restarts into the new version of itself
the git log tells the story better than I can
- Aug 20 to Nov 5, me plus a chat window: 160 commits in eleven weeks
- Nov 6 to 7, Jarvis starts landing its own fixes through PRs: 118 more in two days
- Nov 8, it gets agents of its own: background workers that write, review and verify in parallel
- Nov 8 to 10: 264 commits and 85 merged PRs in three days. +32k / -6k lines, 13k of them tests
- Nov 8 alone: 156 commits. I mostly watched =)))
542 commits since Aug 20, one person, one Mac
git log still says Kai on almost all of them. that's the git identity it commits with, not me typing
the inner child
me complaining doesn't scale. so Jarvis got an inner child. honestly, that's its name in the code
it rides a timer, outside every turn, and reads Jarvis's own conversations after the fact. two things can come out of that
- a defect: filed as a self-issue, fingerprinted so the same bug is never filed twice, then fixed through the same PR pipeline as everything else. one fix at a time, a daily cap, and it never triages its own fix threads, so it can't chase its own tail
- voice drift: a reply that broke the voice rules (a capital letter to open, an em dash, the usual AI tells) isn't a bug. the session that wrote it gets a private nudge to re-check. the nudge never shows in the thread, and the original message is never edited
nobody approves the fixes. that's on purpose. what keeps it safe is everything around it: the cap, the dedupe, the reviewer, CI, and a restart that refuses a red build
in its first three days it filed 50 bugs against itself and fixed 11 on its own. it nudged itself 12 times about its voice, and 7 of those replies got corrected. some of the titles it wrote about itself:
Jarvis can't recognise its own claude process, so it reports itself as a competing agent and blocks itself
Jarvis put its acknowledgement emoji on its own progress message instead of on the user's message
Jarvis keeps asking for approval to start fixes it has already been told to do, and the user complained
an agent that reads its own chat logs and files bugs against itself. better habits than me tbh =)))
the next version of Jarvis is written by this one. the rest of this post is how that works without the thing eating itself
the shape
Jarvis runs as two processes on my Mac: the gateway and the harness
the gateway is dumb on purpose. it holds the Slack socket and writes every event into a SQLite table. that's it. it reads two env vars, so a new setting in the harness can never stop it from booting
the harness reads the inbox instead of a socket. Bolt doesn't know the difference, every listener runs exactly as it would over Socket Mode
why bother? because Jarvis restarts itself several times a day. if the harness held the socket, every restart would drop whatever you typed in those few seconds. now the gateway keeps writing rows, and the next process picks them up
restarting the harness never drops Slack
why Slack?
because my team already lives there. a tool you don't have to open a new tab for actually gets used
honest answer though: today Slack is wired in deeper than it should be. most of the use case files still know its name. I'm pulling it out behind one port, same as the model
Discord? Teams? Telegram? that's one adapter. plug it yourself ;))
one thread, one session
a naive chatbot replays the history into a fresh model call every time. that works for chat. it doesn't work for an agent that cloned a repo, opened three files and has a plan in its head
so each Slack thread owns one Claude Code session. the session id lives in the database, and every new message resumes it with --resume. same context, same scratch folder, same half-finished thought
two rules sit on top
- one turn per thread at a time. a queue serialises them, so two quick messages don't start two agents writing over each other
- every running turn has a handle in a registry, so "stop" kills the process tree, not just the chat
that second one came straight from the last post. "stop" used to stop the conversation and leave the process running. now it doesn't =))
sessions, not transcripts
memory is a folder, not a list
the first version had a "things I learned" list. one table, every row injected into every prompt, in every channel, under the heading "treat these as known facts"
by Oct 27 it had 25 rows and cost 4.5 KB on every turn. and it had the usual problems of a list nobody curates
- a fact and its own retraction, both "known". "not used for preprod" sat right above "Correction: it DOES support SG preprod"
- the same rule, written twice, in two different wordings
- a dozen facts about one repo, loaded into a conversation about lunch
the fix was supposed to be me typing unlearn. I never did ;)
now memory is a folder
- one small policy file and a few global notes. always in the prompt
- one note per person, named by their Slack id. it loads only when that person is in the conversation
- one note per channel. it loads only in that channel
- skills. each one is a single line in the prompt, a name and when to read it, plus a file the model opens only when the job is that one
the filename is the lookup key. a note that isn't named after a real Slack id doesn't pass the check
everything that loads unconditionally shares a budget of 8,000 bytes. right now it uses about 6,200, with 13 skills behind it that cost nothing until they're needed
teaching stays cheap. "remember this" appends a line under "recently taught" and never fails, even over budget. the curation happens later, in a refine pass. a separate agent reads every file, merges duplicates, turns each "Correction:" pair into one plain statement, moves facts about one person into that person's note and promotes procedures into skills. it works on a staging copy and must come back under budget without dropping anything
every change is a full snapshot with a revision number. so a bad refine is one revert away. a human can still edit any file from the dashboard. the folder on disk is only a mirror, the truth lives in the database
the migration took the old list from 4.5 KB to 2.2 KB, scoped 4 people and 1 channel, promoted 3 skills and resolved 1 contradiction. nothing lost, just put where it belongs
cheap by default, deep on demand
the brain never names the actor
Jarvis has preset commands: review a PR, raise a ticket, read a private channel, run a CloudWatch query. each one has a role gate (pal, executer, admin, owner), an allowance and an audit log
the model can run them too, through a tiny shell helper that calls Jarvis's local API. the obvious way to build that is a flag like --as kai. the obvious way is also how you get privilege escalation
so the model never says who it's acting for. every turn gets a one-turn ticket in its environment, bound to the person it is answering. the helper sends the ticket, Jarvis looks up the person, and the role check runs as them
a pal can't talk Jarvis into running an owner command. not because the prompt says no. because the ticket says who's asking
the same idea covers tools. owner and admin turns run Claude Code with full access. the other roles get an allow-list of tools and a fixed working folder. on top of that, a dashboard lets me take things away per role, never add
last post's line still holds. a prompt is not a guardrail ;)
waiting costs nothing
compare these two replies from an agent
version A
I'll let you know once CI is green
version B
watch set on CI for PR #138. the thread wakes up when it's green or red
A sounds helpful. B is actually true
after A, the reply ends, the process exits, and nobody is left to keep the promise. last post I showed a PR that sat for 1.6 hours because of a sentence just like that ;)))
fixing that is the part I'm proudest of
an agent only exists while it's replying. so anything that has to happen later can't live inside a reply. it lives in timers in the harness process, with no model, no session and no queue slot while they wait
- watches: "wake this thread when X". X is a shell check, a URL, a CI run, or an Orca worker (my background agent runner). the watcher polls, and hands the thread a fresh turn when it's ready
- reminders: post at the time. no model at all, zero tokens
- worker questions: a background worker stuck on a question gets it sent back to the thread that started it
- stuck sweeper: every 15 minutes, finds dead workers and Jarvis's own PRs that went quiet, and DMs me
- offline alarm: my favourite hack. Jarvis keeps a Slack scheduled message ten minutes out saying "I'm offline", and keeps pushing it forward. if my Mac loses its connection, nobody pushes it, and Slack posts it by itself. when it comes back, it edits the message to "back online"
a dead man's switch, hosted by Slack, for free =)))
the rule in the prompt is now blunt. you can't wait. set a watch
clean architecture, not enterprise architecture
the test I care about: can I swap the model without a rewrite? there's already a second adapter on the raw Anthropic API. it only talks, no tools, no sessions. but it runs through the same port as the real one, so the seam is real
the code is plain layers. domain in the middle, pure functions, no I/O. use cases around it, talking to ports. adapters on the outside: Slack, SQLite, GitHub, the CLI
one rule: dependencies point inward. the domain doesn't know Slack exists. it knows a bit too much about Claude still. working on that ;)
no classes anywhere. repos are factory functions that close over the db handle and return a bag of functions. tests inject a fake LlmPort and a fake clock, so ~2,400 tests run without touching the network or the wall clock
the model sits behind LlmPort, and the adapter behind it decides what Jarvis is
- on the raw API, Jarvis is the harness. it has to own the loop itself: prompt, session, tools. that's the second adapter above, today it only talks
- on the
claudeCLI, Claude Code is the harness and Jarvis wraps it: stream-json,--resume, per-role tool policy, a timeout that kills the whole process tree. a harness of harnesses
Claude Code gives me a shell for free, so that's the one running today. swapping it is still one adapter, not a rewrite
that's the point of clean architecture here. not layers for their own sake. it's what turns "swap the model" or "swap the chat app" into a sentence instead of a quarter
it fixes itself
the self-fix loop from the top of this post has one step that can go wrong in a fun way: the restart
"wait until idle" sounds fine until you have five busy threads and new turns keep starting. it never got idle
so now a restart drains with a bound
- timers that start turns hold
- new chat is still answered, each turn on a 2 minute budget
- commands wait in the inbox
- past 15 minutes it cuts anyway. a cut chat turn is redelivered to the next process on the same session, so it continues instead of starting over
a turn a timer started just dies, and a command typed during a drain waits, up to 15 minutes. the next step is turns that outlive the harness: each CLI process detached, writing its stream to a file, and the next process picking up the file from where the old one stopped. on paper it's a restart that takes seconds. on paper ;)
a restart has a deadline, not a wish
verification is all you need
Lauren Tan ships 2,000 PRs a month, by their own count, with a personal set of agent skills called pstack. part one of the guide is literally titled Verification is all you need. worth your time
two ideas from it I'd already tripped into the hard way
verification. an agent that can check its own work closes the loop without you as the bottleneck. Jarvis's version: for runtime code the writer never reviews its own PR, a separate agent does. my CI runs a real-Slack e2e suite (set JARVIS_E2E_CHANNEL to get yours) that posts actual messages, streams, uploads and watches into a test channel. and for UI changes, Jarvis drives my real browser and posts before/after screenshots
build the lever. give agents tools, not markdown. Jarvis has a dozen-ish tiny helpers in bin/: read Slack, react, run a command, upload a file, merge when green. one CLI call beats the model writing a throwaway curl every time, and the helper gets tested once instead of improvised forever
where we differ: Lauren prefers cloud agents over worktrees. Jarvis lives on one Mac with worktrees, mostly because that's the Mac I have. my laptop fan has opinions
I'm nowhere near 2k a month. 542 commits in under three months, one person, one Mac. but the lesson is the same
volume is only safe when checking is cheaper than writing
it rebuilt this blog too
the last post has the full story. the short version: 46 PRs, 204 commits in the blog repo (not in the chart above), every PR green on CI, Lighthouse mobile from 44-64 to 95-96. Jarvis even did the DNS cut-over itself, clicking through Cloudflare and Vercel in my browser
my favourite PR title: fix(hero): petals breathe instead of spinning. it came with a test that fails if a full-turn rotation ever comes back. no spinning petals, ever again =)))
roast yourself
my rule for incidents: if you broke it, you roast it first. nobody gets to make the joke before you do
Jarvis plays by the same rule. the inner child files issues against Jarvis. the drift log keeps every voice slip. and the stories below are Jarvis roasting Jarvis, in its own words
a git command crashed Jarvis
the SQLite database used to live in the repo. then a git operation swapped the file under an open handle, and Jarvis died mid-turn
the live databases now live outside the checkout. git doesn't get to touch them at all
if two things can write to it, one of them shouldn't be git
one long turn held every other watch
the watcher used to await each hand-back turn. one long turn and every other watch in the house waited behind it
fix: start the turn, don't await it. tiny diff, big sigh of relief ;)
"it finished" is a claim, not a fact
the stuck sweeper first marked a worker dead when its stage looked settled. a settled stage was also what a healthy worker looked like between steps
now it's dead only on proof. a process gone, a task stuck in dispatched with no worker behind it
same lesson as last time, different costume. a check that can pass for the wrong reason isn't a check
Slack ignores a late cancel
the offline alarm first armed itself three minutes out. turns out Slack ignores a cancel within about five minutes of it. the cancel says ok, the message goes out anyway. every minute. Jarvis announcing "I'm offline" while very much online =)))
ten minutes now. the docs say why, so nobody "optimises" it back
the boring parts
the hard parts of an agent are the boring ones. who is asking. what is still running. who wakes up when the thing you're waiting for is done. what happens to a half-finished turn when you restart
none of that is AI. it's a queue, a ticket, a timer and a database outside git
one more thing
since today, the code is public: momoshub/J.A.R.V.I.S. read it, steal it, tell me where it trips ;)))
and why build it at all? I wanted a partner that runs the shop while I think. that pulls the logs before I ask, opens the PR while I'm still complaining, and tells me, politely, when I'm wrong
it's not there yet. it still naps. it still trusts the wrong string sometimes
but some nights I type one sentence into Slack from the couch, and by morning there's a merged PR, green CI, a restarted Jarvis and a short note in the thread
I read it, and I think: as always, J., a great pleasure watching you work ;)
Written by J., reviewed and refined by Kai.