PROJECT 3.4

🐳 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.

6
Topics
~50
Minutes
Advanced
Level
Project
Type

🎯 The project on one screen

GoalProve that the portable core works for a third executor — dsh-sandbox — and that its skills come from the same canonical source as Claude and Codex, with no manual copying.
You walk away withA 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.
Acceptance criteriaZero drift across all three targets; dsh cites AGENTS.md, context/, tasks/current.md and handoffs/latest.md by name; and no step used remoto-projetos.
1

🐳 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.

dsh container dsh-orchestrator-runtime-1 panel 127.0.0.1:9080 ./dsh local ~/projetos MOUNTED provider: Ollama only (on the machine) your files go in, nothing goes out ./dsh remoto ~/projetos LEFT OUT provider: OpenRouter (internet) better model, without seeing your files mounted skills ~/projetos/dsh-skills → /work/.dsh/skills ./dsh remoto-projetos mount + remote provider 269 secret files at risk requires typing CONFIRMO never for readback

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

Third executor

One more consumer of the same portable context, alongside Claude and Codex.

Tied modes

Mount and provider decided together, never separately.

Frozen version

0.1.1-rc.1 installed locally; boot does not check for updates.

Loopback only

The panel has no login; remote access only via SSH tunnel.

2

🧬 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.

canonical source skills/<name>/ (polyskill) one place, one truth build dist/<runtime>/ ~/.claude/skills117 skills ~/.codex/skills27 skills ~/projetos/dsh-skills 3 hand copies → THIS PROJECT openpcbotv3/skills --v3 destination, left for later drift compares each destination with the build output goal: zero

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 build plus one install update every destination.
  • drift flags 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

Drift

Copies that start out identical and diverge without anyone noticing.

Canonical source

The only place you edit; everything else is generated.

Four consumers

Claude, Codex, dsh and openpcbotv3 — all wanting the same skill.

Silent failure

Drift doesn't raise an error; it produces a wrong result that looks right.

3

🔧 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

Non-standard destination

dsh doesn't live in ~/.<runtime>/skills; it needs a resolver.

Reusing dist/claude

Compatible format: no new build target is needed.

Drift as a contract

A destination that isn't covered by drift isn't truly part of the flow.

Stable flag

Add --all instead of changing what --both does.

4

🧭 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.

1

Write prime in the canonical source

A short SKILL.md in skills/prime/, with the reading order and the response format.

2

Build and install to all three destinations

install prime --all ships the same skill to Claude, Codex and dsh.

3

Start dsh in local mode

Without ~/projetos mounted there's nothing to read; the readback has to happen in local mode.

4

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

Prime

The skill that replaces the nonexistent auto-load with a requested read.

Reading order

AGENTS → context → tasks/current → handoffs/latest, always the same.

Path bridge

Symlinks in work/ that make ~/… resolve inside the container.

Cite the source

An answer without a file name doesn't count as a proven read.

5

🔒 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

  • local for readback and for generating a course: files inside, model inside.
  • remoto to compare prose quality using content pasted by hand.
  • Panel only on 127.0.0.1; from outside, only through an SSH tunnel.
  • ./dsh off when you finish, so no mount stays alive without need.

✗ Never

  • remoto-projetos for 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 .env into dsh-skills "so the skill can find it".
  • Typing CONFIRMO on 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

Tied mount

Whoever sees the files doesn't talk to an external provider, and vice versa.

269 secrets

The measured surface of ~/projetos; the number that justifies the rule.

Deliberate friction

Typing CONFIRMO exists to prevent accidents, not use.

Container ≠ isolation

With network_mode: host, the entire host network is visible.

6

📉 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.

AxisCriterionConclusion if it fails
PortabilityCites AGENTS.md, tasks/current.md, handoffs/latest.md by nameMissing prime, mount or the files
FidelityThe next action matches what's written in the handoffIt skimmed; shorten the files
Prose qualityOrganized text without repetitionModel limit — invalidates nothing
StabilityFinished without OOMInfra: 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 drift returns rc=0 with 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] … → dsh for each one.
  • The prime skill exists in all three targets. Evidence: ls on 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 status logged 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-projetos for convenience and exposing the 269 secrets.
  • Copying to ~/projetos/skills instead of dsh-skills and polluting a published repo.
  • install --dsh overwriting 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.sh undoes the whole patch.
  • Prime: rm -rf skills/prime in the source and targets; nothing else depends on it.
  • Container: ./dsh off brings everything down and unmounts; Claude and Codex stay intact.
  • Memory: ollama stop qwen3.8:27b frees the ~18 GB right away.

Key concepts

RAM preflight

Measure free memory before loading the model, not after the freeze.

The right criterion

Cite the right files; prose quality is a separate axis.

Compare in remote

Before blaming portability, test with a better model.

Second-line executor

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

A real third executor — dsh now runs in a container, with a dashboard at 127.0.0.1:9080 and local and remoto modes tying mount to provider.
No more copying by hand — a --dsh target in sync-skills.sh, with the three skills joining the drift check alongside Claude and Codex.
Context via skill — with no auto-load and no hook, prime tells the model to read AGENTS.md, context/, tasks/current.md and handoffs/latest.md.
Safety and expectations — never 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