This repo is the running sample project for Claude at Work (Manning), by Elton Stoneman.
It's a deliberately realistic codebase: a greenfield product with real design documents, real architectural decisions, and real recurring maintenance work — the kind of project you'd get Claude Code to build.
CollabHub is the product being built: a team collaboration platform that pairs a Slack-like chat experience with a Figma-like collaborative canvas in a single app.
Early-stage. Auth is complete, including federated sign-in through Dex.
Messaging has its core: channels with administration and membership,
messages with history, editing and deleting, and the Socket.IO /messaging
namespace carrying live delivery, the write path and typing indicators. Threads,
reactions, read receipts and search are not built — doc 02
§3.1.5 lists every endpoint the design
names and the service does not implement, with the reason for each. Canvas,
Asset and Worker are still scaffold — a process that reads its configuration and
answers /health/live and /health/ready, with no models, migrations, routes
or job handlers. The SPA has sign-in, a workspace switcher and the whole chat
shell. Chapters of the book add code, configuration and automation to this repo
as they go.
The open-decisions register is the
list of what is settled and what is not. It is maintained as decisions are
made, and this README points at it rather than keeping a second copy that can
disagree with it — which is exactly what happened before. Every significant one
also has an ADR in docs/adr/.
Three are worth knowing before reading any code, because they constrain
deployment or the shape of everything above them. D22 — the browser keeps its
refresh token in an HttpOnly; Secure; SameSite=Strict cookie, so the SPA stores
nothing at all and the SPA and API must be deployed same-site
(ADR). D5 — Auth
federates to an upstream IdP and is never an OpenID Provider itself
(ADR). D2 — an
access token is scoped to exactly one workspace, so switching is a token
exchange (ADR).
Still open and worth knowing about: D28 — there is no user-preferences feature anywhere, which is why the SPA is light-only rather than following the OS; and D16 — retention values are unset, so nothing hard-deletes anything.
collabhub-shared carries the cross-cutting layer so the next service inherits it: RFC 7807
Problem Details, UUID v7, JWKS-backed verification, the token denylist, cursor pagination and
CORS. The job envelope and the ObjectStore protocol are still to come, and
collabhub-contracts is still empty.
docs/design/00-platform-conventions.md |
Start here. The cross-service contract every service doc builds on. |
docs/design/07-open-decisions-register.md |
What is still undecided, and what has been settled. |
docs/adr/ |
One file per significant decision, with the rejected options. |
docs/platform/versions.md |
Tracked upstream versions for every platform component. |
Each component documents itself — how to run it, what it owns, and the rules that are easy to get wrong:
| Component | Design doc | Service README |
|---|---|---|
| Auth | 01 | src/services/auth |
| Messaging | 02 | src/services/messaging |
| Canvas | 03 | scaffold |
| Asset | 04 | scaffold |
| Worker | 05 | scaffold |
| Frontend SPA | 06 | src/frontend |
Five backend services, each independently deployable with its own database and its own container image, plus a single-page frontend:
| Component | Role |
|---|---|
| Auth service | Identity, JWT issuance, workspace membership — the authorization source of truth |
| Messaging service | Channels, threads, messages, reactions; real-time delivery |
| Canvas service | Collaborative design documents — CRDT relay, presence, snapshot persistence |
| Asset service | File uploads and downloads via presigned object-storage URLs |
| Worker service | Headless background jobs: indexing, thumbnails, notifications, exports, retention |
| Frontend SPA | React + TypeScript client for both chat and canvas |
Stack: Python 3.12 · FastAPI / Uvicorn · SQLAlchemy + Alembic · Socket.IO · PostgreSQL · Redis (cache, real-time backplane, and job streams) · Elasticsearch · S3-compatible object storage (Garage) · Dex · Kubernetes · OpenTelemetry into the Grafana LGTM stack.
Python was chosen over .NET — see ADR 260708 for the reasoning.
docs/design/ Architecture and per-service design docs
docs/adr/ Architecture Decision Records
docs/platform/ versions.md — tracked upstream versions
docs/plans/ Strategy, delivery and per-slice implementation plans
src/services/ shared, contracts, and the five backend services (one uv workspace)
src/frontend/ React + TypeScript SPA (Vite)
tests/bdd/ The Gherkin acceptance suite — it spans every service, so it lives here
docker/ One folder per component: its Dockerfile and any files it needs
charts/collabhub/ Helm chart covering every component
charts/collabhub-deps/ Data stores, Dex, Secrets and Dex's Ingress for any environment; credentials required
scripts/ build.sh, test.sh, deploy.sh — the same entry points CI uses
.claude/skills/ Project skills: adr-writer, stack-update-checker
Prerequisites: uv, Node 24, Docker. Deploying to Kubernetes also needs k3d, Helm and kubectl.
cp .env.example .env # nothing in it is a real secret
scripts/test.sh # lint, unit, then integration (integration needs Docker)scripts/test.sh is the one entry point for checks, and CI runs it too. Name layers to run
fewer — lint is ruff, the CollabHub convention checks, eslint, tsc and helm lint; unit
needs no Docker; integration starts real Postgres, Redis and Dex containers (Conventions
§11). Anything after -- goes to pytest:
scripts/test.sh unit # fast, no Docker
scripts/test.sh lint unit -- -x # stop at the first failure
scripts/test.sh e2e # the browser journeys, on a stack it starts and stops
scripts/test.sh all # every layer, e2e included — what CI runsThe no-argument run is lint unit integration. The e2e layer is slower — it builds and
starts a whole Compose stack — so it runs when named, or as part of all (see
Acceptance tests).
Every run rewrites docs/test-report.md after each layer, pass or
fail: layer results, failures with their messages, the journeys feature by feature, and
tests and unit coverage per service. Commit it with the change it describes. A layer you
did not run keeps its last result, marked -- says so.
Bring up the full stack — Postgres, the three Redis instances, Dex, Garage, Elasticsearch, the OTel collector, all five services and the SPA:
docker compose up --buildThen open http://localhost:5173 and sign in as ada@collabhub.dev with the password
collabhub (or grace@, or alan@). Health checks are on :8001 Auth, :8002
Messaging, :8003 Canvas, :8004 Asset, :8005 Worker.
Every account owns a personal workspace and belongs to the shared CollabHub Demo workspace. Two people only see each other's channels in the shared one, so switch to it in the sidebar before trying anything with a second user.
Auth and Messaging have APIs — see their READMEs (auth, messaging) and their interactive docs at http://localhost:8001/docs and http://localhost:8002/docs.
The Gherkin suite in tests/bdd/ drives a real browser through the whole stack.
It truncates the messaging tables before every scenario — scenarios like
"Ada's channel list is empty" cannot pass with real channels in the workspace —
so it runs against a throwaway stack, never the one you demo on.
docker-compose.test.yml is an override that gives that stack its own Compose
project name — and with it its own volumes — and moves the seven ports anyone
reaches from the host. It runs alongside your development stack, so you can
leave that up and keep working.
scripts/test.sh e2e does the whole round trip: it brings the test stack up and
waits until it is healthy, installs Chromium for Playwright if it is missing, runs
the suite, and takes the stack down again whether the scenarios passed or not. It
exits with pytest's status. It needs Docker and a .env (cp .env.example .env).
scripts/test.sh e2e # up, run, down
scripts/test.sh e2e -- --headed -k channels # watch it drive the browser
KEEP_STACK=1 scripts/test.sh e2e # leave the stack up afterwardsKEEP_STACK=1 is for iterating on steps: with the stack left up, rerun just the
suite with uv run pytest tests/bdd -m bdd, which skips the build. What the script
does, by hand:
docker compose -f docker-compose.yml -f docker-compose.test.yml up -d --build --wait
uv run playwright install chromium
uv run pytest tests/bdd -m bdd
docker compose -f docker-compose.yml -f docker-compose.test.yml down| development | test | |
|---|---|---|
| SPA | 5173 | 5183 |
| Auth | 8001 | 8011 |
| Messaging | 8002 | 8012 |
| Dex | 5556 | 5566 |
| Postgres | 5432 | 5442 |
| Asset | 8004 | 8014 |
| Garage (S3) | 3900 | 3910 |
Nothing else is published on the test stack — Canvas, Worker, Elasticsearch and the OTel collector still run, they are just reached over the Compose network by name. Asset and Garage are published because the browser calls them directly (the upload handshake and the presigned PUT/GET). Both stacks running means two of everything, Elasticsearch included. The test stack runs Elasticsearch and the Worker anyway, because the search journey needs both: the Worker indexes a posted message and Messaging searches the index.
--wait only means something for containers with a healthcheck, which is why the
frontend has one: it probes /, the same path the Helm chart's probes use.
Addressing only test-stack ports is also the safety interlock. The suite truncates over 5442, so run against a machine with just the development stack up it fails to connect rather than deleting your channels — it cannot tell the two apart by looking, because they are the same images.
down without -v keeps the development stack's volumes. The test stack's are
dropped by the script itself before each run.
The suite runs against the test stack's frontend container, not npm run dev — in dev, React's StrictMode fires the session restore twice, which spends
the rotating refresh token twice and signs the user out.
For frontend work where you are not running the acceptance tests, the Vite dev server is faster than rebuilding the Nginx image:
cd src/frontend && npm install && npm run devThe SPA's Messaging types are generated from that service's OpenAPI document (register D23). After changing a Messaging route or schema:
uv run python -m messaging.openapi > src/frontend/openapi/messaging.json
cd src/frontend && npm run generate:apiGarage will not serve S3 traffic until a cluster layout is assigned, and has no bucket, key or
CORS rules. docker compose up runs a one-shot garage-bootstrap service that does all of it
(layout, the collabhub-assets bucket, the app's key, and CORS for GARAGE_CORS_ORIGINS) and
exits; asset waits for it. It is the same script the test stack, k3d and the integration tests
run, and it is safe to run again.
If your .env predates attachments, update it. Garage only imports keys in its own format
(GK and 24 hex characters, then 64 hex), so the old collabhub-local key cannot be used:
take the new OBJECT_STORE_* values, OBJECT_STORE_PUBLIC_ENDPOINT, GARAGE_ADMIN_TOKEN,
GARAGE_CORS_ORIGINS, MESSAGING_SERVICE_CLIENT_* and AUTH_SERVICE_CLIENTS from
.env.example (or cp .env.example .env). ASSET_INTERNAL_URL is now Asset's origin, with no
/api/v1/internal path. A Garage volume made with the old key keeps it: docker compose down -v starts it fresh.
The k3d deployment below does the same for you, and routes Garage's S3 API on s3.<host> so
the browser can PUT to a presigned URL. On the lab, scripts/deploy.sh infra now also needs
--set-string messagingClientSecret=....
scripts/build.sh and scripts/deploy.sh build the images and install the Helm chart into a
local k3d cluster — the same scripts CI will run:
scripts/test.sh && scripts/build.sh --push && scripts/deploy.shThen open APP_ORIGIN — http://localhost:8080 by default — and sign in as
ada@collabhub.dev / collabhub, as above. Everything is on that one origin: the SPA and
every public API routed by charts/collabhub's Ingress, and Dex by the dependency chart's,
both behind the Traefik Ingress k3s ships with.
scripts/build.sh [component...] [--push] |
Builds localhost:5500/collabhub/<component>:<tag>. The tag is the short commit SHA, plus -dirty for uncommitted changes |
scripts/deploy.sh cluster |
Creates the collabhub k3d cluster (k3s pinned to versions.md) and its registry on localhost:5500 |
scripts/deploy.sh infra |
Installs KEDA (pinned to versions.md), then charts/collabhub-deps with values-k3d.yaml: Postgres, three Redis, Elasticsearch, Garage (bootstrapped), Dex, the OTel collector, the Secrets and Dex's Ingress |
scripts/deploy.sh app |
Installs charts/collabhub at the current tag. Migrations run as Helm hook Jobs before any pod rolls |
scripts/deploy.sh |
All three, in order. Idempotent — re-run it to upgrade |
scripts/deploy.sh status / down |
What is running / delete the cluster, its registry and all its data |
scripts/deploy.sh preview up / url / down |
This branch's app in preview-<slug>, public on two quick tunnels — see Preview environments |
NAMESPACE=… APP_ORIGIN=… scripts/deploy.sh |
Another instance on the same cluster, e.g. NAMESPACE=collabhub-a APP_ORIGIN=http://a.localhost:8080. *.localhost needs no DNS |
scripts/deploy.sh infra -- <helm args> and app -- <helm args> pass arguments to that
chart's Helm call.
The scripts never use your current kubectl context. Every call names KUBE_CONTEXT —
k3d-collabhub by default — and creating the cluster does not switch to it. cluster and
down refuse any context but k3d-collabhub; infra and app refuse k3d values on any other
context, and any other environment's values on it. IMAGE_REGISTRY, IMAGE_TAG and KUBE_CONTEXT override the defaults — see
.env.example.
An existing k3d cluster from before this change must be recreated once: the dependency release
was renamed from collabhub-local to collabhub-deps, and a new release cannot adopt the
resources the old one owns. down deletes the image registry too, so push again before
deploying:
scripts/deploy.sh down
scripts/deploy.sh cluster
scripts/build.sh --push
scripts/deploy.shk3d and the Compose development stack can run at the same time, but that is two of everything, Elasticsearch included — give Docker the memory for it.
charts/collabhub-deps has no default for any credential (register D41) — it refuses to
render until each one is supplied, and names the one that is missing. On k3d they come from
charts/collabhub-deps/values-k3d.yaml, and it is those values that are development
only: the credentials are throwaway, Dex is full of demo accounts, and nothing is backed up.
scripts/deploy.sh keeps them off every cluster but k3d-collabhub.
Any other environment has its own charts/collabhub-deps/values-<env>.yaml — deploy.sh
requires one, if only for ingress.className — and can keep every credential out of it, on
the command line instead:
DEPLOY_ENV=<env> APP_ORIGIN=http://<host> scripts/deploy.sh infra -- \
--set-string postgres.password=... \
--set-string garage.adminToken=... \
--set-string garage.rpcSecret=... \
--set-string garage.accessKeyId=GK... \
--set-string garage.secretAccessKey=... \
--set-string dex.clientSecret=... \
--set-string 'dex.passwordHash=$2b$10$...' \
--set-string workerClientSecret=...Single-quote the bcrypt hash: it is full of $. --set-string splits on commas, so a
credential passed this way may not contain one, and the Postgres password goes into a DSN
unescaped, so it must be URL-safe — letters, digits and -_.~. Garage only accepts keys in its
own format: GK plus 24 hex digits for the key ID, and 64 hex digits for the secret and the
RPC secret. Auth's signing key is optional — --set-file auth.signingKey=signing.pem — and is
otherwise generated on first install and kept.
Even with real credentials the chart is not production: nothing is backed up, Dex is a password database, and the Secrets are rendered from values rather than a secret store. Four things to know before running it anywhere but your laptop:
- KEDA is cluster-wide.
infrainstalls it as releasekedain namespacekeda, at the pinned version. On a shared cluster that already runs KEDA, that upgrades or downgrades everyone's, so coordinate first. - Rotating Garage's key means a new key. The bootstrap Job imports the key only when
garage.accessKeyIdis not already there, so a changedgarage.secretAccessKeyunder the same ID is ignored. Change both together. - The Postgres password is fixed at first start. It is set when the volume is first
initialised, so changing
postgres.passwordlater breaks every service's connection unless you change the password in the database too. helm uninstall collabhub-depsis not harmless. It deletes the Auth signing-key Secret, which ends every session, and theredis-streamsPVC, which loses any job not yet acknowledged.
scripts/deploy.sh preview up deploys the current branch's whole app into its own namespace,
preview-<slug>, on the local k3d cluster, and makes it public on two
cloudflared quick tunnels — no account needed —
so someone on another network can use it. One tunnel serves the app, the other object storage
(a presigned URL's host is signed, and the app's host routes to the SPA).
scripts/deploy.sh preview up # cluster, build, push, tunnels, deploy; prints both origins
scripts/deploy.sh preview url # prints APP_ORIGIN=… and OBJECT_STORE_ORIGIN=… again
scripts/deploy.sh preview down # stops the tunnels, uninstalls, deletes the namespacecloudflaredmust be onPATH.BRANCH=<name>names the preview instead of the checked-out branch (needed on a detached HEAD).- A rerun reuses live tunnels. If either has died,
upstarts two new ones: the URLs change, so everyone signs in again, but the data is kept. - A quick tunnel does not survive the machine sleeping. When its connection drops for long
enough, Cloudflare deletes the tunnel;
cloudflaredkeeps running and logsTunnel not foundfor ever.upandurlask each URL as well as each process, so they see this as dead. Keep the machine awake while a preview is in use (caffeinate -don macOS). - Roughly 1.5–2 GB of memory per preview, most of it Elasticsearch.
- Do not run two
upat once (both upgrade KEDA), norupwhiledownruns. feature/a_bandfeature/a-bshare one preview, because they share a slug.- Accepted risk: anyone with a preview URL can sign in as the demo users
(
ada@collabhub.dev). The sign-in page no longer shows the credentials; the URL is the secret. - A
~/.cloudflared/config.ymlwritten for a named tunnel is not meant for these quick tunnels.
values-lab.yaml exists for both charts: charts/collabhub's carries ingress-nginx and
AUTH_COOKIE_SECURE: "false"; charts/collabhub-deps's carries nothing but
ingress.className — every credential still arrives on the command line, as above.
export IMAGE_REGISTRY=<registry the lab pulls from> # after `docker login` to it
export DEPLOY_ENV=lab APP_ORIGIN=http://<lab host> KUBE_CONTEXT=<lab context>
scripts/build.sh --push
scripts/deploy.sh infra -- \
--set-string postgres.password=... \
--set-string garage.adminToken=... \
--set-string garage.rpcSecret=... \
--set-string garage.accessKeyId=GK... \
--set-string garage.secretAccessKey=... \
--set-string dex.clientSecret=... \
--set-string 'dex.passwordHash=$2b$10$...' \
--set-string workerClientSecret=...
scripts/deploy.sh appIMAGE_REGISTRY must be a registry the lab can pull from, with docker login done — the k3d
registry at localhost:5500 is unreachable from it, which is why the block above sets it
explicitly rather than taking the default.
The lab is plain HTTP on a real hostname, not http://localhost, the one origin browsers
exempt from Secure — so its refresh cookie runs with Secure off (register D42). The
refresh token then travels the lab's network in clear; acceptable only because the lab is a
trusted network, and never copied to an environment reachable from outside one.
Three things the chart now needs, all per environment:
- KEDA, installed in the cluster — the chart fails to render without the
keda.sh/v1alpha1API, since the Worker'sScaledObjects depend on it. No KEDA and don't want it? Setcomponents.worker.autoscaling.enabled=falseand the Worker renders as plain Deployments instead. networkPolicy.peersset in a values file for that environment —postgres,redisCache,redisRealtime,redisStreams,elasticsearch,objectStore,otelCollector,oidcProvider. Where Postgres, Redis and the rest actually live varies per cluster, so the chart ships no defaults for them, and the render fails, naming whichever peer is missing, until every one an enabled component needs is set. Don't want NetworkPolicy objects at all? SetnetworkPolicy.enabled=false.- A
REDIS_STREAMS_PASSWORDkey in thecollabhub-workersecret, andcomponents.worker.env.REDIS_STREAMS_ADDRESSpointed at R3'shost:port— KEDA'sTriggerAuthenticationand itsredis-streamstrigger read these directly; they are not parsed out ofREDIS_STREAMS_URL.
ingressController and dns are the exceptions: they ship non-empty defaults (ingress-nginx
in namespace ingress-nginx; CoreDNS via k8s-app: kube-dns in kube-system), so they pass
the empty-peer check even on a cluster where they're wrong. That's silent — nothing fails — so
check them by hand for your cluster. Under a CNI that enforces NetworkPolicy, a wrong
ingressController peer blocks all public ingress traffic to every component.
The k3d environment is a worked example of all three: deploy.sh infra installs KEDA,
charts/collabhub/values-k3d.yaml sets every peer (k3s's Traefik as the ingress controller,
and k3s does enforce NetworkPolicy), and charts/collabhub-deps supplies the Secret keys.
helm lint charts/collabhub -f <env-values>.yaml --set components.worker.autoscaling.enabled=false
helm template collabhub charts/collabhub -f <env-values>.yaml --api-versions keda.sh/v1alpha1helm lint has no --api-versions flag, so it can't see the KEDA API either way — the
--set above sidesteps that check the same way a KEDA-less cluster would. helm template
does take --api-versions, so pass it there instead once your peers are set.
The chart deploys CollabHub's own workloads only. Postgres, Redis, Elasticsearch and Garage
are expected to exist already — they have their own lifecycle and backups, and bundling them
would make helm uninstall a data-loss command.
The Ingress is opt-in: set ingress.enabled, ingress.className and — through
scripts/deploy.sh, from APP_ORIGIN — ingress.host. The public prefixes are
fixed in the template, so /api/v1/internal/ (Conventions §5.5) cannot be routed
by a values file. Two constraints remain the environment's:
- The identity provider must be routed on the same host, or be a real IdP with its
own.
charts/collabhub-depsdoes this for Dex. - The SPA and the API must be same-site — one registrable domain, or one
origin. The refresh cookie is
SameSite=Strict(register D22), so splitting them across genuinely different domains silently signs everyone out. Seeauth/cookies.py.
Two project skills live in .claude/skills/ and are used throughout the book:
- adr-writer — captures a decision and its rationale as an ADR in
docs/adr/. - stack-update-checker — compares each entry in
docs/platform/versions.mdagainst its upstream release feed, posts a digest of anything new to Slack, and writes the watermark back so the same release is never announced twice.
MIT — see LICENSE.