Personas — who is running on Scout, and how the owner switches them·
Scout is several agents sharing one rover. The dashboard's Personas control
(topbar pills + the ⚙️ sheet) lets the owner turn each one ON/OFF live, from a
phone, without SSH or docker compose.
| persona | kind | what it is | ON means | OFF means |
|---|---|---|---|---|
| 🎙 rover-voice | container scout-slim-voice |
voice_agent.py --audio rover: bidirectional Realtime voice agent on the rover's own mic + speaker (/rover-mic/*, /rover-speaker/* on the SDK) |
container running, model session open | container removed (compose rm -sf) — a dead bidi session never lingers |
| 🧠 thinker | container scout-slim-thinker |
thinker_loop.py: slow-thinker that observes, explores, reports on Telegram every 60 s |
running | stopped (compose stop -t 10) |
| 🕹 thinker drive | flag | may the thinker move the rover? | prompt says drive allowed | prompt says observe-only and sdk_post("/control") raises inside the thinker process (stop frames still pass) |
| ✈ telegram | container scout-slim-telegram |
telegram_listener.py bot |
running | stopped |
| ⏺ rec | flag | auto-record LeRobot episodes around agent turns (_AutoRecorder) |
episodes saved | begin_turn returns early — nothing written |
The dashboard (dashboard_server.py) and the SDK proxy are not personas and
cannot be toggled from here — that is the whole point of the allow-list.
Why two mechanisms·
Container personas need the docker CLI, and the dashboard container has
neither docker nor the docker socket (mounting /var/run/docker.sock into an
internet-facing service is root on the host for anyone who finds an auth bug).
So a tiny host-side supervisor does it:
browser ─https─▶ dashboard (container) ─unix socket─▶ scout_supervisor.py (host, user unit)
/api/personas/* /run/scout-supervisor/scout-supervisor.sock
(auth middleware) fixed allow-list × {start,stop,status,logs}
token header, audit log, docker compose …
deploy/scout_supervisor.py— stdlibhttp.serveron a unix socket, user unitscout-supervisor.service(WantedBy default.target, linger on). It runs onlydocker compose -f docker-compose.slim.yml -f docker-compose.slim.override.ymlwith the same files as the live stack, so a start never re-creates other services (--no-deps, and voice via--profile voice).- Every request needs
X-Scout-Supervisor-Token(SCOUT_SUPERVISOR_TOKENin.env, shared with the dashboard) and is appended to.supervisor/audit.logwith the requesting passkey subject (X-Scout-Actor). - The socket dir
./.supervisoris bind-mounted read/write into the dashboard only (docker-compose.slim.override.yml). Nothing else can reach it. - Start/stop are asynchronous: the API answers
starting/stoppingat once; the UI polls/api/personasevery 1.2 s until it settles (running,stopped,missing,error+last_error).
Flag personas are switches the processes already poll, so they need no
supervisor at all: tools/persona_flags.py reads/writes
.memory/personas.json (the .memory dir is bind-mounted into every service;
atomic tmp+rename; mtime-cached). The thinker re-reads thinker_drive at the
start of every cycle and the hard gate in tools/_rover_common.sdk_post refuses
/control moves while it is off (SCOUT_PERSONA=thinker marks the process).
_AutoRecorder.begin_turn checks recording per turn. The legacy env vars
SCOUT_THINKER_DRIVE / SCOUT_AUTO_RECORD remain the fallback when the file
has no entry, so an untouched deployment behaves exactly as before.
API (all behind the dashboard's auth middleware — 401 without a passkey session)·
GET /api/personas → {personas:{name:{kind,label,icon,desc,state,on,last_error,…}}, supervisor:"ok"|"unavailable", voice_provider, flags_source}
POST /api/personas/{name} body {"action":"start"|"on"|"stop"|"off"} → {ok, persona, state} 404 unknown persona · 400 unknown action · 503 supervisor down
GET /api/personas/{name}/logs?n=20 → {lines:[…]} (container personas only)
When the supervisor is down the container pills render struck-through and disabled, the flag switches keep working, and the sheet shows the reason.
Voice persona — what to expect·
voice_agent.py on the rover mic streams AEC @ 48 kHz → model @ 24 kHz and
plays the reply on the rover speaker (echo-referenced). It uses
VOICE_PROVIDER/OPENAI_API_KEY (default openai Realtime). The browser-mic
voice (/ws/voice, the 🎤 button) is a separate path and unaffected by this
switch. Don't run both against the same room unless you like feedback.
Operations·
systemctl --user status scout-supervisor # host side
tail -f ~/scout-the-rover/.supervisor/audit.log
cat ~/scout-the-rover/.memory/personas.json # flag state
docker compose -f docker-compose.slim.yml -f docker-compose.slim.override.yml --profile all ps
Tests: python3 -m pytest tests/test_personas.py (allow-list, flag precedence,
thinker gate, state parsing — no docker needed).
Proven 2026-09-17 (through https://scout.cagatay.my, bearer session)·
- unauth → 401 on every route; unknown persona → 404;
restart→ 400 - thinker OFF →
stopping → stopped(Exited 0) in 9 s, audit lines written; ON → running in 3 s, bannerdrive=OFFread from the flag file - thinker drive OFF → next cycle: "No driving"; hard gate unit-tested
- voice OFF → container gone in 14 s (0 containers named voice); ON → running in 4 s, "rover mic → AEC@48000Hz → model @ 24000Hz", "model → rover speaker"
- 390 px phone: no horizontal scroll, 0 console errors; the owner toggled voice ON from the live UI two minutes after it shipped