Back to Home
post-thumb

An Agent Pipeline for dvarapala: Opus Reviews, Sonnet Implements, I Merge

| November 02, 2026 | 15 min to read

Intro

dvarapala is the anti-DDoS gateway I build in Rust, and it runs in front of my own public apps (Part 40). It has grown faster than my reviews of it. I wanted a second pair of eyes that reads the whole repository on a schedule, looks for security, performance and scalability problems and features that never got finished, and files them in the way the project already works: an RFC for a design change, an issue for a bug, and a private advisory for a vulnerability.

Then, once I approve an item, I wanted the implementation done too, up to a green pull request, with the merge left to me.

This post is that pipeline: two n8n workflows on the n8n setup from Part 41, a GitHub App, a new runner tier that can use a shell, and the five bugs I hit while testing it.

TL;DR: Claude Opus reads dvarapala at the current main commit every Monday (security), Wednesday (performance and scalability) and Friday (implementation gaps). Each finding becomes a private draft security advisory, a public issue, or a draft RFC pull request. Nothing is implemented until I add the label agent:implement. Twice a day a Sonnet runner with git, gh and gh stack takes the oldest approved item, opens the pull requests by the project’s own rules, and n8n watches CI, asks for up to two fixes, and labels the item agent:green or agent:needs-human. The agent never merges. First runs: Opus reviews reported 1.92 to 3.33 USD each, the first implementation 0.26 USD, all on my Claude subscription.

The Figure

The finding card, its human stamp and its PR platesLamps: review focus, gate class, CI round, red, amber, greenThe review camera (Claude Opus, read only, Monday, Wednesday and Friday at 08:00, focus rotating through security, performance and scalability, and implementation gaps) drops one card per finding, at most 3 security, 3 bugs and 1 RFC a run. The gate sends security into the locked box as a private draft advisory, where the line stops; a bug into the issue tray; a feature into the RFC tray as a draft RFC pull request. Only a human stamp (the agent:implement label, or an RFC accepted and merged) lets a card back on the belt. The implement station (Claude Sonnet with git and gh, 07:30 and 13:30) builds one PR for a bug, regression test first, or a stack for an RFC, tests layer first. The CI bench checks every 10 minutes: red goes back for a fix, at most 2 rounds; still red after that or after 2 hours lights amber for a human; green goes to the out tray. The agent never merges.

I drew the pipeline as an inspection line, and the one lit part is a finding card riding the belt. The review camera on the left (Opus, read only) scans the repo crate and drops a card, then the sorting gate swings one flap: a security card slides into the locked box and the line stops there, a bug drops into the issue tray and a feature into the RFC tray on the human desk. Nothing leaves a tray until the stamp on that desk comes down on the card, which stands for my agent:implement label or an accepted, merged RFC. The implement station (Sonnet) then presses PR plates onto the card, one for a bug and a stack of three for an RFC, and the CI bench probes them: red sends the card back for a fix, green lights the top lamp and sends it to the out tray for me to review and merge. Click the figure or press 1, 2 or 3 to run a security, bug or RFC finding, and watch the readout in the corner for the station, item, label and CI round. Built with the anatomy skill.

The Situation

  • n8n 2.45.7 in my k0s cluster, with claude-runner: headless Claude Code on my Claude subscription, one Deployment per model tier (Part 41).
  • Claude Code 2.1.316, models claude-opus-5-5 and claude-sonnet-5-5.
  • franzramadhan/dvarapala: public, Rust workspace, about 10 minutes of CI per pull request.
  • dvarapala already has a written process, and the agents follow it instead of inventing their own:
    • CLAUDE.md: an RFC first for anything that changes behaviour or config, from rfc/0000-template.md, with a Test cases table; a row in ROADMAP.md. A bug fix is one PR: a regression test commit, then the fix. Vulnerabilities are reported privately, and an unfixed one is never discussed in a public issue, PR or commit message.
    • .claude/skills/rfc-stack: an accepted RFC is built as a stack of PRs with gh stack: a tests layer first (the RFC’s Test cases as ignored tests), one layer per domain, then the CLI layer.
    • CLAUDE.local.md (not in git): no local cargo build, check, test or clippy; only the cheap checks (cargo fmt, a 500-line file check, cargo deny, the UI build). CI verifies the rest.

Setup

The flow

flowchart LR
  R["Review<br/>Opus, read-only<br/>Mon / Wed / Fri 08:00"] --> S{"kind"}
  S -->|security| A["private draft<br/>advisory"]
  S -->|bug| I["issue<br/>label agent"]
  S -->|feature| F["draft RFC PR<br/>label agent"]
  I --> H["me: label<br/>agent:implement"]
  F --> H
  H --> D["Implement<br/>Sonnet dev runner<br/>07:30 and 13:30"]
  D --> C{"CI"}
  C -->|red, max 2| D
  C -->|green| G["agent:green<br/>I merge"]

Security findings stop at the advisory. Fixing a vulnerability happens in a temporary private fork, and the pipeline does not touch that.

A GitHub App instead of my token

One App for all my projects, franzramadhan-agent-app, installed on dvarapala:

  • Repository permissions: contents, pull requests, issues and repository advisories (read and write); checks, actions and metadata (read). No workflows permission, so a change under .github/workflows stops with a note for me. No webhook, no ruleset bypass.
  • The private key lives in OpenBao and reaches n8n through CREDENTIALS_OVERWRITE_DATA, never a runner pod.
  • Every run mints its own installation token, narrowed to one repository and only the permissions that step needs, valid for 1 hour:
JWT (RS256, iss = App id)
  -> GET  /repos/franzramadhan/dvarapala/installation
  -> POST /app/installations/<id>/access_tokens
       {"repositories": ["dvarapala"], "permissions": {"contents": "write", ...}}
  -> ghs_... token

Commits from the runner carry the App’s bot identity, so it is always clear what an agent wrote.

A runner tier that can use a shell

The runners from Part 41 refuse the Bash tool, because the subscription token is in the process environment. Implementing code needs git, gh and cargo fmt, so I added a separate tier, claude-runner-dev, and accepted the trade for it alone:

  • Sonnet, on buildpack-deps:bookworm-scm (digest pinned). Init containers install Node 24 and Claude Code, gh 2.103.3, the gh-stack 0.3.0 extension and skill, jq, cargo-deny 0.20.2 and rustup 1.29.2 without a toolchain, all with sha256 checks. The first cargo fmt pulls the toolchain the repository pins.
  • One run at a time, 45 minutes, 200 turns, 2Gi memory limit, a 4Gi work directory and 3Gi for Rust.
  • The runner only accepts repositories from an allowlist (one today), checks them out itself and passes the per-run GitHub token to the child process as GH_TOKEN, never logged.
  • dvarapala’s CLAUDE.local.md is not in git, so the runner copies it into the checkout from a ConfigMap, together with a PreToolUse hook that blocks cargo build, check, test and clippy.
  • A CiliumNetworkPolicy limits egress to GitHub, the Rust toolchain host and the crates index, on top of the Anthropic API.
  • Plugins: caveman (terse output), ponytail (minimal code) and pstack.

The Opus reviewer stays read-only: it gets the repository as a tarball at the exact main commit and only Read, Glob and Grep.

Workflow 1: dvarapala review

At 08:00 on Monday, Wednesday and Friday:

  1. Pick the focus for the day: security, performance and scalability, or implementation gap.
  2. Mint a read token. Fetch the main commit, open items labelled agent, draft advisories, open pull requests and the RFC file names. Build a list of titles already raised (so nothing is reported twice) and the next RFC number.
  3. Opus, up to 50 turns and 30 minutes. The prompt says what to look at for the focus, lists what is already raised, and asks for at most 5 findings, each verified in the code with file:line evidence, as one JSON object. For a feature it writes the whole RFC file from the template, its ROADMAP row and the PR body.
  4. Parse, cap (3 security, 3 bugs, 1 RFC), strip long dashes, mint a write token, and route:
    • security: POST /security-advisories, a private draft.
    • bug: an issue labelled agent.
    • feature: branch rfc/NNNN-<slug>, the RFC file, the ROADMAP row inserted after the last RFC row, a PR titled rfc: NNNN <topic>, labelled agent.
  5. One Telegram line per item, or “no new findings”.

The gate is a label

Labels on dvarapala carry the state: agent (raised by the review), agent:implement (my approval), agent:in-progress, agent:green and agent:needs-human. I only ever set one of them. For a bug I add agent:implement to the issue. For an RFC I set its status to accepted in the RFC PR, merge it, and then add agent:implement to that merged PR.

Workflow 2: dvarapala implement

At 07:30 and 13:30:

  1. Take the oldest item labelled agent:implement without a busy label. An RFC PR must be merged and titled rfc: NNNN.
  2. Swap agent:implement for agent:in-progress, so it is never picked twice.
  3. Mint a token (contents, pull requests and issues write; checks and actions read) and call the dev runner with a prompt that follows the project’s rules: for an RFC, confirm the status is accepted, then build the stack with the rfc-stack skill; for a bug, a branch fix/issue-N, the regression test commit, the fix, one PR with Fixes #N. No local build, no merge, no waiting for CI. It answers {"prs": [...], "notes": "..."}.
  4. Every 10 minutes, read the check runs of every PR head. Green when all have finished without a failure. Red goes back to the dev runner with the failing check names and a fresh token (gh run view --log-failed first, then a fix in the layer that owns the code), at most twice. Still pending after 2 hours, or red after two fixes: a human looks at it.
  5. Label agent:green or agent:needs-human, comment with the PRs and the runner’s notes, and send a Telegram message.

Issue 1: The Test Run That Never Started

Symptom

I test every workflow as a temporary copy with a webhook trigger instead of the schedule, and call that webhook from inside the n8n pod. The call answered 404, and n8n showed no execution at all. n8n’s log:

Received request for unknown webhook: This webhook is not registered for POST requests. Did you mean to make a GET request?

Cause

My webhook node had no httpMethod, and the default is GET. I had seen a 404 right after activating a workflow before (n8n registers the webhook a moment later), so my first fix was a retry loop, which did nothing. The log line said exactly what was wrong; I read it after the retries.

Fix

"httpMethod": "POST" on the test trigger.

Issue 2: 403 from a Token That Has the Permission

Symptom

The run created two advisories, then failed on the branch for the RFC:

403 - {"message":"Resource not accessible by integration","documentation_url":"https://docs.github.com/rest/git/refs#create-a-reference","status":"403"}

Cause

The App installation did have contents: write. I checked with an App JWT (GET /app/installations/<id>), then minted a fresh token by hand and created and deleted a test branch with it: 201, then 204. So the App was fine, and the token n8n sent was not.

At that point the workflow used n8n’s built-in GitHub App credential. It mints an installation token and stores it on the credential as an expirable field, and renews it only when a request gets a 401. An installation token keeps the permissions the App had when it was minted. Mine came from my credential test, before I changed the App’s permissions, so it had no contents scope. A missing permission is a 403, not a 401, so n8n never renewed it, and would have kept failing until the token expired, up to an hour later.

Fix

The review stopped using that credential. It mints its own tokens through a JWT credential and three HTTP nodes: a read token at the start, and a write token after Opus has answered, because Opus with retries can outlive a 1-hour token. The implement workflow keeps the built-in credential for its CI polling, which runs for more than an hour: there an expired token gives a 401, and the credential renews it.

Issue 3: The JSON Answer Broke on Its Own RFC

Symptom

Unterminated string in JSON at position 3941 (line 3 column 1469)

in the node that parses Opus’s answer, after a 74-turn review.

Cause

I asked for the answer in a fenced json block and cut it out with a lazy regex, /```json\s*([\s\S]*?)```/. The answer contained a whole RFC file as a JSON string, and that RFC has code blocks of its own, so the regex stopped at the first inner fence, halfway through a string.

Fix

Take everything from the first ```json to the last fence in the answer. Before running Opus again I read the stored execution through the n8n API and parsed it with the new code: 4 findings, including the RFC. The implement workflow’s two parsers got the same fix.

Issue 4: A Notification That Stopped the Run

Symptom

The performance review created three issues and then failed in the Telegram node with Bad request - please check your parameters. Its fourth finding was never processed.

Cause

n8n’s Telegram node sends parse_mode: Markdown when none is set. One issue title contained max_clients, and an unpaired underscore is invalid Telegram Markdown. With n8n’s sequential execution order, the failed node ended the run before the next branch ran.

Fix

Send HTML with &, < and > escaped, and set the Telegram node to continue on error. A lost notification is not worth a lost finding.

Issue 5: Test Copies That Would Not Go Away

My test script deletes the temporary workflow at the end. Two copies stayed:

  • The public API answers 409 to DELETE /workflows/<id> until the workflow is archived (POST /workflows/<id>/archive).
  • My poll loop waited for finished to be true, and an execution that ends in an error has finished: false. It now goes by the execution status.

Results

The reviews

RunFocusTurnsReported costCreated
1securitynot recordednot recorded2 draft advisories, then Issue 2
2security743.33 USDnothing, Issue 3
3security401.92 USD3 draft advisories
4performance and scalability552.19 USD3 issues, then Issue 4

The cost is the API-equivalent price Claude Code reports. On the subscription it is a share of the plan’s usage limit, not a bill.

The security findings are private draft advisories, and they stay out of this post until they are fixed and published. I will fix several of them in one temporary private fork and ship them in one release.

The performance review found three hot paths that hold a lock longer than they need to:

  • #306: the ML tree model scores every request inside the rate limiter’s global lock.
  • #307: the store sync drains and sorts up to max_clients deltas while holding the lock every request takes.
  • #308: the cluster heavy hitter check scans the whole shared list on every request under the cluster lock.

The first implementation

I labelled #308 agent:implement at 20:33 and ran the implement workflow:

  • The dev runner took 8 turns and reported 0.26 USD. It opened #309, fix(engine): index cluster heavy hitters by prefix length, with two commits: the regression test, then the fix (+109, -10).
  • n8n polled CI three times (about 30 minutes), all 15 check runs passed or were skipped, and #308 went from agent:in-progress to agent:green with a comment and a Telegram message.
  • The runner’s notes were worth reading. Nothing was built locally, so CI was the first compile. The regression test is a timing check (20 000 misses over 10 000 networks must finish in under 1 s), because the verdicts did not change and only the speed can fail on the old code. It suggested moving that gate to the criterion bench if it turns out flaky. It also added a one-pass constructor, because building the index through the existing add would have been quadratic for 10 000 entries.

The merge is mine, from my own agent in the dvarapala checkout.

Lessons

  1. Give the agents the project’s own rules. dvarapala already had CLAUDE.md, an RFC template and a stacking skill. Pointing the agents at them gave PRs that look like mine, with no new conventions to review.
  2. Keep a human gate on what gets built. A review that files work is cheap; an implementation is a PR I have to read. One label per item keeps that decision with me.
  3. Route vulnerabilities away from public tracks by construction. The review cannot open a public issue for a security finding: the security branch only knows the advisory endpoint.
  4. A cached token keeps old permissions. After changing a GitHub App, mint a new token. Anything that renews only on 401 will serve a 403 until the token expires.
  5. Read the error the tool already printed. The webhook log said “not registered for POST” before I wrote a retry loop.
  6. Do not cut structured answers with a lazy regex. A model answer that embeds markdown will contain fences; slice to the last one, or ask for raw JSON.
  7. Notifications must not fail the work. The message is the least important node in the workflow; it should never be able to stop the rest.

References