🐳 Project 4: a third executor
So far the course has covered two runtimes. But a third one has been running on your machine for weeks: dsh-sandbox, DeepSeek's agent in a container, with a local or remote model. It has three hand-copied skills (guaranteed drift) and no context layer. This project adds a --dsh target to the canonical skills source, gives dsh a prime that tells it to read the right files, and runs the readback inside it — without ever combining ~/projetos with a remote provider.
🎯 The project on one screen
dsh-sandbox — and that its skills come from the same canonical source as Claude and Codex, with no manual copying.sync-skills.sh with a --dsh target and drift check, a prime skill in ~/projetos/dsh-skills, and a readback run in the panel with a local model and compared against the remote one.AGENTS.md, context/, tasks/current.md and handoffs/latest.md by name; and no step used remoto-projetos.🐳 What dsh-sandbox is
dsh-sandbox is @deepseek-ai/dsh version 0.1.1-rc.1 running in Docker, with a panel at 127.0.0.1:9080 and the container dsh-orchestrator-runtime-1. The version is frozen inside work/node_modules on purpose: a boot never goes back to the registry, and updating is an explicit act. All control goes through a single script at the project root — ./dsh — with five verbs.
The point that matters for this course is how the modes are designed. There is no "turn everything on": disk mounting and model provider are tied together. In local mode, ~/projetos is mounted inside the container and the only provider is Ollama on the machine itself — your files are there, but not a single byte leaves. In remoto mode, the provider is OpenRouter and ~/projetos is not mounted — the model is better, but it sees nothing of yours. The combination of the two exists (remoto-projetos) and requires typing the word CONFIRMO, because it is exactly the dangerous combination.
Read the diagram by following the arrows into the container. Local brings in your files but keeps the model locked to the machine; remote frees the model but leaves the files outside. The red block is the only combination that joins the two — and it exists precisely to be hard to trigger by accident.
Goal: start dsh in the right mode and confirm the panel responds before anything else.
cd ~/projetos/dsh-sandbox
# see what state it is in right now (it may have been up for days)
./dsh status
# for this project: local mode (projects mounted + Ollama only)
./dsh local
# the panel only listens on loopback; from outside, use an SSH tunnel
curl -sf -o /dev/null -w '%{http_code}\n' http://127.0.0.1:9080
# expected: 200
# container and memory cap
docker ps --filter name=dsh-orchestrator-runtime-1
# when you are done
./dsh off
How to verify: ./dsh status shows the active mode and curl returns 200. If you need access from another machine, the way is ssh -L 9080:127.0.0.1:9080 … — dsh refuses to bind to 0.0.0.0 by the authors' design, and the app has no authentication at all.
The container is not the cost: it weighs about 85 MB idle, capped at 3 GB. The real weight is the model loaded on the host, with no cap: from 17 GB to 45 GB depending on what you choose. That is the number that decides whether the machine can handle it — not Docker.
Key concepts
One more consumer of the same portable context, alongside Claude and Codex.
Mount and provider decided together, never separately.
0.1.1-rc.1 installed locally; boot does not check for updates.
The panel has no login; remote access only via SSH tunnel.
🧬 Copying by hand is guaranteed drift
Today dsh has three skills, and all three got there via cp: formato-curso-v2, formato-curso-v5 and capa-inema (a dependency of the first two). They live in ~/projetos/dsh-skills, mounted in the container as /work/.dsh/skills. The format is compatible — it is the same SKILL.md as Claude's — and that is why the copy works. The problem is not that it fails today; it is that it won't stay the same tomorrow.
Call it drift: you fix a bug in the Claude skill, test it, publish it, and the dsh copy stays on the old version. Nothing breaks loudly. dsh simply generates a course with the standard from two months ago and you find out in the output. The diagnosis is clear about the scale of the problem: there are already four skill consumers, not two — Claude (117), Codex (27), dsh (3 manual copies) and openpcbotv3 (16 of its own). Without a canonical source, each one diverges on its own, and the divergence grows over time.
A canonical source on the left, a build in the middle, four destinations on the right. The first two already come out of the build; the third (dsh) is what this project adds; the fourth is left for later. The box on the right is what turns this into a guarantee: without drift, you have four copies and no proof that they match.
✓ Skill generated by the build
- ✓Fix it at the source, and one
buildplus oneinstallupdate every destination. - ✓
driftflags it before the user does. - ✓Automatic backup of the previous destination on every install.
- ✓A single review serves all four consumers.
✗ Skill copied by hand
- ✗Nobody remembers which copy is the newest.
- ✗The divergence shows up in the output months later and looks like a model bug.
- ✗A fix turns into four edits, and you forget one.
- ✗Without a checker, "it's in sync" is faith, not fact.
Watch the folder name: the destination is ~/projetos/dsh-skills, not ~/projetos/skills — the latter is already the inematds/skills git repository for the Agent Skills course. Copying to the wrong name pollutes a published repo. Check the path before running any cp -a.
Key concepts
Copies that start out identical and diverge without anyone noticing.
The only place you edit; everything else is generated.
Claude, Codex, dsh and openpcbotv3 — all wanting the same skill.
Drift doesn't raise an error; it produces a wrong result that looks right.
🔧 Adding the --dsh destination to sync-skills.sh
The scripts/sync-skills.sh you already used in Track 2 has four verbs: import, build, drift and install. It assumes two runtimes throughout, with the for rt in claude codex loop and the $HOME/.$rt/skills/$s path — an elegant trick that works because both folders follow the same naming pattern. dsh breaks that pattern: its destination is ~/projetos/dsh-skills/<nome>, which doesn't fit the formula.
The good news is that dsh accepts the same SKILL.md as Claude. So there's no need for a new polyskill build target: what gets copied to dsh is the dist/claude/<nome> the build already produces. The change is small and surgical — a function that resolves the destination path per runtime, the --dsh case in install, and adding dsh to the drift loop, which is the part that really pays off.
Goal: apply the patch that adds the third destination, keeping drift as the check for all three.
# --- in scripts/sync-skills.sh, right after `mkdir -p skills dist` ---
# resolve each runtime's destination folder (dsh breaks the ~/.<rt>/skills pattern)
dest_for() {
case "$1" in
claude|codex) printf '%s/.%s/skills/%s\n' "$HOME" "$1" "$2" ;;
dsh) printf '%s/projetos/dsh-skills/%s\n' "$HOME" "$2" ;;
*) return 1 ;;
esac
}
# dsh consumes the SAME format as Claude: the source is dist/claude
src_for() {
case "$1" in dsh) echo claude ;; *) echo "$1" ;; esac
}
# --- in the `drift` verb: replace `for rt in claude codex` with: ---
for rt in claude codex dsh; do
out="$d/dist/$(src_for "$rt")/$s"; tgt="$(dest_for "$rt" "$s")"
[ -d "$out" ] && [ -d "$tgt" ] || { echo "[não instalada] $s → $rt"; continue; }
if diff -rq "$out" "$tgt" >/dev/null; then echo "[ok] $s → $rt"; else echo "[DRIFT] $s → $rt"; rc=1; fi
done
# --- in the `install` verb: replace `for rt in claude codex` with: ---
for rt in claude codex dsh; do
case "$tgt" in --both) [ "$rt" = dsh ] && continue ;; --all) ;; --$rt) ;; *) continue ;; esac
out="skills/$s/dist/$(src_for "$rt")/$s"; [ -d "$out" ] || { echo "rode build antes: $out"; exit 1; }
dest="$(dest_for "$rt" "$s")"
[ -e "$dest" ] && cp -a "$dest" "$(dirname "$dest")/.$s.bak-$(date +%s)"
mkdir -p "$(dirname "$dest")"; cp -a "$out" "$dest"; echo "instalada: $dest"
# kept from the original: Codex also discovers skills in ~/.agents/skills
[ "$rt" = codex ] && [ -d "$HOME/.agents/skills" ] && cp -a "$out" "$HOME/.agents/skills/$s" && echo "espelhada: ~/.agents/skills/$s"
done
How to verify: bash -n scripts/sync-skills.sh passes with no syntax errors, and scripts/sync-skills.sh drift now prints three lines per skill instead of two — the third ending in → dsh.
Goal: bring the three hand-copied skills into the canonical flow and get drift down to zero.
cd ~/projetos/agente-claude-codex
# 1. save the current copies before overwriting (they are the known good state)
cp -a ~/projetos/dsh-skills ~/projetos/dsh-skills.bak-$(date +%Y%m%d)
# 2. import into the canonical source (from the Claude original)
scripts/sync-skills.sh import formato-curso-v2 formato-curso-v5 capa-inema
scripts/sync-skills.sh build
# 3. now install into dsh as well
for s in formato-curso-v2 formato-curso-v5 capa-inema; do
scripts/sync-skills.sh install "$s" --dsh
done
# 4. the proof
scripts/sync-skills.sh drift; echo "rc=$?"
# expected: only [ok] lines, rc=0
How to verify: rc=0 and no [DRIFT] lines. If drift shows up right after the install, the hand-made copy had some local edit that never made it back to the source — compare it with the backup from step 1 and carry the difference into skills/<nome>/ before moving on.
Why --both still means two: the patch keeps --both as Claude+Codex and introduces --all for all three. Anyone already in the habit of typing --both won't be surprised by an install in a new location — changing the behavior of an existing flag is the kind of trap that only shows up three weeks later.
Key concepts
dsh doesn't live in ~/.<runtime>/skills; it needs a resolver.
Compatible format: no new build target is needed.
A destination that isn't covered by drift isn't truly part of the flow.
Add --all instead of changing what --both does.
🧭 Giving dsh context: the prime skill
Skills resolved, context still missing. dsh scans $DSH_HOME/skills and that's it: it has no auto-load of AGENTS.md or CLAUDE.md. If you open the panel inside a migrated project, it sees the files (in local mode) but doesn't know it should open them. Since there's no hook, no open event and no global instruction, the only lever available is the one it already uses: a skill.
Hence the prime skill. All it does is ask for reading in the right order: AGENTS.md, then context/, then tasks/current.md, then handoffs/latest.md — the same order Claude and Codex follow by instruction. It's the same idea as topic 5 of Project 2: when the event doesn't exist, the behavior becomes an explicitly requested read. And since dsh accepts Claude's format, this skill comes from the same canonical source and goes to all three destinations.
Write prime in the canonical source
A short SKILL.md in skills/prime/, with the reading order and the response format.
Build and install to all three destinations
install prime --all ships the same skill to Claude, Codex and dsh.
Start dsh in local mode
Without ~/projetos mounted there's nothing to read; the readback has to happen in local mode.
Run the 5 questions in the panel
The same ones from Track 2's readback. The criterion is citing the file, not how nice the answer looks.
Goal: create the prime skill and install it to all three destinations, including dsh.
cd ~/projetos/agente-claude-codex
mkdir -p skills/prime
cat > skills/prime/SKILL.md <<'MD'
---
name: prime
description: Loads the project context before any work. Use at the
start of every session, before the first edit, and whenever you lose the thread.
---
# Prime — read before acting
Read, in this order, and only then respond:
1. `AGENTS.md` at the project root — the stable rules.
2. `context/overview.md` — the verified facts. If there is
`context/decisions/`, also read the most recent decision.
3. `tasks/current.md` — what is in progress right now.
4. `handoffs/latest.md` — what the last session did and what the next action is.
If any of these files doesn't exist, say which one is missing instead of making things up.
Close the prime with four lines, each citing the file it came from:
- **Project:** what it is, in one sentence.
- **State:** what is done and what is pending.
- **Next action:** the exact sentence written in `handoffs/latest.md`.
- **Rule that matters most here:** the constraint from `AGENTS.md` that
most affects the next action.
MD
scripts/sync-skills.sh build
scripts/sync-skills.sh install prime --all
ls ~/projetos/dsh-skills/prime/SKILL.md
How to verify: ls finds the file, and scripts/sync-skills.sh drift shows [ok] prime → dsh alongside claude and codex. Then, in the panel at 127.0.0.1:9080 with dsh in local mode, ask "use the prime skill on the project /projetos/<your-pilot>" and check that the four lines come back with a file name on each one.
Watch the path bridges: inside the container, ~/projetos shows up as /projetos and skills are seen at /work/.dsh/skills. There are two symlinks in work/ precisely so that the ~/... paths written in the skills resolve in there — they look broken when you view them from the host, and that's expected. When asking for something through the panel, use the inside path.
Key concepts
The skill that replaces the nonexistent auto-load with a requested read.
AGENTS → context → tasks/current → handoffs/latest, always the same.
Symlinks in work/ that make ~/… resolve inside the container.
An answer without a file name doesn't count as a proven read.
🔒 Security: 269 secrets and the non-negotiable rule
The audit counted 269 secret files inside ~/projetos — among them the two .env files the whole course treats as the single source of API keys, wifi/.env and openpcbotv2/.env. Mounting that tree into a container whose model provider is remote means giving a third-party service the chance to read any of them, because the agent decides on its own which files to open. There is no "it won't look": the only guarantee is the one built into the design.
That is why mount and provider are tied together. The decision was made once, in the script, not in every session: local gives access to the files and keeps the model inside the machine; remoto lets the model out and takes the files off the table. The third option exists and requires typing CONFIRMO — and this project's rule is short: never use remoto-projetos for readback. Readback is precisely the exercise of sending an agent to read files from your disk; it is the worst possible moment to have an external provider on the line.
✓ Allowed combinations
- ✓
localfor readback and for generating a course: files inside, model inside. - ✓
remototo compare prose quality using content pasted by hand. - ✓Panel only on
127.0.0.1; from outside, only through an SSH tunnel. - ✓
./dsh offwhen you finish, so no mount stays alive without need.
✗ Never
- ✗
remoto-projetosfor readback — it puts the 269 secrets together with an external provider. - ✗Exposing the panel with
--host 0.0.0.0: the app has no authentication at all. - ✗Copying a
.envintodsh-skills"so the skill can find it". - ✗Typing
CONFIRMOon autopilot — the friction exists so you stop and think.
Goal: confirm, before running readback, that the active mode is the safe one — and measure the size of the surface.
cd ~/projetos/dsh-sandbox
# how many secret files the mount would expose (count only, never the content)
find ~/projetos -maxdepth 3 \( -name '.env' -o -name '.env.*' -o -name '*credential*' \) \
-type f 2>/dev/null | wc -l
# the active mode — before asking the panel anything
./dsh status
# direct check: does the /projetos mount exist in this container?
docker inspect dsh-orchestrator-runtime-1 \
| python3 -c 'import json,sys; [print(m["Source"], "->", m["Destination"]) for m in json.load(sys.stdin)[0]["Mounts"]]'
# the panel must not be listening outside loopback
ss -ltnp 2>/dev/null | grep 9080
# expected: 127.0.0.1:9080 — if 0.0.0.0:9080 shows up, take it down NOW with ./dsh off
How to verify: if status says local, inspect shows /projetos mounted and ss shows only 127.0.0.1, you can proceed. Any other combination is a reason to stop and fix it before typing the first question into the panel.
About network_mode: host: the container runs without network isolation because dsh only accepts binding to 127.0.0.1 and, with bridge, Docker's proxy can't reach the internal loopback. The consequence is that it sees every loopback service on the host — Ollama on 11434, iccmonit on 9003, and whatever else is running. This is not a cosmetic detail: it kills the feeling of "it's isolated because it's a container". The real isolation here comes from the modes, not from the network.
Key concepts
Whoever sees the files doesn't talk to an external provider, and vice versa.
The measured surface of ~/projetos; the number that justifies the rule.
Typing CONFIRMO exists to prevent accidents, not use.
With network_mode: host, the entire host network is visible.
📉 Honest expectations: what the local model delivers
The local model on this machine is qwen3.8:27b, about 18 GB loaded on the host, with no cap. It competes for RAM with inemavox — and the history of OOM crashes is recorded in the monitoring, it's not a hypothesis. If you're going to run readback in dsh, do the preflight: check free memory first, and don't leave a heavy voice synthesis running.
On quality, the diagnosis's message is blunt: the skills were written for Claude's behavior, and with a local model the result falls short. But "falls short" has to be measured on the right axis. What this project tests is context portability, and the criterion is citing the right files. Poorer prose, looser structure and less nuance are limits of the model. Confusing the two leads to the wrong conclusion — "portability failed" when what failed was the expectation of writing quality.
🧪 The test that separates model from portability
Run the same question in both modes. In remoto dsh can't see ~/projetos, so paste the file contents into the question itself. If the answer improves a lot, the bottleneck was the model; if it still doesn't cite any file, then the problem really is your context layer.
| Axis | Criterion | Conclusion if it fails |
|---|---|---|
| Portability | Cites AGENTS.md, tasks/current.md, handoffs/latest.md by name | Missing prime, mount or the files |
| Fidelity | The next action matches what's written in the handoff | It skimmed; shorten the files |
| Prose quality | Organized text without repetition | Model limit — invalidates nothing |
| Stability | Finished without OOM | Infra: model too large for the free RAM |
Goal: run the memory preflight and record the readback result in both modes, without jumping to conclusions.
# preflight: does the ~18 GB model fit right now?
free -g | awk '/Mem:/ {print "livre:", $7, "GB"}'
# rule of thumb: under 24 GB free, don't load the 27b alongside inemavox
# who is already holding memory
ps -eo rss,comm --sort=-rss | head -5 | awk '{printf "%6.1f GB %s\n", $1/1048576, $2}'
# models loaded in Ollama right now
ollama ps
# after the readback, record the result alongside the other two runtimes
cd ~/projetos/<seu-piloto>
mkdir -p relatorios
$EDITOR relatorios/readback-dsh-local.md # which files were cited, and the next action it read
$EDITOR relatorios/readback-dsh-remoto.md # same question, context pasted by hand
How to verify: both reports exist and the verdict is written in one sentence — "cited the four files, prose inferior to Claude" is a pass; "didn't cite any file in either mode" is a failure of the context layer, and then the problem is in the prime or the mount, not the model.
✅ Acceptance criteria (check off with evidence)
- ☐
sync-skills.sh driftreturnsrc=0with all three targets listed. Evidence: command output. - ☐The 3 skills that used to be copied by hand are now generated by the build. Evidence:
[ok] … → dshfor each one. - ☐The
primeskill exists in all three targets. Evidence:lson the three paths. - ☐The readback in dsh cites all four files by name. Evidence:
relatorios/readback-dsh-local.md. - ☐No step used
remoto-projetos. Evidence:./dsh statuslogged before each round. - ☐No OOM during the exercise. Evidence: the preflight written down and inemavox stopped or untouched.
⚠️ Risks in this project
- •OOM: the ~18 GB model loads with no ceiling and fights inemavox for the same RAM.
- •Using
remoto-projetosfor convenience and exposing the 269 secrets. - •Copying to
~/projetos/skillsinstead ofdsh-skillsand polluting a published repo. - •
install --dshoverwriting a copy with a local edit that was never pushed back to the source. - •Concluding "portability failed" by judging the prose quality instead of the citations.
↩️ One-command rollback
- ✓dsh skills:
rm -rf ~/projetos/dsh-skills && mv ~/projetos/dsh-skills.bak-AAAAMMDD ~/projetos/dsh-skills. - ✓Script:
git checkout -- scripts/sync-skills.shundoes the whole patch. - ✓Prime:
rm -rf skills/primein the source and targets; nothing else depends on it. - ✓Container:
./dsh offbrings everything down and unmounts; Claude and Codex stay intact. - ✓Memory:
ollama stop qwen3.8:27bfrees the ~18 GB right away.
Key concepts
Measure free memory before loading the model, not after the freeze.
Cite the right files; prose quality is a separate axis.
Before blaming portability, test with a better model.
dsh doesn't replace Claude or Codex; it proves the context travels.
Self-check (optional): in the dsh readback in local mode, the model cited AGENTS.md, tasks/current.md and handoffs/latest.md, but the text came out repetitive and poorly organized. What do you conclude?
🎯 Project summary
local and remoto modes tying mount to provider.--dsh target in sync-skills.sh, with the three skills joining the drift check alongside Claude and Codex.remoto-projetos for readback; and the criterion is citing the right files, not pretty prose.Next project:
3.5 — Client workspace: Venn, scope and canaries