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:

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"//|&#47;&#47;", 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&plus;&plus;, 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).

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.

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:

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

The real task in numbers

Article author: Klavdiya Kondakova