Personal product · built by an AI agent team
Filed as an issueShipped by agents
LightChat is a local-first chat app — one Rust codebase for web, desktop and mobile. What sets it apart is how it gets built: six role-scoped AI agents coordinate through GitHub labels, and an issue written in plain words walks itself to a reviewed pull request, a live preview environment of its own, a Playwright QA pass and a recorded video. A person does three things: files the issue, approves the work to start, and merges.
The engineering here is the delivery system itself — the agent harness and its guardrails, a preview environment per pull request on k3s, and a GitOps pipeline where a merge moves staging and only a person's tag moves production.
LightChat — harness & workspacek3s · ArgoCDSheet 0151
Agent-built pull requests
17
GitHub Actions workflows
56
Architecture decision records
Figures as of September 2026. 90 pull requests to date, 51 of them opened by agents, across 78 issues. ~76k lines of Rust in 524 files.
01 · The agent loop
A label-driven state machine/new-issue
The idea is filed exactly as written — not tidied, expanded or investigated. Interpreting it is the next role's job, and it happens in public on the issue.
status:triage
Product owner — hunts for duplicates, grounds the request in the code with file paths, asks only what the code cannot answer, classifies it as a bug, task, tech debt or milestone, and writes Given/When/Then acceptance criteria.
status:ready
A person applies it — the only gate before an agent starts spending. The architect then decides whether the wire contract changes and records an ADR, or cuts a milestone into ordered child issues.
status:in-progress
Full-stack developer — continues the branch if one exists, otherwise opens agent/issue-<n> with a pull request that closes the issue, then runs cargo check and cargo test and reports what they said.
status:needs-review
Reviewer — reads the diff for issue fidelity (criterion by criterion, with line references), scalability, code patterns and architecture. Objections send it back to the developer.
status:deploying
The pull request is built and deployed into a namespace of its own. The devops agent runs only when that fails, to decide whether the cause is the machinery or the change.
status:qa
QA — drives the live preview with Playwright as seeded test users, several browser contexts at once, and attaches the videos and the HTML report to the pull request.
status:solved
The person is mentioned and merges. Nothing in the harness can merge — the loop ends where a person's judgement starts.
status:conflicted
Another merge broke this one. A sweep on every push to main catches it, the developer merges main back in, and the change goes round the preview and QA again — because the branch is no longer what QA drove.
status:needs-human
Absorbing. A spent budget, a refused diff or an agent asking for help lands here, and every automation stops.
02 · What holds the loop together
One workflow reacting to its own writes- The issue holds the state — every job ends by applying the next
status:* label, and that label starts the next job. One agent-router.yml is the whole loop; the pull request only mirrors the issue's labels and is never read as a source of truth. - A GitHub App as the second identity — events raised by
GITHUB_TOKEN cannot start a workflow, so under it every transition would stall silently. A per-job App token, scoped to one repository and expiring in an hour, is what lets the loop re-trigger itself. - One transition, one label event — two label writes land in the same concurrency group and GitHub keeps only one. Decorative role labels are written with the token that cannot trigger, and re-entering a state is a remove-and-add
reapply, never a detour through another state. - Retry budgets per phase — counters live in a marker comment on the issue: review 5, QA 5, deploy 3, merge conflicts 3. A spent budget means
status:needs-human; a run that never reached the model counts against a separate infrastructure budget, so an outage does not spend the developer's attempts. - One piece of work, one ticket — a review objection, a QA defect or a merge conflict is fixed on the same pull request. Nothing forks a second issue, because two issues carry two sets of labels and budgets that end up disagreeing.
- Milestones fan out — the architect files ordered child issues (
Blocked by #A), the parent waits at status:blocked, and issue-lifecycle.yml releases it when the last child closes. - A retry can see why the last one failed — the context builder appends the issue's latest comments to the prompt, so a second attempt reads the reviewer's or QA's findings instead of repeating the first.
03 · Six agent roles
Personas, not path globsproduct-ownerRuns at triage and writes no files — its whole output is one comment and a set of labels: duplicates, code-grounded findings, questions, a classification and acceptance criteria.
architectRuns at ready. Owns the shared wire-contract crate, the ADRs and the contracts document, and cuts milestones into ordered child issues.
full-stack-developerRuns at in-progress. Takes one change end to end — API, client, SDK crates, UI and tests — and never touches the harness.
reviewerRuns at needs-review. Never writes code: posts a review against a four-part rubric and writes its verdict to a file.
devopsRuns only when a preview deploy fails. The one role allowed to edit workflows, manifests and the harness itself — which is exactly why its pull requests still need a person to merge.
qaRuns at qa. Writes Playwright specs under qa/ and nothing else, so it cannot make a failing assertion pass by editing the code under test.
04 · Guardrails on the agents
Each one closes a hole found by running itA token that cannot push
The agent holds contents: read plus issue and pull-request write. The workflow commits and pushes after the agent exits, from a checkout that held no credential while the agent had a shell.
The token is pinned on the step
$GITHUB_ENV is job-wide, so a token written there reaches every later step. The agent step pins its own GH_TOKEN — the fix for a real bug where the agent held the push token while two documents and a green test said otherwise.
One coarse diff guard
Every role but devops is refused .github/, agents/, deploy/ and qa/; QA is refused everything outside qa/; every role is refused credential-shaped paths and lines. A violation stops the run before a pull request exists.
The verdict is a file
The reviewer writes approve or request-changes to .agent/verdict, exact-matched and failing closed. It used to be grepped from the log — which contains the diff, so a pull request could approve itself.
No agent merges anything
No workflow calls a merge API, and the harness's own test suite asserts it. Branch protection binds administrators too, and merge-audit.yml reports a merge with no pull request, by a bot, or before QA passed.
A pinned supply chain
claude-code-action receives a write token and a model credential on a self-hosted runner, so it is pinned to a commit, not a mutable tag. One composite action is the only file that names the agent vendor.
Every job is capped below an hour
45 minutes for jobs that compile or drive a browser, 30 for reads, 10 for label moves — the App token's one-hour lifetime minus room to report the result.
Everything in a pull request is data
Every role is told that titles, bodies, diffs and comments are the material under review, never instructions — and untrusted text reaches scripts through the environment, never through expression interpolation.
05 · Per-pull-request previews
A namespace per PR, applied with kubectlTrigger
The router deploys at status:deploying — only after the reviewer approves. A label anyone could apply would put an unreviewed diff on a public hostname.
Build
API and web images tagged pr-<n>-<sha12> and pushed to GHCR; the landing site is built only when its own paths changed.
Per-PR bundle
The API URL is compiled into the WASM bundle, so every pull request builds its own web image. On a persistent runner the sources are touched to force the recompile, and the job greps the bundle and fails if it names any API but its own.
Namespace
lightchat-pr-<n>, labelled for the capacity guard and the teardown, rendered as a throwaway Kustomize overlay over a preview base and applied with kubectl apply -k — no ArgoCD and nothing committed.
Isolation
Its own API, web app, MongoDB, Redis and tunnel client, under a ResourceQuota — 500m CPU and 1 GiB requested, 2 CPU and 2 GiB limit, 12 pods.
Hostnames
pr-<n>-app-lightchat.eduindev.com and its API twin cost one generated frp proxy block each. Wildcard DNS and TLS already terminate at the edge, so there is no DNS record, certificate or ingress to create.
Secrets
Self-provisioned. Registry and tunnel credentials are copied from production; a fresh Mongo password and JWT secret are generated per pull request, with a media bucket of its own. The runner's ServiceAccount can read exactly three named production secrets.
Sign-in
PREVIEW_AUTH seeds four users behind a sign-in picker — Ana, Beto and Cami in a group, Ana and Beto in a DM, Dani in nothing. The API refuses to start with the flag under APP_ENV=production, and Dani is the negative control every spec asserts against.
Capacity
At most five previews. The apply script exits 3 naming the pull requests that hold them, applies serialise on one concurrency group, and an unreachable cluster fails closed instead of reading as zero namespaces.
Cluster access
The kubeconfig lives on one labelled runner, not in a GitHub secret — no workflow can print it and no agent can reach it.
Failure report
When a build or apply fails, a report job reads its own logs and writes the reason onto the pull request, because the devops agent called next holds no Actions scope.
Teardown
Closing or merging the pull request deletes the namespace with its database and removes its image versions from GHCR; a scoped reclaim step keeps the runners' disks from silently filling.
06 · Staging, production & GitOps
main is staging · a tag is production- A merge deploys staging, a tag deploys production — nothing else moves either environment, and no agent or label transition can create a tag.
- A separate GitOps repository —
lightchat-cd holds a shared base/ with prod/ and staging/ Kustomize overlays, one ArgoCD Application each, so a Deployment edited once reaches both environments. - A reusable image bump —
cd-bump runs kustomize edit set image in the right overlay and commits it; every deploy shares one concurrency group and rebases and retries. Rolling back is reverting the bump commit. - Releases rebuild instead of promoting — the API and app URLs are compiled into the bundles, so staging's images cannot serve a production hostname. Each build greps its bundle for the right URL, and the version job refuses a tag that disagrees with
Cargo.toml. - Staging meets production's configuration first — it runs
APP_ENV=production, so the CORS allowlist assert and the preview-auth refusal fire there before they fire in production, with its own database, Redis, bucket and JWT secret. - Horizontally scalable API — two replicas behind a PodDisruptionBudget; Socket.IO fan-out and presence go through Redis, and migrations run in-process behind a Redis lease renewed every 10 s against a 30 s TTL.
- No ingress, no open port — an frp client dials out to the edge, where Traefik terminates a wildcard certificate from Let's Encrypt; a LiveKit SFU carries calls through a single forwarded UDP port.
- OpenTelemetry — traces, metrics and logs over OTLP into Grafana, with a service name per environment and per preview.
07 · CI/CD on self-hosted runners
17 workflows- CI is not a pull-request gate — it runs the platform builds on
main and on tags. On a pull request, correctness moves to where it is cheap: the developer's own check and test run, then the reviewer, then QA against a running copy. - A homelab runner fleet — four Linux runners plus macOS and Windows; only the runner labelled
kubectl can reach the cluster, and only preview jobs ask for it. - Nothing installs toolchains —
ensure-tools.sh verifies what a job needs at its second step and stops naming the missing tool and the runner; check-tool-declarations.sh keeps every workflow's declarations honest. - Tag-driven releases — one tag builds desktop apps for macOS, Windows and Linux plus Android, checksums them into a draft GitHub Release, and rebuilds the three service images for production.
- The harness tests itself —
test-harness.sh asserts there is no merge call anywhere, every job's timeout, a token on every step that calls GitHub, and that no workflow provisions a toolchain. - Seventeen workflows — agent router, conflict sweep, issue lifecycle, merge audit and label sync; preview deploy and teardown; three service deploys and a reusable CD bump; CI; five release workflows.
08 · Tools
What the harness is built fromAgents
Claude Codeclaude-code-actionClaude OpusSlash commandsGitHub Appsgh CLI
Automation
GitHub ActionsComposite actionsReusable workflowsBashjqLabels as state
Delivery
DockerGHCRKubernetes (k3s)KustomizeArgoCDfrpTraefikLet's Encrypt
Quality
Playwrightcargo testClippyrustfmtArchitecture decision records
Application
RustDioxus 0.7AxumSocket.IOMongoDBRedisSQLCipherLiveKitS3 (Garage)OpenTelemetry
09 · Lessons from running it
Every rule has an incidentThe previews that never ran
The first design leased one of five slots and committed an overlay for an ArgoCD ApplicationSet that existed in no commit. Every run was green and no preview ever came up — so previews became a namespace per pull request, applied directly.
A green run that did nothing
A role label and a status label raced in one concurrency group; when the role label won, the run exited cleanly and no reviewer ever started. Now a transition writes exactly one label that can trigger.
A QA pass that failed at the end
A 79-minute QA run passed, then got a 401 posting the result — the App token lasts an hour. The suite itself took 3.4 minutes; the rest was deliberation. Jobs are now capped against the token, and the brief sets a budget.
An outage is not a silence
Five runs died in under a second without reaching the model, and five issues sat waiting on a person for eight hours. Execution failures now retry on a budget of their own, separate from review and QA.
A disk that filled invisibly
Per-commit preview images piled up to 282 on one runner, and an out-of-disk build dies with no failed step and no log. A scoped reclaim step now runs first and refuses legibly below 5 GB free.
A bundle pointing at the wrong PR
A persistent runner's build directory let one pull request reuse another's compiled API URL. The build now forces the recompile and greps the bundle, failing unless the URL is its own.