Auth, errors, examples
The MCP tools and HTTP API pages are the reference: every tool, every endpoint, generated from the registry the server registers from. This page is the part a reference table cannot carry. How you authenticate, what failure looks like, and one worked run from start to finish.
Two doors
Agents and people use OAuth. Point an MCP client at https://mainmind.app/mcp. You will be redirected to a connect page and asked for your invite code once. The code identifies you: it decides your name, your role, and which organization you mount. Nothing else about you is stored, and the plugin ships no secrets.
Machines use a bearer token. Every MCP tool has a plain-HTTP twin so a cron job or a script with no MCP support can drive the identical contract:
Authorization: Bearer <token>
Be aware of what that token is today. It is a single operator credential held as a Worker secret for the whole deployment, not a per-customer key you can mint, scope, or rotate yourself. If you are running your own instance you set it; if you are mounted on someone else's, you use OAuth and the bearer is not yours to hold. Per-tenant API keys are not built. Treat the bearer as an operator credential and keep it off anything shared.
Tenants
Every organization is a tenant. Your mount already knows which one you are on, so you rarely name it. Where a call can act across organizations, the tenant is ?tenant= on a GET and tenant in the body on a POST, and it defaults to the mount's own organization.
What failure looks like
Errors come back as JSON with a single error key and an HTTP status:
{ "error": "unauthorized" }
| Status | When | Body |
|---|
| 400 | The body is not JSON, or a required field is missing | {"error":"bad json"} or the missing field |
| 401 | No bearer, or a bearer that does not match | {"error":"unauthorized"} |
| 404 | The route exists but the thing does not, or the route is unknown | {"error":"not found"} or {"error":"not yet"} |
| 405 | Wrong method for a real route | {"error":"method"} |
A malformed tenant is its own case. Any call carrying a tenant slug that is not lowercase letters, digits and hyphens is rejected with {"error":"bad tenant slug"} rather than quietly falling back to a default organization. Rejecting instead of defaulting is deliberate: a typo must never write into somebody else's company.
One trap worth knowing before it costs you an afternoon. An unknown path under /api/ answers 401, not 404, when you are unauthenticated, because the auth gate runs before routing. A typo in a path therefore looks exactly like a broken token. Authenticate first, then read the status:
curl -s https://mainmind.app/api/nope
{"error":"unauthorized"}
curl -s -H "Authorization: Bearer $TOKEN" https://mainmind.app/api/nope
{"error":"not yet"}
Two endpoints need no auth at all, which makes them the right first call when you are checking connectivity: /api/health and /api/surface.
Limits and paging
There is no cursor or offset anywhere yet, and every list endpoint clamps silently rather than erroring, so a request for more than the maximum returns a truncated page that looks complete. The current ceilings:
| Endpoint | Default | Maximum |
|---|
GET /api/events | 50 | 200 |
GET /api/nodes | 200 | 500 |
GET /api/runs | fixed at 40 open plus 12 recent | no parameter |
POST /api/projection takes at most 500 nodes per call. Chunk a larger repository, and let only the first call use replace.
A worked run
Open a run before real work, report progress while it happens, and close it when it ends. This is what makes an agent visible to the whole team while it is working, rather than after. Every response below is real output from the calls shown.
Open it.
curl -s -X POST https://mainmind.app/api/runs \
-H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"task":"Reconcile yesterday","actor_label":"Nightly cron","harness":"curl"}'
{"ok":true,"run_id":"run_5b4b9603-719","started_at":"2026-08-19T14:21:34.807Z"}
Say what you are doing. Call this as you move between steps. A run that stops heartbeating shows as stalled rather than working, which is a fact derived from the clock and not a judgement about the run.
curl -s -X POST https://mainmind.app/api/runs/run_5b4b9603-719/heartbeat \
-H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"doing":"matching payouts"}'
{"ok":true,"run_id":"run_5b4b9603-719","heartbeat_at":"2026-08-19T14:21:35.536Z"}
Close it. Use landed when the work is done, awaiting-ruling when it is parked on a decision (the run stays open and visible), conflict when the target moved, failed when it broke.
curl -s -X POST https://mainmind.app/api/runs/run_5b4b9603-719/finish \
-H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"status":"landed","outcome":"Everything matched."}'
{"ok":true,"run_id":"run_5b4b9603-719","status":"landed"}
Putting your own knowledge in
POST /api/projection is how a company file becomes readable by every mount. You send paths and file contents; the service parses the frontmatter, indexes the text for search, and embeds it for semantic search.
curl -s -X POST https://mainmind.app/api/projection \
-H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{
"tenant": "alder-and-ash",
"commit": "9f2a1c4",
"mode": "replace",
"nodes": [
{ "path": "ORG.md", "content": "# Alder & Ash\n\nWhat we do and why." },
{ "path": "processes/refunds.md", "content": "---\nkind: process\n---\n# Refunds" }
]
}'
The response reports what landed, what it could not parse, and what it embedded:
{ "ok": true, "built_at": "...", "ingested": 2, "total": 2,
"findings": [], "vectors": { "embedded_nodes": 2, "chunks": 7, "errors": [] } }
Things worth knowing before you run it:
mode: "replace" deletes every existing node for that tenant and rebuilds from what you send. It can only ever touch its own tenant, but within that tenant it is destructive. Use merge to add or update without deleting.
findings is not an error list. It is the parser being honest. Any frontmatter line the profile does not accept is reported rather than silently dropped, so a field that would have vanished shows up here instead.
- Send at most 500 nodes per call and keep each body comfortably inside one batch. Large repositories should be chunked, with only the first call using
replace.
- Embedding is delta based. Unchanged files keep their hash and are not re-embedded. If the semantic layer fails, search degrades to keyword matching and the failure is reported in
vectors.errors rather than swallowed.
commit is a label you supply, carried back on every answer so a reader knows which version of your files they are looking at. It is not validated.
The reference for every field is on the HTTP API page.
What is not built yet
/api/health reports which channels of the runtime contract are open, and it is the honest answer rather than this page. At the time of writing, telemetry, runs, projection and surface are live; checkout, envelope, proposals and scopes are not.
Two consequences you will meet in the reference:
scopes on run_start and run_heartbeat is recorded and shown, so a human can see what a run claims to hold. It is not enforced: nothing stops two runs claiming the same scope. Do not build coordination on it yet.
proposal_ref on run_finish, and the diff, branch, base_sha, repo and pr_number fields on POST /api/asks, belong to the proposal path. They are stored and passed through to the decision page, and the machine that holds your git credential is what acts on them. The service itself holds no git credential and merges nothing.