song-lingo is a small tool I made for learning Japanese songs: paste a YouTube MV link, and Gemini helps me transcribe the lyrics, label Romanization (Romaji), provide Traditional Chinese translation and grammar notes, and then uses Gemini TTS to generate a "teacher demonstration audio." After I record myself shadowing the song, Gemini transcribes my pronunciation and provides a word-by-word score.
A few days ago, in a single Claude Code session, I rewrote the entire backend from Python + Next.js API routes to Go 1.26 (Issues #44–#49, Tracking Issue #50, PR #51–#56). The architecture after the rewrite looks like this:
/api to the Go server in the same container (127.0.0.1:8081). asia-east1)/data`` genai SDK, Gemini REST (voices / interactions), Firestore transactions, kagome for tokenization and conversion to Hiragana, a custom Hepburn Romanization table, and a port of Python's difflib.SequenceMatcher to Go (aligned with The rewrite was done, it worked, and tests passed. But "writing and reviewing your own code" always has blind spots. I happened to want to try Herdr—a tool that allows multiple AI Agents to collaborate in the same window, each occupying a Pane. So, I conducted an experiment:
Let Claude Code be the controller and Reviewer on the left, and the Antigravity CLI (agy) be the implementer on the right. The two Agents communicate via Herdr, taking the song-lingo Go backend from Code Review all the way to Cloud Run deployment.
herdr-app and herdr-core
Herdr is divided into two layers:
| Component | Role | Description |
|---|---|---|
herdr-app |
Desktop App | Manages the layout of spaces > tabs > panes . You can split panes left/right and run programs directly in a new pane. |
herdr-core (CLI called hc ) |
Terminal Core | Responsible for running " terminals that don't die when the window is closed ," providing commands like run / send / wait / read / keys / get for Agents to operate other terminals. |
A small side note: I initially told Claude Code to "use herdr --skill to learn first," but the herdr command couldn't be found—the actual executables are ~/.local/bin/herdr-app and ~/.local/bin/herdr-core.
--skill: An Instruction Manual for AI Agents
I think Herdr's smartest design is that both CLIs have a --skill parameter. Running herdr-app --skill and herdr-core --skill prints an "instruction manual for AI Agents."
So, Claude Code's first step wasn't to blindly try commands, but to read both manuals before starting. The manuals contain many sensible guidelines, such as: background Agents must use --bg, provide a name and clear scope, don't let sub-agents spawn more sub-agents, and use hc wait instead of sleep polling when waiting.
| Command | Purpose |
|---|---|
$HC_TERMINAL_ID |
Every terminal has an address; Claude Code itself is t4 . |
hc run --bg --name xxx <agent> "<task>" |
Start a named Agent in the background and assign a task. |
herdr-app do pane.split --direction right --pane <id> --argv [...] |
Split the pane to the right on the screen and run a program in the new pane. |
hc send <t> "..." |
Paste text and press Enter; used to assign tasks or provide Review feedback. |
hc wait <t> /hc wait --children all |
Wait for the other party to finish (replaces sleep polling). |
hc read <t> --lines 0 |
Read the other party's entire current screen. |
hc keys <t> enter |
Send a keystroke, such as answering a permission dialog. |
hc get <t> |
Check status: idle /working /blocked /done ; whenblocked , it includes amsg , e.g.,Approve RunCommand: ... . |
This is the most critical piece of the collaboration. When assigning a task, I would require agy: "When finished, please run herdr-core send t4 '...' to report back." After agy finishes, that message appears directly in Claude Code's input, looking like this:
<hc from="t7">ready for review #57 ...</hc>
In other words, Claude Code doesn't need to keep staring at the right screen; agy will "knock on the door" when done. Reporting is asynchronous, and every message carries a source address for tracking.
The first time I had Claude Code open agy, it used hc run --bg to run it in the background—it worked functionally, but I couldn't see what agy was doing. So, the second time, I specifically requested "in a pane on the right," and Claude Code used herdr-app do pane.split to open agy on the right. The final configuration was:
flowchart LR
subgraph Herdr["Herdr Window (Same Tab)"]
direction LR
CC["Left Pane: Claude Code (t4)<br/>Controller / Reviewer<br/>Opens Issues, views diffs, runs go test -race, approves"]
AGY["Middle Pane: agy (t7)<br/>Implementer<br/>Code Review, modifies code, runs tests, commit/push"]
BLOG["Right Pane: Claude Code<br/>Blog Writer<br/>(The one writing this article)"]
end
Human(["Me (Human)<br/>Can see every Pane, can intervene anytime"])
CC -->|"hc send: Assign tasks / Review feedback"| AGY
AGY -->|"herdr-core send t4: ready for review #N"| CC
CC -->|"hc keys: Grant read-only permissions"| AGY
CC -->|"hc send: Write blog"| BLOG
BLOG -->|"herdr-core send t4: blog draft ready"| CC
Human -.-> CC
Human -.-> AGY
The first task was simple: ask agy to review the Go code under backend/, read-only, no subagents, and write the results to a file, reporting back with hc send t4 when finished.
agy's interface showed it was running Claude Sonnet 5.5 (plan mode), and it eventually produced 17 issues, sorted by importance. Claude Code helped me organize them into three categories:
| Category | Issues found by agy |
|---|---|
| Concurrency and Data Integrity | During Re-annotate (one Gemini call can take up to 5 mins), if I modify lyrics on the proofreading page, the write-back will be overwritten—the two sides use different mutexes ;EditLine 's global lock spans across Firestore calls;WriteAtomic 's temporary filename is fixed as.tmp . |
| Resource Limits and Leaks | TTS generation has no concurrency limit (this is a paid API); the failure cache only grows and stores duplicates; 429 errors without Retry-After have no rate limiting at all;TeacherVoice re-readsvoices.json every time and holds a global lock during network calls; request bodies lackMaxBytesReader ; background jobs usecontext.Background() , so they aren't cancelled on shutdown. |
| Performance and Structure | listSongs parses all songs sequentially and reads progress for each; demonstration audio is read entirely into memory before being returned; the same Error type exists in 3–4 places; CLIspeak andtts.Service logic are redundant. |
Here is a detail I really liked: Claude Code did not accept everything blindly. Before opening an Issue, it first used grep to verify the most important one—"re-annotate and review use different mutexes"—and only after confirming it was true did it start opening Issues. The Reviewer's conclusions also need to be Reviewed.
My instructions to Claude Code were:
"Open issues and fix them sequentially. Mainly let the agy pane on the right do the modifications. You are responsible for reviewing and giving feedback. Only after you approve should agy commit and push."
Claude Code consolidated the 17 items into 5 Issues (#57–#61), all performed on the same branch go-review-fixes, with one commit per Issue, and commit messages including Fixes #N.
sequenceDiagram
participant CC as Claude Code (t4)<br/>Reviewer
participant AGY as agy (t7)<br/>Implementer
participant GH as GitHub
CC->>AGY: hc send: Issue #N task + implementation points + "do not commit"
AGY->>AGY: Implementation + gofmt -l . && go vet ./... && go test ./...
AGY->>CC: herdr-core send t4 'ready for review #N'
CC->>CC: git diff + run go test -race personally
alt Has issues
CC->>AGY: hc send: Review feedback
AGY->>CC: Report back again after fix
end
CC->>AGY: Approved, you may commit & push
AGY->>GH: git commit (Fixes #N) + push
AGY->>CC: Report commit sha
This is the part I want to share the most. Out of the 5 Issues, 3 required a second round of fixes, and 2 passed on the first try:
| Issue | Topic | Review Result | Commit |
|---|---|---|---|
| #57 | Per-song lock | Design correct, but no tests written → requested them | e5df5f4 |
| #58 | TTS concurrency limit and failure cache | Caught error message regression → requested fix and tests | d67ec5a |
| #59 | Request limits, timeout, graceful shutdown | Caught shutdown sequence and time budget errors → redesigned | de38741 |
| #60 | Song list concurrency, demo audio streaming | ✅ Passed first try | bc1615a |
| #61 | Unified error types, CLI refactor, signal handling | ✅ Passed first try | deb1fb7 |
agy's per-song lock design was correct. Claude Code also specifically confirmed one thing: manual translations and the reviewed flag are both written into the transcript, so "comparing the entire transcript before writing back" indeed catches all edits.
But agy didn't write tests. So Claude Code requested extracting the logic into saveAnnotation and adding two cases: normal write when unchanged, and return conflict and no write when edited.
While cleaning up the failure cache, agy deleted the server-side recentErrors. All tests passed, but Claude Code caught a regression:
When the TTS quota is exhausted, "other sentences that have never been generated" checking ?error would get {error: null}—the frontend then couldn't display the reason "quota exhausted."
The fix was to make LastError return a still-valid quota error when there is no failure record for that sentence, and add a test.
This was the most valuable bug catch in my opinion. The graceful shutdown agy wrote was: srv.Shutdown waits 10s, then background jobs wait another 10s. There were two problems:
srv.Shutdown would wait for it to finish, but its context hadn't been cancelled yet—it was essentially waiting for itself.
Claude Code requested changing it to a single 9-second deadline and adjusting the sequence:
// Single deadline, leaving a bit of buffer for Cloud Run's SIGTERM grace period
ctx, cancel := context.WithTimeout(context.Background(), 9*time.Second)
defer cancel()
runner.Cancel() // 1. Cancel background and ongoing analysis work first
srv.Shutdown(ctx) // 2. Then shut down HTTP server (sync analysis already cancelled, won't hang)
runner.Wait(ctx) // 3. Finally wait for jobs to wrap up within the same deadline
(The above is illustrative of the sequence and single-deadline concept.)
*os.File + ModTime handed to http.ServeContent for streaming (getting Range and Last-Modified for free).apierr.Error, CLI speak now uses tts.Service.GenerateAll, switched to signal.NotifyContext, and daily quota checks now use type fields instead of string comparisons.
Looking back at these three rejected Issues, there's a common theme: they were all "tests pass, but it hurts in production" problems—test gaps, frontend error message regressions, and shutdown sequences. This is exactly the greatest value of "one writing, one reviewing."
After all 5 commits passed, PR #62 was opened and merged, and #57–#61 were automatically closed. Then, a new revision was deployed using gcloud run deploy --source ..
Post-deployment verification was completed by Claude Code running fetch directly through a Chrome instance logged into IAP:
| Verification Item | Result | Corresponding Change |
|---|---|---|
Homepage /api/songs |
200 (10 songs) |
#60 Concurrent reading |
| Learning page | 200 |
— |
| Generated demo audio | 200 audio/wav |
#60 Streaming |
| Demo audio Range request | 206 |
#60 http.ServeContent |
With If-Modified-Since |
304 (New feature : now hasLast-Modified ) |
#60 ModTime |
| PATCH with 70 KB | 413 "Request content too large." |
#59 MaxBytesReader |
| Cloud Logging | Only two WARNINGs, both from the test requests themselves | — |
The previous revision was also kept, allowing for an immediate rollback if needed.
agy "prompt" will exit with code 2
Intuitively, you might start agy like this:
agy "Please review the Go code in backend/..."
The result is an immediate error and exit (exit 2):
Prompts are read only from -p/--print, -i/--prompt-interactive, or stdin
The correct way is agy -i "..."—interactive startup with an initial task. The Herdr manual also warns not to use -p one-off mode, because the Agent needs to remain "observable and follow-upable" to conduct the Review back-and-forth.
The first time you start agy in a folder, it pops up "Do you trust the contents of this project?". At this point, the hc get status becomes blocked (auth). Claude Code just needs to use hc keys go-review enter to confirm.
After opening agy with pane.split, the screen looks idle, and the initial prompt seems unsent. Claude Code tried to resend it with hc send, but was blocked by hc:
t7 is blocked (permission)... typing would answer its dialog; read its screen first
It turns out the task had already arrived, and agy was just stuck waiting for permission. This protection mechanism in hc prevents accidentally typing a task description into a permission dialog—if that actually happened, the result could be an unintended approval or rejection of a command.
When agy is in plan mode (read-only research), it can only analyze. To have it start modifying code, you must press shift+tab to switch modes; Claude Code used hc keys t7 shift+tab to switch it. The first round of Code Review was perfect for plan mode, and it only switched out when modifications were needed in the second round.
Every shell command in agy requires permission. Pressing Enter for each one is too tedious, so Claude Code wrote a small driver script with the following logic:
flowchart TD
A["hc wait t7"] --> B["hc get t7 Check status"]
B -->|"idle / done"| Z["End, wait for agy report"]
B -->|"blocked"| C["hc read Read full permission screen"]
C --> D{"Contains dangerous command?<br/>rm -rf / git reset / git checkout<br/>--force / curl / gcloud write operations…"}
D -->|"Yes"| H["Stop, let human decide"]
D -->|"No"| E{"Is it git commit / push?"}
E -->|"Yes, and no ALLOW_GIT=1"| H
E -->|"No, or Reviewer approved"| F["hc keys t7 enter Grant permission"]
F --> A
This script was refined several times during the process, each time providing a practical lesson:
msg gets truncatedhc get, but it would be truncated with "...". The truncated part might hide a second command. Changed to hc read the entire permission screengcloud A; gcloud deploy single-line grep missesdescribe / list / read / ls / du). gcloud storage ls and gcloud storage du are actually read-only; they were blocked initially but later added to the allowlist.
The line for automated granting was drawn like this:
git commit / push`` ALLOW_GIT=1 after Claude Code Review passes
Agents can trust each other enough to say "you can run tests," but "writing to git, writing to the cloud, making external requests" must go through a Reviewer or a human.
If you also want to use Herdr (or similar tools) to have two Agents write and review, you can refer to this checklist:
herdr-app --skill and herdr-core --skill first to confirm actual executable paths.pane.split to open the implementer Agent alongside, so a human can see and intervene at any time.agy -i "...", don't use positional arguments, and don't use -p one-off mode.herdr-core send <controller address> when finished.hc wait, not sleep polling blocked, go test -race, don't just rely on the implementer's report.
As I finish writing this, agy on the right is already running the next task: using its GCP skill to analyze song-lingo's performance on Cloud Run—service settings, cold starts, gcsfuse, Firestore, etc.—proposing 5–8 solutions, each with evidence, expected benefits, monthly costs, and risks, for Claude Code to decide which to implement. This part is still ongoing; I'll share more later.
My biggest takeaway from using Herdr for the first time is: Multi-Agent collaboration is finally "visible."
herdr-core send t4 'ready for review #N' lets the implementer "knock on the door," and the Reviewer uses And this article itself was written by Claude Code in the rightmost Pane of Herdr—after finishing, it will use herdr-core send t4 to report back to the controller on the left.