A Rust/Axum service that listens for git-forge webhooks, runs the claude CLI against the affected repo, and posts the result back as an issue/MR/PR comment. Supports GitLab and GitHub today; Codeberg (Forgejo) is planned. A Vue 3 SPA on the same port shows live task status, branch diff, captured stdout, and pending operator approvals.
Engineering rules live in .claude/CLAUDE.md and the full architecture map in docs/architecture.md. Read those first if you're going to modify the code.
The Tasks dashboard — every agent run across all connected repos, with provider, branch, trigger, status, and spend:
The Stats view — time spent by the agent, grouped and windowed:
Copy .env.example to .env and set:
| Var | Default | Purpose |
|---|---|---|
DATABASE_URL |
— (required) | postgres://user:pass@host/db or sqlite://path/to/file.db?mode=rwc. ?mode=rwc is required — sqlx won't create a missing file otherwise. Slash count matters: sqlite://agent.db (two slashes) is a relative path; sqlite:///agent.db (three) is absolute (/agent.db at the filesystem root — almost never writable, and easy to type by mistake). sqlite::memory: also works but only persists for the process lifetime |
REPO_BASE_PATH |
/tmp/claude-jobs |
working-tree base: <base>/<service_slug>/<project_slug>/<branch> |
LISTEN_ADDR |
0.0.0.0:3000 |
bind address |
PUBLIC_BASE_URL |
unset | externally reachable base URL (e.g. https://agent.example.com); used to auto-register provider webhooks. Unset → auto-registration skipped, wire hooks by hand |
MAX_CONCURRENT_JOBS |
3 |
task-admission ceiling — a per-turn semaphore plus the queue's count of live/in-flight tasks, not a fixed count of parallel claude processes: a warm-but-idle session (between turns) holds an admission slot but no process is actively running, so admitted work can exceed this many simultaneously-executing turns (#159 Docs) |
TASK_TOKEN_BUDGET |
1000000 |
per-task soft output-token budget; at 50 % the runner kills claude and finalizes the task failed (session_id kept, so the operator can Resume after the 5 h rate-limit window) |
OPERATOR_APPROVAL_TIMEOUT_SECS |
0 |
seconds an unanswered tool-approval waits before auto-denying. 0 = wait indefinitely (never auto-deny — the default). A turn parked on an approval releases its MAX_CONCURRENT_JOBS permit and re-acquires on resolve (#84), so even an indefinite wait never starves other turns |
JOB_TIMEOUT_SECS |
0 |
per-turn wall-clock cap; past it the runner SIGKILLs the agent and its whole process group (orphaned test processes included) and leaves the task resumable. 0 = disabled |
WARM_IDLE_TIMEOUT_SECS |
1800 |
keep_warm session_mode projects only — seconds a warm-but-idle session may sit between turns before the runner closes it gracefully (resumable completed, session_id kept — a later comment/message resumes it via claude -r). A warm agent holds a MAX_CONCURRENT_JOBS slot, so eviction keeps idle sessions from starving the queue. 0 = disabled (sessions stay warm indefinitely) |
API_BEARER_TOKEN |
unset | when set, gates /api/* and the SPA; paste it into the SPA on first load |
NVM_DIR |
unset | NVM install dir (e.g. /home/agent/.nvm). When set, the runner resolves the node toolchain NVM would activate (honouring the worktree's .nvmrc, else the default alias) and prepends its bin to the agent's PATH. Unset → disabled |
PROJECT_DB_ADMIN_URL |
unset | admin Postgres DSN with CREATE ROLE+CREATE DATABASE rights. When set, the runner provisions a throwaway role + database per task (agent_task_<id>), injects its DSN into the agent's env, and drops both at session end. Unset → disabled |
PROJECT_DB_HOST_FOR_AGENT |
host from admin URL | host:port the agent uses in the injected DATABASE_URL/PGHOST (may differ from the admin connection in containerized setups) |
RUST_LOG |
agent=info |
tracing filter |
The Vue SPA is baked into the release binary by rust-embed — no runtime path override.
Forge credentials (GitLab, GitHub, …) are not in .env — they live in the service table and are managed via the admin UI.
Everything goes through the Makefile — the SPA must be built before the binary (rust-embed bakes frontend/dist/ in) and the targets enforce that ordering:
make run # build SPA + cargo run (migrations run on startup)
make build # release build (SPA + binary)
make dev # hot-reload SPA: vite on 5173, API-proxied to the agent
make deploy # release build, then install the binary + restart the systemd unitVisit http://localhost:3000 and (if API_BEARER_TOKEN is set) paste the token. Node version is pinned in .nvmrc (nvm use first).
Go to Services → Add service and fill in:
| Field | Example |
|---|---|
| Kind | gitlab (multiple allowed) / github (single) |
| Slug | personal |
| Display name | Personal GitLab |
| Base URL | https://gitlab.com |
| Bot username | group_NNN_bot_agent (the generated bot handle) |
| Access token | GitLab: a Group/Project Access Token (glpat-…) with api + write_repository, Maintainer/Owner role — this gives the agent its own bot identity (a group_NNN_bot_* service account), so it acts as itself, not you. GitHub: a PAT with repo + admin:repo_hook. The token does git transport (token-HTTPS clone/push), note posting, and webhook registration |
| Webhook secret | a random string |
GitLab bot token. Mint the Group Access Token under the group's Settings → Access Tokens, or with the CLI:
glab token create --group my-group --name agent-bot \ --scope api --scope write_repository --role maintainer --expires-at 2027-06-08Access tokens expire within 365 days — rotate before expiry (manually, or via the access-tokens
…/rotateAPI). Seedocs/application-integration.md.
After saving, the service detail page shows the Webhook URL to paste into the forge.
Per service:
POST /webhook/gitlab/<slug> # X-Gitlab-Token = the service's webhook_secret
POST /webhook/github/<slug> # X-Hub-Signature-256 HMAC-SHA256 of body, key = webhook_secret
<slug> is the value you entered when creating the service. Service-detail page renders the full URL for convenience.
Auto-registration. When PUBLIC_BASE_URL is set, the agent registers the webhook itself the first time it sees a project (idempotently — re-runs update in place rather than duplicating), so the manual steps below are a fallback. The PAT needs the hook-admin scope (admin:repo_hook on GitHub; api + Maintainer/Owner on GitLab). GitHub App services use a single app-level hook instead.
Project → Settings → Webhooks → Add new webhook:
- URL —
https://<your-agent-host>/webhook/gitlab/<slug> - Secret token — the
webhook_secretyou saved - Trigger — Issues events, Comments, Merge request events (Confidential variants too if you want)
- SSL verification — on
You can register the same webhook at group level (Settings → Webhooks on the group) so it applies to every project beneath it.
To register via API instead of UI:
curl --request POST \
--header "PRIVATE-TOKEN: $GITLAB_PAT" \
--header "Content-Type: application/json" \
--data '{
"url": "https://your-agent.example.com/webhook/gitlab/personal",
"token": "the-webhook-secret",
"issues_events": true,
"note_events": true,
"merge_requests_events": true,
"confidential_issues_events": true,
"confidential_note_events": true,
"enable_ssl_verification": true
}' \
"https://gitlab.com/api/v4/projects/<project_id>/hooks"Repo → Settings → Webhooks → Add webhook:
- Payload URL —
https://<your-agent-host>/webhook/github/<slug> - Content type —
application/json - Secret — the
webhook_secretyou saved - Events — Issues, Issue comments, Pull requests, Pull request reviews
| Event | Action |
|---|---|
| Issue assigned to the bot user | Implement, push branch, open MR/PR |
| MR/PR review with changes requested | Fix and push |
| Any other MR/PR review | Post a review note |
Issue/MR/PR comment containing @<bot_username> |
Reply in-thread |
| Issue closed / MR/PR closed/merged | Release the checked-out branch |
Trigger mode. What counts as "assigned to the bot" is governed by the service's trigger_mode (overridable per event type): assignee (default — the bot is among the issue's assignees), label (the issue carries the configured trigger_label), or both. A GitHub App can't be an issue assignee, so App services must use label mode. Comments containing @<bot_username> reply in-thread regardless of mode, and a comment on an issue/MR that already has a task is always delivered to that task's live session. See docs/application-integration.md.
All routes under /api/* require Authorization: Bearer $API_BEARER_TOKEN when that env var is set. /webhook/*, /health, /github_app/callback, and /ws sit outside that middleware (/ws authenticates in-band — its first frame carries the token).
The core surface the SPA drives:
GET /api/tasks # list; optional ?task_state= / ?agent_state=
POST /api/tasks # operator dispatch: { project_id, trigger } → pending task
GET|PATCH|DELETE /api/tasks/{id} # detail / edit run inputs / delete (DELETE force-kills if running)
POST /api/tasks/{id}/confirm # pending → queued
POST /api/tasks/{id}/kill # SIGKILL, preserving session_id for Resume
POST /api/tasks/{id}/continue # resume via `claude -r <session_id>`
POST /api/tasks/{id}/fresh_session # clear session_id, restart — next turn is a brand-new claude session
GET /api/tasks/{id}/diff # git diff of the worktree
GET /api/tasks/{id}/events # persisted agent timeline
GET /api/auth_requests # pending operator approvals (?status=, ?task_id=)
POST /api/auth_requests/{id}/resolve
GET /ws # single app-wide live stream (in-band auth)
The full route table — task stats/bulk/token/message/retry, all /api/projects, /api/services (+ github_app/gitlab_token sub-routes), /api/providers, /api/models, /api/queues, and the webhook/callback routes — is the canonical docs/architecture.md#http-surface. Tokens and webhook secrets on git services are write-only — never returned by GET.
Tool gating runs entirely over the stream-json control protocol on the stdin/stdout the runner already owns — there is no PreToolUse hook, no /internal/authcheck route, and no hook script. --permission-mode default --permission-prompt-tool stdio makes the CLI emit a can_use_tool request for every non-trivially-safe tool. handle_permission (src/jobs/permission.rs) allows edits/reads immediately, matches Bash against the project's allowed_operations glob list, and on a miss (or an AskUserQuestion) opens an auth_requests row, surfaces it to the SPA over the WS, and parks until the operator resolves it. OPERATOR_APPROVAL_TIMEOUT_SECS=0 (the default) waits indefinitely; >0 auto-denies on expiry.
Full protocol — the control_request/control_response framing and the AskUserQuestion answers path — is documented at docs/architecture.md#how-an-operator-approval-works.
make test runs the Rust suite against both DB backends (Postgres, then a sqlite:// pass — the DB-backed tests select the driver from the DSN scheme) followed by the frontend vitest suite. CI (.github/workflows/ci.yml) runs the whole make verify gate — lint + tests — against a Postgres service on every push.

