Mission 01a00538 ("ClawHDF5 REsearch and Refactor") failed 19 minutes and 93,762
tokens into its research phase with `pair failed: 403 Forbidden`, and its coding
phase was then correctly skipped as unreachable. The cause was not the coding
phase and not the model — it was pairing.
A per-mission runtime is authenticated with a SINGLE-USE pairing code, and the
bearer token it returns was cached in memory only. Any restart of the server
discarded that token; the next turn re-paired with a code the gateway had
already spent and got 403 — permanently, for that mission. A deploy, a crash or
an OOM would each do it. The durable-run machinery exists precisely so work
survives a restart; pairing was the one thread that did not, and it failed
closed.
`missions.runtime_token` persists the token at the moment pairing succeeds, and
the worker seeds the executor's cache from it, so a new process reuses the
credential instead of re-pairing. Persisting is best-effort: failing to save
must not fail a turn that just paired successfully.
Verified by reproducing the original failure: launched a mission, confirmed the
token was written, restarted the server MID-PHASE, and watched the mission run
to completion with no pairing failure.
Co-Authored-By: Claude Opus 5 <[email protected]>
/api/world/live is a 2s database poll, which is right for queryable state and
wrong for a token stream: reasoning only became visible after a step finished
and its row was written. This adds a process-wide broadcast bus that
topology_exec publishes to as the runtime's WebSocket delivers frames, and the
SSE handler forwards without waiting for the next tick.
Measured: the pushed frame arrived ~2.2s before the polled copy of the same
text.
Design notes worth keeping:
- A global (OnceLock), not an AppState field. The publisher is reached through
phase_runner -> topology_worker -> MissionTap, none of which hold AppState;
threading a handle through all of them would put a UI concern into four
layers that have no other reason to know about one.
- Lossy by design. A slow subscriber lags and skips rather than applying
backpressure to the agent producing. mission_events remains the durable
record; this bus is the fast path, never the source of truth.
- Only `claw_<uuid>` aliases are attributed. The governor, door and evaluator
drive real turns under other names, and attributing their output to an agent
would put words in someone's mouth. Asserted in a test.
- The poll no longer emits `reasoning`: with both paths live, every turn
arrived TWICE — once pushed, once polled ~2s later. The row is still written;
this feed just is not its second mouth.
CEILING, measured rather than assumed: turns are not token-level because the
runtime is not streaming. zeroclaw's claude_cli provider runs
`claude -p --output-format json`, which returns ONE result object when the turn
completes — there are no incremental tokens to forward. Making this genuinely
token-by-token needs `--output-format stream-json` and incremental parsing in
the zeroclaw fork, not here. The bus is in place and will carry them the day it
does.
Co-Authored-By: Claude Opus 5 <[email protected]>
The last of the three declared-but-never-emitted event types.
`agent.task.update` had no producer anywhere in the backend, so the card read
"idle — no active task" for an agent that was mid-turn.
Derived rather than newly instrumented: an agent is working on its crew's
RUNNING mission, and that mission's phases are the steps (completed/skipped →
done, running/evaluating → active, else pending). Nothing is emitted for an
agent with no running mission, so "idle" stays truthful rather than freezing on
a stale last-known task.
Verified on a live mission: 116 agent.task.update events observed on
/api/world/live, carrying the mission title and phase steps, with the state
advancing pending → active as the phase started.
That closes the set. Of the seven cards in the command centre, five were dark:
three had no emitter at all and two read a table the mission path never wrote.
DOORS and LOOPS were correctly wired the whole time and were reporting an honest
zero.
Co-Authored-By: Claude Opus 5 <[email protected]>
`agent.reasoning.delta` and `agent.tool.call` have been declared in the taxonomy
and listened for by the command centre since it shipped — and NOTHING ever
emitted them. The world feed emitted five types; neither was among them, so
REASONING STREAM could not populate no matter what an agent did.
The feed is a database poll, not a push bus, so a live card can only show what
was persisted. The worker already holds each step's output text and the claw
that produced it, so it records a `reasoning` mission_event (truncated — the
card renders a tail, not a transcript, and mission_events is capped per phase),
and the feed emits it forward from a cursor that starts at the current max so a
page load streams rather than replaying history.
`tool.call` is emitted from the same place. On the container tier it will stay
empty, and that is correct rather than broken: those agents are tool-free behind
the §15 door. Tool lines appear where agents actually hold tools.
Verified on a live mission: agent.reasoning.delta observed on /api/world/live
carrying the agent's own text, keyed by agentId.
Co-Authored-By: Claude Opus 5 <[email protected]>
The command centre's SPEND, ACTIVITY and THROUGHPUT cards read `usage_events`,
and nothing on the mission path ever wrote a row: `cm_billing::charge` was
called only from the agent-run path. Measured mid-mission with 14 agents live,
`usage_events` was 0 while a crew had just burned 15k tokens — so an agent that
had done real work reported zero cost and zero activity.
The worker already knew everything needed: it logs node, role and token count
per step, and the node's `attrs.agent` carries the `claw_<uuid>` binding the
runtime dispatches on. This routes that to the ledger.
`charge`'s run_id is now Option. `usage_events.run_id` references `agent_runs`,
and a topology turn has no row there — passing its `topology_runs` id was a
foreign-key violation, which is exactly what the first attempt hit. NULL is the
honest value; the agent-run caller still passes its real id.
The executor reports one total rather than an in/out split, so the cost is right
(credits price the sum) and the columns record it as output rather than
inventing a split.
Verified end to end on a real mission: 4 agents, 1046-8670 tokens each, credits
attributed per agent, and the SPEND/ACTIVITY queries now return real numbers.
Co-Authored-By: Claude Opus 5 <[email protected]>
Scoping a Gitea connection to `osobh` — the personal namespace clawmates itself
lives in — failed with "org 'osobh' not found or PAT lacks access". The sync
only ever called /orgs/{owner}/repos, and Gitea serves user namespaces from
/users/{owner}/repos. The error pointed at permissions for what was really a
wrong endpoint, which is the kind of message that sends you to rotate a token
that was fine.
Retry as a user on 404 before giving up, and say what was actually checked.
Verified: owner=osobh now syncs 7 repos, owner=redclaw 22 — 29 instead of the
182 an unscoped connection pulls.
Co-Authored-By: Claude Opus 5 <[email protected]>
A soft delete marked the row and left it. The agent stayed in the table forever,
kept appearing on any surface that forgot `deleted_at IS NULL`, and deleting it
again did nothing — the decision was recorded and never honoured. Two agents on
this deployment had been in that state since June.
`deleted` is now a fifth lifecycle state, collected with NO grace window: a
human already decided, months ago. It takes usage_events with it, which is the
explicit trade — the alternative is rows that outlive the decision to delete
them.
Two endpoints, because this was previously only answerable by reading the
database by hand:
GET /api/claws/lifecycle the census: who is active, completed,
orphaned, deleted — and what is reapable
POST /api/claws/lifecycle/sweep run the reap now, rather than waiting out
the hourly timer for a decision already made
Verified end to end: census reported both as `deleted`/`reapable`, the sweep
returned {"reaped":2,"failed":0}, and agents and usage_events both went to 0.
The safety property is unchanged and re-asserted by a new test: adding `deleted`
did not make `owned` or `active` reapable.
Co-Authored-By: Claude Opus 5 <[email protected]>
Deleting an agent looked like a no-op: it disappeared from the workforce but
stayed on the Team board, and deleting it again did nothing because the row was
already marked. Two queries selected from `agents` without `deleted_at IS NULL`:
routes/team.rs the leaderboard — the surface still showing them
routes/world.rs the "working" set — a deleted agent holding a stale
agent_containers row rendered as live
Observed on this deployment: /api/workforce correctly returned nothing while
/api/team/leaderboard returned two agents soft-deleted back in June.
NOT changed: those rows still exist. Making delete permanent means hard_purge,
which also deletes usage_events — billing history, 6 credits on one of these
two. Discarding that as a side effect of tidying a roster is an explicit
decision, not something a display fix should smuggle in.
.sqlx regenerated: team.rs uses the compile-time-checked query! macro, so the
cached entry no longer matched.
Co-Authored-By: Claude Opus 5 <[email protected]>
A mission mints a crew, and the only thing that reaped one was DELETING the
mission. A mission that merely completed left its agents in the roster forever,
and a crew whose reap was skipped or failed left agents bound to nothing —
indistinguishable in the UI from the operator's own staff.
Four states, from one query:
owned no agent_template_link row → hand-created. NEVER reaped.
active on a running/draft mission → working right now. Kept.
completed every mission terminal → reaped after a 24h grace.
orphaned minted, bound to nothing → reaped.
The discriminator is `agent_template_link`, which mission_orchestrator writes
per minted claw. This matters more than it looks: verified on live data, a
hand-created agent and an orphaned crew member both have ZERO team links and are
structurally identical by binding alone. Judging orphanhood by "no team" would
delete the user's workforce. Provenance is the only honest signal.
The grace window exists because the results view, the World's 24h replay and
"who did this work?" all read the crew AFTER the run ends; reaping on the
terminal transition deletes the answer exactly when the question gets asked. A
completed crew with no usable timestamp is KEPT — a missing date must never read
as "old enough to delete".
Also fixes the delete summary, which reported how many claws were FOUND rather
than purged: "reaped 4 claw(s)" was printed by a delete that purged none, which
is precisely the log you would read while wondering why the agents are still
there. It now reports purged / kept / FAILED, and failed > 0 is the orphan case.
Verified against live data — all four states observed, including the two that
look alike.
Co-Authored-By: Claude Opus 5 <[email protected]>
Chromium and fonts-liberation were 758 MB of an 875 MB image: 87% of the server
image was a browser it never launched.
It was installed for the Slice 6 mission PDF renderer, which no longer exists —
every call site passes `render_pdf: false` because markdown is the deliverable —
and NOTHING in the workspace reads the CHROMIUM_BIN this image set. The only
Chromium the platform actually uses is `browser.goto`, which runs it inside the
agent's dedicated egress-enabled BROWSER container
(cm-runtime/src/tools/browser.rs), never in the server.
Measured on gw-04: 875 MB -> 212 MB. The remainder is debian-slim (75 MB), git
and its dependencies (~95 MB) and the server binary (42 MB). git stays: research
topic clones shell out to it, which is why this image left distroless in the
first place.
Verified in the slimmed image: git 2.39.5 present, CA bundle present, chromium
absent, binary executable, templates and all 8 skills shipped.
That 663 MB was paid on every deploy, every registry push, and every air-gapped
bundle.
Co-Authored-By: Claude Opus 5 <[email protected]>
`docker rm -f` without -v orphans the anonymous volume the postgres image
declares. cm-testkit creates a database per test, so each CI run left ~2.8 GB
behind: 38 GB of dangling volumes had accumulated on gw-04, most of the 99 GB
-> 23 GB drop in free space over one day.
Note for anyone reaching for `docker volume prune` to clean this up: don't. On
gw-04 the dangling set also contained traefik-acme (Let's Encrypt certificates)
and all three CI cargo caches. Only the anonymous 64-hex volumes were safe to
remove.
Co-Authored-By: Claude Opus 5 <[email protected]>
On workflow_dispatch GITHUB_REF_NAME is the BRANCH, so the upload step created a
Gitea release AND a git tag both named "main" — a tag sharing the branch name,
from a run that was only meant to be a smoke test. Both have been deleted.
Gated on github.ref_type == 'tag'. A dispatch now exercises build, SBOM, sign
and offline verify, and stops there.
Co-Authored-By: Claude Opus 5 <[email protected]>
Every other step in the release job is inert with respect to prod — build,
SBOM, sign, offline verify. The rehearsal is the only one whose purpose is to
stand a full stack up and tear it down with `down -v`, and it was doing that on
the machine serving production. On 2026-08-13 it adopted the live compose
project and destroyed clawmates_pgdata.
The script itself is now safe (unique -p, a guard against the production project
name, and a health probe pointing at the port the bundle actually publishes) and
is kept for use on a build box or throwaway VM. What changes here is only WHERE
it runs, which was the real problem: a destructive verification step does not
belong on the host it can destroy.
Releases still build, sign, verify offline in a --network none container, and
upload to Gitea.
Co-Authored-By: Claude Opus 5 <[email protected]>
The health check polled 127.0.0.1:18080, but deploy/compose/docker-compose.yml
publishes "8080:8080" and deploy/airgapped/install.sh does not rewrite ports.
Nothing was ever listening on 18080, so the rehearsal always ended in
"platform never became healthy" — regardless of whether the install worked.
Visible now only because the earlier failures (no cargo, compose v1, project
collision) all stopped the script before it got this far.
Co-Authored-By: Claude Opus 5 <[email protected]>
INCIDENT: the release rehearsal destroyed production data on gw-04.
deploy/compose/docker-compose.yml declares `name: clawmates` at the top level,
and that beats --project-directory. So `compose up` from a temp directory did
not create an isolated stack — it ADOPTED the running production stack of the
same name, recreated its containers, and then the cleanup trap's `down -v`
deleted its volumes, including clawmates_pgdata. Prod came back with an empty
database: 177 repos, all missions and all agents gone. There were no backups.
The fix is `-p rehearse-$$` on every invocation, plus an assertion that refuses
to run under the production project name. Isolation here was implicit and
therefore not isolation at all.
Co-Authored-By: Claude Opus 5 <[email protected]>
The v1 fallback I added a commit ago cannot work: deploy/compose/docker-compose.yml
uses v2-only syntax — a top-level `name:` and long-form
`env_file: {path, required}` — so docker-compose 1.29 rejects the file outright
("'name' does not match any of the regexes"). A fallback that always fails is
worse than no fallback, so the script now requires v2 and fails immediately with
what to do about it.
$COMPOSE overrides the detection. gw-04 is deliberately left WITHOUT a
`docker compose` plugin: installing one system-wide would flip the production
rolling deploy (clawmates-deploy.sh prefers v2 when present) off docker-compose
v1 as an invisible side effect of a release change. The runner gets a standalone
v2 binary at /opt/act-runner/bin/docker-compose and the workflow passes it in,
so prod keeps rolling exactly as it did.
Verified on gw-04: standalone v2.32.4 runs, and `docker compose` still resolves
to nothing, so clawmates-deploy.sh takes its v1 branch unchanged.
Co-Authored-By: Claude Opus 5 <[email protected]>
The rehearsal reached "First boot" — bundle assembled, signed, verified offline,
images loaded, install staged — and then died with
`unknown flag: --project-directory`. That message is misleading: gw-04 has no
docker compose v2 plugin at all, only docker-compose 1.29.2, so `docker compose`
is parsed as `docker` with a bogus flag rather than reported as a missing plugin.
Use the same v2-then-v1 fallback deploy/gw-04/clawmates-deploy.sh already needs.
v1.29.2 supports --project-directory, so the invocations are otherwise unchanged.
Co-Authored-By: Claude Opus 5 <[email protected]>
The rehearsal hardcoded `cargo build -p clawmates-bundler`, so it died with
"cargo: command not found" on the release runner — gw-04 builds Rust inside a
container and has no toolchain of its own. The release job had already built the
bundler two steps earlier, so it was also redundant work.
CLAWMATES_BUNDLER now short-circuits that build when it points at an executable,
falling back to cargo otherwise, so running the script by hand is unchanged.
Everything before this step already passed on the runner: images built, SBOMs
generated, bundle assembled and signed, and "bundle OK: 94 artifacts verified
offline" inside a --network none container.
Co-Authored-By: Claude Opus 5 <[email protected]>
First dispatch failed at exit 127, "target/release/clawmates-bundler: No such
file or directory". The bundler builds inside a container where /w/target is a
NAMED VOLUME, so the binary was written somewhere no later host step can see —
the workspace's target/ stays empty. Copy it to .tools/ (bind-mounted) and
assert it landed, so the next occurrence fails at the build step with a clear
message instead of two steps later as a missing file.
deploy.yml does not hit this because it copies clawmates-node into
frontend/public/dl/ from inside the same container.
Co-Authored-By: Claude Opus 5 <[email protected]>
It could never have run as written: `runs-on: ubuntu-latest` matches no runner
on this forge, and `softprops/action-gh-release` talks to GitHub's API. There
are zero tags and zero releases, which is consistent with it never having fired.
Rewritten for this runner:
- runs-on: gw04 (the only reachable x86_64 host; prod artifacts must be amd64)
- the bundler builds in a rust container with the shared cargo cache volumes —
gw-04 has no cargo, and installing a toolchain onto the production gateway to
build a release is the wrong trade
- release creation + asset upload go to Gitea's own API, create-or-reuse so a
re-run of a tag updates rather than 409s
- syft installs into the workspace, not /usr/local/bin: the host executor runs
as root on the gateway and a release should leave nothing behind
- a disk-reclaim step, because the artifacts are GBs of image tarballs on a box
that is also serving production. It removes only the versioned images it
created — never a blanket prune, since clawmates/agent-*:dev exist in no
registry and are the source of the microVM rootfs files
- workflow_dispatch added so the pipeline can be exercised without minting a tag
BUNDLE_SIGNING_KEY now exists as a repo secret (fresh ed25519 keypair; nothing
depended on a previous one). The signing and offline-verify steps are unchanged:
verification still runs inside a --network none container, which is the whole
air-gapped contract.
.github/ is now empty and removed.
Co-Authored-By: Claude Opus 5 <[email protected]>
It targets `runs-on: ubuntu-latest`, which no runner on this Gitea provides, so
every push left a failed job in the Actions tab. .gitea/workflows/deploy.yml now
covers the gates that actually hold: the full `cargo test --workspace` (including
the DB- and docker-backed integration suites, which this workflow never ran) plus
frontend typecheck and tests.
What is deliberately NOT carried over, because none of it passes today and
silently keeping a red gate is worse than removing it:
cargo fmt --all --check 63 files drift
clippy -D warnings pre-existing warnings across the workspace
ci/check-loc.sh MissionWizard.tsx 1153 lines vs a 1100 soft limit
ci/check-no-placeholders.sh false positive on `vec!["rg", "TODO", "src"]`,
which is test DATA, not a placeholder
playwright e2e needs a browser toolchain on the runner
Re-adopting any of these is a cleanup project, not a workflow edit. The scripts
under ci/ are kept so that work has somewhere to start.
Co-Authored-By: Claude Opus 5 <[email protected]>
Found while removing .github/workflows/ci.yml: three of the four files in that
change were unformatted, and four of the diffs were newly introduced (the new
prompt tests and the tool_preamble format! call). Formatting only the files that
change already touched — a repo-wide `cargo fmt` would be 63 files of unrelated
churn and belongs in its own commit.
Mechanical; `cargo test -p cm-api --lib` stays at 322 passed.
Co-Authored-By: Claude Opus 5 <[email protected]>
Skips the login form and lands on the dashboard. It performs a REAL backend
login — the API still issues and can revoke the session — so this does not
weaken auth; it only removes a form for a deployment with exactly one operator.
Gated on BOTH LOCAL_AUTOLOGIN_EMAIL and LOCAL_AUTOLOGIN_PASSWORD, and refuses
outright in clerk mode. Prod sets neither, so the route 404s there. Two
conditions rather than one flag: a single misread value should not be able to
hand a session to an anonymous visitor.
The route emits a RELATIVE Location — inside the container request.url is the
0.0.0.0:3000 bind, so NextResponse.redirect would send the browser to a host
that only exists in Docker — and the cookie's secure flag keys on
x-forwarded-proto rather than NODE_ENV.
Co-Authored-By: Claude Opus 5 <[email protected]>
Level up now sits at the bottom of the Agents sidebar, labelled with the
selected agent's name, and renders only once an agent is selected — it is hidden
during select mode so the reap bar stays the single footer action there. It is
gone from the ClawCommandCenter header.
Repos open with every org folded. Rather than seeding a "collapsed" set with all
keys on load, the state tracks EXPANDED: a smaller change that also stays correct
for orgs that arrive later from a sync, which a seeded set would render open.
With 182 repos across 8 orgs, an all-expanded default buried the org names the
list is meant to be navigated by.
Co-Authored-By: Claude Opus 5 <[email protected]>
The canvas host is a position:relative BLOCK, so flex:1 on MissionCanvas's root
was inert and its height collapsed to its content. That starved the scroller
beneath it — scrollHeight === clientHeight — so it never scrolled, and the
overflow spilled past the page and was clipped by the host's overflow:hidden.
Long results were rendered and then thrown away. Every sibling canvas already
used position:absolute; inset:0; missions was the only one that did not.
Measured after, on a brief 5x the viewport: one scroller, clientH 736 vs
scrollH 3244, scrolling 0 -> 2508 (exactly scrollH - clientH, i.e. the true
bottom), zero page overflow, tab strip pinned throughout.
Also removed five nested scrollers (70vh on live events; maxHeight caps on run
streams, phase summaries, artifact bodies and error traces). Those existed only
to work around the missing height and would have become portholes onto the very
content the operator is trying to read. The xterm pane keeps its bounded box —
FitAddon needs one, and a terminal owning its scrollback is correct.
Deleting the header's description peek reclaims 104px for results (header
256 -> 152px); the same text renders in full in Setup -> Overview, as the code's
own comment noted.
Streaming now follows only when already at the bottom, via a shared
useStickToBottom hook replacing two byte-identical copies, plus a "jump to
latest" pill neither had. Defaults collapse by mission state, and a remount key
fixes scrollTop leaking between tabs — a bug that only appears once scrolling
works.
Co-Authored-By: Claude Opus 5 <[email protected]>
Every agent on a research_only mission refused to work, each reporting it was
"in Claude Code", had no /mission/repo, and only had Read/Edit/Bash. All three
statements were true. The run still recorded completed — 5 turns, 7.4k tokens,
0 artifacts, no error.
The machinery is correct when a repo IS bound (verified on a live prod
per-mission container: /mission/repo present, all 5 agents pinned). Only the
repo-less path was broken, in three layers that disagreed by construction:
- sync_in no-oped without a host checkout and copy mode does not bind /mission,
so NOTHING created /mission/repo. The microVM tier already creates it, for the
stated reason that "the guest needs the workspace to exist before the agent
writes into it". Creating it host-side also un-breaks sync_out, equally a
no-op before, so work survives across phases instead of being wiped.
- pin_agent_workspaces returned Ok after pinning ZERO agents, so the
deliberately-fatal guard in mission_orchestrator could never fire. Its error
text already described the exact outcome we got.
- The prompt advertised ZeroClaw tool names and explicitly denied `bash`, while
every executor ends in `claude -p`: microVM passes Read/Edit/Write/Bash/Agent,
session passes Read/Edit/Write/Bash, and claude_cli agents get Claude Code's
native toolset — ZeroClaw's gating never reaches the subprocess. It was
telling agents to use missing tools and avoid present ones.
And it went green because mission_outputs logged the failed collect and
continued — with the fail-empty rule and the NO-OUTPUT marker both BELOW that
continue, so the phase was retried forever and never failed. The retry is now
bounded by a grace window off completed_at.
Verified end to end: mission completed, agent wrote
/mission/repo/research/firecracker_vs_docker.md, collected and registered as a
document artifact (6.6 kB of real content).
Co-Authored-By: Claude Opus 5 <[email protected]>
cm-runtime/cm-sandbox tests shell out to `docker` via std::process, so the
mounted socket alone was not enough — browser_tool failed with
`docker available: NotFound`. Mount the host binary rather than apt-installing
docker.io: the container is fresh every run, so an install would re-download
~100 MB each time and cache nothing.
Verified on gw-04 that a mounted /usr/bin/docker talks to the host daemon
(client=29.1.3 server=29.1.3), and that the agent-*:dev images these tests need
are already present there.
Co-Authored-By: Claude Opus 5 <[email protected]>
cm-files' s3_store test starts a real MinIO via testcontainers. Without the
socket it does not skip — it fails with
`Client(Init(SocketNotFoundError("/var/run/docker.sock")))`, which looks like a
broken test rather than a missing capability. It passed locally only because the
Mac's docker socket was visible to the test process.
Sibling containers testcontainers starts are reachable because the test
container already shares the host network.
Co-Authored-By: Claude Opus 5 <[email protected]>
First run failed in `cargo test --workspace`: "failed to load source for
dependency clawhdf5", preceded by three "spurious network error: invalid packet
line" retries. Two separate causes, both needed:
- libgit2 cannot fetch from Gitea's smart-HTTP. images/server.Dockerfile already
sets CARGO_NET_GIT_FETCH_WITH_CLI for exactly this; the test step did not.
- quantumclaw/clawhdf5 is private (401 anonymous), so the CLI fetch needs a
credential. Supplied via an insteadOf rewrite from a repo secret, so the token
is masked in logs and never committed.
The server image build does not hit this — it builds only clawmates-server,
which does not pull cm-brain's clawhdf5 path.
Co-Authored-By: Claude Opus 5 <[email protected]>
Closes the one manual step left in the pipeline. gw-04 has run
clawmates-deploy.timer every minute since July, pulling :latest and rolling on
drift — the CD half already worked. What was missing was anything that moved
:latest, since the old build host (tank) is packed for the move.
The runner lives on gw-04 because it is the only reachable x86_64 host and prod
images must be linux/amd64: web-01 is aarch64 and the fleet build boxes are
offline. Host executor, capacity 1, so builds serialize rather than competing
with production traffic.
Three details that are not obvious:
- `docker push :latest` does NOT move the tag on this registry once the manifest
exists under another tag. The PUT-the-manifest step is what actually moves it,
and its absence is how a "successful" deploy could leave prod on a stale image.
- The final step verifies the image prod is RUNNING, not the one we pushed. A
green edge on the old image is the failure this pipeline exists to prevent.
- broker is built here too. It had no :latest tag at all, so gw-04's deploy loop
logged a pull failure every single cycle since 2026-08-11.
Also ignore the local env backups: `.env` was ignored but `.env.bak.*` was not,
and those copies hold real credentials.
Co-Authored-By: Claude Opus 5 <[email protected]>
The card used h-full, so it stretched to fill the column — on tall windows
it grew past the reference (808px at a 920px viewport) instead of staying a
content-sized panel. WorkClaw centers a fixed ~652px card that floats with
empty space above/below. Now phone/tablet → md:h-[652px] (md:max-h-full so
it still caps on short windows), centered via justify-center on the column
wrapper; full + mobile keep h-full to fill. Verified at a 920px window: card
652px, top 166 / bottom 818 (centered, floating) instead of 808 stretched.
At the 720px test viewport it caps to the column either way, so no baseline
change — full suite (37) green.
Co-Authored-By: Claude Fable 5 <[email protected]>
Dropped the avatar + name + role + online dot from the top of the chat
header (the claw is already shown in the rail and the welcome state). The
header now holds only the right-aligned action cluster (and the device/
Close overlay while the panel is open). Removed the now-unused agent prop
+ Avatar/Agent imports; ChatWorkspace no longer passes agent to the header.
Regenerated chat-welcome + computer-home baselines; full suite (37) green.
Co-Authored-By: Claude Fable 5 <[email protected]>
Measured sidebar parity:
- Container: dropped the aside's px-1 so the rail is the full w-rail 176px
(was 168px inner); added bg-background (solid #0a0a0a, was transparent);
padding now 8px 0 24px (pt-2 pb-6).
- Bottom nav (Skills/Apps/Team/Credits): 14px/400 → 12px/500 (text-xs
font-medium, leading-[1.3]); trailing credits balance likewise.
- Claw-name labels: active 400→600 (font-semibold), inactive 400→500
(font-medium); kept 12px.
Verified live at 1440: rail width 176, bg rgb(10,10,10), nav 12px/500,
active claw name 12px/600. Rail-wide change → regenerated all 7 visual
baselines; full suite (37) green.
Co-Authored-By: Claude Fable 5 <[email protected]>
The <body> carried Tailwind's `antialiased` (-webkit-font-smoothing:
antialiased), which inherits through the whole tree and renders every
chat/message/UI text thinner & lighter than WorkClaw. WorkClaw leaves the
browser default (`auto`), giving the crisper/heavier weight. Removed the
class — body is now just `min-h-full`; computed -webkit-font-smoothing is
`auto`. The font itself was always 14px/400 Geist; only the smoothing
differed. Global rendering change → regenerated all 7 visual baselines;
full suite (37) green.
Co-Authored-By: Claude Fable 5 <[email protected]>
--font-sans was 'var(--font-geist), system-ui, sans-serif'; WorkClaw's
chain includes ui-sans-serif before system-ui. Added it so the fallback
order matches: geist, geist Fallback, ui-sans-serif, system-ui, sans-serif.
Fallback-only — Geist always loads (vendored locally), so rendering is
unchanged; full suite (37, incl. visual) green with no baseline diff.
(The lowercase 'geist' / 'geist Fallback' names are next/font's
auto-generated, metric-adjusted family names — CSS font-family matching is
case-insensitive, so they render identically to 'Geist'/'Geist Fallback'.)
Co-Authored-By: Claude Fable 5 <[email protected]>
The absolute panel overlaid the full-width centered chat column, hiding the
messages/composer behind it (WorkClaw avoids this by reserving the panel's
width as padding on the chat). Introduced a shared --computer-width var,
set once on the ChatWorkspace row per ?device= (phone 448px, tablet 67%,
full 100%; 0 when closed). Both the panel <aside> width and a new chat-
column wrapper's md:pr read it, so they never drift: opening the panel
squeezes the centered max-w-3xl column into the left space (re-centered)
while the panel overlays the reserved right area. Animated with
transition-[padding]/[width] + the signature easing for the 'condense'
effect. The header is outside the padded wrapper, so its cluster shift is
unaffected. Verified at 1440: composer right clears the panel left edge in
both tablet (561<593) and phone (920<992) — nothing hidden.
86 unit + 31 functional E2E green.
Co-Authored-By: Claude Fable 5 <[email protected]>
When the panel opened the cluster only shifted a fixed 168px, so the
Slack/Sessions/New icons stayed near the right instead of moving to the
middle. Now the open-state margin matches the panel width (phone 448px,
tablet/full 67%), so the cluster lands at the panel's LEFT edge — the
middle column beside the chat — mirroring WorkClaw's shrinking-pane move.
Animated with transition-[margin]. Verified at 1440: collapsed Computer
ends 1420 (flush right); tablet → 600 (panel left 593); phone → 972
(panel left 992). Device/Close overlay stays far-right; no overlap.
86 unit + 31 functional E2E green.
Co-Authored-By: Claude Fable 5 <[email protected]>
The cluster was absolute-centered (left-1/2 -translate-x-1/2), pinning it to
the header center. WorkClaw right-aligns it with ml-auto in normal flow.
Switched to: title back to min-w-0 flex-1, action cluster ml-auto in flow
(vertical centering via the header's items-center). Verified at 1440:
collapsed cluster is flush right (Computer ends at 1420 = px-5 from edge).
When the panel is open the cluster shifts left (md:mr-[168px]) so the
absolute device/Close overlay sits to its right with a clean gap (Computer
ends 1252, toggles 1296-1420) — no overlap.
86 unit + 31 functional E2E green.
Co-Authored-By: Claude Fable 5 <[email protected]>
Action cluster now Slack → Sessions → New → separator → Computer, chip-styled.
Full suite (37) green.
Co-Authored-By: Claude Fable 5 <[email protected]>
Match WorkClaw's collapsed action cluster. Left-to-right now reads
Slack → Sessions → New → │ separator │ → Computer (coral):
- New Slack quick-launch chip (brand /services/slack.svg) as the first
action, opening the Computer → Slack app via setParams({app:'slack'})
(same handler as the panel home tile; connect-gated there).
- Slack/Sessions/New wrapped in their own md+ sub-group and given the
raised-chip treatment (bg-neutral-900 + ring-1 ring-inset ring-white/8
+ shadow-bubble), preserving hover/active.
- Thin 24px vertical separator (w-px h-6 bg-white/12) before the coral
Computer toggle, which stays size-[42px]/shadow-launcher and always
visible (mobile-reachable; the action group hides <md per WorkClaw).
Handlers/aria-labels/icons of Sessions/New/Computer unchanged. The
header 'Slack' button doesn't collide with the panel-scoped Slack tests.
Verified live: cluster order Slack→Sessions→New→Computer; Slack opens
?app=slack.
86 unit + 31 functional E2E green.
Co-Authored-By: Claude Fable 5 <[email protected]>
Was a single right-aligned row where the title's flex-1 bunched the action
icons and the device controls together on the right. Now matches WorkClaw's
two-zone pattern:
- Identity stays left (capped width so it never reaches center).
- Action cluster (Sessions / New / Computer) is its own group, absolutely
centered in the header (measured at ~808, the chat-header center).
- Device-size toggles + Close are a separate, absolutely-positioned
top-right overlay (z-50, above the panel), shown only while the panel is
open. Toggles are md+ only; Close stays (now also reachable above the
mobile full-screen panel). Measured far-right at 1296–1420.
Button classes, icons, aria-labels and handlers are unchanged — only the
grouping/positioning wrappers differ. The panel card starts below the
header (pt-88), so both zones stay visible/clickable when the panel is open.
86 unit + 31 functional E2E green.
Co-Authored-By: Claude Fable 5 <[email protected]>
The panel is now a 847px (67%) absolute overlay at tablet, covering the
right of the chat which stays full-width underneath. Fresh capture; full
suite (37) green.
Co-Authored-By: Claude Fable 5 <[email protected]>
The previous pass left 'full' as an in-flow flex sibling, so it fought the
chat for layout space and overlapped (chat crushed to a sliver, panel
relative on top). Now the panel is an absolute right-anchored overlay in
EVERY state (never in-flow) — the chat keeps full width and never reflows;
opening the panel just covers the right N%:
- phone → md:w-[448px]
- tablet → md:w-[67%] (≈ the measured 847px at 1440)
- full → md:w-full (covers everything right of the rail)
Transition is now plain transition-[width] on the absolute aside.
Rail collapse in full mode: LeftRail reads ?device, and when the panel is
open in 'full' it collapses 176→80px, icon-only — labels/credits/user-text
hidden (md+) via a group/rail data-collapsed flag, animated in parallel
with the panel via transition-[transform,width]. Verified at 1440: tablet
panel=847 rail=176, phone=448, full panel=1360 rail=80; chat composer.left
stays 472 in phone/tablet (no shift). 24px card inset + 32px radius kept
throughout.
86 unit + 31 functional E2E green.
Co-Authored-By: Claude Fable 5 <[email protected]>
Rail logo (coral paw-print brand tile) and 58px agent tiles updated in
workspace-home, chat-welcome, computer-home (the last also shows the
panel as a right overlay — chat no longer shifts). Fixed the visual
signIn helper: it matched the login page's lowercase 'clawmates' heading
and could screenshot mid-navigation, so workspace-home captured the login
page — now it waits for navigation off /login and matches 'Clawmates'
exact. Avatar unit test updated for the 58px/rounded-20 rail tile.
Full suite (37) green.
Co-Authored-By: Claude Fable 5 <[email protected]>
PART 2 — panel positioning (the 'pushes the chat left' root cause):
DevicePanel was an in-flow flex sibling, so opening it consumed width and
shoved the chat. Now phone/tablet anchor it as an ABSOLUTE right-edge
overlay (md:absolute right-0 top-0 h-full z-30, w-[448px]/w-[550px]) so
the chat keeps full width and does NOT reflow; only 'full' stays in-flow
(grow-[4]) and lets the chat shrink. The chat row is now relative; the
aside is pointer-events-none with the screen card pointer-events-auto so
the header toggles/close stay clickable under the overlay's transparent
top padding. Verified live: composer.left is identical (472px) panel
open vs closed in tablet — the chat no longer shifts.
PART 1 — left rail:
- Logo in its own 80px header row as a 48px black rounded-2xl brand tile
with a coral PawPrint mark (our placeholder logo) + sr-only 'clawmates'.
- Agent tiles bumped to 58px (Avatar 'rail' size + rounded-[20px]
squircle), centered, gap-1, 2px coral active ring (rounded-[22px]).
- Hover-revealed ⋯ menu per agent row (new AgentRowMenu): a 176px #1A1A1A
popover with Pin + Settings rows (coral 15px icons, 14px labels), closes
on outside-click/Escape. Pin pins the claw to the top of the rail
(persisted in localStorage, client-side); Settings deep-links to that
claw's Computer → Settings (the claw redirect now forwards ?app=).
86 unit + 31 functional E2E green.
Co-Authored-By: Claude Fable 5 <[email protected]>
Composer centered in the welcome stack with attach/screenshot icons and
no Send, 12px subtitle, rounded 126px hero avatar, and the larger chat
header (58px avatar / 18px name / 14px role). Captured fresh; full suite
(37) green.
Co-Authored-By: Claude Fable 5 <[email protected]>
Measured welcome-screen + active-conversation feedback:
Composer redesigned with two variants (no Send button — Enter to send,
Shift+Enter for newline; attach + screenshot icon affordances on the
left). 'welcome' = short single-row 672px-capped pill, icons inline;
'active' = taller two-row card (textarea on top, 36px icon-button row
below), placeholder switches to 'Type your message…'. A cream Stop pill
appears while the agent is generating, wired to a real useChat.stop()
that aborts the SSE and finalizes the partial reply (new 'stopped'
reducer action).
Empty state un-pinned: the composer is now part of the centered welcome
stack (avatar → heading → subtitle → composer → chips) instead of being
pinned to the viewport bottom. Subtitle dropped to 12px. Hero avatar
kept at 126px but with a proportional 40px squircle radius (a flat 17px
read as square at that size).
Chat header sized up to match: 58px avatar (new Avatar 'header' size),
name 18px/600 neutral-200, role 14px. The neutral-500 role/subtitle tone
nudged to #7d7d7d to clear AA contrast on #0a0a0a.
84 unit + 31 functional E2E green; verified both states live.
Co-Authored-By: Claude Fable 5 <[email protected]>
Tablet-width card, size toggles + close in the global header, and the
enlarged dock (56px tiles / 34px glyphs / 11px labels). Captured from a
verified-fresh server; full suite (37) green.
Co-Authored-By: Claude Fable 5 <[email protected]>
Measured panel feedback — four fidelity gaps closed:
1. Size toggles + close moved OUT of the panel card header into the
global chat header (where the reference mounts them), beside a thin
divider. DeviceSizeToggle restyled to 28px p-1.5 rounded-md with
inactive at opacity-30, active at full. The panel card header now
carries only the home/back button + title + status dot.
2. Device-state widths wired (were stuck at 400px). DevicePanel now
manages its own complementary <aside> sized by flex per ?device=:
phone basis-[448px] (400 card), tablet basis-[550px] (502 card),
full basis-0 grow-[4] — fluid, fills the row beside the chat. Verified
live: aside measures 448 / 550 / 1011 at a 1440 viewport. Wrapper
px-3→px-6 (24px gutters); card is now w-full (width driven by aside).
3. The grow animates — transition-[flex-basis,flex-grow] with the
signature --duration-normal / ease-app on the aside.
4. Dock glyphs enlarged: tiles size-12→size-14 (56), glyph 22→34, plus
the 11px label under each tile.
Mount/unmount preserved via transitionend (flex-basis|flex-grow), as is
role=complementary aria-label=Computer, the device-panel-theme testid,
and the mobile full-screen overlay. p3/p4 updated to find the toggle +
close at page scope (now in the header, outside the panel region).
83 unit + 31 functional E2E green.
Co-Authored-By: Claude Fable 5 <[email protected]>
The previous baselines were captured against a stale next-start server
(Playwright reuseExistingServer reused a ghost on :3100 serving the
pre-fix bundle), so they encoded the broken render. Deleted and
regenerated from a verified-fresh server: chat-welcome (coral name, 2×2
pill chips, #1A1A1A composer), computer-home (real Chrome/Slack brand
tiles, coral Claw Chat glyph, frosted dock, icon size-toggle), credits,
marketing, team-orgchart. Full suite (37) green against them.
Co-Authored-By: Claude Fable 5 <[email protected]>
Side-by-side comparison surfaced that several restyle classes silently
generated NO CSS (confirmed against the compiled .next CSS) — invisible
to tests because unit tests assert class strings, not computed styles,
and visual baselines were captured from the broken build.
Root causes fixed:
- rounded-radius-button (51) / rounded-radius-squircle (3) generated
nothing → every pill/button/chip was border-radius:0. Tailwind v4 maps
--radius-button to 'rounded-button', not 'rounded-radius-button'.
Replaced with rounded-full / rounded-[17px].
- duration-normal/fast/slow (58) generated nothing → transitions had
easing but 0 duration (snapped). Replaced with the token-arbitrary
form duration-(--duration-normal). (Also fixed an over-replace that
double-wrapped var(--duration-normal) inside animate-[…] values.)
- HomeScreen.tsx was the OLD emoji version — the R3 rewrite was never
committed. Restored: brand Chrome/Slack squircle tiles, coral
GradientGlyph icons, 56px tiles with press-scale, and the frosted
glass dock (bg-white/[0.07] backdrop-blur-[40px] saturate-150).
Targeted gaps from the feedback:
- raised surface --color-surface-warm-muted #1f1f1f → #1a1a1a (composer,
chips, tabs)
- welcome chips: max-w-[422px] gap-3 → centered 2×2 grid
- composer input + rail nav labels → 14px
- DeviceSizeToggle: Full/Tablet/Phone text → lucide monitor/tablet/
smartphone icons (aria-label keeps the radio names)
- SW cache bumped v1→v2 so redeployed clients purge the stale bundle
- LeftRail drawer: ref-during-render → prev-state pattern (lint)
Compiled CSS now emits .rounded-full, transition-duration:var(--duration-
normal), border-radius:17px, and shadow-dock-capsule. Button/Avatar unit
tests updated to the real class names. 83 unit tests green.
Co-Authored-By: Claude Fable 5 <[email protected]>
The Playwright backend harness now aborts if something else (e.g. the
compose stack) is already on :8080, instead of letting reuseExistingServer
silently point every journey at the wrong backend with the wrong seed —
the failure mode that surfaced mid-restyle. Also gitignore the local
data/ blob-store artifacts.
Co-Authored-By: Claude Fable 5 <[email protected]>
- Regenerated all 7 @visual darwin baselines for the restyled surfaces
(login, workspace home, chat welcome, computer panel, credits) and
added two new ones: the marketing landing (full page) and the Team
org-chart tab.
- Credits visual made deterministic: balance + the whole usage card are
masked (testids), and the runway line always renders so the card
height is constant — shared-backend credit spend during the full run
no longer shifts the diff.
- Full gate sweep green end to end: 173 Rust tests + clippy/fmt clean,
83 frontend unit + lint + typecheck, all 37 Playwright journeys
(functional + visual), axe serious/critical at zero across every
surface, LOC ≤1250 and no-placeholder gates.
The interface now matches the reference design system across the app,
the Computer panel, the global pages, and a new light-theme marketing
site — design system only, our brand throughout.
Co-Authored-By: Claude Fable 5 <[email protected]>
- SlidePanel gains an prop: at ≤md the open Computer panel
becomes a fixed full-screen layer (max-md:!w-full beats the inline
width) instead of a cramped docked rail — the measured mobile/tablet
behavior. The screen card goes full-bleed (no radius/shadow/max-width)
on small screens; desktop docking is unchanged.
- LeftRail collapses behind a hamburger at ≤md and slides in as a drawer
over the content with a backdrop, auto-closing on navigation
(render-phase route check, no effect). Desktop keeps the static 176px
rail (the hamburger and drawer transforms are max-md-scoped, so the
1440 desktop suite is untouched).
83 unit tests; desktop p0/p3/a11y journeys green; verified at 390px the
rail collapses to the toggle.
Co-Authored-By: Claude Fable 5 <[email protected]>
A new (marketing) route group with its own LIGHT layout (slate palette +
marketing coral #E95656 + periwinkle cards on near-white), the inverse of
the dark app. The middleware rewrites '/' → /marketing for logged-out
visitors (keyed on the session cookie / Clerk __session); authed users
fall through to the workspace app unchanged.
Built to the measured marketing system with OUR copy and honest claims —
NO SOC 2 / compliance badges: floating white pill nav, the 75px/800/-3px
display hero with a single coral keyword span, dual pill CTAs (coral
primary / white-bordered secondary), trust badges ('free credits',
'no credit card', 'self-hostable & air-gapped'), periwinkle feature
cards, a structural-security section (approval-gated, broker-held creds,
air-gappable — our real differentiators), an interactive FAQ accordion,
CTA band, and footer.
E2E (p7-marketing): renders for logged-out visitors, FAQ accordion works,
asserts NO SOC 2 claim, and an axe pass (coral CTAs sized to clear the
AA large-text threshold). p0-shell updated: logged-out '/' now shows
marketing with a Sign-in link to /login.
Co-Authored-By: Claude Fable 5 <[email protected]>
Shared PageChrome (28px/600 title + muted desc + top-right action pill)
now fronts every global page. Skills → 2-col r24 Card grid with team
install counts. Credits → three-card layout (balance w/ Buy credits;
usage meter w/ runway; promo; Talk-to-sales → mailto). The Stripe Buy
credits button only mounts when /api/billing/config reports it enabled
(honest degradation) and opens a real Checkout Session.
Team page gains the three reference tabs via SegmentedTabs: Members
(restyled), Claw org chart (real /api/team/orgchart — members grouped
with the claws they manage, each a deep link into chat), and Leaderboard
(real /api/team/leaderboard — claws ranked by usage with a coral bar).
New /apps global page (workspace-wide connections via ?workspace=true):
category pills + SearchPill + 2-col rows with inline API-key connect;
Apps added to the rail nav.
Wizard restyled to the system: coral-fill white-text CTAs with the glow
shadow, coral progress bars, swatch enter animation, system inputs —
all step text/behavior preserved.
83 unit + 29 functional E2E + a11y green; contrast fixed (subtle-fg →
muted-fg on cards).
Co-Authored-By: Claude Fable 5 <[email protected]>
- GET /api/team/orgchart — each member grouped with the claws they manage
(agents.managed_by); GET /api/team/leaderboard — every claw ranked by
its real usage_events rollup (credits/tokens/runs, zeros included).
Both tested against real Postgres.
- Stripe Buy-credits (the Slack/Clerk integration pattern): [billing]
config (stripe keys + price + webhook secret + credits_per_pack);
POST /api/credits/checkout opens a real Checkout Session; POST
/api/billing/stripe verifies Stripe's t=,v1= HMAC (constant-time) and
grants one credit lot, idempotent on the session id; GET
/api/billing/config gates the button (honest degradation when unset).
Offline tests: signed grant + replay no-double-grant + forged-sig 400 +
config flag. Live checkout creation deferred to a CM_LIVE_STRIPE test.
- /apps global page support: clawId now optional on connect + directory;
absent => workspace-wide connection (app_connections.agent_id NULL) via
new connections::list_for_workspace.
- ApiError gains a From<sqlx::Error> so inline queries use ? cleanly.
cm-api 10 test files incl. team_tabs (2) + stripe_billing (3); clippy clean.
Co-Authored-By: Claude Fable 5 <[email protected]>
Panel chrome (CONSOLIDATED-computer-panel-handoff): the 448px docked rail
now hosts a 400×652 r32 'screen' card with the measured wallpaper stack —
135° slate/navy base gradient tinted by the agent accent, a #000/.40
vignette, and a frosted blur(40px) white/5 overlay with grain on top.
Header carries the rose-500 status dot (home) or a back chevron (drilled),
keeping the 'Computer home' / 'Close computer' accessible names stable.
Home screen: 56px tiles with 16px-radius icon containers (Chrome/Slack
brand marks on white squircles, coral-gradient lucide glyphs elsewhere),
11px labels, active:scale-[0.92] press; the frosted dock capsule uses the
exact blur(40px) saturate(1.5) bg-white/7 r24 + inset/drop shadow with
48px black tiles.
App window choreography: opening an app zooms it out of the tapped tile
(transform-origin + --app-origin-scale captured at click time → the
app-shell-zoom-in keyframe), ~0.28s ease-app.
All 8 apps restyled to the inventory spec via shared AppShell helpers
(SubViewHeader, PanelEmptyState = muted lucide glyph + 16/600 + 12 muted):
Files 45px rows w/ folder/file/chevron glyphs; Slack segmented tabs
(active white bg / aubergine text) + cream connect gate; Skills pinned
cream 'Add Skill'; Add Apps category pills + SearchPill + coral connect;
Settings grouped r12 cards + coral radios + cream Save + lucide delete;
Browser/Routines/ClawChat lucide empty states. Every test marker text and
aria-label preserved.
83 unit + 29 functional E2E green; lucide icons throughout (emoji gone).
Co-Authored-By: Claude Fable 5 <[email protected]>
Shell (main-chat-shell-spec): rail rebuilt around a 48px squircle claw
avatar stack with a 2px coral active ring + name beneath + online dot,
dashed 'New claw' tile, lucide-icon nav pills with the live credits
balance shown inline (coral when negative, aria-hidden so the nav link
name stays 'Credits'); 80px transparent chat header with 36px round
sessions/new-session icon buttons and the 42px coral-glow Computer
launcher; 768px centered content rail. RosterList computes the active
claw from the pathname so AgentRosterItem stays presentational.
Chat (chat-message-components): asymmetric layout — user pill #1A1A1A
radius 24/24/4 with inset white ring + dual shadow, capped 75%, vs the
bubble-less assistant message (50px squircle avatar, 15px gap, plain
14px/1.7 text); messageSlideIn entrance; 126px welcome avatar with the
24px/600/-0.6px heading (claw name in coral) and lucide-led suggestion
chips; floating 24px-radius neutral-800 composer card with the cream
Send pill; StepTrace rows restyled to the system.
Brand marks (Chrome, Slack) vendored to public/services from the bundle.
83 unit tests, full 29-journey functional E2E green.
Co-Authored-By: Claude Fable 5 <[email protected]>
Made the Docker Compose route turn-key for a real self-host, then stood
the whole stack up and drove a live chat through it.
- First-owner bootstrap (cm-auth::bootstrap_owner): a fresh local-auth
install has no users and no signup route, so the initial Owner +
workspace are provisioned ONCE from CLAWMATES_BOOTSTRAP_* env on first
boot — idempotent, never clobbers an existing install (keys on 'any
workspace exists'). Two real-Postgres tests (creates + signs in;
second call is a no-op). Wired into server boot, guarded on a
non-empty password
- deploy/compose/README.md: full production bring-up — services, the
security topology, every config knob, Anthropic vs local-LLM, the
broker-key backup, ops, and TLS/SSE proxy notes
- .env.example fleshed out (bootstrap, LLM, auth mode, OTLP); compose
uses optional env_file so only the knobs you set are injected (unset
options never override clawmates.toml with empty strings)
- volume-init one-shot chowns the broker's named volumes so the non-root
scratch broker can write its socket + generated master key
Deployed locally and verified end to end: all 5 containers healthy,
broker generated its key, server bootstrapped owner@…, login + /api/user/me
work, and a real message streamed a live Anthropic response through the
gateway. Captured screenshots of login, workspace home, chat, and the
Computer panel.
166 Rust tests (+2 bootstrap).
Co-Authored-By: Claude Fable 5 <[email protected]>
Last open item from the roadmap + post-1.0 list. Run against the live
Clerk instance closing-seasnail-39.clerk.accounts.dev.
- Backend (crates/cm-auth/tests/live_clerk.rs, CM_LIVE_CLERK=1): pulls
REAL discovery + JWKS from the live instance, mints a REAL session JWT
via Clerk's Backend API (create user -> open session -> session token),
and runs it through AuthService::authenticate — verify + JIT provision
(keyed on the real sub), duplicate-subject suppression, tamper
rejection against the live JWKS. Decodes the instance domain from the
publishable key; cleans up the test user after. PASSING
- Frontend: built with AUTH_MODE=clerk + real keys, next start serves
Clerk's <SignIn /> at /login wired to the instance (instance domain +
data-clerk attributes present in the HTML). Both halves confirmed
end to end against production Clerk
- docs/clerk.md: documented the smoke procedure for both halves
166 Rust tests (+6 live, key-gated). Keys used via env only, never
stored — rotate them (they passed through chat).
Co-Authored-By: Claude Fable 5 <[email protected]>
- scripts/netpol-cluster.sh: a kind cluster with Calico (default CNI
disabled) — the only way to PROVE the §15 default-deny NetworkPolicy,
since kindnet accepts the object but never enforces it. New live test
on that cluster: outbound connect to 1.1.1.1 dropped, DNS egress
dropped, while API-server exec keeps working (not pod network).
Kernel-level enforcement of the sandbox egress claim, demonstrated
- K8sDriver::connect_with_context: pin a kubeconfig context instead of
ambient. The whole k8s suite now pins its cluster explicitly — the
netpol cluster's creation had silently switched the current context
and stranded the seccomp test on the wrong cluster (fixed and made
impossible to recur)
- CI: netpol-cluster up + calico egress test in the sandbox-k8s job
- Visual-regression lock (plan P6): @visual Playwright spec with
animation-disabled, masked-dynamic-region screenshots of login,
workspace home, chat welcome, computer panel, credits; darwin
baselines committed (5 PNGs); CI excludes @visual until linux
baselines are generated there. Full local suite: 33 journeys
165 Rust tests + 5 live kind tests (2 clusters) + 33 journeys.
Co-Authored-By: Claude Fable 5 <[email protected]>
Running the opt-in live suite (CM_LIVE_LLM=1 + ANTHROPIC_API_KEY) against
the real API immediately surfaced a launch blocker: Anthropic (and
OpenAI) restrict tool names to ^[a-zA-Z0-9_-]{1,128}$ — our ENTIRE
registry uses dotted names (clock.now, email.send, shell.exec, ...).
The scripted provider never enforced the pattern, so every real-model
deployment would have 400'd on the first tool call.
- Fix at the provider boundary, where it belongs: wire_tool_name /
internal_tool_name codec (dots <-> __) applied in BOTH HTTP providers
at all three sites (tools list, assistant tool_use echo, inbound
tool_use decode). Internal naming (DB step rows, scenarios, UI traces)
unchanged. Offline unit test round-trips every registry name through
the wire pattern
- New live tests, all passing against api.anthropic.com (Haiku 4.5):
- provider tool ROUND TRIP: real ToolUse arrives, ToolResult ships
back exactly as a checkpoint would reassemble it, model completes,
real usage events on the wire
- full runtime loop: real model calls clock.now, run completes, REAL
token usage metered, credits decremented
- the #1-risk validation: a real model's email.send intercepted ->
suspended -> approved -> checkpoint RESUMED against the live API ->
completed -> outbox exactly 1 (checkpoint/resume fidelity end to end)
- Stray TC_OPENAI_COMPAT_* envs renamed to CM_OPENAI_COMPAT_*
No credentials stored anywhere; the key was passed via env only.
163 Rust tests (+5 live, key-gated).
Co-Authored-By: Claude Fable 5 <[email protected]>
Full-depth rename per the approved plan; the 'claw' product vocabulary
(claws, /claws routes, clawId, Claw Chat) stays — it is now the brand.
- Display brand: Clawmates (manifest, titles, hero, login/rail logo
'clawmates'); default host app.clawmates.work; registry
ghcr.io/clawmates
- Crates tc-* -> cm-* (16 crates + all imports); binaries
clawmates-server/broker/bundler; images clawmates/*; env prefix
CLAWMATES_* (+ CM_TEST_DATABASE_URL / CM_LIVE_LLM); config
clawmates.toml; helm chart deploy/helm/clawmates with clawmates-*
resources; db names clawmates*; sockets /run/clawmates; cookie
cm_session; kind cluster clawmates-test; seccomp node profile
clawmates-agent-profile.json
- All 9 Playwright brand assertions updated in lockstep; historical
spec document left untouched as the only remaining 'TeamClaw'
- Local env migrated: dev pg clawmates-dev-pg/clawmates_dev, shared
test server clawmates-test-pg, kind cluster recreated with image +
profile, compose images rebuilt under clawmates/*
Verified end to end: 161 Rust + 68 frontend tests, 29 Playwright
journeys, 4 live kind tests, helm/install/LOC/placeholder gates, and
the clean-room install rehearsal serving the clawmates login page from
a signed bundle of the rebuilt images.
Co-Authored-By: Claude Fable 5 <[email protected]>
- tc-telemetry: fmt subscriber always; with [telemetry] otlp_endpoint
set, spans batch-export over OTLP/HTTP. Tested against a REAL OTLP
receiver decoding the actual protobuf (official proto types): the
emitted span and service.name arrive on the wire. No endpoint = no
export = no network (air-gap stance). tower-http TraceLayer gives
every API request a span
- The broker finally has its own image (images/broker.Dockerfile,
9.5MB from scratch) — the Helm chart referenced one that never
existed — and the compose deployment now RUNS the broker, sharing a
socket volume with the server (the unix-socket equivalent of the K8s
sidecar). Compose secret flows were silently dead before this
- server.Dockerfile fixes surfaced by the rehearsal: the workspace
build needs tools/ (bundler joined the workspace) and
images/seccomp/ (include_str! profile) in the build context
- scripts/rehearse-install.sh (plan: clean-VM rehearsal): assembles a
REAL signed bundle from the built images (server/frontend/broker/
postgres/socket-proxy), runs the customer path — offline verify,
docker load, compose up — and asserts /healthz plus the served login
page before teardown. Passing locally; wired as a release.yml step,
which also builds/ships the broker + socket-proxy images now
161 Rust tests + 29 journeys; clean-room rehearsal green.
Co-Authored-By: Claude Fable 5 <[email protected]>
- K8sDriver.with_localhost_seccomp(profile): sandbox pods run under the
STRICT allowlist instead of the runtime default. Proven live on kind:
the harness installs the profile onto the node, a pod runs ordinary
work as uid 10001, and unshare is kernel-denied inside the pod — the
same probe the Docker suite uses, now passing on both targets
- Helm: sandbox.seccomp=localhost renders a DaemonSet that installs the
chart-shipped profile into /var/lib/kubelet/seccomp on every node
(ConfigMap + hostPath); ci/check-helm.sh enforces the chart copy stays
byte-identical to images/seccomp/agent-profile.json and asserts the
hardened render (DaemonSet + profile + HPA)
- server HPA (autoscaling/v2, CPU target) behind
server.autoscaling.enabled
- SandboxManager.warm(n): a background warmer keeps n pre-provisioned
sandboxes ready so an agent's first exec skips container startup;
unhealthy pool entries are discarded, reuse never drains the pool,
shutdown destroys assigned AND pooled. [sandbox] warm_pool config
(default 0). Real-Docker test: prefill -> assign -> refill -> reuse ->
clean shutdown
160 Rust tests + 4 live kind tests.
Co-Authored-By: Claude Fable 5 <[email protected]>
- The compose server NEVER sees the raw Docker socket (plan risk #5):
tecnativa/docker-socket-proxy on an isolated engine_net with exactly
CONTAINERS/POST/EXEC/DELETE/VERSION allowed; server reaches it via
DOCKER_HOST. DockerDriver honors DOCKER_HOST (connect_to). Proven by a
REAL proxy test: full sandbox lifecycle works through the allowlist
while /networks, /secrets, and /images all 403 — the blast-radius cap
if the server is ever owned. (This also fixes compose deployments,
where sandbox provisioning previously had no engine access at all.)
- Gateway load test (plan P6): 40 concurrent SSE streams against one
server — every run completes with the full §13 event vocabulary,
every journal strictly monotonic, every resumeFrom=0 replay byte-equal
to its live stream
- release.yml: SBOMs (syft, spdx-json) for all four images shipped
INSIDE the signed bundle; final verification now runs in a
--network none container — proving the customer's verify path needs
no internet, not just claiming it
159 Rust tests.
Co-Authored-By: Claude Fable 5 <[email protected]>
- src/lib/auth/bearer.ts is the single identity dispatch for both
server-side token consumers (RSC apiFetch and the /api proxy route):
local -> httpOnly tc_session cookie; clerk -> Clerk getToken() session
JWT. The Clerk SDK is imported lazily, so the air-gapped/local path
never loads it
- Runtime env (AUTH_MODE / CLERK_PUBLISHABLE_KEY / CLERK_SECRET_KEY),
deliberately NOT build-time NEXT_PUBLIC_*: the same standalone image
serves both deployment targets
- Conditional <ClerkProvider> in the root layout (publishableKey passed
at render from runtime env); /login renders Clerk's <SignIn /> in
clerk mode and the local form otherwise; proxy.ts middleware delegates
to clerkMiddleware() only when active
- Helm: frontend deployment injects the Clerk keys from a Secret when
auth.mode=clerk
- mode.ts unit-tested (default local, exact-match clerk, loud failure
without the publishable key); the local path stays proven by all 29
journeys; the Clerk branch is thin delegation to the SDK, exercised in
deployment smoke per docs/clerk.md
157 Rust + 68 frontend tests + 29 Playwright journeys.
Co-Authored-By: Claude Fable 5 <[email protected]>
- tc-auth JwtVerifier: OIDC discovery -> JWKS, RS256 with the issuer
pinned, 5s leeway (the crate's default 60s would double the life of
Clerk's 60s session tokens), key cache with one refresh on unknown kid
(Clerk rotates). Serves auth.mode = clerk AND generic oidc — a Clerk
instance IS an OIDC issuer, so one verifier covers both
- AuthService.authenticate dispatches: JWT-shaped bearers take the
hosted-identity path, everything else stays a local opaque session.
External users JIT-provision keyed by the stable sub claim
(users.auth_subject, unique partial index in migration 0007); an
existing local account with the same email is LINKED, not duplicated;
role tracks the issuer claim every request (org:admin -> Owner)
- Config auth.mode = "clerk" (requires issuer_url; validated), server
pins the issuer at boot, Helm values/configmap accept mode=clerk
- Tests with REAL crypto, no mocks: fresh RSA keypairs, a live local
issuer publishing real discovery + JWKS docs, Clerk-shaped tokens —
JIT + role mapping, repeat-subject no-dup, expired refused (leeway
regression), wrong-key forgery refused, foreign issuer refused, and
the full router round trip with Authorization: Bearer <session JWT>
- docs/clerk.md: dashboard session-token customization (email + org
role claims), config, @clerk/nextjs getToken() wiring, what CI proves
157 Rust + 63 frontend tests + 29 journeys. Air-gapped installs keep
local auth — Clerk is a cloud-only alternative, not a replacement.
Co-Authored-By: Claude Fable 5 <[email protected]>
- PWA (§16): hand-rolled 60-line service worker (network-first pages with
offline fallback, cache-first hashed statics, /api NEVER touched — SSE
and approvals stay live), app manifest with §2 identity, stdlib-
generated coral claw icons, prod-only registration. E2E asserts
manifest, real PNG icons, an ACTIVATED service worker, and the /api
bypass. (Serwist was tried and dropped: its webpack plugin fights
Next 16's Turbopack builds; sixty lines we own beat a plugin we fight.)
- Route motion (§3): (workspace) template re-mounts per navigation with a
quiet fade-rise, zeroed under prefers-reduced-motion. The a11y sweep
now settles running animations before scanning — axe was reading
mid-fade opacity as contrast failures
- OAuth browser flow vs REAL dex: the e2e harness boots dexidp/dex with
static client + password; the journey drives the actual dex login form
from /api/apps/oauth/start through the callback 303 and asserts the
app reads connected (closing the P4 deferral honestly)
- release.yml: tag-triggered — builds all four images + postgres, saves
tarballs, assembles the SIGNED air-gapped bundle (compose, config,
migrations, seccomp profile, installer, bundler binary), derives the
public key via the new Could not find command "pubkey". subcommand (tested), verifies
the bundle customer-style with the public half only, attaches tarball
+ public key to the GitHub release
153 Rust + 63 frontend tests + 29 Playwright journeys.
Co-Authored-By: Claude Fable 5 <[email protected]>
- SandboxSpec gains an egress flag (default false — the kernel suite
still proves zero-network for agent sandboxes). Egress-enabled
containers exist ONLY for the browser: no credentials, no broker
route, bridge network with host-gateway alias for local test pages
- images/agent-browser: Alpine Chromium, uid 10001, setuid bits
stripped — same non-root hardening as agent-base
- browser.goto tool: headless chromium --dump-dom in the agent's
browser container; HTML stripped to readable text (4k cap) and
returned with output_taint=web; viewport screenshot captured,
base64'd out of the container, stored in the blob store
- Taint semantics tightened: the step that PRODUCED untrusted output
now carries its own taint (recorded before the step row), not just
later steps — chat.inbox test updated to the stricter §15 reading
- GET /api/claws/{id}/browser/viewport.png serves the latest capture;
BrowserApp polls it and renders the live viewport (spec §7.1),
keeping the empty state until the agent has browsed
- Proven end to end with REAL Chromium against a REAL local page:
content 'Revenue up 14 percent' returned tainted web; the gated
email.send that follows carries 'web' in its approval taint_sources
(untrusted content can never quietly reach outward); screenshot
verified by PNG magic bytes
152 Rust tests + 63 frontend + 27 Playwright journeys.
Co-Authored-By: Claude Fable 5 <[email protected]>
- SandboxManager (tc-runtime): one container per agent, provisioned
lazily on first use, reused for the manager's lifetime, replaced
transparently if dead, destroyed on shutdown
- shell.exec tool: sh -lc inside the agent's sandbox; stdout/stderr/
exit_code return to the model as the step output. No external effects
declared — the sandbox boundary (uid 10001, no caps, seccomp
allowlist, read-only rootfs, zero egress) is the §15 control here,
not an approval gate
- RuntimeConfig.sandboxes (+ with_sandboxes builder); [sandbox] config
{image, enabled}; the server connects the Docker driver at boot and
tolerates an absent engine (shell.exec reports it per-call)
- Tests with the REAL DockerDriver: a scripted run executes two
commands — output proves uid 10001 from inside, and /home/agent state
written by the first call is read by the second (same sandbox); a
deployment without a sandbox runtime records honest error steps and
the run still completes
151 Rust tests + 27 Playwright journeys.
Co-Authored-By: Claude Fable 5 <[email protected]>
- images/seccomp/agent-profile.json is now a TRUE ALLOWLIST: Docker's
default profile (vendored from moby v27.5.1, defaultAction ERRNO) with
18 syscalls an agent never needs stripped from the allow groups
(unshare, ptrace, bpf, mount family, setns, module loading,
perf_event_open, process_vm_*, reboot, quotactl, ...); arch map trimmed
to x86_64 + aarch64. All 6 Docker kernel assertions still green.
- K8sDriver (tc-sandbox feature 'k8s', kube-rs): one hardened pod per
sandbox — runAsUser 10001, cap-drop ALL, no-new-privs via
allowPrivilegeEscalation=false, RuntimeDefault seccomp, read-only
rootfs with emptyDir /tmp + /home/agent, resource limits, no service
account token — in a PSS-restricted namespace carrying a default-deny
NetworkPolicy (applied server-side apply, idempotent). Exec via the API
server attach channel with exit codes parsed from v1.Status.
- Live suite (feature 'k8s-tests') against a REAL kind cluster: uid /
CapEff==0 / NoNewPrivs / rootfs probes from inside pods, PSS label +
deny-all policy asserted via the API, lifecycle. Honest limits in the
rustdoc: Localhost seccomp profile and CNI-enforced egress are
per-cluster provisioning (kindnet does not enforce NetworkPolicy).
- rustls 0.23 process provider pinned to ring at driver connect.
- CI: dedicated sandbox-k8s job (helm/kind-action) running the suite.
149 Rust tests + 3 live kind tests; clippy clean including the k8s feature.
Co-Authored-By: Claude Fable 5 <[email protected]>
- S3BlobStore (object_store, path-style) behind the same BlobStore trait,
tested against a REAL MinIO container (round trip, overwrite, NotFound
on get and delete, nested keys); [storage] backend=local|s3 config with
validation + server-side selection (S3 creds via env overlay)
- Helm chart: server pod with the secret broker as a SIDECAR sharing a
private emptyDir unix socket (no network hop carries credentials),
frontend, optional local PVC vs S3, OIDC/oauth values, unbuffered-SSE
ingress annotations, NetworkPolicies (frontend->server only), hardened
securityContexts; ci/check-helm.sh lints AND asserts the rendered
topology properties
- deploy/airgapped/install.sh: offline signature+checksum verification via
the bundled teamclaw-bundler BEFORE any docker load; --verify-only mode;
ci/test-install.sh rehearses clean/tampered/wrong-key paths with the
real binary
- CI: helm gate + installer rehearsal wired in
149 Rust tests; helm lint + rendered assertions green; installer
verify-path rehearsal green.
Co-Authored-By: Claude Fable 5 <[email protected]>
- Concurrency soak (exit criterion): 12 concurrent gated runs, every
decision attempted twice concurrently, explicit resumes racing the
durable sweeper — exactly one execution per approval, grants consumed
at most once, every decision audited, zero stuck runs, zero unaudited
executions. (Testkit pool raised to 20 connections; the 5-connection
pool starved the storm.)
- axe a11y sweep (exit criterion): serious+critical violations fail CI on
login, shell, chat, computer home, settings app, all global pages, and
the wizard. Two real violations found and fixed: aria-label on a plain
div (wizard progress -> role=group) and a button directly inside a <dl>
(settings -> plain bordered list).
- tools/bundler (exit criterion): keygen / assemble / verify CLI — copies
artifacts, writes manifest.json + sha256 checksums.txt + a detached
ed25519 signature; verification is fully offline (keyless signing is
internet-dependent and disqualified). Tests: round trip, tampered
artifact caught by hash, tampered checksum list caught by signature,
wrong key refused, missing artifact reported.
147 Rust + 63 frontend tests + 27 Playwright journeys (incl. 4 a11y).
Co-Authored-By: Claude Fable 5 <[email protected]>
- LlmEvent::Usage across all three providers (Scripted deterministic
word-count accounting; Anthropic message_start/delta usage; OpenAI-compat
stream_options include_usage)
- tc-billing: ceil(tokens/1000) min 1 credit; lots drain oldest-first under
FOR UPDATE; balance clamps at zero while the usage ledger records the
full obligation; promo codes redeem exactly once via CAS (migration 0006)
- Runtime charges every completed run (billing failure never fails a run);
proven: 1 token in + 3 out -> 1 credit deducted
- API: GET /api/team/usage, POST /api/credits/redeem (409 on reuse, audited)
- Credits page: balance, 7-day usage meter with runway estimate, PromoRedeem
- /claws/new is the full §9 wizard: ?step=identity|access|slack deep-linked
progress, accent swatches + name randomizer, access toggles, optional
Slack step, explicit review-and-confirm (creation = live agent), animated
provisioning state -> straight into chat
- E2E: chat decrements the visible balance and fills the usage meter;
WELCOME500 adds exactly 500 once then refuses; wizard round trip
140 Rust + 63 frontend tests + 23 Playwright journeys.
Co-Authored-By: Claude Fable 5 <[email protected]>
- migration 0005 oauth_states: one-time states (10-min TTL), consumed by a
CAS DELETE on callback — replays and forgeries both 404
- POST /api/apps/oauth/start: OIDC discovery on the configured issuer (or
the custom MCP issuer for authType=mcp_oauth), state row, authorize URL
- GET /api/apps/oauth/callback: code exchanged at the REAL token endpoint
(client id+secret form POST); the access token goes straight to the
broker (test proves it never appears unencrypted in Postgres); connection
row + audit; redirects to the claw's Add Apps panel
- [oauth] config (issuer/client/redirect_base) wired through AppState
- Tests against a real local IdP server (discovery + validating token
endpoint): full round trip, broker-held token, replay/forged state
refused, bad code fails exchange, mcp_oauth uses the custom issuer while
plain oauth refuses without a configured IdP
- AddAppsApp: live connection badges + inline API-key connect per app
(E2E: connect Notion by key from the directory)
136 Rust + 63 frontend tests + 21 Playwright journeys.
Co-Authored-By: Claude Fable 5 <[email protected]>
- Broker op VerifySlackSignature: v0 HMAC-SHA256 computed INSIDE the broker
(constant-time compare); the signing secret never crosses the socket.
Slack secrets are one JSON credential {bot_token, signing_secret}; the
broker extracts the right field per operation
- Public POST /api/slack/events: signature verified against connected slack
connections via the broker; forged signatures 401; url_verification
handshake echoed only when signed; app_mention starts a real run in the
agent's dedicated '💬 Slack' session — and the agent's reply is itself a
gated outbound post
- SlackApp Connection tab captures bot token + signing secret
- Integration test: forged 401, signed challenge, signed mention -> run ->
slack.post pending in the approval queue
- E2E: full loop — connect, gated outbound (sink empty -> exactly one post),
then a node-crypto-signed mention -> approval card -> approve -> 'On it!'
lands in the sink
134 Rust + 63 frontend tests + 21 Playwright journeys.
Co-Authored-By: Claude Fable 5 <[email protected]>
- app_connections repo; POST /api/apps/connect (keys/basic): the credential
goes to the secret broker over its socket and only the encrypted ref lands
in the row; disconnect endpoint; /api/apps directory merged with live
connection status; audit rows for connect/disconnect
- Broker protocol: InvokeHttp carries a JSON body
- slack.post tool (SendsExternally -> gated): marked broker_executed — the
runtime skips its own grant consumption and the BROKER independently
verifies + consumes the single-use grant, then calls Slack with the bot
token injected; the runtime never sees the credential
- Config: [broker] socket_path + [slack] base_url; e2e harness spawns the
real teamclaw-broker daemon and the server hosts an e2e-only /__slack sink
- SlackApp: Connection tab stores the token via the broker; connected state
- Integration test: blocked while pending -> approved -> sink received
exactly one post with 'Bearer xoxb-test-token' -> grant replay refused
- E2E journey: connect Slack in the panel -> gated post card with preview ->
sink empty while pending -> approve -> exactly one post, queue clear
133 Rust + 63 frontend tests + 21 Playwright journeys.
Co-Authored-By: Claude Fable 5 <[email protected]>
- tc-db: sessions/messages/steps/runs/run_events repos (atomic seq assignment,
history with ordered step traces, journal replay-from-offset); migration 0003
- tc-llm: provider-neutral ChatRequest/LlmEvent; ScriptedProvider (scenario
TOML, word-level deltas, multi-turn tool legs — ships in production for
e2e/air-gap smoke), AnthropicProvider (Messages SSE), OpenAiCompatProvider
(vLLM/Ollama/llama.cpp); opt-in live tests via TC_LIVE_LLM=1
- tc-runtime: run loop with persist-before-emit event journal, real built-in
clock.now tool, step rows on the reply message, tool-error resilience,
broadcast channels for live attach
- tc-api: agent CRUD + settings/full (tenant-isolated, RBAC'd, audited),
sessions create/list/history?tools=true, POST /api/gateway SSE with
monotonic ids and exact resumeFrom journal replay (tested equal to live)
- teamclaw-server: config-driven provider factory
83 Rust tests green, all against real Postgres / real TCP.
Co-Authored-By: Claude Fable 5 <[email protected]>