Software that files its own fix. With the safety built first.
Self-healing here means one specific thing. When your system crashes, a loop detects it, diagnoses it, proposes an exact code fix, reviews that fix adversarially, runs your full test suite against it, and then either commits it with a complete audit trail or rolls it back to a clean tree. You choose how much of that runs unattended. The cage below is what makes that choice safe.
Who this is for
You run an error tracker. We use Sentry; any tracker with an issues API works. You have a git repo with a test suite you actually trust. You can invoke an LLM agent from the command line with an enforceable tool whitelist. We use Claude Code; the pattern is agent-agnostic. And you have Slack, or any channel your team actually reads. If any of those are missing, build them first. This page will still be here.
Two registers. The main text teaches the pattern, which ports to any stack. Boxes like this one show our reference implementation: real numbers from our running system, so you can see one working instance. Our numbers are outputs of our environment. Tune yours.
Build the cage before the animal.
Most people ask what the AI can fix. The better first question is: what stops a bad fix from shipping? Every unattended commit this system makes must pass through all eleven of these. Skip one and you are not running this system. You are running a slot machine with push access.
| Guardrail | The rule | The failure it prevents |
|---|---|---|
| Full-suite gate | Every proposed diff runs your entire test suite before commit. Not a subset. | A fix that quietly breaks three other things. |
| Exact-diff-only writes | The only thing that can touch the repo is a reviewed, machine-materialized git diff. | "The agent improvised" is not in this system's vocabulary. |
| Read-only proposer | The diagnosing agent gets read tools only. Enforced by whitelist, not by prompt. | A helpful agent editing files mid-diagnosis. |
| Independent reviewer | A second, fresh agent tries to refute every proposal. Any error in review counts as a fail. | One model grading its own homework. |
| Two-strike breaker | Two consecutive failed or rejected proposals pause the whole loop until a human unpauses it. | A bad day becoming a bad week. |
| Per-issue cooldown | One attempt per issue per 24 hours. | The loop hammering the same wall all night. |
| Daily budget | At most 5 proposals a day, one undecided at a time. | Volume replacing judgment. |
| Regression watchdog | An auto-healed issue that returns within 7 days strikes the breaker. | Declaring victory on a symptom. |
| Kill switches | Independent flags shut down each layer. Remove the tracker key and everything is off. | Needing a deploy to stop the machine. |
| Notify-with-revert | Every unattended commit posts to your channel with the exact revert command. | Hunting through docs during an incident. |
| Audit lineage | Every transition writes an audit line. Every commit names its approver and the issues it resolves. | Archaeology instead of history. |
Tick what is true today. The dot resolves when you are actually ready, not when you feel ready.
Some stacks should not run this yet.
Your test suite is slow enough that you batch runs instead of running on every change. Or it is flaky, and a gate that lies is worse than no gate. Or coverage is thin exactly where the crashes happen. Or nobody reads the alert channel on weekends.
None of these are permanent. All of them are disqualifying today. A propose-only loop is still worth running while you fix them.
One real crash, walked through every gate.
Here is the morning of July 14 in slow motion. Six screens in our dashboard had been crashing for weeks, with six different error signatures. Every one traced to the same seam: a fetch wrapper that let a degraded server answer "success" with no data attached. Watch that one bug travel the whole loop.
The tracker catches every crash and groups them by signature. Three things make this layer trustworthy rather than noisy. Scrubbing: a before-send hook strips user data from every event, and session replay stays off. The scrubber is load-bearing; give it a regression test. Release stamping: every event carries version plus git sha, so "which commit broke this" is a lookup, not a hunt. Signal hygiene: security denials and expected noise stay in local audit logs. The tracker gets crashes only. Scheduled jobs post check-ins, so silence is itself an alert.
Our security layer once flooded the tracker with junk issue groups minted from routine denials. One mirror-filter regex ended the burn. Fix the generator, not the instances.
A poller pulls unresolved issues every 5 minutes, ranks them by event volume and recency, and renders a queue in our dashboard. The cache is stale-while-error: an API failure never wipes what you knew, it only marks it stale. Every issue deep-links back to the tracker. The queue is where a human, or later the loop itself, decides what gets attention.
A read-only agent gets the stack traces and one instruction that matters more than the rest: cluster stack-similar issues to one root cause. Six signatures, one seam, one proposal that names all six. The agent cannot write. Its whitelist has read tools only, so "read-only" is enforced by the runtime, not by the prompt. Its output is verbatim find-and-replace edits, never a diff. The server materializes those edits into a canonical git diff and restores the tree byte-for-byte.
Our first live proposal covered all six issues in one diff: two hunks, one file, at the shared fetch seam. Risk: low. The clustering instruction is why one morning closed six groups.
A second agent, fresh context, one job: try to refute the proposal. It reads the diff and every crash site, and hunts for the case where the fix makes things worse. It answers in line format, because a one-token verdict cannot be broken by a long string. And it fails closed: a parse error, a timeout, or an unreadable answer all count as a fail. Nothing ships on a mumble.
VERDICT: pass CONCERN: one endpoint still returns a bare object instead of the standard envelope CONCERN: callers that relied on the malformed shape will now see a clean failure REASONING: the guard normalizes bad GET envelopes to the standard error shape, which every crash site already handles.
The reviewer passed our fix and still raised real concerns. One became a tracked task. A reviewer that only says yes is decoration.
The executor is the only thing in the entire system that can write. It runs a fixed chain of gates. Passing every gate earns a commit. Failing any gate lands in the same place: a clean tree, nothing committed, the evidence kept.
- 1 · PreflightAre the diff's target files clean? A teammate editing the same file means wait, not strike.pass ↓fail ↳ wait
- 2 · Applygit apply the materialized diff. A diff that no longer applies stops here, tree untouched.pass ↓fail ↳ rollback
- 3 · Full suiteEvery test. Not the fast ones. Not the related ones. All of them.green ↓red ↳ gates 4 and 5
- 4 · Flake checkA failing file re-runs alone, with the diff still applied. Passing alone means a contention flake, tolerated within a small bound.flake ↓real red ↳ gate 5
- 5 · Baseline checkIs the suite red without the diff too? Then the main branch is broken, not the fix. Wait, do not blame the proposal.ours ↓baseline red ↳ wait
- 6 · CommitLineage trailers: who approved, which issues this resolves, the heal id.↓
- 7 · Push + resolveIssues resolve in the tracker with the sha attached. A regression rule reopens them loudly if the crash returns.↓
- 8 · NotifyThe channel gets the summary and the exact undo command. You are never more than one paste away from reversing it.done
heal: guard the GET envelope at the shared fetch seam
(resolves APP-9, APP-8, APP-A, APP-7, APP-B, APP-1P) [via error-queue]
Approved-By: heal-reviewer (auto, operator tier: low)
Heal-Id: fe3c740e
Sentry-Issue: https://yourorg.sentry.io/issues/APP-9/
The best thing this system did all week was refuse to ship.
What the loop said that day
Condensed from our alerts channel, July 14, 2026. Relative times shown where the exact minute is not in the log.
Four levels. Earn each one.
Do not build this in a weekend and flip everything on. Each level below is a complete, useful system on its own. Each ends with a gate you must witness in your own repo before moving up. The autonomy is not the achievement. The gates are.
Level 0 · Watch: detect and triage
needs: TRACKER_DSNWire the tracker with a scrubber and release stamping. Set two alert rules: first-seen and regression, both into your channel. Add check-ins to every scheduled job, so a job that silently stops running becomes an alert instead of a mystery. Then build the poller and the ranked queue.
const EVERY = 5 * 60 * 1000; // 5 minutes
setInterval(async () => {
try {
const issues = await tracker.listIssues({ status: 'unresolved' });
const ranked = issues
.map(i => ({ ...i, score: i.events / Math.max(1, hoursSince(i.lastSeen)) }))
.sort((a, b) => b.score - a.score);
cache.write({ issues: ranked, fetchedAt: Date.now() });
} catch (err) {
cache.markStale(err.message); // stale-while-error: never wipe what you knew
}
}, EVERY);
tracker.init({
dsn: process.env.TRACKER_DSN, // unset = the whole integration is off
sendDefaultPii: false,
release: `app@${VERSION}+${gitShortSha()}`, // "which commit broke this" is a lookup
beforeSend(event) { return scrubUserData(event); } // give this a regression test
});
Throw a deliberate error in production code. It must reach your channel in under a minute, scrubbed of user data, stamped with a release. Skip nothing until this works.
Level 1 · Diagnose: propose-only
adds: HEAL_LOOP=onSpawn a read-only agent against your ranked queue. Give it the stack traces, the repo, and the clustering instruction. Store its proposals in a queue with the cooldowns from the cage. Nothing writes anything yet. You read the diagnoses and apply the good ones by hand.
const proposal = await agent.run({
tools: ['Read', 'Grep', 'Glob'], // no Write, no Bash: it cannot touch the repo
cwd: REPO_ROOT,
prompt: buildPrompt(issueCluster), // traces + "cluster to ONE root cause"
timeoutMs: 15 * 60 * 1000,
});
// output contract, strictly validated:
// { summary, risk, resolvesIssues: [...], edits: [{ file, find, replace }], testPlan }
const diff = materializeDiff(proposal.edits); // server-side, below
function materializeDiff(edits) {
assertInsideRepo(edits.map(e => e.file)); // refuse absolute paths and ".." segments
const before = snapshotBytes(edits); // exact bytes, for the restore
try {
for (const e of edits) applyExactlyOnce(e); // find must match verbatim, exactly once
return git('diff'); // git writes the canonical, appliable diff
} finally {
restoreBytes(before); // the repo is untouched after this returns
}
}
Run it for a week minimum. Apply at least one proposed diff by hand, after verifying the diagnosis yourself. If the diagnoses are wrong, stay here. Wrong at L1 costs reading time. Wrong at L3 costs a bad commit.
Level 2 · Approve: human-gated commits
adds: AUTO_APPROVE=off · breaker + budgets liveBuild the executor with the full gate chain from the loop section. Add an approve and reject control on an exact diff view, with a double-confirm. The breaker, cooldown, and daily budget go live here. Every approval is a named human. Every transition writes an audit line.
async function applyProposal(p) {
if (dirtyTargets(p.diff)) return wait('someone is editing these files');
if (!gitApply(p.diff)) return fail('diff no longer applies'); // tree untouched
const suite = runFullSuite();
if (!suite.green) {
if (isolatedPass(suite.redFiles, { withDiff: true }))
{ /* contention flake, tolerated within a small bound */ }
else if (!baselineGreen()) { rollback(); return wait('main is red without this diff'); }
else { rollback(); return fail('the diff broke the suite'); }
}
const sha = commit(p, { trailers: lineage(p) }); // Approved-By, issue links, heal id
push(); resolveIssues(p.resolvesIssues, sha);
notify(`Healed. ${sha}. Undo: git revert ${sha}`);
return ok(sha);
}
You have personally watched one red suite roll back to a clean tree with nothing committed. Trigger it on purpose with a deliberately bad edit if you must. Do not proceed on faith.
Level 3 · Unattend: reviewer-gated auto, low-risk only
adds: AUTO_APPROVE=low · regression watchdogAdd the adversarial reviewer between proposal and executor. The chain becomes: propose, review, and only a reviewer PASS on a low-risk proposal reaches the executor unattended. Medium and high risk still wait for a human click. The 7-day regression watchdog arms on every auto-healed issue. Every auto-commit posts notify-with-revert.
const text = await freshAgent.run({ tools: ['Read','Grep','Glob'], prompt: refutePrompt(p) });
const verdict = /^\s*VERDICT:\s*(pass|fail)\b/im.exec(text)?.[1];
const concerns = [...text.matchAll(/^\s*CONCERN:\s*(.+)$/gim)].map(m => m[1]);
if (!verdict) return { pass: false, error: 'unparseable review' }; // fail closed
return { pass: verdict === 'pass', concerns };
We ran the manual path first. Four things failed on our first live day, and each one became a permanent gate (see the museum below). Only after that, and an explicit operator decision, did we set AUTO_APPROVE=low. Never start at "all".
Two clean weeks at L2, plus the ladder contract in the governance section signed by whoever owns the repo. Then flip to low, not all. Revisit the tier after a week of unattended heals.
Separate appendix, separate switch: runtime actions (restart a wedged job, clear a stale lock) can run behind their own flag. Keep it off until you have supervised dry-runs, and never point it at anything that moves money.
Four failures we keep on display.
Every guardrail on this page exists because something real broke. These four broke on our first live day. Each is now a permanent part of the system. Steal the lessons without paying for them.
The corrupt patch
The first approved fix died at the apply gate: "corrupt patch at line 25." The agent had hand-written a unified diff and got the arithmetic wrong. Nothing shipped; the rollback held. Agents now output verbatim find-and-replace edits, and git itself writes the diff.
What it built: exact-diff-only writes
The verdict that broke JSON
The reviewer returned its verdict as JSON, and one long explanation string arrived unterminated. The parse failed, and fail-closed held: no commit. Verdicts moved to line format, where a one-token answer cannot be broken by a long sentence.
What it built: the fail-closed reviewer
The flake that killed a good fix
A reviewer-passed heal was rejected because one unrelated test file goes red under parallel load. It had done the same thing earlier that day, without any diff. The executor now re-runs red files in isolation with the diff applied. Genuine reds still roll back.
What it built: flake discipline inside the suite gate
The red that wasn't ours
A teammate's commit turned the main branch red while a heal was in flight. The loop blamed the proposal and burned a strike on someone else's bug. Now it checks the baseline: a suite that is red without the diff means wait, not strike.
What it built: baseline awareness
What it costs. What it pays.
Agent tokens. Every heal is two agent runs: a proposer and a reviewer, minutes of runtime each. Cheap next to an engineer-hour, but not free.
Tracker plan realities. Alert rules and issue APIs are standard. Extras are not: budget for cron monitors if you want vendor-side check-in alerts, and treat vendor AI root-cause add-ons as optional. The loop is complete without them.
The discipline tax. A green, fast, trusted test suite is the entry fee, and it never stops being due. The loop amplifies whatever your suite is: rigor or noise.
Human time, early. The L1 and L2 weeks are real hours spent reading diffs. That reading is what buys the right to stop reading.
Our plan includes one active cron monitor; more run about $0.78 per month each (public Sentry pricing). The vendor's AI root-cause add-on answered 402 until budgeted. We link out to it and let the propose agent do the diagnosis.
Crashes triaged in minutes, not weeks. The 5-minute poll turns a crash into a ranked queue entry with the stack trace attached before anyone notices the symptom.
Silent-error debt stops compounding. Six of our screens crashed for weeks because nobody had a spare afternoon. One unattended morning ended all six.
Mornings become a log read. The channel tells you what crashed, what the loop proposed, what the reviewer thought, what shipped, and how to undo it. Two minutes, and you know.
The audit trail is free. Lineage trailers answer "what changed and why" forever, for every heal, without anyone writing a postmortem.
Who holds the keys.
Autonomy is a governance decision wearing a technical costume. Write this part down before you flip anything.
The ladder contract
- Name the owner. One human owns the repo's autonomy level. Not a team, not a rotation. A name.
- L0 to L1: the owner turns on proposals after the L0 gate passes.
- L1 to L2: the owner reads a week of diagnoses and signs off on their quality.
- L2 to L3: two clean weeks, and the owner has personally watched a rollback happen.
- Any incident: anyone on the team can flip the loop off, no permission needed. Turning it back on takes the owner.
What never gets automated
Money paths: billing, payouts, bank syncs. Schema migrations. Security configuration. Anything that talks to a customer. The loop fixes crashes. It does not do surgery. Ours will not even auto-restart a wedged job on a money path; those stay propose-only regardless of any flag.
The circuit panel
Every layer has its own switch. Shell environment beats the env file for these flags, so a supervised terminal can dry-run a level without changing the system's defaults.
| Flag | Effect |
|---|---|
| TRACKER_DSN removed | Everything off. The app boots identically without it. |
| HEAL_LOOP=off | Loop off. No proposals, no reviews, no commits. |
| AUTO_APPROVE=off | Default. Every diff waits for a human click. |
| AUTO_APPROVE=low | Reviewer-gated auto for low-risk proposals only. |
| AUTO_APPROVE=all | Any risk, unattended. We do not run this. Neither should you. |
| AUTO_HEAL=off | Default. Runtime restart actions propose only. A separate ladder with its own dry-runs. |
Tune our numbers to your suite
Every constant on this page is an output of our environment. Our full suite is 1,900+ tests and finishes in about 4 minutes, so 5 proposals a day is cheap for us. If your suite takes an hour, your budget is different and your cooldowns are different. Copy the gates. Do not copy the constants.
And the last line of the contract: you own this loop, its commits, and its risk. This page is the map, not a warranty.
What mornings look like now.
The tracker is quiet, or it isn't. Either way the channel tells the story: what crashed overnight, what the loop proposed, what the reviewer thought, what shipped, and the one line that undoes it. You read for two minutes and start the day knowing.
The morning of July 14, ours read: one alert at 9:31, one proposal, one passing review, one green suite, six issues closed, one revert command nobody needed. We built the cage first. Then we let it run.
Building your own second brain?
Write us what you are wiring up and where it fights you. We answer operator to operator.
Write to us See what we build