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:

EnvWhy
GITHUB_PAT, REPO_URL, SLACK_WEBHOOK_URLmigrate() loads config(), which requires them
MAINARK_ISSUE_PREFIX or LINEAR_TEAM_KEYAllocating ids for new issues

POST /api/issues supports TASK_BACKEND=sqlite only.

EnvDefaultPurpose
MAINARK_HTTP_HOST127.0.0.1Bind address. Set to a private interface (e.g. a Tailscale IP) to expose it.
PORT3000Listen 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:

ConditionStatuserror
MAINARK_API_TOKEN unset on the server503api_token_not_configured
Header missing or token wrong401unauthorized

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

FieldTypeRequiredNotes
titlestringyesTrimmed; 1–500 characters.
descriptionstringnoUp 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.
dependsOnstring[]noExisting issue ids; it is not started until they are done. Unknown ids are refused.
autoDispatchbooleannotrue 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

StatuserrorCause
400invalid_jsonBody was not JSON.
400invalid_titleMissing, blank, or over 500 characters.
400invalid_descriptionNot a string, or over 100,000 characters.
400invalid_issuedependsOn names an unknown issue, or autoDispatch is not a boolean.
501backend_not_supportedTASK_BACKEND is not sqlite.
503issue_prefix_not_configuredNeither 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:

RouteReturns
GET /api/issuesLatest issues (limit, max 1000)
GET /api/issues/bootstrapIssues plus the current change head
GET /api/issues/changes/headCurrent change head
GET /api/issues/changesChanges after after (limit, max 500)
POST /api/issues/batchIssues for a list of ids (a read despite the verb)
GET /api/issues/:idOne issue
GET /api/issues/:id/eventsEvents 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.

RouteAuthBehaviour
GET /api/architecture?product=openLatest snapshot summary + gaps
POST /api/architecture/snapshotbearerValidate; reject stale ingestSequence; atomically upsert snapshot and reconcile gaps
POST /api/architecture/gaps/:id/open-taskbearerIdempotent 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.

RouteAuthBehaviour
GET /api/content/:product/changelogbearerLoad changelog MDX from the product's configured read branch; return parsed timeline plus distinct blobSha and commitSha
GET /api/content/:product/navbearerDocs nav tree from meta.json + MDX under docsPath at the read-branch tip
GET /api/content/:product/doc?path=bearerOne MDX/MD file (path must be under docsPath); returns body, frontmatter fields, blobSha, commitSha
GET /api/content/:product/search?q=bearerSubstring search over a corpus cached in memory by product/repo/commitSha
POST /api/content/:product/savebearerRace-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.