Skip to content

API

Mission Control’s orchestrator exposes a private implementation API on 127.0.0.1 and server.port (4400 by default). It does not serve the renderer: Electron loads that from brevi://app.

Every management request and dashboard WebSocket requires a random per-launch token known to the Electron renderer. The listener cannot be exposed through server.host; ordinary browsers on the same machine cannot access the management surface. /api/health, OAuth callbacks, and worker-authenticated routes are the narrow exceptions described below.

Method Path Returns
GET /api/health HealthResponse
GET /api/config Redacted BreviConfig
GET /api/tickets Ticket[]: the current eligible queue
GET /api/runs Run[], newest first
GET /api/runs/:id Run
GET /api/runs/:id/events RunEvent[]: full history
GET /api/runs/:id/artifacts/:name Raw artifact bytes
POST /api/tickets/:id/run Run: manually queue a ticket
POST /api/runs/:id/cancel Run
POST /api/runs/:id/retry Run
POST /api/runs/:id/followup Run: start a follow-up on a completed run’s open PR
GET /api/runs/:id/pr PrStatusResponse
POST /api/runs/:id/resume ResumeRunResponse
POST /api/runs/:id/release Run
WS /ws/runs/:id/attach Web-terminal bridge into the retained sandbox
WS /ws/worker The worker channel: where brevi-worker daemons enroll, register, and receive dispatches
GET /api/workers FleetResponse: every enrolled worker
POST /api/workers/provision WorkerProvisionResponse: set up a Linux worker over SSH
POST /api/workers/:id/rename FleetResponse
POST /api/workers/:id/drain FleetResponse
POST /api/workers/:id/enable FleetResponse
DELETE /api/workers/:id FleetResponse: revoke the worker’s enrollment
GET /api/worker/demand FleetDemandResponse, a worker credential required
POST /api/worker/state FleetDemandResponse, a worker credential required
PUT /api/settings/credentials CredentialsUpdateResponse
POST /api/connect/:provider ConnectResponse
POST /api/connect/github/poll DevicePollResponse
GET /api/connect/r2 R2Status
POST /api/connect/r2 R2ConnectResponse
GET /api/connect/linear/callback HTML (OAuth redirect target)
GET /api/github/repos GithubRepo[]
GET /api/pulls PullListResponse: PRs across every configured repo
GET /api/pulls/:repo/:number PullDetailResponse
POST /api/pulls/:repo/:number/merge PullMergeResponse
POST /api/pulls/:repo/:number/close { ok: true }
POST /api/pulls/:repo/:number/reopen { ok: true }
POST /api/pulls/:repo/:number/ready { ok: true }: take the PR out of draft
POST /api/pulls/:repo/:number/comment { ok: true }
POST /api/pulls/:repo/:number/review { ok: true }
POST /api/pulls/:repo/:number/reply { ok: true }
POST /api/pulls/:repo/:number/resolve-thread { ok: true }
GET /api/linear/projects LinearProject[]
PUT /api/settings SettingsUpdateResponse
GET /ws WebSocket upgrade

Errors are { "error": string } with status 400 (invalid), 404 (not found), 409 (conflict, e.g. the ticket already has an active run), 410 (gone, e.g. a resumable sandbox’s retention window passed), or 500.

{
"ok": true,
"version": "0.1.0",
"sandboxProvider": "bwrap",
"hostMemMib": 16384,
"hostExecution": { "kind": "local-worker" }
}

sandboxProvider is the sandbox this orchestrator would use (bwrap on current releases). hostMemMib is total host memory in MiB (optional; unused by the current dashboard).

hostExecution says whether the machine running the orchestrator can execute runs itself: { kind: "local-worker" } (Linux with bwrap; the host spawns and supervises a worker on this machine), or { kind: "none", reason: "bwrap-unavailable" | "unsupported-platform" }. On "none", Mission Control explains that queued runs wait for a worker instead of failing. Absent from older orchestrators.

type RunStatus =
| "queued" | "preparing" | "running" | "finalizing"
| "completed" | "failed" | "cancelled";
interface Run {
id: string;
ticket: Ticket;
status: RunStatus;
sandbox: { provider?: string; id?: string; retainedUntil?: string; workerId?: string };
agentSessionId?: string; // captured from the Claude stream, powers resume
createdAt: string;
queuedAt?: string;
queueReason?: string; // why a queued run hasn't been dispatched yet, unset once it is
startedAt?: string;
finishedAt?: string;
result?: RunResult; // prUrl / branch / summary / artifacts
error?: string;
costs: CostEntry[];
costTotals?: CostTotals;
prUrl?: string; // the ticket's PR, kept at run level so it survives retries
prState?: "open" | "draft" | "merged" | "closed";
}

GET /api/runs/:id/artifacts/:name serves a file from the run’s artifact directory with a guessed content type. Names that escape the directory are rejected with 400.

Cancelling a terminal run is a no-op and returns it unchanged; cancelling a queued run marks it cancelled immediately; cancelling the active run aborts it.

prUrl and prState are set once a run opens a PR and track it at run level, so a retry (which clears result) never loses sight of the PR. prState is the last observed GitHub state: refreshed by a lazy background poll (about every 2 minutes, for the most recent runs whose PR hasn’t merged or closed, whatever the run’s own status) and whenever a run’s detail view is opened, and streamed to the dashboard over the WebSocket as a run-updated message. The sidebar’s PR chip and inline actions render from it.

queueReason is set by the scheduler’s placement step whenever a queued run has no worker to go to (no workers connected, every worker draining, the fleet at capacity) and cleared the moment the run dispatches. The dashboard shows it on the run’s card and detail view so a stuck queue explains itself.

sandbox.provider on a new run is bwrap.

costs has one CostEntry per agent execution (an attempt, or a future phase/subagent), each carrying label, provider, an optional model, token counts (inputTokens / outputTokens / cacheReadTokens / cacheWriteTokens), an optional costUsd (absent when only tokens are known), and estimated, true when the cost is computed from a pricing table or modeled on a subscription login rather than reported by the provider. An entry may also carry an optional breakdown: an array of per-model CostModelUsage rows, each with model, its own token counts (inputTokens / outputTokens / cacheReadTokens / cacheWriteTokens), and an optional costUsd; the entry’s own token and cost figures are the roll-up (sum) of its breakdown rows. breakdown is present when the execution spanned several models (e.g. a delegated Claude run with an implementer subagent), whether measured from the agent’s transcripts by ccusage inside the sandbox or reconstructed from the output stream; single-model executions stay flat. costTotals sums those entries for the whole run, and beyond the run-wide sums it also carries byModel: an array of CostModelTotal rows, one per distinct model used anywhere in the run, each with summed token counts (inputTokens / outputTokens / cacheReadTokens / cacheWriteTokens), an optional costUsd, and an estimated flag; a model used by several attempts or phases appears once with summed figures, and the dashboard displays this per-model roll-up rather than the per-execution entries.

A completed or failed run keeps its sandbox disk around for sandbox.retentionHours (see Configuration): the checkout with the run’s changes, installed dependencies, and credentials, ready to pick the conversation back up.

POST /api/runs/:id/resume boots that sandbox back up (if it isn’t already) and prepares an interactive claude --resume <sessionId> session inside it:

interface ResumeRunResponse {
run: Run;
attach: RunAttachInfo;
}
type RunAttachInfo = { kind: "worker"; workerId: string; workerName: string };

attach names the worker that holds the retained sandbox. The session itself is never opened on the scheduling host: the desktop terminal connects to WS /ws/runs/:id/attach, which the host relays to that worker’s PTY.

Errors: 404 when the run doesn’t exist, 409 when the run hasn’t finished yet, another follow-up is starting, or its worker is disconnected, 410 once the retention window has passed and the disk was reclaimed, and 400 when the run has no captured agent session id (resume is Claude-only for now; Codex runs don’t report one).

POST /api/runs/:id/release stops a resumed sandbox’s compute again, keeping its disk until the retention window ends, and returns the updated Run. The desktop terminal calls it on detach; it’s a no-op when nothing is booted.

POST /api/runs/:id/followup starts a follow-up on a completed run’s open PR: it rebases the PR branch onto the latest base inside a sandbox (reusing the run’s retained sandbox when still within the retention window, otherwise a fresh checkout), lets the agent resolve conflicts and address unresolved review threads, review summaries, and new comments, pushes with --force-with-lease, and posts one summary comment on the PR; the run’s console streams the whole session and costs append as follow-up entries. The checkout and push target come from the PR itself (its own repository and live head branch), every push gets a summary comment (a drift-only rebase included), and the Linear ticket state is untouched; follow-ups run even while Linear is disconnected.

Errors: 404 unknown run, 409 when the run is not completed, another execution is active, or the PR is merged/closed, 400 when the run has no PR or GitHub is not connected.

GET /api/runs/:id/pr returns { url, number, state } with state open | draft | merged | closed, used by the dashboard to hide the follow-up button once the PR is merged or closed.

WS /ws/runs/:id/attach is what the run detail page’s “Open terminal” button connects to. A finished run’s retained sandbox lives on the worker that executed it, never on the scheduling host, so the host relays: it asks that worker to open the resume session and passes its PTY bytes through to this socket. The renderer needs no SSH access. Messages are JSON in both directions:

// server -> client
type AttachServerMessage =
| { type: "data"; data: string } // terminal output
| { type: "exit"; code: number } // the session process ended
| { type: "error"; message: string }; // resume failed (same reasons as POST /resume)
// client -> server
type AttachClientMessage =
| { type: "input"; data: string }
| { type: "resize"; cols: number; rows: number };

Multiple terminal views share one booted sandbox; it stops again when the last one disconnects.

Runs execute on dedicated brevi-worker daemons. Mission Control provisions a Linux machine over SSH, mints a single-use pairing token inside the main process, and exchanges it for a durable worker credential.

GET /api/workers returns every enrolled worker, connected or not, oldest enrollment first:

interface FleetResponse {
workers: WorkerView[];
}
interface WorkerView {
id: string; // assigned by the host at enrollment, e.g. "wk-3f9a1c22b0"
name: string;
state: "active" | "draining";
connection: "online" | "offline";
local?: boolean; // true for the worker the host spawns on its own machine
capabilities?: WorkerCapabilities; // absent for a worker that has never connected
activeRuns: number; // leases this worker holds right now
enrolledAt: string;
connectedAt?: string; // when the current connection was established, absent while offline
lastSeenAt?: string; // last register or heartbeat, absent until the first connect
address?: string; // remote address of the live channel, when connected
}
interface WorkerCapabilities {
os: string; // process.platform, e.g. "linux" or "darwin"
arch: string; // process.arch, e.g. "x64" or "arm64"
provider: string; // current workers always report "bwrap"
maxConcurrency: number; // dispatched runs this worker executes at once, 1 to 64
version: string; // brevi version running on the worker
}

No credential material ever appears here: the host stores only the sha256 of each worker’s credential (in ~/.brevi/fleet.json, mode 0600), and even that never leaves the process.

POST /api/workers/provision accepts non-secret SSH connection settings:

{ "host": "worker.example.com", "port": 22, "user": "deploy", "identityFile": "/Users/me/.ssh/id_ed25519", "name": "build-1", "concurrency": 2 }

The response is { "ok": true, "output": "..." }. The endpoint fails if the worker channel is not remotely reachable. The pairing token is never returned to the renderer: it is written over SSH stdin to a short-lived mode-0600 file and consumed by the installer through --token-file.

POST /api/workers/:id/rename with { "name": "..." } (trimmed, control characters stripped, capped at 60 characters; empty is 400), POST /api/workers/:id/drain (finish in-flight runs, accept nothing new; the state is persisted and survives reconnects), POST /api/workers/:id/enable (put a drained worker back in rotation), and DELETE /api/workers/:id (revoke: the credential dies and the worker is disconnected at once, unable to reconnect with what it holds) all return the updated FleetResponse, or 404 for an id that is not enrolled. Rename and revoke answer 400 for the local worker: drain it instead.

GET /api/worker/demand?workerId=<id> is what a worker’s own supervisor polls to decide whether its machine needs to be awake (e.g. brevi’s managed macOS VM, which stops the guest when idle and has to cold-start it again for queued work, so it cannot just ask the guest). It is the one route here whose caller is not the dashboard but a program on the worker’s machine, so it does not rely on the listener’s bind address: it authenticates with that worker’s own durable credential, as Authorization: Bearer <credential>, and answers 403 with { "error": string } for an unknown worker or a missing or wrong credential. It is served by the fleet listener as well as the dashboard’s, since the fleet listener is the one a remote worker’s machine can reach.

{
"queuedRuns": 2,
"activeRuns": 3,
"connectedWorkers": 1,
"spareCapacity": 0,
"worker": { "id": "wk-3f9a1c22b0", "connected": true, "state": "active", "activeRuns": 3, "attachSessions": 0 }
}

queuedRuns is work waiting for a worker with room; activeRuns, connectedWorkers and spareCapacity are fleet-wide, with draining workers left out of the spare capacity. worker is the caller’s own worker and is answered while it is offline too (connected: false with zeroes), which is the point: that is exactly when a supervisor has to decide to boot it. Its state is what makes queuedRuns readable: the scheduler never dispatches to a draining worker, so a supervisor must not treat a queue as a reason to wake a drained machine, and brevi’s macOS supervisor does not.

POST /api/worker/state?workerId=<id>&state=draining (or state=active), authenticated the same way, lets a supervisor drain or re-activate its own worker, and answers with the demand as it stands after the change. That single round trip is what makes powering a machine off safe. Demand alone is a snapshot: a run queued a moment after it is read still gets dispatched to a worker that is online, and cutting the power then kills it mid-execution. Draining first closes the window, since the scheduler stops placing runs on this worker before the answer is written, so whatever that answer reports in flight is the complete set of work a shutdown would destroy. brevi’s macOS supervisor drains, checks, and only then stops the VM, restoring active on the next boot; a drain an operator placed is left alone, and never lifted by a supervisor.

WS /ws/worker is the channel the dedicated brevi-worker daemon connects to. Each worker dials it outbound and never accepts inbound connections, so a worker behind NAT needs no port open. The wire protocol is defined as zod schemas in packages/shared/src/worker.ts and is versioned; an incompatible worker is rejected on registration.

It is served in two places: always by the dashboard’s own listener (server.host / server.port), so a worker on this same machine can always enroll, and additionally by a dedicated listener bound to fleet.host / fleet.port (see Configuration) when fleet.host is set. That second listener exists so a worker on another machine can reach /ws/worker without exposing the unauthenticated management API above, which stays on server.host regardless; the dedicated listener serves nothing else beyond the credential-authenticated GET /api/worker/demand above, and every other request on it gets a 404.

The first frame on every connection is register, which carries the protocol version, the worker’s capabilities, the leases it still believes it owns, and an auth envelope:

// worker -> host
type RegisterMessage = {
type: "register";
protocolVersion: number;
auth:
| { kind: "pairing"; token: string } // enrolling
| { kind: "credential"; workerId: string; secret: string }; // every connect after
name: string; // preferred display name, honoured only when enrolling
capabilities: WorkerCapabilities;
activeLeases: RunLease[]; // so in-flight run reporting resumes after a drop
};
// host -> worker
type RegisteredMessage = {
type: "registered";
protocolVersion: number;
heartbeatIntervalMs: number;
hostVersion: string;
workerId: string; // the host assigns it; a worker never picks its own
name: string; // the fleet's name for this worker, which a rename may have changed
state: "active" | "draining";
credential?: string; // only on the connection that redeemed a pairing token
};
type RejectedMessage = {
type: "rejected";
code: "invalid-token" | "expired-token" | "unauthorized" | "protocol" | "malformed";
reason: string;
};

A pairing envelope redeems the token once and enrolls the machine; the registered answer carries the assigned workerId and the durable credential, which the worker stores at ~/.brevi/worker.json (mode 0600, the only fleet secret on its disk) and presents as { kind: "credential" } on every later connect. A pairing token is single-use and dies on redemption or at its expiry, and the host keeps only its hash in memory, never on disk. rejected is answered with a code so the worker can branch without parsing prose: invalid-token / expired-token let a worker that also holds a credential retry with it, unauthorized means the enrollment is gone for good and the worker deletes its stored credential, and protocol / malformed are build or frame problems retrying cannot fix. The host closes the socket right after sending it. A connection whose first frame is not a valid register within 10 seconds is rejected and dropped the same way.

After registration the worker sends heartbeat every 15 seconds (WORKER_HEARTBEAT_MS) with the leases it still claims, and the host answers heartbeat-ack carrying the worker’s current state, so a drain reaches a worker even if it missed the push. Silence longer than fleet.heartbeatTimeoutSeconds (45 by default) drops the connection; a worker that drops mid-run has fleet.reconnectGraceSeconds (120 by default) to come back and resume reporting before the host gives up on its leases.

Runs travel the same channel: dispatch for each run the host hands over (answered with dispatch-accepted or dispatch-rejected), a stream of run-patch / run-event / run-artifact / run-memories / run-usage-snapshot frames as the run executes, and finally run-complete carrying the whole terminal state, which the host answers with run-complete-ack. The worker keeps claiming a lease until that acknowledgement arrives, so a run that finished while the socket was down replays its completion on the next connection instead of being recorded as a disconnect failure. cancel and discard travel the other way, as do attach-open and the rest of the interactive-session relay behind the web terminal. A dispatch carries the credentials that one run needs (GitHub, agent, Linear) inline over this authenticated connection, provisioned into that run’s sandbox and gone with it, so a worker is never configured with a connector secret of its own. Enrollment copies none either, which is what makes revoking a worker enough to cut off its access.

Each dispatch carries a lease (runLeaseSchema) with an expiresAt, and the frame is only sent once that claim is on disk, so no worker is ever executing a run the host has no durable record of. A worker cut off from the host does not pause: it keeps executing and buffers every reporting frame it would have sent, each stamped with a per-lease sequence number, then replays the backlog once register succeeds again. The host answers with lease-ack (on registration and on every heartbeat), naming the highest sequence number it has applied for each lease and restating the lease’s deadline. That number is a contiguous watermark: it never steps over a frame that has not landed, so the worker trims only what the host really has, and an ack that repeats the previous number is the worker’s cue to resend. A run-complete is held, unacknowledged, until everything below it has landed, because its ack is what tells the worker to discard the lease’s buffer. When the worker’s bounded buffer has to drop frames it says so with lease-gap, and the host steps over that range and records the loss against the run rather than waiting forever. Only run-event and run-artifact frames may be shed that way; the frames that shape the host’s own copy of the run (run-patch, run-memories, run-usage-snapshot, run-complete) are never dropped.

run-usage-snapshot carries a minimized, ccusage-compatible copy of one Claude session’s usage (token counts, ids, timestamps, model, and any pre-calculated cost, never prompt or response content), exported after every agent execution. It may also carry an optional subagentId, naming a subagent transcript nested under that session rather than the session’s own main transcript. The host validates it (path-safe project key, session id, and, when present, subagentId, byte cap WORKER_MAX_USAGE_SNAPSHOT_BYTES, optional content hash) and archives it atomically under ~/.brevi/ccusage/claude/projects/; a snapshot without subagentId replaces the session’s earlier main-session snapshot, and one carrying subagentId replaces that subagent’s earlier snapshot under .../projects/<project-key>/<session-id>/subagents/, so replays never double-count. A snapshot sequenced below a run-complete has landed before that completion is acknowledged. A re-export at the end of an attach session travels without a lease, best effort. See Costs and usage for querying the archive.

If a worker never comes back, fleet.heartbeatTimeoutSeconds plus fleet.reconnectGraceSeconds after its last contact the lease expires: the run is marked interrupted and requeued for another worker, or completed by adopting its pull request when that worker already opened one. Expiry frees the host to hand the run elsewhere, so the old execution is fenced from both ends: a worker that rejoins still claiming a dead lease is told lease-lost and aborts it, and a worker that never rejoins enforces expiresAt itself and stops on its own. The host persists its queue and every outstanding lease under ~/.brevi, so restarting it resumes in-flight runs instead of treating its own reboot as a fleet-wide disconnect; a restored lease is authoritative over the run’s recorded status, so a run that was still queued when the host stopped is not dispatched a second time.

Two more host-to-worker frames belong to the fleet rather than to a run: worker-state is pushed the moment an operator drains or re-enables a worker, so a drain takes effect at once rather than at the next heartbeat, and revoked tells a worker its enrollment is gone, on which it deletes its stored credential, shuts its runs down gracefully, and exits instead of reconnecting into a rejection loop.

PUT /api/settings/credentials
Content-Type: application/json
{ "linearApiKey": "lin_api_…", "githubToken": "", "anthropicApiKey": "sk-ant-…", "xaiApiKey": "xai-…" }

Only the fields you send are touched. Each is validated against its provider before being saved; invalid keys are rejected per field while valid ones in the same request are still applied. An empty string disconnects that provider without validation.

{
"results": {
"linear": { "ok": true, "detail": "Connected as Jane" },
"anthropic": { "ok": false, "detail": "Anthropic rejected this credential" }
},
"config": { "linear": { "apiKey": "***" } }
}

POST /api/connect/:provider with provider one of linear, github, anthropic, codex, grok runs the one-click strategy chain and reports what the dashboard should do next. It returns one of four shapes:

| { status: "connected"; provider; detail: string; config: BreviConfig }
| { status: "device"; provider: "github"; userCode: string;
verificationUri: string; interval: number; expiresIn: number }
| { status: "redirect"; provider: "linear"; url: string }
| { status: "manual"; provider; reason: string }
  • "device" → show userCode, open verificationUri, then poll POST /api/connect/github/poll every interval seconds. That returns { status: "pending" }, { status: "connected", detail, config }, or { status: "error", detail }.
  • "redirect" → open url. Linear sends the browser back to GET /api/connect/linear/callback?code=…&state=…, which exchanges the code, saves the token, broadcasts the new config over the WebSocket, and renders a small “you can close this window” page.
  • "manual" → show the key input; reason explains what brevi looked for and didn’t find.

GET /api/github/repos lists repos visible to the connected token, most recently pushed first, as { fullName, defaultBranch, private, description, pushedAt }. It returns 400 when GitHub isn’t connected.

Repo mappings themselves are edited through PUT /api/settings (below), like every other config field.

The Pull Requests page’s API. :repo is the URL-encoded repo key from config.repos; every route returns 400 when GitHub isn’t connected, and GitHub API failures map to 404 (unknown PR), 409 (state conflicts such as an unmergeable PR), or 400.

GET /api/pulls lists pull requests across every configured repository, newest activity first, as { pulls: PullSummary[], errors }. Each PullSummary is { repo, remote, number, url, title, state, author, baseBranch, headBranch, createdAt, updatedAt, mergedAt? } with state one of open | draft | merged | closed. A repo whose lookup fails lands in errors as { repo, remote, message } instead of failing the list.

GET /api/pulls/:repo/:number gathers everything the detail view renders: the summary plus body, draft, headSha, mergeableState?, additions, deletions, changedFiles, conversation (comments, reviews, review threads with resolution state and diff hunks), files (with GitHub’s unified patch when available), commits, and checks (check runs and commit statuses, checksLookupFailed when that lookup was incomplete).

The write routes take JSON bodies:

  • POST .../merge { method: "merge" | "squash" | "rebase" }{ merged: true, message }; when GitHub declines the merge, the route answers with an error status and GitHub’s message instead of merged: false
  • POST .../close, POST .../reopen, POST .../ready (no body)
  • POST .../comment { body }: a plain conversation comment
  • POST .../review { event: "APPROVE" | "REQUEST_CHANGES" | "COMMENT", body }
  • POST .../reply { commentId, body }: reply inside a review thread (commentId is PullComment.id of a thread comment)
  • POST .../resolve-thread { threadId, resolved } (threadId is PullThread.id)

GET /api/memories returns everything brevi remembers, keyed by repository remote (owner/name), newest first. Repositories with nothing stored are omitted.

interface MemoriesResponse {
repos: Record<string, RepoMemory[]>;
}
interface RepoMemory {
id: string;
text: string; // the fact, one line
createdAt: string;
updatedAt: string; // last time a run recorded it again
hits: number; // how many runs have recorded it
ident?: string; // ticket that last recorded it
}

POST /api/memories/:repo/forget with { "id": "..." } drops one memory; POST /api/memories/:repo/clear drops all of a repository’s. :repo is the URL-encoded remote. Both return the updated MemoriesResponse, 404 when there is nothing to drop, and 500 when the deletion could not be written to disk. Memories are written by runs, never by the API.

There is no stored credential for R2: GET /api/connect/r2 probes the host’s wrangler CLI live on every call.

interface R2Status {
installed: boolean; // wrangler CLI is on the host
loggedIn: boolean; // `wrangler whoami` reports an identity
account?: string; // account email, when logged in
bucket: string; // config.r2.bucket, "" if unset
publicBaseUrl: string; // config.r2.publicBaseUrl, "" if unset
ready: boolean; // installed && loggedIn && bucket && publicBaseUrl
}

POST /api/connect/r2 starts the one-click flow:

| { status: "connected"; r2: R2Status }
| { status: "login-started"; detail: string }
| { status: "unavailable"; reason: string }

"connected" means wrangler whoami was already authenticated, nothing to do. "login-started" means brevi spawned wrangler login on the host, which opens a browser for interactive OAuth; the dashboard should poll GET /api/connect/r2 until loggedIn flips. "unavailable" means wrangler isn’t installed; reason says so.

The bucket and its public base URL are config fields like any other; set them with PUT /api/settings.

PUT /api/settings is the only write path for ~/.brevi/config.json. The body carries a deep-partial patch of the fields one form card owns, so a save never touches anything the caller did not send, including fields another tab or a hand edit changed in the meantime:

{
"patch": {
"agent": { "orchestratorModel": "claude-opus-5", "orchestratorEffort": "medium" },
"sandbox": { "concurrency": 2 }
}
}

Objects merge key by key; arrays and scalars replace. null removes a key, which is how a repo mapping is deleted ({"repos": {"web": null}}).

interface SettingsUpdateResponse {
config: BreviConfig; // redacted, for re-rendering every form
applied: "live" | "restart";
}

The patch is merged onto the config on disk, the whole result is validated against the config schema, and only then is the file replaced (written to a temp file and renamed, so a reader never sees a half-written config). A rejection returns 400 with the zod message, prefixed by the field path, and nothing is written:

{ "error": "agent.orchestratorEffort: Invalid option: expected one of \"low\"|\"medium\"|\"high\"" }

A non-empty r2.publicBaseUrl has to parse as an http(s) URL; that check lives in the schema.

Credential fields are refused here with 400: linear.apiKey, linear.refreshToken, linear.tokenExpiresAt, github.token, and the six agent.* keys. Most of them are masked in every read, so accepting them would let a form round-trip the mask over a live secret; linear.tokenExpiresAt is not itself masked and is refused because the OAuth flow maintains it. They are written by the Connect flows and PUT /api/settings/credentials, which verify each key with its provider. connect.linearClientSecret is write-only rather than refused: it can be set, but the literal mask value is rejected. The fleet section holds no secret at all: worker credentials are minted, not configured, and only their hashes are stored (see Workers).

The check compares credential values on the merged result, not paths in the patch, so deleting a whole section ({"linear": null}, which would let the schema defaults refill it with empty strings) is refused the same way as setting the field directly.

applied says whether the change is already in effect. Almost everything is read per run or per poll and applies live; server.port, server.host, fleet.host, and fleet.port are bound once at startup and answer "restart".

config.json stays the source of truth in both directions: the orchestrator watches the file and picks up hand edits without a restart, broadcasting the reloaded config over the WebSocket. An external edit that does not validate is logged and ignored, leaving the running settings alone.

Connect to ws://localhost:4400/ws. The server sends a hello immediately, then pushes changes:

type ServerMessage =
| {
type: "hello";
runs: Run[];
tickets: Ticket[];
config: BreviConfig;
linearStatus: LinearStatus;
workers: WorkerView[];
}
| { type: "config"; config: BreviConfig }
| { type: "tickets"; tickets: Ticket[] }
| { type: "workers"; workers: WorkerView[] }
| { type: "run-updated"; run: Run }
| { type: "run-event"; event: RunEvent }
| { type: "linear-status"; linearStatus: LinearStatus };
type LinearStatus = {
state: "disconnected" | "connected" | "auth-error" | "refresh-failing";
error?: string;
};
type ClientMessage =
| { type: "subscribe"; runId: string }
| { type: "unsubscribe"; runId: string };

Every config payload is redacted. By default a client receives run-event messages for all runs; once it subscribes to at least one run id it receives events only for its subscriptions. linear-status is pushed whenever the Linear connector’s state changes, e.g. an OAuth token refresh failing, so the dashboard can show a Reconnect prompt without polling for it. auth-error means the stored credential is dead and polling is paused until a reconnect; refresh-failing means the expired token can’t be refreshed for a transient reason (network, rate limit), polling is paused, and brevi retries by itself until a refresh succeeds. workers (in hello, and pushed again on its own as a workers message) is the same WorkerView[] GET /api/workers returns, so the Workers page never has to poll: an enrollment, a renamed or drained worker, a heartbeat’s fresh capabilities, a connect or a drop, and a revocation all arrive this way.

RunEvent is one of a status change, a log line (stdout / stderr / system), an agent event forwarded from the agent’s stream-json output, an artifact reference, a cost entry recording one agent execution’s LLM usage, or a limit event recording the agent usage limit that ended an execution. Events are also persisted as JSONL, which is what GET /api/runs/:id/events replays.

https://api.brevi.dev hosts brevi’s OAuth applications so one-click Connect works without registering anything. It is a Hono app on Cloudflare Workers (apps/api in the repo) and holds only the OAuth client id/secret; tokens are returned to your machine and stored in ~/.brevi/config.json.

The local orchestrator calls it automatically when connect.githubClientId / connect.linearClientId are unset. connect.apiBase overrides the base URL.

Method Path Purpose
GET /health Liveness check
POST /oauth/github/device/code Start a GitHub device authorization with brevi’s client id
POST /oauth/github/device/token Poll that authorization for an access token
GET /oauth/linear/authorize?state=&port= 302 to Linear’s authorize URL with brevi’s client id
POST /oauth/linear/token Exchange { code, port } for an access token, using the secret held server-side
POST /oauth/linear/refresh Exchange { refresh_token } for a fresh access token, using the secret held server-side

/oauth/linear/authorize builds the redirect back to http://localhost:<port>/api/connect/linear/callback, so the token exchange lands on your machine; port is your server.port. The state you pass through is the one the local orchestrator checks on the callback.

Both /oauth/linear/token and /oauth/linear/refresh return { access_token, refresh_token?, expires_in? }, forwarded from Linear’s own response, so the orchestrator can store the refresh token and proactively refresh the access token before it expires. /oauth/linear/refresh returns 401 only when Linear rejects the grant itself (a revoked or invalid refresh token), passes a 429 rate limit through along with its Retry-After header, and returns 502 on other upstream failures, so clients can tell “reconnect required” apart from “try again later”.

Deploying your own copy needs three secrets (GITHUB_CLIENT_ID, LINEAR_CLIENT_ID, LINEAR_CLIENT_SECRET) and a Linear app registering http://localhost:<port>/api/connect/linear/callback redirect URIs for the ports you use.