# Architecture teabot is a single binary with a small set of internal packages. The daemon polls Gitea, decides which events warrant work, and runs each unit of work as a disposable Docker container. ``` main.go └── internal/cli cobra command tree (run, config) └── internal/dispatch poll loop, event filtering, job orchestration ├── internal/gitea read-only Gitea REST client ├── internal/state processed-event persistence (JSON) ├── internal/prompt per-event prompt construction ├── internal/config config + tea-config parsing └── internal/docker containerised Claude execution (Runner iface) ``` ## Poll cycle `dispatch.Dispatcher` runs one `pollRepo` per watched repo each interval: 1. **List** open issues, open pull requests, and recent comments via the Gitea API (one client authenticated as the first personality — reads only). 2. **Seed on first contact.** The very first poll of a repo records everything currently open/recent as *processed* without dispatching anything, so a fresh install does not stampede every existing item. The `seeded` flag is persisted per repo. 3. **Classify & dispatch** subsequent events: - a new issue → implementer session, - a new pull request → reviewer session, - a new comment on a thread teabot **acted on** → follow-up session. ## Filtering rules (loop prevention + dedup) Three independent guards decide whether an event becomes a job: - **Loop prevention** — any event authored by one of teabot's own personality logins is skipped. This is what stops the bot reacting to its own PRs and comments in an infinite loop. Bot-authored items are still *recorded* as processed so they are never reconsidered. - **Author allowlist (security)** — because jobs run `--dangerously-skip-permissions` with a prompt built from event text, teabot dispatches only for events whose author is on the `allowed_authors` list (with an optional per-repo override). This is **fail-closed**: an empty allowlist dispatches nothing. Non-allowlisted events are recorded (never re-triggered) and logged, but never spawn a container. Comment follow-ups are gated on the *new comment's* author, so an untrusted comment on a bot thread cannot reopen the injection path. See [configuration](configuration.md#author-allowlist-security). - **Dedup** — every dispatched issue/PR index and every seen comment ID is recorded in the state store (`~/.local/state/teabot/state.json`). An item is marked processed *before* its job starts, so a subsequent poll (or a restart mid-job) cannot double-launch it. Comment follow-ups additionally require the parent issue/PR to be in the *acted-on* set — teabot only continues threads it started, never arbitrary comment threads. See [configuration](configuration.md) for the implication. ## Job execution Each session is a `docker.Job` handed to a `docker.Runner`. The production `DockerRunner`: 1. Materialises a per-job scratch dir containing the prompt, a static `job.sh` entrypoint, a **private copy** of the Claude config dir (so subscription-token refreshes never mutate the host's `~/.claude`), and a copy of the personality's tea config. 2. Runs `docker run --rm --entrypoint /bin/bash /teabot/job.sh` with: - the scratch files bind-mounted (`:ro,z` under SELinux), - the tea config mounted at `$HOME/.config/tea/config.yml` and `XDG_CONFIG_HOME` set, so `tea` acts as the bot identity, - git identity + a credential-store token so clones and pushes authenticate, - `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` injected only when configured (otherwise the mounted subscription credentials are used). 3. `job.sh` configures git, clones the repo, and runs `claude --print --dangerously-skip-permissions < /teabot/prompt.txt`. The `Runner` interface means the whole dispatch layer is unit-testable with a fake runner — no Docker daemon required. Concurrency is bounded by a semaphore sized to `max_concurrent`, and every job has a `job_timeout`. ## Container image teabot reuses the existing **`git.unkin.net/unkin/agent-dev:latest`** image, which already bundles the `claude` CLI plus a Go/Node/Python/`tea` developer toolchain and language servers. The image is configurable via `job_image`, so a purpose-built image can be substituted without code changes.