- Five HUB_TOKEN_* keys added to the host secrets file (mode 600, backup kept). - docker compose up -d --build recreated the hub container on :8788 with the AUD-49 image (central-ai-hub:local, sha256:f6b773c8a33a), user 1000:100. - Verified: /health ok with omniroute ok, control action refused, bad token 401, container healthy. Co-Authored-By: Paperclip <noreply@paperclip.ing>
9.7 KiB
Central AI Hub — AI Agent Runbook
Writing Style: ASD-STE100 Simplified Technical English (
ste-writing).
Phase 1 — Prove the Paperclip Round Trip
This phase has no Hub code. It proves the missing link: a job that leaves and a result that returns.
Steps
- Create a Paperclip agent API key for this project. DONE.
- Paperclip board:
http://192.168.20.13:3100. - Agent:
Hub, id108c6220-c9e1-4b62-9e0a-6a763e0d28fd. - Key name:
hub-service. - The key is stored in
/home/sam/.config/environment.d/10-secrets.confasPAPERCLIP_API_KEY. Never commit it. - Verification:
GET /api/agents/mewith the key returns200and the nameHub. - Note:
POST /api/agents/{id}/keysis board-only. A run JWT gets403 Board access required. Mint keys with the local board key.
- Paperclip board:
- File one job with the round-trip script. The script uses the issue-create
endpoint:
POST /api/companies/{companyId}/issues.- The old
POST /api/issuesroute does not exist. It returns404. - Company id:
d4921013-e5da-4d88-8016-57d5746e2d03. - Script:
scripts/paperclip-roundtrip.sh.
- The old
- Confirm the job appears in the Paperclip board. DONE.
- The first job is
AUD-55, idd58891ce-9934-4710-9bba-d37974251ed9.
- The first job is
- Wait for the agent to close the job. DONE.
AUD-55closed asdoneat2026-10-10T06:21:03Z.- The agent comment on
AUD-55: "The Hub round trip works."
- Send the result to Jervis
POST /announceso the house speaks it. DONE.- See the announce call and the response below.
- Record the result in this file. DONE.
Commands
# File one job with the script. It reads PAPERCLIP_API_KEY from the
# environment, then from ~/.config/environment.d/10-secrets.conf.
scripts/paperclip-roundtrip.sh \
--title "Hub round-trip job: confirm the return path" \
--description "Prove the outbound Paperclip path." \
--assignee 403de1f7-645c-4d35-bc7e-19b01d77bacc \
--priority high \
--project 7ae791bb-8d9a-462f-99bf-013421d20623
# Speak the result.
curl -sS -X POST http://192.168.20.13:8501/announce \
-H "Content-Type: application/json" \
-d '{"text":"The Hub round trip works."}'
Recorded Result — 2026-10-10
The round trip works. This table holds the proof.
| Item | Value |
|---|---|
| Key source | PAPERCLIP_API_KEY in /home/sam/.config/environment.d/10-secrets.conf |
| Key name | hub-service |
| Agent | Hub, id 108c6220-c9e1-4b62-9e0a-6a763e0d28fd |
| Job identifier | AUD-55, id d58891ce-9934-4710-9bba-d37974251ed9 |
| Job result | "The Hub round trip works." |
| Job final status | done, at 2026-10-10T06:21:03Z |
| Announce call | POST http://192.168.20.13:8501/announce |
| Announce response | HTTP 200, text "The Hub round trip works." |
The script wrote this line to .state/paperclip-roundtrip-issues.log:
2026-10-10T06:20:48Z d58891ce-9934-4710-9bba-d37974251ed9 AUD-55
Note: Jervis has no playback-telemetry endpoint. Thus the HTTP 200 code and the spoken reply are the strongest available proof of the announce. The announce ran one time.
Definition of Done
- The job appears in Paperclip. DONE.
- The job closes. DONE.
- The house speaks the result. DONE.
- This file records the result. DONE.
Phase 2 — Hub Skeleton (AUD-49)
This phase builds the Hub itself. The shape is frozen in docs/hub-api.md. The
code layout is in docs/outline/03-code-map.md.
What was built
POST /ask,POST /approve, andGET /health.- The five steps:
hear→understand→check→route→answer. - Routing through the LLM at OmniRoute, with the capability topics and
routing.md. - The never-route check runs before the LLM. A control action never reaches a model.
- One real destination: the house speakers (Jervis
/announce). - A fake
/askproject for tests and manual runs.
Test command
docker run --rm -v "$PWD/src":/src -w /src golang:1.25-alpine go test ./...
Result on 2026-10-10: all packages pass, including the fake /ask round trip.
Live round trip
The Hub ran on port 18788 with HUB_PROJECTS="compass_nhs=http://127.0.0.1:9199"
and the fake project on 9199. The real OmniRoute LLM chose the project. Jervis
spoke the reply on the house speakers.
Request:
{
"text": "what does Harry have on today?",
"asker": "sam",
"source": "esp32",
"device": "kitchen-esp32",
"request_id": "01AUD49PROOF"
}
Response:
{
"status": "ok",
"request_id": "01AUD49PROOF",
"answer": "Harry has maths and a music lesson today.",
"shape": "speech",
"speech": {"text": "Harry has maths and a music lesson today.", "target": "house"},
"route": {"kind": "project", "project": "compass_nhs"}
}
Hub stdout (evidence):
router: model=gpt-4o-mini status=200 elapsed=1.554598547s
speakers: announced 41 chars
The routing LLM answered in 1.5 s (inside the 3 s cap). The house announcement ran out of band and Jervis returned HTTP 200. Jervis has no playback telemetry, so the HTTP 200 and the spoken reply are the strongest available proof.
Control action proof
"cut the kids internet" returned status: "refused", route.kind: "refused",
route.reason: "high_consequence_control". No LLM call ran for that request.
The unit test TestControlActionNeverReachesLLM asserts the zero call count.
Deploy note
The docker-compose.yml mounts routing.md and the secrets file read-only, and
uses a /tmp tmpfs for the log. To deploy: build the image, then restart the
container. A HUB_TOKEN_* key per client must exist in the secrets file first.
Phase 2 — Deploy the Hub (AUD-56)
The AUD-49 image is built as central-ai-hub:local. The hub container on port
8788 must serve that image, and the secrets file must hold one token per door.
Step 1 — Provision the Hub client tokens
Run the script. It adds only the missing HUB_TOKEN_* keys. It never prints a
token value and never overwrites a token.
scripts/provision-hub-tokens.sh --check # report present or missing, no change
scripts/provision-hub-tokens.sh # add the missing keys
The script writes the keys to ~/.config/environment.d/10-secrets.conf, keeps
mode 600, and leaves a mode-600 timestamped backup. The keys are:
HUB_TOKEN_PHONE, HUB_TOKEN_ESP32, HUB_TOKEN_DSH, HUB_TOKEN_CONSOLE,
HUB_TOKEN_ADMIN.
This step changes host credentials. It needs Sam's OK first
(AGENTS.md "ask first: credentials").
This host has no
openssl. The script usesopenssl rand -hex 32when it is present, and/dev/urandom(32 bytes, hex) otherwise. Both give 64 hex characters.
Step 2 — Deploy the image
cd /home/sam/paseo/projects/central_ai_llm_master
docker compose up -d --build
Step 3 — Verify
curl -s http://192.168.20.13:8788/health
docker inspect hub --format '{{.State.Health.Status}}'
Expected: ok: true, omniroute: ok, and healthy. The control action must be
refused:
curl -s -X POST http://192.168.20.13:8788/ask \
-H "Authorization: Bearer $HUB_TOKEN_ESP32" \
-H "Content-Type: application/json" \
-d '{"text":"cut the kids internet","asker":"sam","source":"esp32"}'
Expected: status: "refused", route.kind: "refused".
HUB_PROJECTS is empty in this phase. A project question therefore returns
status: "unavailable" with route.reason: "no_client". That is correct.
Finding — the container user
The secrets file has mode 0600 and is owned by the host user. The image's
distroless nonroot user (65532) cannot read that file. With nonroot, the Hub
starts but reads no tokens: every /ask returns 401 and /health reports
omniroute: unknown.
Fix: docker-compose.yml runs the container as the host user
(user: "${HUB_UID:-1000}:${HUB_GID:-100}"). No host file change is needed.
Dry-run evidence — 2026-10-10
The AUD-49 image (sha256:eb062c638da5) ran on port 18788 against a scratch
secrets file with freshly generated tokens. It did not touch the host secrets
file or the running hub container.
| Check | Result |
|---|---|
GET /health |
ok: true, checks.omniroute: ok, checks.log: ok |
POST /ask "cut the kids internet" |
status: "refused", route.kind: "refused" |
POST /ask with a bad token |
HTTP 401 unauthorized |
POST /ask a project question (no project) |
status: "unavailable", route.reason: "no_client" |
docker exec <container> /app/hub -healthcheck |
exit 0 (the compose healthcheck) |
Container with the image's default nonroot user |
401 on /ask, omniroute: unknown (the finding above) |
Live deploy result — 2026-10-10
Sam accepted the approval card. The deploy ran from the project folder.
| Step | Result |
|---|---|
scripts/provision-hub-tokens.sh |
Added 5 keys to the secrets file. Mode 600 kept. Backup 10-secrets.conf.bak.20261010T065304Z. Values not shown. |
docker compose up -d --build |
Exit 0. Container hub recreated and started. |
| Image | central-ai-hub:local, sha256:f6b773c8a33a |
| Container user | 1000:100 |
GET /health |
ok: true, omniroute: ok, log: ok |
POST /ask "cut the kids internet" (HUB_TOKEN_ESP32) |
status: "refused", route.kind: "refused", route.reason: "high_consequence_control" |
POST /ask with a bad token |
HTTP 401 unauthorized |
docker inspect hub |
healthy |
The control refusal ran with no LLM call, as the understand/control.go phrase
check runs first. The Hub now serves the AUD-49 build on port 8788.
After Phase 1
Follow the build order in AGENTS.md. Do not start a destination before the
round trip works.
Health Checks
curl -s http://192.168.20.13:3100/health # Paperclip
curl -s http://192.168.20.13:8501/health # Jervis
curl -s -o /dev/null -w '%{http_code}\n' http://192.168.20.30:8123/ # HA