---
title: Auth, errors, examples
order: 5
---
# Auth, errors, examples

The [MCP tools](/docs/mcp-tools) and [HTTP API](/docs/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:

```json
{ "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:

```json
{ "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](/docs/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.
