Claude Code as a supervisor of OpenCode workers
Introduction
In this setup Claude Code does not make
the change.
It hands the task to OpenCode in a
Docker container, reviews what comes back
and sends the worker what is wrong.
The worker side, the script and the options of
opencode run, is in the article
Claude Code as supervisor,
OpenCode in Docker as worker.
This page is the side of Claude Code: what starts the loop, what it
reads and writes, and what it checks before it calls a change done.
Why not a Claude Code subagent?
The worker runs another model, by default the
free OpenCode model, and under
--auto in a container that sees one worktree
(OpenCode in Docker).
Claude Code reads only the report and the git diff, so its own context
stays small.
The phrase that starts it
Without a phrase Claude Code does a task itself, as usual.
A message that starts with one of these phrases hands the task to the
worker:
assign to opencode: <task> delegate to opencode: <task> /assign-to-opencode <task>
The last one is the skill itself, called as a slash command: every skill in .claude/skills/ can be called by its name.
The skill
Claude Code loads a skill when the message matches its description
(writing skills for AI assistants).
The description names the phrases, and the body is the loop.
This is the skill used for the task on this page, with the
project-specific lines of the review step shortened to what any repo
has:
mkdir -p .claude/skills/assign-to-opencode
vi .claude/skills/assign-to-opencode/SKILL.md
--- name: assign-to-opencode description: "The user says \"assign to opencode ...\" (also \"delegate to opencode\", \"give it to opencode\", /assign-to-opencode): you supervise and review, OpenCode in Docker does the work through container/tools/ai/delegate.py" --- ## Use when The user's message starts with "assign to opencode", "delegate to opencode" or "give it to opencode", or they run `/assign-to-opencode <task>`. You are the supervisor: you write the brief, start the worker, review its result on the host and send fixes. You do not make the change yourself, not even a small part of it. ## Do not use for - OpenCode in the container: this skill is for Claude Code on the host. - A task without the phrase: do it yourself as usual. ## Workflow 1. Name the task with a slug: lower case and dashes, e.g. `erlang-lesson-32`. The worker gets the new worktree `../site-agent-<slug>` on the branch `agent/<slug>` from `main`; pass `--worktree PATH` to give it an existing one. Tell the user in one line: slug, worktree, model. 2. Write the brief to the scratchpad (template below). The worker starts cold: name the files, the skills to load, what done means and the checks it can run in the container. Read the target files yourself only as far as the brief needs. 3. Start it with Bash `run_in_background` and wait for the notification; do not poll: `python3 container/tools/ai/delegate.py start <slug> --brief <file>` Add `--rw <path>` when the task edits outside container/src, `-m <provider/model>` only when the user named a model. Exit 3 means the model left the catalog: ask the user before you pick a paid model. 4. Review when it ends. The report shows the status, errors, the worker's last answer and the changed paths. Then, on the host: - `git -C <wt> status --short` and `git -C <wt> diff`; read new files. For big generated batches: tests and a sample of pages, not every file. - The linters on the touched files and a render of the touched pages with the docroot of the worktree. - Tests with the venv of the main checkout and no cache, so the worktree gets no ignored files (the launcher refuses the next round): `cd <wt>/container && <main>/container/.venv/bin/python -m pytest -p no:cacheprovider <tests> -q` - The rules of AGENTS.md: version headers, line endings, links, the language of the page. - It does what the user asked, and nothing else. 5. Problems: write a fix file, one bullet per issue with `file:line`, what is wrong and what to do, and send it into the same session, in the background: `python3 container/tools/ai/delegate.py fix <slug> --brief <fix-file>`. The worker keeps its context, so do not repeat the task. At most 3 fix rounds; after that, report what is still wrong and ask the user. 6. A round that failed before doing the work (an `error:` line, a usage limit, "Background service failed to start", `interrupted`): read the report, fix the cause, then send one `fix` that says to continue. 7. Done: tell the user what changed (paths), the checks and their results, the rounds and the cost, and what the worker left for the host. Then ask about the commit, with explicit-path `git -C <wt> add` and `git -C <wt> commit` commands. Never commit, merge or remove the worktree yourself. `delegate.py status` lists every task; `status <slug>` prints the last report again. State and logs: `container/reports/delegate/<slug>/`. ## Brief template ``` # Task: <one line> <what and why in 2-5 lines; the user's words where they matter> ## Files - edit or create: <paths> - read first: <paths> ## Load first <skills from the routing table of AGENTS.md> ## Done when - <checkable outcomes> ## Checks you can run here - php -l <files>; <other checks that need neither git nor the local site> ## Out of scope - commits (there is no git), anything outside the files above End with: the files you changed, the checks you ran and their result, and what is left to run on the host. ```
OpenCode reads .claude/skills/ too, and in the
container it would see this skill.
The first line under "Do not use for" tells the worker that the skill
is not for it.
The route in AGENTS.md
A task about a page also fits the skills for pages.
A row at the top of the routing table of
AGENTS.md says that the phrase comes first, and
Claude Code reads that table at the start of every session:
vi AGENTS.md
| Task | Load these skills first | |---|---| | Claude Code only: the message starts with "assign to opencode" (or "delegate to opencode") | `assign-to-opencode`: you supervise and review, OpenCode in Docker does the work |
The permission for the script
Claude Code runs delegate.py once per round and
once for each status.
An allow rule in .claude/settings.json spares
you a prompt each time.
Claude Code cannot add it itself in auto mode.
Its try was blocked:
Permission for this action was denied by the Claude Code auto mode classifier. Reason: [Self-Modification].
So you add the rule.
This command keeps every rule that is already there, and adds the new
one to the allow list:
jq '.permissions.allow = ((.permissions.allow // []) + ["Bash(python3 container/tools/ai/delegate.py *)"] | unique)' .claude/settings.json > .claude/settings.json.new && mv .claude/settings.json.new .claude/settings.json
jq '.permissions.allow, (.permissions.deny | length)' .claude/settings.json
[ "Bash(python3 container/tools/ai/delegate.py *)" ] 12
The 12 deny rules are the ones the file had before
(deny rules for OpenCode and Claude Code).
You can also add it by hand with the command
/permissions.
The brief
The task for this page came as one message:
assign to opencode: "http://127.0.0.1:8080/dev/cpp/sfml2_tetris.php#video this page has no video chapter but has video in table of contents it should be fixed. also it has legacy Статьи про С++ related articles block - it should be removed. also it is not translated to english - need to be translated, also it has empty introduction section - need to be written"
The worker starts cold, so before the brief Claude Code reads the page
as far as the brief needs.
Its questions, and what the reading gave:
- Was there ever a video? No: the history of the page and of its Russian twin has no video. So the fix is to remove the entry from the contents, not to add a section.
- Where does the old block come from? From one include of a file that 9 other pages still include. So the brief removes the line and keeps the file.
- What is still Russian? 68 lines: the title, keywords, description, a heading and the comments in the code. Also the language setting of the page says Russian.
- What can the worker run? Its image has php, python3 and rg, so the brief asks for php -l and a render.
The brief, with the skill names of the repo left out:
# Task: fix and translate the English page /dev/cpp/sfml2_tetris.php (Tetris in C++ with SFML 2) The page lives on the English site but is still in Russian. The user asked for four things: 1. The table of contents links to `#video`, but the page has no video section, and no video was ever there. Remove the `'#video', 'Видео'` entry from `$tcont_urls`. Do not add a video section. 2. Remove the legacy "Статьи о C++" related-articles block: the line `include('_cpp_articles_inc.php');` near the end of the page. The page already shows related articles through `RelatedArticles` and `ArticlePage`, so keep those. Leave `_cpp_articles_inc.php` itself alone, because other pages still include it. 3. Translate everything still in Russian into English: the `$page_*` variables at the top, the same values in the `new ArticlePage(...)` arguments, the `Код` heading (→ "Code"), and every Russian comment in the C++ code block. Set `$page_lang = 'ru';` to `'en'`. Keep the code itself and its HTML escaping exactly as they are, and change only the comment text. 4. Write the empty Introduction: 1-3 short paragraphs on what the page gives the reader, based only on what the code on the page really does. Read the code first and invent nothing. Link the first mention of SFML and of C++ to their articles, and use English pages only. ## Files - edit: container/src/dev/cpp/sfml2_tetris.php (LF line endings; keep them) - read first: container/src/dev/cpp/RelatedArticles.php, container/src/dev/cpp/sfml.php - for reference only, do not edit: container/src/ru/code/cpp/sfml2_tetris.php (the Russian twin) ## Done when - no `#video` in `$tcont_urls`, no `_cpp_articles_inc.php` include in the page - no Cyrillic left in the file: `grep -n '[А-Яа-яЁё]' container/src/dev/cpp/sfml2_tetris.php` prints nothing - the Introduction has real English text with the links above ## Checks you can run here - `php -l container/src/dev/cpp/sfml2_tetris.php` - render with a shim that sets DOCUMENT_ROOT to /work/container/src, expect exit 0 ## Out of scope - every other file, the code logic, the class of the code block - commits (there is no git), the sitemap End with: the files you changed, the checks you ran and their result, and what is left to run on the host.
Two facts in the first draft of this brief were wrong: the path of the
tool that finds articles to link, and the class of the code block.
Both came from memory.
Claude Code checked every path and class of the brief against the repo
before the start and fixed them.
The worker follows a wrong path as willingly as a right one.
Start and wait
Claude Code starts the round with the Bash tool and the option run_in_background:
python3 container/tools/ai/delegate.py start cpp-sfml2-tetris --brief cpp-sfml2-tetris-brief.md
Command running in background with ID: bepj3sbgq. Output is being written to: /tmp/claude-1000/.../tasks/bepj3sbgq.output. You will be notified when it completes
Then it ends its turn with one line for you: the slug, the worktree and
the model.
It does not poll.
Claude Code gets a notification when the process exits, and only then
reads the report.
In the meantime you can give it other work, as with this page, which was
written while the worker ran.
The review
The report said done, and the worker listed its
own checks as passed.
Claude Code checks again on the host, because the worker's list is a
claim, not a proof.
Only the comments of the code changed. The translation touched a long code block. A short script compares every code line of the old and the new file with the comment cut off:
python3 -I - container/src/dev/cpp/sfml2_tetris.php <<'EOF'
import re, subprocess, sys f = sys.argv[1] old = subprocess.run(["git", "show", f"HEAD:{f}"], capture_output=True, text=True, check=True).stdout new = open(f, encoding="utf-8").read() def code(text): out = [] for line in text.splitlines(): if line.startswith("<code>"): out.append(re.split(r"//|//", line, maxsplit=1)[0].rstrip()) return out a, b = code(old), code(new) print("code lines old/new:", len(a), len(b)) print("non-comment differences:", sum(x != y for x, y in zip(a, b)))
EOF
code lines old/new: 441 441 non-comment differences: 0
The introduction says only what the code does. It names a
15 by 25 field, seven shapes, a fall every 0.3 seconds, 0.05 seconds
with the down arrow, and one point per full line.
Each number was looked up in the code of the page:
grep -nE 'field_height =|field_width =|float</span> delay|delay = 0.05|userPoints \+= 1' container/src/dev/cpp/sfml2_tetris.php
126:<code><span class="blue">const</span> <span class="blue">int</span> field_height = 25;</code> 128:<code><span class="blue">const</span> <span class="blue">int</span> field_width = 15;</code> 174:<code> userPoints += 1;</code> 416:<code> <span class="blue">float</span> delay = 0.3;</code> 459:<code> <span class="violet">if</span> (sf::Keyboard::isKeyPressed(sf::Keyboard::Down)) delay = 0.05;</code>
The page itself. php -l, a render with the docroot of the worktree, and the PHP linter:
No syntax errors detected in container/src/dev/cpp/sfml2_tetris.php render exit 0 OK: all selected tools clean.
In the rendered page: <html lang="en">,
no Cyrillic, no #video and no old block.
The title writes C++ as C++,
like the other C++ pages of the site.
A fix or the end
In this task the review found nothing, so there was no fix round.
When it finds something, Claude Code writes one point per issue with
the file and line, what is wrong and what to do, and sends it into the
same session (send a fix).
- At most 3 fix rounds. After that Claude Code reports what is still wrong and asks you.
- A round that failed before any work, such as a model that is not available, gets its cause fixed and one more round that says to continue.
- No fix by hand. Even a one-letter fix goes to the worker, so the worktree holds only the worker's work and its review.
A key for a paid model
Another task of this setup, a check of the image paths of the site,
ran its first round on the free OpenCode model.
The review left a long list for round 2, and Claude Code chose a paid
model for it, opencode-go/deepseek-v4.1-flash
of OpenCode Go, in the same session
(another model from
the next round on).
That model needs the OpenCode Go key.
Claude Code asked for a key file, and this is how it went.
1. You make the file, in your own terminal.
Not with ! in the Claude Code session: the
output of a ! command lands in the
conversation, and a prompt that waits for your input may not work
there.
Do not paste the key into the chat either: it would stay in the
transcript.
mkdir -p ~/.config/opencode-agent && chmod 700 ~/.config/opencode-agent
( umask 077; read -rsp 'OpenCode Go key: ' k; echo; printf '%s\n' "$k" > ~/.config/opencode-agent/opencode.key )
read -s does not show what you type.
The key goes into the shell itself, not into a command line, so it is
not in your shell history, and printf, a
built-in of the shell, writes it without a process that shows it in
the process list.
umask 077 inside the parentheses makes the new
file readable only by you.
Tested with a dummy key:
stat -c '%a %U %n' ~/.config/opencode-agent ~/.config/opencode-agent/opencode.key
700 me /home/me/.config/opencode-agent 600 me /home/me/.config/opencode-agent/opencode.key
The OpenRouter page makes its key file with install -m 600 and an editor (a key for the container). The file is the same; read -s keeps the key off the screen.
2. Claude Code checks that the file is there, and does not read it.
test -r ~/.config/opencode-agent/opencode.key && echo present
present
Then it starts the round with the path of the file:
python3 container/tools/ai/delegate.py fix img-src-check -m opencode-go/deepseek-v4.1-flash --opencode-key ~/.config/opencode-agent/opencode.key --brief fix-1.md
3. What happens to the key.
- Where it may be. The launcher refuses a key file inside the repo or the worktree, so the key cannot be mounted or committed. Tested with a copy of the dummy key in the worktree: ERROR: keep the key file outside the repo and the worktree.
- One run. The launcher reads the file and gives the key to that one container run as the environment variable OPENCODE_API_KEY. Docker gets only the name, -e OPENCODE_API_KEY, so the key is not in the arguments of docker run either. Nothing is written into the container or the worktree.
- The state. delegate.py keeps only the path of the file. A search for the dummy key in the state file and in the logs of every round found it nowhere.
- Claude Code. It runs test -r on the file and nothing else.
4. What is still exposed.
While the run lasts, the key is in the environment of the container.
The worker can read it with env, and anyone who
can use Docker on your machine can read it with
docker inspect.
Tested on a running container with the dummy key:
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' opencode-agent-site-agent-delegate-selftest-161385 | grep OPENCODE_API_KEY
OPENCODE_API_KEY=dummy-not-a-real-key
That is the price of this design.
Under --auto the worker can use the key, so give
it a key of its own with a spending limit, which you can delete without
touching your other keys.
5. Not recommended: a stored login.
opencode auth login in a container stores the
key in the shared sessions volume, and every later worker run sees it,
whether you meant it or not
(a stored login: check it, use it,
make one or remove it).
In this setup the check found one:
docker run --rm -v opencode-agent-data:/home/agent/.local/share/opencode opencode-agent auth list
OpenCode Go OpenCode Go stored
With it, a run on opencode-go/ works without any
key file, and the worker of every task, the free ones too, can reach the
key in the volume.
Remove it, and keep the key in the file of step 1.
Wait until no round runs: the logout writes to the same database in
the volume that a running worker uses.
Here round 2 was still running, so Claude Code waited until its
process ended (tail --pid), and then the
login was removed:
docker run --rm -v opencode-agent-data:/home/agent/.local/share/opencode opencode-agent auth logout opencode-go "OpenCode Go"
┌ Log out of an account │ ◇ Removed account from OpenCode Go │ └ Done
docker run --rm -v opencode-agent-data:/home/agent/.local/share/opencode opencode-agent auth list
No authenticated integrations
From now on a run on opencode-go/ needs
--opencode-key.
A run on the free model needs no login and works as before.
6. When the key is refused.
The next task, a list of the image files of the site, started on the
same model with the key file.
Its round ended after 30 seconds:
python3 container/tools/ai/delegate.py start image-list --base agent/img-src-check -m opencode-go/deepseek-v4.1-flash --opencode-key ~/.config/opencode-agent/opencode.key --rw container/tests --rw container/docs --rw container/tools/images --rw container/tools/ai/docker --rw .pre-commit-config.yaml --brief brief-image-list.md
model: opencode-go/deepseek-v4.1-flash: not checked: only opencode/<id> models have a public catalog worktree: created /home/me/github/site-agent-image-list on agent/image-list from agent/img-src-check round 1 of image-list started 2026-11-04T18:11:02+02:00; events: /home/me/github/site/container/reports/delegate/image-list/round-1.jsonl task: image-list, round 1 (start), branch agent/image-list worktree: /home/me/github/site-agent-image-list model: opencode-go/deepseek-v4.1-flash session: ses_e58aba3daffeZbYfWe45LlfgqI started: 2026-11-04T18:11:02+02:00, ended 2026-11-04T18:11:32+02:00 status: failed, exit 1 events: error 1 cost: $0.0000, tokens 0 in / 0 out error: provider.auth: Upstream request failed: Invalid credential worker's last answer: none worktree changes: none files: /home/me/github/site/container/reports/delegate/image-list/round-1.{prompt.md,jsonl,err}
The request was refused before the first step: no change in the
worktree, no cost.
Round 2 of the image check had worked on this model before the logout
of step 5.
It had used the stored login: the key in the file had never been
tested.
The file was written again, and a round sent at once failed the same
way, 12 seconds in.
Test the key in a shell of the container before you send a round
again: one short request costs less than a round, and it tells a wrong
key from a problem of one model.
Test the key yourself, in your own terminal, in a shell of the
container with the key file.
The worktree of the failed task has no changes, so the shell can use
it:
container/tools/ai/opencode-docker.sh --opencode-key ~/.config/opencode-agent/opencode.key ../site-agent-image-list shell
At the /work $ prompt:
opencode auth list
OpenCode Go OPENCODE_API_KEY environment OpenCode Console OPENCODE_API_KEY environment
The key reached the container.
environment says only that the variable is
set, not that the key works
(how to find out if there is
a stored login).
One short request on two models of OpenCode Go:
opencode run -m opencode-go/deepseek-v4.1-flash "say ok"
> build · deepseek-v4.1-flash Error: Upstream request failed: Invalid credential
opencode run -m opencode-go/deepseek-v4-pro "say ok"
Error: Upstream request failed: Invalid credential
What the answers mean:
- No line with OpenCode Go and environment in auth list. The key does not reach the container: check the path after --opencode-key.
- Invalid credential on both models. The key is not a working OpenCode Go key: a part of it, another key, or a deleted one. Copy it again from your OpenCode Go account and write the file again with the command of step 1.
- Only one model fails. The problem is on the side of OpenCode, for that model: wait, or pick another model.
- Both answer ok. The key works: send the round again.
Here both models failed.
The key was copied again, the file written again, and the same test
in a new shell:
opencode run -m opencode-go/deepseek-v4.1-flash "say ok"
> build · deepseek-v4.1-flash ok
opencode run -m opencode-go/deepseek-v4-pro "say ok"
> build · deepseek-v4-pro ok
Claude Code then checks the file without reading the key: its mode, its size and the time it was written.
stat -c '%a %s %y' ~/.config/opencode-agent/opencode.key
600 52 2026-11-04 21:08:23.768605839 +0200
A new time means the file was written again.
The file of the refused key had 31 bytes.
A file much shorter than the one of a working key is worth a second
look: a key cut short when it was pasted, or another key.
Then the task goes on in its session.
The refused rounds did no step, so the brief of round 1 is still the
task.
The round only has to say so:
python3 container/tools/ai/delegate.py fix image-list --message "The previous attempts failed on an authentication error before any step ran; the key is fixed now. Do the whole task from your first message, from the start, and end with the summary it asks for."
round 3 of image-list started 2026-11-04T21:10:14+02:00; events: /home/me/github/site/container/reports/delegate/image-list/round-3.jsonl task: image-list, round 3 (fix), branch agent/image-list worktree: /home/me/github/site-agent-image-list model: opencode-go/deepseek-v4.1-flash session: ses_e58aba3daffeZbYfWe45LlfgqI started: 2026-11-04T21:10:14+02:00, ended 2026-11-04T21:19:49+02:00 status: done, exit 0 events: step_finish 85, step_start 86, text 4, tool_use 92 cost: $0.0726, tokens 135579 in / 23566 out
The same session id as the refused round 1, and the work done in
10 minutes.
fix keeps the model and the key file of the
task, so the command needs neither.
What Claude Code does not do
- It does not commit or merge. At the end it asks whether you commit, and gives the git add and git commit commands with explicit paths.
- It does not remove the worktree. You remove it after the merge.
- It does not edit the files of the task. The phrase means the worker does the work.
- It does not touch a task it did not start. During this task delegate.py status also listed a running task that Claude Code had not started; it stayed as it was.
The real task in numbers
- One round of 3 minutes, with the free OpenCode model, cost $0.
- One file changed in the worktree: 88 lines in, 72 out.
- Left for you: a look in the browser and the commit of the branch agent/cpp-sfml2-tetris.
Article author: Klavdiya Kondakova