HTTP API
Mainark's HTTP surface. Read routes are open; write routes require a bearer token.
Running it
src/index.ts starts the full daemon (reconciliation loop, interrupted-run
recovery, Linear-poll dispatch) and serves HTTP as part of that. Hosts running
the ledger path — mainark coordinate --watch --auto-review — want the API
without those loops, so there is a standalone entry point:
bun run src/server/standalone.ts
It migrates the database and serves. Nothing else.
Required — it exits at boot listing whatever is missing:
| Env | Why |
|---|---|
GITHUB_PAT, REPO_URL, SLACK_WEBHOOK_URL | migrate() loads config(), which requires them |
MAINARK_ISSUE_PREFIX or LINEAR_TEAM_KEY | Allocating ids for new issues |
POST /api/issues supports TASK_BACKEND=sqlite only.
| Env | Default | Purpose |
|---|---|---|
MAINARK_HTTP_HOST | 127.0.0.1 | Bind address. Set to a private interface (e.g. a Tailscale IP) to expose it. |
PORT | 3000 | Listen port. |
MAINARK_API_TOKEN | — | Bearer token for write routes. Unset means write routes return 503. |
Do not bind to 0.0.0.0. The intended deployment is a private interface
only — a tailnet address, matching how Valkey is bound on the Fulreach hosts.
There is no TLS termination here and the token is the only credential.
Authentication
Write routes and POST /agents/probe require Authorization: Bearer <MAINARK_API_TOKEN>. The probe runs the real developer and reviewer agents, which spend credits. Tokens are
compared in constant time. Failure modes:
| Condition | Status | error |
|---|---|---|
MAINARK_API_TOKEN unset on the server | 503 | api_token_not_configured |
| Header missing or token wrong | 401 | unauthorized |
Unset fails closed rather than leaving the endpoint open.
POST /api/issues
Creates one issue in the configured task backend. This is the ingest door for clients that cannot run the CLI — Cursor on a phone, and later the Fulreach customer request frontend.
curl -X POST http://127.0.0.1:3000/api/issues \
-H "Authorization: Bearer $MAINARK_API_TOKEN" \
-H 'content-type: application/json' \
-d '{"title":"Fix the empty state on /campaigns","description":"## Problem\n..."}'
{
"issue": {
"id": "MAI-41",
"title": "Fix the empty state on /campaigns",
"workStatus": "todo",
"url": "mainark:issue/MAI-41",
"autoDispatch": false
}
}
Body
| Field | Type | Required | Notes |
|---|---|---|---|
title | string | yes | Trimmed; 1–500 characters. |
description | string | no | Up to 100,000 characters. Markdown is expected — the implement brief passes it to the agent verbatim, so the more specific it is the better the result. |
dependsOn | string[] | no | Existing issue ids; it is not started until they are done. Unknown ids are refused. |
autoDispatch | boolean | no | true lets the unattended sweep start it (when MAINARK_COORDINATE_AUTO_DISPATCH is on). Default false. |
Issues are always created as work_status: todo. Everything arriving here is
new work; moving it on is the dispatcher's job.
The identifier comes from id_sequences using MAINARK_ISSUE_PREFIX, so it is
allocated the same way as any issue created by the CLI.
Errors
| Status | error | Cause |
|---|---|---|
400 | invalid_json | Body was not JSON. |
400 | invalid_title | Missing, blank, or over 500 characters. |
400 | invalid_description | Not a string, or over 100,000 characters. |
400 | invalid_issue | dependsOn names an unknown issue, or autoDispatch is not a boolean. |
501 | backend_not_supported | TASK_BACKEND is not sqlite. |
503 | issue_prefix_not_configured | Neither MAINARK_ISSUE_PREFIX nor LINEAR_TEAM_KEY is set. |
POST /api/issues/plan
Creates a whole plan: one issue per step, with dependencies between steps, all
or nothing. Bearer token as above. It uses the same format and rules as the
plan inbox, and cli/push-plan.mjs sends it.
{
"title": "Move sessions to Supabase",
"autoDispatch": true,
"steps": [
{ "title": "Add the sessions table", "description": "…" },
{ "title": "Write sessions through it", "description": "…", "dependsOn": [1] }
]
}
It returns 201 with {"issues": [{"id": "MAI-50", "title": …}, …], "autoDispatch": true}.
The ids are in step order. A bad plan returns 400 invalid_plan with the reason, and nothing is written.
Read routes
Unauthenticated, mounted at /api/issues:
| Route | Returns |
|---|---|
GET /api/issues | Latest issues (limit, max 1000) |
GET /api/issues/bootstrap | Issues plus the current change head |
GET /api/issues/changes/head | Current change head |
GET /api/issues/changes | Changes after after (limit, max 500) |
POST /api/issues/batch | Issues for a list of ids (a read despite the verb) |
GET /api/issues/:id | One issue |
GET /api/issues/:id/events | Events for an issue |
Plus GET /health. POST /agents/probe is not open — see Authentication.
Architecture
Product architecture snapshots and derived gaps. Design notes: product-architecture.md.
| Route | Auth | Behaviour |
|---|---|---|
GET /api/architecture?product= | open | Latest snapshot summary + gaps |
POST /api/architecture/snapshot | bearer | Validate; reject stale ingestSequence; atomically upsert snapshot and reconcile gaps |
POST /api/architecture/gaps/:id/open-task | bearer | Idempotent create-or-return linked issue |
Snapshot body (required fields): product, generatedAt, gitSha, ingestSequence (integer ≥ 0), manifest (with matching product, optional services / domains / workflows), nx (projects, dependencies: [{from,to}]), optional notes.
Stale ingest (ingestSequence older than stored) returns 409 with status: "ignored_stale". Same sequence + same gitSha returns 200 unchanged. Same sequence + different gitSha returns 409 with status: "sequence_conflict" (DB unchanged).
Product content (docs / changelog)
Git-backed product documentation via the GitHub Contents API. All content routes require bearer MAINARK_API_TOKEN (no open-read). The web app proxies them behind a session so the browser never sees MAINARK_API_TOKEN or GITHUB_PAT.
| Route | Auth | Behaviour |
|---|---|---|
GET /api/content/:product/changelog | bearer | Load changelog MDX from the product's configured read branch; return parsed timeline plus distinct blobSha and commitSha |
GET /api/content/:product/nav | bearer | Docs nav tree from meta.json + MDX under docsPath at the read-branch tip |
GET /api/content/:product/doc?path= | bearer | One MDX/MD file (path must be under docsPath); returns body, frontmatter fields, blobSha, commitSha |
GET /api/content/:product/search?q= | bearer | Substring search over a corpus cached in memory by product/repo/commitSha |
POST /api/content/:product/save | bearer | Race-safe edit: resolve tip → compare expectedBlobSha → branch from that commit → commit → open PR (no auto-merge). 409 conflict if the file blob changed. |
Save body: { path, content, expectedBlobSha, titleHint? }.
Configured products (v1: fulreach) live in src/content/productContentConfig.ts. Fulreach contract: product repo docs/ops/mainark-docs-contract.md. Design notes: product-content.md.