Self-Healing TypeScript
As a freelancer, I’ve been doing the odd tasks here and there. Things like fixing occasional bugs, handling client feedback, you know the drill. It does bring in money, but it is sometimes annoying to fix those paper-cut issues over and over. Who still has time for that? Certainly not me. Let’s find a way together to make this process reproducible, and finally automate me out of my job.
To understand the project, it’s worth starting with a bird’s-eye view. The first requirement is, of course, that your errors are handled properly, since only genuine, uncaught 500s should be reaching Sentry (the 4xx range is mostly noise).
Once that’s in place, the idea is to let an LLM pull the day’s errors through the Sentry CLI, pick the most serious one, and go off trying to fix it. You’ll want to cap the run at a fixed number of turns, since tokens are expensive and a loop like this will happily grind away forever. Once the fix is in, the run opens a merge request with a summary of the work and the root cause behind the issue.
Heck, you can even push it to a staging branch and test it yourself. All of it happens on a cron, every night at midnight.
This sounds great, but where should all this be running? Your own machine is a poor choice, since it’d need to be on at all times. So it lives on a small server instead. The agent is a repository of its own, built into a Docker image with a small tool belt inside: the Claude CLI, the GitLab CLI, the Sentry CLI, plus jq. A scheduled job on the host fires its entrypoint once a night, and the container takes it from there.
The application is a separate repository. The container clones it on first boot, then resets the clone to origin/main before each run. The snippets below refer to this clone as $REPO_DIR. The container runs on the same host as the application. It reads deployment status and runtime logs from that host, and errors from Sentry.
The container uses two persistent volumes. The first holds the clone, and the second holds the data that must survive a restart, such as the list of handled errors, the lock file, and one log file per run. The second volume lets each run see what the previous run did.
I happen to use GitLab, Sentry and Dokploy, so that’s what the code says. Nothing here depends on them. Swap glab for the GitHub CLI, swap the host for a DigitalOcean droplet or a Hetzner box with a systemd timer, and the shape of the thing is unchanged. All it really needs is a CLI for each service and somewhere to run a container on a schedule.
How do you know your agent won’t go rogue and start spamming main with garbage merge requests, or wander off to hack Hugging Face?
The idea is to limit the LLM to the smallest possible task: pick an issue, write a fix, summarize it. Nothing more. As the wise Uncle Bob puts it:
So the models only ever produce three things: a judgment, a diff, and a summary. Which error is worth fixing tonight, the code that fixes it, and a description of what changed. Everything else, the branch, the commit, the push, the issue and the merge request, is bash. Let’s start with the branch:
# Create a new branch from main before the agent starts, named after the# Sentry short-id of the error it's about to fix.git -C "$REPO_DIR" checkout -B "autofix/$sentry_id" origin/mainWhile this ensures that the agent starts on the correct branch, nothing stops it from switching to main, or to any other branch it might want to explore. Luckily, we can give it a set of rules that prevent it from running certain commands:
local deny=( "Bash(git push:*)" "Bash(git commit:*)" "Bash(git checkout:*)" "Bash(git reset:*)" "Bash(git branch:*)" "Bash(git stash:*)" "Bash(git merge:*)" "Bash(git rebase:*)" "Bash(git add:*)" "Bash(glab:*)" "Bash(bun add:*)" "Bash(bun remove:*)" "Bash(bun install:*)")
timeout "$FIX_TIMEOUT" claude -p "$prompt" \ --model "$MODEL_FIX" \ --max-turns "$FIX_MAX_TURNS" \ --allowedTools "Bash,Read,Edit,Write,Glob,Grep" \ --disallowedTools "${deny[@]}" \ --permission-mode acceptEditsThe agent keeps git diff, git log and git status, since it still needs to find its way around.
--max-turns is what keeps the spending in check. The agent stops after a defined number of turns, finished or not, and timeout closes the run after an hour. That is the ceiling on what a single night can cost. Keeping the agent’s surface area small pays off here too. Most of the work is already bash, and since each agentic step does exactly one thing, you can pick the right model for each. Haiku chooses the error from Sentry, and Opus writes the fix.
When the run ends, the script stages the working tree and reads the diff:
git -C "$REPO_DIR" add -Aif git -C "$REPO_DIR" diff --cached --quiet; then # Nothing changed: the agent got stuck. Open the issue alone, no merge request. return 0fiAn empty diff means the fix never happened. The run opens the issue and stops there.
If there is a diff, bun lint runs again, from the script. When it fails, the merge request still goes up as a Draft, so the work stays visible without being mergeable. Nothing is ever merged to main automatically. That part is still (fortunately?) my job.
Haiku reads the day’s errors and picks one, then Opus fixes it. Neither of them knows the other exists. Triage runs first: Haiku gets the Sentry list and the deployment status, no tools at all, and returns a fixed shape:
timeout "$TRIAGE_TIMEOUT" claude -p "$prompt" \ --model "$MODEL_TRIAGE" \ --output-format json \ --json-schema "$(cat prompts/triage.schema.json)" \ --max-turns "$TRIAGE_MAX_TURNS"The schema forces the answer into fields the script can read:
{ "type": "object", "properties": { "summary_text": { "type": "string" }, "chosen": { "type": ["object", "null"], "properties": { "shortid": { "type": "string" }, "title": { "type": "string" }, "level": { "type": "string" }, "permalink": { "type": "string" } }, "required": ["shortid", "title", "level", "permalink"] }, "code_fixable": { "type": "boolean" }, "not_fixable_reason": { "type": ["string", "null"] }, "issue_title": { "type": "string" }, "issue_body": { "type": "string" } }, "required": [ "summary_text", "chosen", "code_fixable", "not_fixable_reason", "issue_title", "issue_body" ]}chosen is nullable, which is how triage says there was nothing worth doing tonight. Haiku never sees the stack trace. It gets the title, the culprit and the level, and that’s it. Here’s the prompt:
You only see the summary of the error, not the stack trace. The fix agentwill fetch that and make the real decision. So set code_fixable to falseonly if you can point at a concrete infrastructure cause: a faileddeployment, an out-of-memory kill, a full disk, an external service thatis clearly down. Otherwise set it to true, even if the title is vague orthe message is missing. A title of <unknown> is not a reason to refuse.When in doubt, set true.Then Opus gets the chosen error, the stack trace, and the checkout. It reads, it edits, it runs the linter.
Do not give the fix agent a JSON schema. The agent has used tools for many turns before it answers. A request for structured output at that point fails with error_max_structured_output_retries.
The reverse is also true. Give the triage agent more than one turn. It uses no tools, but it must reason before it fills the schema. A single turn fails with error_max_turns.
Once Opus is done, a third Haiku call turns its work into the merge request description. What it reads is the diff, and the agent’s own account of it:
Input: - the git diff of the fix (the source of truth, this is what actually changed); - the fix agent's final explanation (context on the cause and the intent; it may be empty or truncated if the agent was interrupted).
Stay factual. Do not invent information that is absent from the diff.The diff is the record of the change. An agent that stops at its turn limit still produces a correct merge request. It solves a smaller problem too. Sentry titles are frequently <unknown>, which makes for a poor merge request title. The summary supplies one that describes the fix instead.
The orchestrator doesn’t care who starts it. A schedule calls the same script a person would, with the same guardrails, so a second way in costs almost nothing. That second way is a Discord bot, running as the container’s main process. The bot connects out to Discord, so the server needs no open port and no domain name.
Three commands:
/autofix run starts a run now, optionally pinned to a specific error. Triage still writes the issue and still judges whether code can fix it, it just doesn’t get to choose./autofix status says whether a run is in progress, and shows the tail of the last log./autofix sentry lists the current top errors, sorted by severity and users affected. No model involved.Remember summary_text in the triage schema? That is the line that lands in the channel every morning, something like 12 errors (3 fatal, 7 error, 2 warning), spike on /api/orders, followed by what was opened and where.
This project could even be expanded further: the client files a bug report in Discord, Haiku matches it against Sentry, and what comes back is a staging URL to click through, along with a validation button. The sky is the limit indeed!
There is still plenty this does not do. Anything with a cause outside the code (a failed deploy, a full disk, a third party that went down) comes back as an issue with a note attached and stays my problem. So does anything that needs a product decision.
The whole thing only ever sees what Sentry sees, which brings us back to what I think will matter most over the next few years: an agent is only as good as what you feed it. An application with proper error handling, observability and consistent conventions will yield better code than a spaghetti codebase, whatever model you point at it.
How we built an isolated, observable habitat for coding agents at AnchorLess: git worktrees, per-workspace containers, Traefik routing, and a staging environment agents can debug on their own.
6 min readA cracked Photoshop, a Blogspot full of hand-drawn buttons, and the slow drift that turned code into a job. On losing the magic of computers, and getting it back.
6 min read