Eva Board architecture
Eva Board is a Go API (Fiber) plus an Expo web UI, backed by PostgreSQL, that runs an autonomous build → verify → review → ship loop against a user's GitHub repository. This document is the deep-dive companion to the self-hosting guide.
1. System overview
┌────────────────────────────────────────────────┐
│ │
Web UI ─────┤ │
(Expo) │ │
│ Backend API (Go / Fiber, port 8080) │ ┌──────────────┐
│ ├──────▶│ PostgreSQL 16 │
GitHub │ │ └──────────────┘
Webhooks ───┤ │
│ │ │
│ ├──▶ Coding agent CLI ───┼──▶ git worktree ──▶ git push
│ │ (Claude Code, etc.) │
│ │ │
│ ├──▶ Codegen agent │ verification + review
│ │ (Claude Code in repo │ (same CLI, separate prompts)
│ │ worktree) │
│ │ │
│ └──▶ GitHub REST API │ open PR
│ │
└────────────────────────────────────────────────┘
The API process is the only long-running component. Coding-agent CLIs are
forked per card and live for the duration of a single invocation. Git
worktrees are created on demand under <RepoPath>/../worktrees/<short-id>/.
2. The autonomous loop
The loop is owned by AgentManager in
backend/internal/board/agent.go and
executed by runAgent in
backend/internal/board/agent_runner.go.
A card is moved to develop (via the API or UI), which calls
AgentManager.StartAgent(cardID). That method is idempotent — only one run
per card may exist at a time.
Each StartAgent spawns a goroutine that performs the following steps:
-
Prepare worktree.
prepareWorktreeresolves the base ref (origin/<base>preferred, falls back to local<base>) and runsgit worktree addat<repo>/../worktrees/<shortID>, creating branch<BranchPrefix><shortID>(default prefixeva-board/). Existing worktrees are pruned and retried once so re-runs are idempotent. -
Initial coding-agent invocation.
invokeCodingAgentbuilds a prompt from the card (title, description, acceptance criteria, plus any queued feedback) and hands it to the configuredcodegen.Agent(from the standaloneteslashibe/codegen-gomodule — seeagent.go). The CLI is forked with the worktree as its working directory and the prompt on stdin. Combined output is captured (default cap: 10 MiB). -
Auto-commit + force push.
autoCommitIfNeededstages anything the agent left dirty and commits with a pass-numbered message.pushBranchforce-pushes toorigin. If a per-user GitHub PAT is configured we splice it into the remote URL withx-access-tokenso we don't mutate the shared remote config. -
Verification loop (≤
MaxVerifyIterations, default 3). For each iteration: reload the card (so user edits to acceptance criteria are honoured), computegit diff <base>...<branch>, score it against the criteria with an LLM call (verifyCardinbackend/internal/board/verify.go). OnAllPassed, exit the loop. Otherwise, re-invoke the coding agent with a "failed criteria" feedback string and repeat. Exhaustion → card ends infailedagent status. -
Review loop (≤
MaxReviewCycles, default 5).ReviewCard(backend/internal/board/review.go) asks the LLM to verdict the diff asAPPROVEorREQUEST_CHANGES. OnAPPROVE, break out. OnREQUEST_CHANGES, re-invoke the agent with the suggestions, commit + push, re-run the full verification loop, then loop back to review. Re-verification failure during a review cycle is fatal — we don't ship code that no longer satisfies criteria. -
Open PR.
openPullRequestposts to GitHub viabackend/internal/github/pulls.go, persistspr_number/pr_urlon the card, moves it to theprcolumn, and stampsreview_status = APPROVE. -
Webhook follow-up.
WebhookHandler(backend/internal/board/webhook_handler.go) listens for GitHubpull_requestevents. Merged → card moves todone; closed unmerged → card moves back toreview.
StopAgent(cardID) cancels the run's context. SubmitFeedback(cardID, ...)
appends to a per-run queue that's drained into the next agent prompt;
useful while the loop is iterating.
3. Package map
All packages live under backend/internal/.
| Package | One-liner |
|---|---|
apperrors | Typed HTTP errors and a Fiber-compatible handler. |
auth | Identity service for the magic-link auth flow + Fiber middleware. |
board | Cards, agent loop, verification, review, settings, curate/triage/spring-clean, SSE broker, GitHub webhook handler. |
bootstrap | Wires config + DB pool + cipher into a Core struct used by cmd/server. |
codegen | Pluggable coding-agent CLI wrapper. claude-code and generic implementations. |
config | Env-var loader. The single source of truth for all knobs. |
db | pgx pool helpers and embedded SQL migrations. |
github | GitHub REST client (PRs, issues, users) and webhook signature verification. |
httputil | Small Fiber helpers (current user ID extraction, JSON shape helpers). |
llm | OpenRouter / OpenAI-compatible client used by verify + review. |
security | AES-GCM cipher used to encrypt GitHub PATs at rest. |
Entry points live under backend/cmd/:
| Binary | Purpose |
|---|---|
cmd/server | The HTTP API (port 8080). |
cmd/migrate | CLI for up, down N, version. |
4. Data model
Migrations are numbered .up.sql/.down.sql pairs under
backend/internal/db/migrations/ and
embedded into the binary via embed.go.
| Table | Migration | Purpose |
|---|---|---|
users | 001_users.up.sql | Identity row keyed by identity_key (magiclink:<email> or dev:<email>). |
auth_codes | 002_auth_codes.up.sql | One-time magic-link codes / tokens; consumed on verify. |
board_cards | 003_board_cards.up.sql | Card content, column_name (backlog/develop/review/pr/done), agent_status (idle/running/verifying/reviewing/failed/succeeded), worktree_branch, pr_number, pr_url, review_status, freeform metadata JSONB. |
board_settings | 004_board_settings.up.sql | Per-user GitHub config: AES-GCM-encrypted PAT, owner/repo, local repo_path, codegen_agent, max_verify_iterations, max_review_cycles. |
Cards are owned by a user (user_id FK with ON DELETE CASCADE). Position
ordering is per-user-per-column via (user_id, column_name, position).
There is a partial unique index on pr_number WHERE pr_number IS NOT NULL
so the webhook handler can resolve card by PR number cheaply.
5. Real-time updates
Live agent progress streams from the API to the UI over Server-Sent Events.
agent_runner.go ──▶ Broker.Publish(Event)
│
├──▶ in-memory subscriber map keyed by user_id
│
▼
EventsHandler.Stream (GET /api/board/events, SSE)
│
▼
Browser EventSource
Broker (backend/internal/board/events.go)
keeps a per-process subscriber map and a 256-event ring buffer for resume.
Each subscriber has a 64-event channel; if a client falls behind, the
oldest queued event for that subscriber is dropped silently rather than
blocking the publisher.
EventsHandler.Stream
(backend/internal/board/events_handler.go)
sets text/event-stream, honours Last-Event-ID for resume, sends a
heartbeat comment every 15 s, and routes only events whose UserID
matches the authenticated session — no cross-tenant fan-out.
Event types: agent_started, agent_progress, agent_finished,
verification_started, verification_result, review_started,
review_result, pr_created, card_moved, error.
6. Security model
Auth. Email magic-link via the magiclink-auth-go library. Successful
verification mints an HS256 JWT signed with JWT_SECRET. The JWT is
attached as a bearer token by the Expo client; auth.Middleware
(backend/internal/auth/middleware.go)
verifies it and resolves an internal user_id per request. All /api/*
routes are mounted behind authMW.RequireAuth() (see
backend/cmd/server/main.go).
GitHub PATs. Stored encrypted at rest using AES-GCM with a 32-byte key
sourced from TOKEN_ENCRYPTION_KEY
(backend/internal/security/encryption.go).
Plaintext only ever lives in memory while the agent loop is running and is
never logged. Rotating the key invalidates all stored PATs and forces users
to reconnect GitHub.
Webhook deliveries. POST /webhooks/github is mounted outside the
auth middleware on purpose — GitHub authenticates with the
X-Hub-Signature-256 HMAC. github.VerifySignature
(backend/internal/github/webhook.go)
constant-time-compares the delivered HMAC against HMAC-SHA256(body, GITHUB_WEBHOOK_SECRET). Empty secret = misconfiguration → reject.
Internal IDs. Cards expose UUIDs externally; the agent loop uses the first 8 hex chars of the UUID for branch names and worktree paths so collisions are extremely unlikely but the values stay human-readable.
7. What's NOT in v1
The v1 launch is intentionally single-tenant, single-process, single-user- per-account. Features that have been deliberately deferred:
- Multi-tenancy beyond per-user isolation. Each user has their own cards, settings, GitHub PAT, and agent runs, but there is no organisation/team layer, no shared boards, no role-based access.
- HA / horizontally scaled API. The SSE broker is in-process. Running more than one API replica would split subscribers and lose events. A Redis-backed (or NATS-backed) broker is the obvious next step.
- Background scheduler binary. Triage and spring-clean run on demand
via the curate handler. There is no separate
cmd/schedulercron process yet. - Native mobile apps. Native iOS and Android via Expo + EAS. See
docs/mobile.mdfor the simulator + device install path. - Billing / metering. No Stripe, no per-tenant LLM cost accounting.
- GDPR delete + export tooling.
userscascades to cards and settings, but there is no audited self-serve export/delete flow. - Pluggable LLM providers beyond OpenAI-compatible. The
llmpackage speaks the OpenAI chat-completions schema via OpenRouter. Anthropic native, Bedrock, Vertex, etc. would each need an adapter.
These are tracked as future v1.1+ work; see the GitHub issue tracker for the latest list.