Skip to content

REST API

All dashboard endpoints are prefixed with /api. Three endpoints live outside that prefix: /health, /webhook/github and /internal/reviews/:id/findings.

Authentication

When GitHub OAuth is configured (BETTER_AUTH_SECRET, GITHUB_APP_CLIENT_ID and GITHUB_APP_CLIENT_SECRET all set), every path under /api/ requires an authenticated session cookie. Exactly two are exempt:

  • /api/auth/* — better-auth's own endpoints
  • /api/auth-status

An unauthenticated request to any other /api path gets 401 with the plain-text body Unauthorized. When OAuth is not configured the gate is off and the whole API is open — see Architecture.

Errors

Two different error bodies exist, depending on where the failure happens.

Handlers that reject a request themselves mostly return a plain-text body:

404 Not found

Anything that falls through to the global error handler returns JSON:

json
{ "error": "not found" }

with { "error": "internal error" } for 5xx. A request body that violates a route's schema is rejected by Elysia with 422 and Elysia's own validation error body, before the handler runs.

Per-endpoint bodies that differ from these are documented below.

Conventions

  • Row shapes come from @fouine/shared and are listed in the type appendix. This page names them (200 → ReviewRow[]) rather than repeating columns.
  • Timestamps (created_at, completed_at) are integer Unix epoch seconds, not ISO strings.
  • enabled on a repo or a skill row is a SQLite integer, 0 or 1 — not a JSON boolean. The one place a real boolean is accepted is the PUT /api/skills/:name body.
  • Review status is one of pending, running, completed, failed, skipped.
  • Route params match a single path segment. :owner and :name are joined back into owner/name server-side.

Repos

List repos

GET /api/repos

200 → RepoRow[], newest created_at first.

Get repo

GET /api/repos/:owner/:name

200 → RepoRow. 404 if the repo is not registered.

Register repo

POST /api/repos
Content-Type: application/json

{
  "full_name": "owner/repo",
  "installation_id": 12345
}
FieldTypeRequiredDescription
full_namestringyesowner/repo
installation_idnumberyesGitHub App installation ID

200 → RepoRow. Upsert: a new repo is inserted with enabled = 0, and re-posting an existing one updates only installation_idenabled, prompt and model are left alone.

Update repo

PUT /api/repos/:owner/:name
Content-Type: application/json

{
  "prompt": "Review this code for...",
  "model": "opencode-go/deepseek-v4-flash",
  "enabled": 1
}
FieldTypeOmitted meansDescription
promptstringcleared to nullPer-repo review prompt
modelstringcleared to nullPer-repo model override
enablednumberunchanged1 active, 0 disabled

All three fields are optional in the schema, but prompt and model are written unconditionally: omitting either clears it. Send the current value to keep it. Only enabled falls back to what is already stored.

200 → RepoRow (the row after the write). 404 if the repo is not registered.

Delete repo

DELETE /api/repos/:owner/:name

204, no body. Deleting a repo that does not exist is not an error — it also returns 204.

Trigger the improver

POST /api/repos/:owner/:name/improve

Queues an out-of-band run of the prompt improver for this repo. Fire-and-forget: the run happens after the response.

202 → { "ok": true }. 404 if the repo is not registered. The improver may still decline to run (too soon, no new completed reviews); that decision is logged server-side and is not visible in the response.

List repo reviews

GET /api/repos/:owner/:name/reviews

200 → ReviewRow[] — the last 200 reviews for the repo, newest id first. No query params; the limit is fixed.

List reviews for one PR

GET /api/repos/:owner/:name/pr/:number

200 → ReviewRow[] — the last 200 reviews for that PR number, newest id first.

Reviews

List reviews

GET /api/reviews
ParamTypeDefaultDescription
limitnumber100Clamped to 1…1000; a non-numeric or non-positive value falls back to 100
statusstringnoneExact match on status, skipped included
rangestringnoneSee Stats filters
from, toYYYY-MM-DDnoneSee Stats filters
repostringnoneExact owner/name
modelstringnoneExact model spec

200 → ReviewRow[], newest id first. skipped rows appear in this list on purpose — the timeline records that fouine looked and found nothing new.

Get review

GET /api/reviews/:id

200 → ReviewRow. 404 if there is no review with that id.

Get review findings

GET /api/reviews/:id/findings

200 → FindingRow[], ordered by id. An unknown review id returns 200 [], not a 404.

Get review session

GET /api/reviews/:id/session

Shells out to opencode export <session_id> and returns the transcript.

  • 200 → the parsed OpenCode session JSON (shape defined by opencode, not by fouine).
  • 404 if the review does not exist, or has no session_id.
  • 200 → { "error": "session-unavailable", "detail": "<stderr>" } if the export command fails or prints nothing.
  • 200 → { "error": "session-unparseable", "raw": "<first 1000 chars>" } if the output is not JSON.

The two error cases are 200 responses, not error statuses — check for the error key.

Retry review

POST /api/reviews/:id/retry

Re-fetches the PR from GitHub and starts a fresh review of it, tagged with trigger retry. The original row is left as it is; the retry creates a new one.

Failed reviews also retry themselves automatically, once: 60 seconds after a genuine failure, the server re-runs the review (trigger retry, attempt 1). A review stopped by a user, superseded by a newer push, or interrupted by a server restart is never auto-retried, and neither is the retry itself. If another review for the same PR is already running when the delay elapses, the retry stands down. When the retry itself fails, fouine posts a comment on the PR saying so.

  • 202 → { "ok": true } — queued.
  • 404 if the review does not exist (Not found) or its repo is no longer registered (repo not found).
  • 502 → { "ok": false, "error": "..." } if GitHub cannot be reached or the PR cannot be fetched.

Stop review

POST /api/reviews/:id/stop

Aborts a live run. If no live run is found but the row is still pending/running, the row is marked failed with the error Stopped by user.

  • 200 → { "ok": true, "live": true|false }live says whether an in-process run was actually aborted.
  • 200 → { "ok": false, "reason": "already completed" } if the review already reached a terminal status.
  • 404 if there is no review with that id.

Stats

Stats filters

GET /api/stats, GET /api/stats/charts and GET /api/reviews share one filter parser.

ParamTypeDefaultDescription
range24h | 7d | 30d | 90d | allnoneRolling window ending now. all = no cutoff
fromYYYY-MM-DDnoneInclusive lower bound, UTC
toYYYY-MM-DDnoneInclusive upper bound, UTC (compared as < to + 1 day)
repostringnoneExact owner/name
modelstringnoneExact model spec

Rules the handler actually applies:

  • Omitting range, from and to means no date filter at all — all-time.
  • If either from or to parses as a real date, the pair wins and range is ignored.
  • A from/to window where from >= to drops the upper bound rather than erroring.
  • An from/to value that is not a valid YYYY-MM-DD date (2026-13-45, 2026-02-30) is treated as absent.
  • An explicit range that is not one of the five listed values falls back to 30d.
  • An empty query string (?repo=) is "no filter", not a filter on the empty string.

Days are bucketed in UTC, matching how the daily aggregates are grouped.

Stats summary

GET /api/stats

Accepts the filter params above. 200 →

KeyType
projectsProjectStatsRow[]
modelsModelStatsRow[]
dailyDailyStatsRow[]
triggersTriggerStatsRow[]
latency{ avg: number | null, count: number, p95: number | null } — seconds
topCostTopCostRow[] — top 5 by cost
severitySeverityStatsRow[]
allModelsstring[]

allModels is deliberately unfiltered: it lists every model ever recorded so a filter dropdown never loses its own options.

Every aggregate here excludes skipped reviews. latency is computed over completed reviews only.

Chart panels

GET /api/stats/charts

Same filter params. Split out from /api/stats because the latency trend reads one row per completed review. 200 →

KeyType
reliabilityReliabilityRow[]
latency{ day: string, count: number, p50: number | null, p95: number | null }[]
latencyTruncatedboolean
findingsDailyFindingsDailyRow[]
topFilesTopFileRow[] — top 10

latency percentiles are nearest-rank, computed per UTC day. The underlying sample query is capped at 5000 rows; latencyTruncated is true when that cap was hit, meaning the trend was drawn from a partial population.

Models

Search models

GET /api/models?q=glm

Backs the model autocompletes in the dashboard. q filters on the provider/model spec (case-insensitive); results are capped at 100.

Only models from configured providers are returned — see configured below. Add all=1 to search the whole models.dev catalog instead, for picking a model on a provider whose key you haven't added yet.

json
{
  "total": 112,
  "providers": ["commandcode", "opencode", "opencode-go", "zai-coding-plan"],
  "models": [
    {
      "id": "zai-coding-plan/glm-5.2",
      "provider": "zai-coding-plan",
      "providerName": "Z.AI Coding Plan",
      "model": "glm-5.2",
      "modelName": "GLM-5.2",
      "configured": true
    }
  ]
}

A provider is configured when fouine holds a key that reaches it:

  • zai-coding-plan when the GLM Coding Plan key is set
  • commandcode when the Command Code key is set (its models come from the catalog bundled with the @brainervirus/opencode-commandcode plugin, since models.dev does not carry the provider; ids are commandcode/<config key>, e.g. commandcode/deepseek-v4-flash)
  • opencode and opencode-go when the OpenCode key is set
  • any provider already named by the default model, the improver model, or a per-repo override — a live config never disappears from its own picker, even if its key is missing

providers lists that set. Without all=1 the response contains only these, which is ~112 models rather than the 5,755 models.dev knows about.

The catalog comes from models.dev via @opencode-ai/models and is cached in-process for 30 minutes; add ?refresh=1 to rebuild it. If models.dev is unreachable, the snapshot bundled in that package (at most ~24h stale) is used instead, so the picker still works with no egress.

Settings

Get settings

GET /api/settings

200 → a flat key-value object of every stored setting, values as strings. Keys written by the dashboard are opencode_api_key, zai_api_key, commandcode_api_key, opencode_model, refine_model, implement_model, default_prompt and improver_model. A key that has never been set is absent from the object.

json
{
  "opencode_api_key": "sk-...",
  "zai_api_key": "sk-...",
  "opencode_model": "opencode-go/deepseek-v4-flash",
  "default_prompt": "Review this PR..."
}

The API key is returned as stored, unredacted.

Update settings

PUT /api/settings
Content-Type: application/json

{
  "opencode_api_key": "your-key",
  "zai_api_key": "your-z-ai-key",
  "commandcode_api_key": "your-command-code-key",
  "opencode_model": "opencode-go/deepseek-v4-flash",
  "default_prompt": "Review this PR...",
  "refine_model": "opencode-go/deepseek-v4-flash",
  "implement_model": "opencode-go/deepseek-v4-flash",
  "improver_model": "opencode-go/deepseek-v4-flash"
}

Model fields follow a most-specific-first cascade: a repo's own override, then the repo's review model, then the global agent default (refine_model / implement_model), then the global review default (opencode_model). Sending an empty string deletes the stored row, falling back down the cascade.

All fields are optional; an absent field keeps its stored value. An explicit "" deletes the stored row so the fallback takes over — for the two API keys that is the environment, for the four model fields the cascade above, for the toggles, prompts and implement label their built-in defaults. The one exception is default_prompt: "" is a no-op, so a global review prompt can only be replaced, never unset, through the API.

200 → the full settings object, as GET /api/settings.

Test connection

GET /api/settings/test

Sends one small real prompt through the configured key and model. Costs roughly one request against the provider.

200 → { "ok": true, "text": "OK" } (text is the reply, truncated to 200 chars), or 200 → { "ok": false, "error": "..." }. Failures are reported in the body, not by status code.

Skills

Global reviewer skills fetched from skills.sh or GitHub. Enabled skills are materialised into the opencode config directory and picked up by subsequent reviews.

List skills

GET /api/skills

200 → SkillMetaRow[], newest created_at first. The stored file blob is never returned.

Install skill

POST /api/skills
Content-Type: application/json

{ "url": "https://github.com/owner/repo/tree/main/skills/my-skill" }

Fetches the skill, pins it to the commit SHA it was fetched at, stores it enabled, and writes it to disk. Re-installing an existing skill by name updates it in place and re-enables it.

200 → SkillMetaRow. 422 → { "error": "..." } if the URL cannot be parsed or the fetch fails.

Enable / disable skill

PUT /api/skills/:name
Content-Type: application/json

{ "enabled": true }

enabled is a real JSON boolean here and is required. It is stored as 0/1.

200 → SkillMetaRow. 404 → { "error": "not found" }.

Delete skill

DELETE /api/skills/:name

204, no body. Deleting an unknown skill also returns 204.

Chat

POST /api/chat
Content-Type: application/json

{
  "messages": [
    { "role": "user", "parts": [{ "type": "text", "text": "What did reviews cost last week?" }] }
  ]
}

Not a JSON endpoint. On success the response is an AI SDK UI message stream — the format useChat consumes directly. Read it as a stream, not with response.json().

Request body:

FieldTypeConstraint
messagesarray1–40 items
messages[].idstringoptional, ≤ 128 chars
messages[].roleuser | assistant | systemrequired
messages[].partsarray≤ 64 items
messages[].parts[].typestring≤ 64 chars
messages[].parts[].textstringoptional, ≤ 4000 chars

Extra keys on a part are accepted and ignored.

Behaviour worth knowing before scripting against it:

  • Only user messages with text parts survive server-side. Assistant and system turns sent by the client are discarded, and the model never sees its own prior replies — every answer is recomputed from a fresh query.
  • The model has exactly one tool: a read-only SQLite SELECT against fouine's own database. It runs at most 6 steps per turn.
  • Aborting the request aborts the upstream model run.
  • Nothing is persisted. The thread lives entirely in the client.

Statuses:

  • 200 with a stream on success.
  • 400 → { "error": "..." } for a configuration or input problem the handler can name — no API key set, no usable question, a question over the character limit.
  • 422 if the body violates the schema above.

Model errors and rejected SQL are not statuses: they arrive inside the stream as text.

Live events

GET /api/events
GET /api/events?repo=owner/name

An SSE stream (text/event-stream) under the same auth gate as the rest of /api. With ?repo=owner/name the stream is filtered server-side to that repo; without it, all repos.

Each data frame carries a monotonic per-boot id and a JSON payload whose type field names the event:

id: 42
data: {"type":"review:updated","repo":"owner/name","review":{...}}

A named heartbeat event is sent immediately on connect and after every 25s of silence.

Events are nudges to refetch, not a replay log — Last-Event-ID is ignored and there is no buffered history. The event types, payloads and the reconnect model are documented in Architecture.

Endpoints outside /api

Health

GET /health

200 → { "ok": true }. Never behind the auth gate.

Auth status

GET /api/auth-status

200 → { "enabled": true|false } — whether OAuth is configured. Under /api but explicitly exempt from the gate, so the login page can ask before it has a session.

GitHub webhook

POST /webhook/github
X-Hub-Signature-256: sha256=...
X-GitHub-Event: pull_request
X-GitHub-Delivery: <uuid>

The GitHub App's webhook receiver. The raw body is HMAC-verified against the configured webhook secret before anything is dispatched.

200 → { "ok": true } once the delivery has been verified and dispatched. 401 → { "error": "invalid signature" } if verification fails. Not behind the OAuth gate — the signature is its authentication.

Internal findings write-back

POST /internal/reviews/:id/findings

Not for external callers. This is a loopback surface: the opencode post_comment / post_review tools call it on the server itself, right after posting to GitHub, so fouine keeps a structured record of what was flagged. It sits outside the OAuth gate and is guarded instead by a shared secret regenerated on every boot and passed in a request header — a secret no client outside the process ever holds. Treat it as an implementation detail; it is not part of the API surface a self-hoster scripts against.

Type appendix

The shapes below live in packages/shared/src/index.ts and are imported by both the server and the dashboard. All timestamps are Unix epoch seconds.

RepoRow

FieldType
full_namestring — owner/repo
installation_idnumber
promptstring | null
modelstring | null
enablednumber — 0 or 1
created_atnumber

ReviewRow

FieldType
idnumber
repo_full_namestring
pr_numbernumber — 0 for improver runs
titlestring | null
session_idstring | null
statusstring — pending | running | completed | failed | skipped
errorstring | null
triggerstring | null — opened | synchronize | reopened | command | retry
attemptnumber — 0 for a first run, 1 for the single automatic retry after a failure
costnumber | null — set at completion
tokensnumber | null — set at completion
modelstring | null — resolved spec, set at completion
check_run_idnumber | null
patch_idstring | null — git patch-id --stable over base...head, success path only
created_atnumber
completed_atnumber | null

Nullable columns are also null on rows written before that column existed.

FindingRow

FieldType
idnumber
review_idnumber
repo_full_namestring
pr_numbernumber
kindstring — inline | summary | comment
severitystring | null — blocking | nit | question, inline rows only
eventstring | null — COMMENT | APPROVE | REQUEST_CHANGES, summary rows only
pathstring | null — inline rows only
linenumber | null
bodystring
github_review_idnumber | null
github_comment_idnumber | null
created_atnumber

SkillMetaRow

FieldType
namestring — primary key
source_urlstring
ownerstring
repostring
pathstring
refstring — pinned commit SHA
descriptionstring | null
enablednumber — 0 or 1
created_atnumber

Aggregate rows

TypeFields
ProjectStatsRowrepo_full_name, reviews, cost, tokens, avg_duration: number | null (seconds)
ModelStatsRowmodel, reviews, cost, tokens
DailyStatsRowday (YYYY-MM-DD, UTC), reviews, cost, tokens
TriggerStatsRowtrigger (unknown when null), count
SeverityStatsRowseverity, count
TopCostRowid, repo_full_name, pr_number, cost, tokens: number | null, model: string | null
ReliabilityRowday, completed, failed, in_flight (pending + running)
FindingsDailyRowday, severity, count
TopFileRowpath, count

cost is in the provider's billing units as reported by the opencode session; COALESCE keeps sums at 0 rather than null when every row in a group has a null cost.

Released under the MIT License.