docs: rewrite for box-first model + webhooks + notifications

This commit is contained in:
Gabriel Brown
2026-07-11 18:23:49 -04:00
parent 1fbb2dd0a1
commit a255a06a35
5 changed files with 332 additions and 166 deletions
+225 -100
View File
@@ -10,7 +10,8 @@
<p align="center">
Spoon is a self-hostable fork maintenance cockpit built around managed forks,
durable maintenance threads, and OpenCode-powered workspaces.
durable maintenance threads, and a persistent per-user dev box that agents,
terminals, and project commands all run inside.
</p>
<p align="center">
@@ -61,14 +62,38 @@ in the data model, but GitHub is the active automation surface today.
Spoon keeps raw GitHub drift visible while also tracking ignored upstream
changes so irrelevant commits do not keep a fork permanently actionable.
- **OpenCode workspaces**
Agent work happens in an isolated workspace with a file tree, browser editor,
diff viewer, command panel, logs, artifacts, and draft PR actions.
- **One persistent per-user box**
Every user owns a single long-running Fedora container `spoon-box-{username}`
with a persistent home. Every thread's agent turn, terminal session, and
project command `docker exec`s into that same box — there is no per-job
throwaway container.
- **Three agent runtimes**
Codex, OpenCode, and Claude Code all run inside the box behind one adapter
interface, selected per thread and validated at queue time against the AI
provider profile.
- **Workspaces**
Agent work happens in a workspace with a file tree, browser editor, diff
viewer, command panel, logs, artifacts, an interactive terminal, and draft PR
actions.
- **My Machine**
A `/machine` surface to start/stop/restart the box, open a `~`-rooted
terminal, and browse the persistent home.
- **GitHub webhooks**
A signature-verified webhook keeps drift fresh on `push` and flips connection
status on `installation` changes; the hourly cron is a fallback.
- **Notifications**
An in-app bell plus preference-gated transactional email for maintenance
threads, agent turns, needed input, sync failures, and connection re-auth.
- **User-owned providers and secrets**
AI provider profiles, Codex/OpenCode auth, and per-Spoon project secrets are
encrypted. Secrets are redacted from logs and refused from commits when
materialized into env files.
AI provider profiles, Codex/OpenCode/Anthropic auth, and per-Spoon project
secrets are encrypted. Secrets are written `0600`, redacted from logs, and
refused from commits when materialized into env files.
- **Draft PR handoff**
Code changes become branches and draft pull requests. Spoon does not
@@ -138,12 +163,23 @@ custom work exists.
</details>
<details>
<summary><strong>OpenCode workspaces</strong></summary>
<summary><strong>The box and workspaces</strong></summary>
Spoon's optional agent worker is designed to run outside Convex actions. The
worker claims queued jobs, clones the current GitHub fork, creates a branch,
starts an isolated workspace, and exposes workspace operations to the Next app
through server-only API proxies.
worker claims queued jobs, clones the current GitHub fork into the owner's
persistent home, creates a branch, and exposes workspace operations to the Next
app through server-only API proxies.
Everything runs inside **one long-running container per user**,
`spoon-box-{username}`. The box is a Fedora image (`docker/agent-job.Dockerfile`)
started with `sleep infinity` and a `--memory 4g` / `--cpus 2` cap. Each
thread's agent turn, terminal, and project commands `docker exec` into that same
box; the box's `/home/{username}` is a bind-mounted persistent home so dotfiles,
installed tools, shell history, and thread checkouts under `~/Code/{spoon}/{branch}`
survive across sessions. The box is reference-counted by the worker and reaped
after `SPOON_AGENT_BOX_IDLE_MS` idle (default 30m). There is no per-job
`docker run --rm` container and no separate `opencode serve` container — that
path was removed.
Workspace capabilities:
@@ -154,16 +190,18 @@ Workspace capabilities:
- inspect diffs
- send thread messages to the agent
- run configured commands
- use an interactive terminal (xterm.js → box PTY; see
[docs/agent-terminal.md](docs/agent-terminal.md))
- store logs and artifacts
- push a branch
- open a draft PR
The browser never receives worker tokens and never talks directly to the worker
or job container.
or the box.
Worker cleanup is available in `Settings -> Worker`. It can delete old terminal
workspace records and ask the active worker to remove orphaned job containers
and inactive work directories.
Worker cleanup is available in `Settings -> Worker`. It can delete stale
workspace records and ask the active worker to remove orphaned containers and
inactive work directories (persistent per-user homes are preserved).
Local worker development:
@@ -174,20 +212,53 @@ bun dev:next:worker
bun dev:next:worker:staging
```
Local host-run worker commands still load env through Infisical, then
`scripts/dev-agent-worker` selects Podman when available, falls back to Docker,
and publishes the OpenCode server on a localhost port so the host worker can
reach the job container. Override with:
Local host-run worker commands load env through Infisical, then
`scripts/dev-agent-worker` selects Podman when available and falls back to
Docker. Override the container CLI with:
```env
SPOON_AGENT_CONTAINER_RUNTIME=podman
SPOON_AGENT_CONTAINER_ACCESS=host_port
```
</details>
<details>
<summary><strong>Production agent runtime images</strong></summary>
<summary><strong>Agent runtimes</strong></summary>
Three agent CLIs run inside the box behind one `AgentRuntime` adapter interface
(`apps/agent-worker/src/runtime/*-adapter.ts`), selected by `job.runtime`:
| Runtime | CLI | Selected for |
| ---------- | ----------------------------------- | -------------------------------------------------- |
| `codex` | `@openai/codex` | OpenAI providers and Codex ChatGPT-login snapshots |
| `opencode` | `opencode-ai` | OpenAI-compatible API-key providers (default) |
| `claude` | `@anthropic-ai/claude-code@2.1.207` | Anthropic providers |
The runtime is validated at queue time (`runtimeSupport.ts` +
`agentJobs.insertJob`): a job is rejected unless the resolved runtime is one the
AI provider profile supports. `runtimesForProfile` maps profiles to runtimes —
ChatGPT-login snapshots → `codex` only; `openai` API keys → `opencode`/`codex`;
Anthropic API keys → `claude`/`opencode`; Anthropic `anthropic_oauth_json`
credential snapshots → `claude` only; every other OpenAI-compatible API key →
`opencode`.
Auth is materialized into the box before a turn:
- **Codex ChatGPT-login** profiles get the encrypted `auth.json` written to
`CODEX_HOME/.codex/auth.json`.
- **Anthropic API-key** profiles authenticate via `ANTHROPIC_API_KEY` in the
environment (no file needed).
- **Anthropic `anthropic_oauth_json`** credential-snapshot profiles get the
encrypted OAuth blob written to `~/.claude/.credentials.json`.
- **API-key** profiles run through OpenCode.
All materialized auth files are written `0600`. Treat those saved auth files
like a password and only use them on trusted workers.
</details>
<details>
<summary><strong>Production runtime images</strong></summary>
Gitea CI builds and pushes three production images:
@@ -197,32 +268,36 @@ git.gbrown.org/gib/spoon-agent-worker:latest
git.gbrown.org/gib/spoon-agent-job:latest
```
The worker image is the long-running service that polls Convex. The job image is
the isolated workbench that the worker launches for each agent job. For the MVP,
production should use the repo-provided JS/TS workbench image:
The worker image is the long-running service that polls Convex. The
`spoon-agent-job` image is the **box** image — the per-user Fedora dev box the
worker `exec`s into (it is not launched fresh per job). Point the worker at it
with:
```env
SPOON_AGENT_JOB_IMAGE="git.gbrown.org/gib/spoon-agent-job:latest"
```
The job image includes Node 22, Bun, pnpm and yarn through Corepack, npm, git,
ripgrep, Python, build tools, OpenCode, the Codex CLI, and the Claude Code CLI.
It is not the forked project's production runtime; it is the agent execution
environment.
The box image is Fedora 41 with Node, Bun, pnpm and yarn, npm, git, ripgrep,
Python, build tools, plus interactive tooling (neovim, tmux, fzf, fd, bat, eza,
zoxide, gh, gum, oh-my-posh) and the pinned agent CLIs OpenCode, Codex, and
Claude Code. It is not the forked project's production runtime; it is the agent
execution environment.
Production worker runtime requirements:
- `spoon-agent-worker` must run as a separate service.
- The worker needs `/var/run/docker.sock` mounted so it can launch job
containers.
- Production should keep `SPOON_AGENT_CONTAINER_RUNTIME=docker` and
`SPOON_AGENT_CONTAINER_ACCESS=network`.
- The production Docker host must be logged into `git.gbrown.org` so worker jobs
can pull the private `spoon-agent-job` image.
- The worker needs `/var/run/docker.sock` mounted so it can create and `exec`
into per-user boxes on the host daemon.
- Production should keep `SPOON_AGENT_CONTAINER_RUNTIME=docker`.
- `SPOON_AGENT_HOST_WORKDIR` must equal the absolute host path backing
`SPOON_AGENT_WORKDIR` (identical inside and outside the worker container) so
the host daemon can bind-mount per-user homes under `${WORKDIR}/homes/{username}`.
- The production Docker host must be logged into `git.gbrown.org` so the worker
can pull the private `spoon-agent-job` box image.
- `SPOON_WORKER_TOKEN` must match the value stored in Convex production env.
- `spoon-next` needs `SPOON_AGENT_WORKER_URL=http://spoon-agent-worker:3921` and
`SPOON_AGENT_WORKER_INTERNAL_TOKEN` so Next API routes can proxy workspace
file, diff, message, command, and draft PR actions.
`SPOON_AGENT_WORKER_INTERNAL_TOKEN` so Next API routes can proxy workspace and
box file, diff, message, command, terminal, and draft PR actions.
- `spoon-agent-worker` also needs `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`.
If the private key is stored in a single-line dotenv value, encode newlines as
literal `\n` characters so the worker can restore the PEM before using it.
@@ -241,26 +316,57 @@ curl -H "Authorization: Bearer $SPOON_AGENT_WORKER_INTERNAL_TOKEN" \
Deployment readiness checklist:
1. Production Convex env has `SPOON_WORKER_TOKEN`, `SPOON_ENCRYPTION_KEY`,
GitHub App env, and Convex Auth signing keys.
GitHub App env (including `GITHUB_APP_WEBHOOK_SECRET`), and Convex Auth
signing keys.
2. Compose env has `SPOON_AGENT_WORKER_URL`,
`SPOON_AGENT_WORKER_INTERNAL_TOKEN`, `SPOON_AGENT_JOB_IMAGE`, and the GitHub
App private key.
App private key; the `spoon-next` image is built with
`NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL`.
3. The production Docker host can pull private images from `git.gbrown.org`.
4. `Settings -> Worker` reports the expected job image, runtime, network, and
active workspace count.
4. `Settings -> Worker` reports the expected box image, runtime, network, and
active box count.
5. The first test thread uses a configured API-key provider or a trusted Codex
login profile.
6. If a worker restart leaves stale workspace state, use the workspace recovery
panel or `Settings -> Worker` cleanup.
6. If a worker restart leaves stale state, use the workspace recovery panel or
`Settings -> Worker` cleanup.
API-key based AI provider profiles run through OpenCode. Codex ChatGPT login
profiles run through the Codex CLI: Spoon writes the encrypted `auth.json` into
the isolated job workspace as `CODEX_HOME/.codex/auth.json` before execution.
Anthropic profiles run through the Claude Code CLI: API-key profiles authenticate
via `ANTHROPIC_API_KEY`, while `anthropic_oauth_json` credential-snapshot profiles
have their encrypted OAuth blob written into the isolated job workspace as
`~/.claude/.credentials.json` before execution. Treat those saved auth files like
a password and only use them on trusted workers.
</details>
<details>
<summary><strong>GitHub webhooks</strong></summary>
Spoon exposes a signature-verified webhook as a Convex `httpAction` at
`POST /webhooks/github` (`packages/backend/convex/githubWebhooks.ts`,
`http.ts`). Configure the GitHub App webhook URL as
`<CONVEX_SITE_URL>/webhooks/github` and set `GITHUB_APP_WEBHOOK_SECRET`; the
endpoint fails closed (HTTP 503) when no secret is configured and rejects any
payload whose `x-hub-signature-256` does not verify.
- `push` → a targeted drift refresh for every Spoon tracking that repository.
- `installation` / `installation_repositories` → the matching connection's
status flips to `revoked` (deleted/suspended), `active` (created/unsuspended),
or otherwise `needs_reauth`, surfaced in `Settings -> Integrations`.
The hourly `refreshDueSpoons` cron remains a fallback, so drift still refreshes
even if a webhook is missed.
</details>
<details>
<summary><strong>Notifications</strong></summary>
Spoon delivers an in-app bell plus preference-gated transactional email
(`packages/backend/convex/notifications.ts`). Five kinds are emitted:
`maintenance_thread`, `agent_turn_finished`, `agent_needs_input`, `sync_failed`,
and `connection_needs_reauth`.
- Every event inserts an in-app notification (bell badge via `unreadCount`,
list via `listMine`, `markRead` / `markAllRead`).
- Email is sent through UseSend (`USESEND_*`) only when the user has an email
and the matching per-kind preference is not disabled. Unset preferences
default to enabled.
- Per-kind email toggles live in `Settings -> Notifications`. Web push is out of
scope.
</details>
@@ -289,45 +395,54 @@ a password and only use them on trusted workers.
<details>
<summary><strong>Core tables</strong></summary>
| Table | Purpose |
| ------------------------ | --------------------------------------------------------- |
| `spoons` | Managed fork records |
| `threads` | Durable maintenance and work conversations |
| `threadMessages` | Messages inside threads |
| `syncRuns` | Upstream checks, sync attempts, and maintenance decisions |
| `ignoredUpstreamChanges` | Intentional ignore records that affect effective drift |
| `gitConnections` | Git provider connection metadata |
| `spoonRepositoryStates` | Latest cached upstream/fork state |
| `spoonCommits` | Cached upstream and fork-only commits |
| `spoonPullRequests` | Cached fork/upstream pull requests |
| `spoonSecrets` | Encrypted per-Spoon environment variables |
| `spoonAgentSettings` | Per-Spoon runtime, branch, command, and env-file settings |
| `aiProviderProfiles` | Encrypted provider/auth profiles used by OpenCode |
| `agentJobs` | Worker-executed workspace jobs and PR lifecycle |
| `agentJobEvents` | Append-only worker event log |
| `agentJobArtifacts` | Diffs, summaries, command output, PR body drafts |
| `agentWorkspaceChanges` | Recorded user, agent, and command file changes |
| Table | Purpose |
| ------------------------- | ----------------------------------------------------------- |
| `spoons` | Managed fork records |
| `threads` | Durable maintenance and work conversations |
| `threadMessages` | Messages inside threads |
| `syncRuns` | Upstream checks, sync attempts, and maintenance decisions |
| `ignoredUpstreamChanges` | Intentional ignore records that affect effective drift |
| `gitConnections` | Git provider connection metadata |
| `spoonRepositoryStates` | Latest cached upstream/fork state |
| `spoonCommits` | Cached upstream and fork-only commits |
| `spoonPullRequests` | Cached fork/upstream pull requests |
| `spoonSecrets` | Encrypted per-Spoon environment variables |
| `spoonAgentSettings` | Per-Spoon runtime, branch, command, and env-file settings |
| `aiProviderProfiles` | Encrypted provider/auth profiles (Codex/OpenCode/Anthropic) |
| `agentJobs` | Worker-executed workspace jobs and PR lifecycle |
| `agentJobEvents` | Append-only worker event log |
| `agentJobArtifacts` | Diffs, summaries, command output, PR body drafts |
| `agentWorkspaceChanges` | Recorded user, agent, and command file changes |
| `userDotfiles` | Encrypted per-user dotfiles overlay + repo/setup config |
| `userEnvironment` | Per-user box/home environment config |
| `notifications` | In-app notification rows (bell) |
| `notificationPreferences` | Per-user email notification toggles |
</details>
<details>
<summary><strong>Important routes</strong></summary>
| Route | Purpose |
| --------------------------------- | --------------------------------------- |
| `/` | Public product landing page |
| `/dashboard` | Maintenance overview |
| `/spoons` | Managed fork list |
| `/spoons/new` | Manual/GitHub Spoon creation |
| `/spoons/[spoonId]` | Spoon detail dashboard |
| `/spoons/[spoonId]/agent/[jobId]` | Interactive workspace |
| `/threads` | Global thread queue |
| `/threads/[threadId]` | Thread detail |
| `/settings/profile` | User profile settings |
| `/settings/integrations` | GitHub and service integration settings |
| `/settings/ai-providers` | AI/OpenCode provider profiles |
| Route | Purpose |
| --------------------------------- | ----------------------------------------------- |
| `/` | Public product landing page |
| `/dashboard` | Maintenance overview |
| `/spoons` | Managed fork list |
| `/spoons/new` | Manual/GitHub Spoon creation |
| `/spoons/[spoonId]` | Spoon detail dashboard |
| `/spoons/[spoonId]/agent/[jobId]` | Interactive workspace |
| `/threads` | Global thread queue |
| `/threads/[threadId]` | Thread detail |
| `/machine` | My Machine: box status, terminal, home |
| `/settings/profile` | User profile settings |
| `/settings/integrations` | GitHub and service integration settings |
| `/settings/ai-providers` | AI provider profiles (Codex/OpenCode/Anthropic) |
| `/settings/dotfiles` | Per-user dotfiles overlay + repo |
| `/settings/notifications` | Email notification preferences |
Legacy `/updates` and `/agents` routes redirect into `/threads`.
The Convex `httpAction` `POST /webhooks/github` (mounted at
`<CONVEX_SITE_URL>/webhooks/github`) handles GitHub App webhooks. Legacy
`/updates` and `/agents` routes redirect into `/threads`.
</details>
@@ -428,16 +543,17 @@ not call Infisical.
<details open>
<summary><strong>Public Next variables</strong></summary>
| Variable | Used for |
| --------------------------------- | ------------------------------------------- |
| `NEXT_PUBLIC_SITE_URL` | Canonical Spoon web URL |
| `NEXT_PUBLIC_CONVEX_URL` | Convex client URL |
| `NEXT_PUBLIC_DEPLOYMENT_URL` | Convex dashboard/deployment URL when needed |
| `NEXT_PUBLIC_PLAUSIBLE_URL` | Plausible analytics endpoint |
| `NEXT_PUBLIC_SENTRY_DSN` | Browser Sentry DSN |
| `NEXT_PUBLIC_SENTRY_URL` | Sentry instance URL |
| `NEXT_PUBLIC_SENTRY_ORG` | Sentry organization |
| `NEXT_PUBLIC_SENTRY_PROJECT_NAME` | Sentry project name |
| Variable | Used for |
| --------------------------------------- | ------------------------------------------------------------------- |
| `NEXT_PUBLIC_SITE_URL` | Canonical Spoon web URL |
| `NEXT_PUBLIC_CONVEX_URL` | Convex client URL |
| `NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL` | Browser-facing worker WS base for the terminal (**build-time** var) |
| `NEXT_PUBLIC_DEPLOYMENT_URL` | Convex dashboard/deployment URL when needed |
| `NEXT_PUBLIC_PLAUSIBLE_URL` | Plausible analytics endpoint |
| `NEXT_PUBLIC_SENTRY_DSN` | Browser Sentry DSN |
| `NEXT_PUBLIC_SENTRY_URL` | Sentry instance URL |
| `NEXT_PUBLIC_SENTRY_ORG` | Sentry organization |
| `NEXT_PUBLIC_SENTRY_PROJECT_NAME` | Sentry project name |
</details>
@@ -492,15 +608,16 @@ not call Infisical.
| `SPOON_AGENT_WORKER_URL` | Internal worker HTTP URL used by Next |
| `SPOON_AGENT_WORKER_HTTP_PORT` | Worker HTTP port |
| `SPOON_AGENT_WORKER_INTERNAL_TOKEN` | Server-only token for Next-to-worker proxy |
| `SPOON_AGENT_JOB_IMAGE` | Agent job container image |
| `SPOON_AGENT_TERMINAL_SECRET` | HMAC secret for terminal/box tokens (falls back to the internal/worker token) |
| `SPOON_AGENT_JOB_IMAGE` | Per-user box (Fedora) image the worker `exec`s into |
| `SPOON_AGENT_RUNTIME` | Runtime mode, currently Docker/Podman-oriented |
| `SPOON_AGENT_CONTAINER_RUNTIME` | Container CLI used by worker, `docker`/`podman` |
| `SPOON_AGENT_CONTAINER_ACCESS` | `network` in prod, `host_port` for host dev |
| `SPOON_AGENT_BOX_IDLE_MS` | Idle time before a per-user box is reaped (default `1800000`) |
| `SPOON_AGENT_MAX_CONCURRENT_JOBS` | Worker concurrency limit |
| `SPOON_AGENT_JOB_TIMEOUT_MS` | Job timeout |
| `SPOON_AGENT_WORKDIR` | Worker work directory |
| `SPOON_AGENT_JOB_TIMEOUT_MS` | Agent turn timeout |
| `SPOON_AGENT_WORKDIR` | Worker work directory; per-user homes live under `homes/{username}` |
| `SPOON_AGENT_HOST_WORKDIR` | Host path matching `SPOON_AGENT_WORKDIR` when the worker runs in Docker and controls the host Docker socket |
| `SPOON_AGENT_NETWORK` | Optional job container network |
| `SPOON_AGENT_NETWORK` | Optional box container network |
</details>
@@ -528,12 +645,20 @@ not call Infisical.
- GitHub drift refresh, commit cache, PR cache, and sync-run history
- Effective drift and ignored upstream change records
- Global Threads page and Spoon-scoped Threads tab
- OpenCode/Codex-oriented agent worker and browser workspace foundation
- Persistent per-user box the worker `exec`s into for agent turns, terminal, and
commands
- Three agent runtimes (Codex, OpenCode, Claude Code) behind one adapter, with
queue-time runtime validation
- My Machine surface (box status/start/stop/restart, terminal, home browser)
- Interactive workspace terminal (xterm.js → box PTY)
- Per-user dotfiles overlay + optional dotfiles repo
- Signature-verified GitHub webhook with hourly cron fallback
- In-app + email notifications with per-user preferences
- Monaco editor with optional Vim mode
- Diff viewer, command panel, worker logs, and artifacts
- Encrypted Spoon secrets and bulk `.env` import
- Encrypted AI provider profiles, including Codex auth JSON and API-key
provider support
- Encrypted AI provider profiles, including Codex/Anthropic auth JSON and
API-key provider support
- Authentik, GitHub, and password auth through Convex Auth
- Self-hosted Convex/Postgres deployment model