LLM Instructions
The whole Madless API in one Markdown block. Paste it into Claude, ChatGPT, Cursor or anything else that takes context — it carries the auth shape, the rules that will otherwise bite (snake_case bodies, the data envelope, cursor pagination), and 14 worked recipes for the multi-step jobs.
This is the file, not a summary of it
The block below is read straight from /llms.txt, the same document served to crawlers and AI tools — there is no second copy to fall out of date. For depth beyond it, the block points at the full OpenAPI 3.1 spec.
Copy and paste
Pin it to a system prompt or a project rules file, or drop it into a single message before asking a model to write against the API.
# Madless API
> Madless is the place you keep your own mind — it keeps what you let in, laid out in space and connected, so you see what you think. A map, not a feed. AI that makes you think, in a sea of AI that thinks for you. Your knowledge stays yours.
## Base URL
```
https://{space}.madless.com
```
Replace `{space}` with your space subdomain (e.g., `demo`). The apex, `https://madless.com`, and `https://api.madless.com` work too — the same API and the same spec: a key always finds its own space there.
A key sent to a DIFFERENT space's subdomain is a `421` (`misdirected_request`) whose `base_url` names the key's own address. Nothing is answered from the wrong space.
## Authentication
All API requests require an API key passed as a Bearer token:
```
Authorization: Bearer mad_sk_...
```
API keys are minted by a space admin in Settings → Developer → API Keys, and minting any key — live `mad_sk_…` or a sandbox test key `mad_sk_test_…` — needs the Pro plan's `api-keys` entitlement; on any other plan the mint is a `403` with `code: "missing_entitlement"`. Each key has scoped permissions (e.g., `concepts.read`, `concepts.write`).
**A request that does not prove who it is gets a `401`, never a `404`.** Six causes, each with its own `code` and `detail`, the same on every host: `no_credential` (nothing at all — a key in the URL or the body is never read); `unsupported_scheme` (`Basic …`, any other scheme, or a bare `mad_sk_` with no `Bearer` in front); `malformed_credential` (a Bearer value that is neither a `mad_sk_` key nor a token); `unknown_key` (a well-formed `mad_sk_` this environment has never seen — the body names the `environment` you reached); `expired_token` (a token past its own expiry); `invalid_token` (a token that did not verify, or a revoked grant). Every one carries a `WWW-Authenticate: Bearer` challenge — with no `error` when nothing was presented, `error="invalid_request"` for a header in the wrong form, `error="invalid_token"` for a credential that did not authenticate. Branch on `code`, never on `detail`. Keys are bound to the environment that minted them, so a development key sent to production is unknown there, and the `detail` says so.
**Scopes are sized so you can be given less than everything.** Reading concepts (`concepts.read`), writing them (`concepts.write`), **destroying** them (`concepts.delete`), the graph — relations, label definitions, proposals (`graph.read` / `graph.write`), spatial arrangement — views and worlds (`canvas.read` / `canvas.write`), and behavioural history — the act log and the trail (`history.read` / `history.write`) are each granted on their own. `concepts.delete` is deliberately separate from `concepts.write`: trashing is reversible and rides the write scope, but purging (`DELETE /api/v1/concepts/{id}?permanent=true`) and emptying the bin are not, so they have their own fence. A key can hold `concepts.write` and still be unable to destroy anything.
**`proposals.write` is the suggest-only door.** It files a proposal of ANY kind while holding no write scope at all, and carries the proposals READ with it so a connector that files blind can see what became of its suggestion. It buys nothing at resolution: accepting is priced by kind, exactly as the equivalent direct write is, and the always-review floor stays person-only. If all you need is to suggest, ask for this and nothing else.
**Keys are server-side credentials, but `/api/v1/*` is CORS-reachable from a browser with a Bearer token.** For token (Bearer) callers the API reflects your request `Origin` back in `Access-Control-Allow-Origin` (it sends `Vary: Origin`, never `*`, and never `Access-Control-Allow-Credentials` — tokens aren't auto-attached like cookies, so there's nothing to protect with a credentialed allow-list). `Access-Control-Expose-Headers` names every header the API answers with — `Link`, `Location`, `ETag`, `Retry-After`, `X-Request-Id`, `X-Idempotency-Replay`, the `X-RateLimit-*` trio, `WWW-Authenticate`, `Allow` and the rest — so browser code can read them. There is no public read-only key and no cookie/credentialed CORS, so a browser call must still carry the `Authorization: Bearer mad_sk_...` header itself — never ship a `mad_sk_*` key to an untrusted client. Prefer a server, or use the Madless MCP server for assistant access.
**Discover what your key can do:** call `GET /api/v1/users/me`. It is identity and access, nothing else: who you are (`user`), the key's `scopes` and the space's effective `entitlements` (`access` — what gated endpoints actually check; entitlements are space/plan-level, never per-key), and the environment you reached (`deployment`). `deployment.base_url` is that environment's apex (`https://madless.com` in production) — not your space's address; the address a key belongs to is the `base_url` a `421` names. Use it instead of inferring access from which calls return 403. The plan and every meter — AI credits, media minutes, images, storage, views with people — are `GET /api/v1/usage`, which needs no scope.
## Critical Rules
1. **All request bodies use `snake_case`** field names (e.g., `label_ids`, not `labelIds`). **A name says its cardinality:** a filter or field that takes ONE value is singular (`?label_id=`, `concept_id`), one that carries a set is plural (`?concept_ids=`, `label_ids`, `file_ids`) — and a bulk body always names the thing it carries (`file_ids`, `concept_ids`, `note_ids`), never a bare `ids`.
2. **All successful responses are wrapped in `{ "data": ... }`** — always read `response.data` to get the actual payload.
3. **Request schemas are strict** — unknown fields return 400 with one `errors[]` entry PER stray key, each pointing at the key itself (`{ "detail": "`kind` is not a field here", "pointer": "#/kind", "code": "unrecognized_keys" }`). Don't guess fields; check the OpenAPI spec.
4. **Pagination is cursor-based** — list endpoints accept `?limit=20&cursor=<opaque_string>`. A cursor goes back only to the list that minted it — on any other it is a `400` `invalid_cursor` naming that list. `next_cursor` is ALWAYS present on a cursor-paginated list: a string when more exists, `null` on the last or only page. **`limit` caps at 100 on every paged list, with two exceptions a board is read whole from: `GET /concepts` and `GET /labels` cap at 500.** Over the cap is a `400` `invalid_limit`, never a silent clamp. Two lists take no `limit` and no `cursor` at all: `GET /canvas/views` and `GET /canvas/worlds` always come back whole, in one page, and either parameter there is a `400` `unknown_parameter`.
5. **Errors follow RFC 9457** — `{ "type": "https://developers.madless.com/errors#not_found", "title": "not found", "status": 404, "detail": "...", "instance": "/api/v1/...", "request_id": "..." }`, served as `application/problem+json`. A refusal with a finer name than its class carries `code` on the body (`not_yours`, `merge_cap_exceeded`, `view_full`, …). Validation failures add an `errors[]` array (`detail`, `pointer`, `code`), and `detail` there says what to send instead (`title is required`, `Must be one of: …`). A field you left out is coded `required`, on every route; a field you sent with the wrong type or value keeps `invalid_type` / `invalid_value`. A missing `Idempotency-Key` where one is required is a header, not a field, and has its own code: `idempotency_key_required`. Branch on `type` / `code`, never on `detail`. Every `404` carries a `code` naming what was not found — `concept_not_found`, `view_not_found`, `file_not_found`, … — so a request naming two ids says which one is gone; a path that is no route at all is `route_not_found`, and a method the path does not take is a `405` with `code: method_not_allowed` and an `Allow` header naming the methods it does. Every code is listed at https://developers.madless.com/errors.
6. **Send an `Idempotency-Key` header (a UUID) on writes.** On most writes it is optional but recommended. Four writes refuse to run without one — `POST /keep`, and the invitation writes `POST …/invitations`, `POST …/invitations/resend` and `DELETE …/invitations` — answering `400` `idempotency_key_required`; the spec marks the header `required` on exactly those. Replays of the same key return the original result instead of duplicating the write, and carry `X-Idempotency-Replay: true` so you can tell a replay from a fresh write. 🔴 **A key belongs to one request body.** A retry with the same key and the same body replays the original; the same key with a different body is refused with `422` and code `idempotency_body_mismatch` (with `first_used_at`), and nothing is written. So mint a fresh UUID for every distinct write, never one per session. Keys live 24 hours, scoped to your space and user. Only successes are replayed: a 4xx/5xx is not cached, so retrying after an error runs afresh.
7. **Timestamps are ISO 8601 UTC strings — every one, both directions.** If a field ends in `_at` it is a string, and so is every `since` / `until` in a query. Send a zone (`2026-07-25T12:00:00Z` or `+01:00`); a bare local stamp or a number is a 400.
8. **Query parameters are strict too.** A read answers only to the parameters it documents, in snake_case. Anything else — a typo, a camelCase spelling, a parameter another operation reads — is a `400` with code `unknown_parameter` and a `pointer` naming it, never a silently unfiltered list. A list is ONE comma-joined value (`kinds=a,b`); sending a name twice is a `400` with code `repeated_parameter`.
9. **Missing or unknown credentials are a `401`** with a `WWW-Authenticate: Bearer` challenge — see Authentication. A `403` means the key is real but lacks a scope or the plan lacks an entitlement.
10. **v1 is stable and additive-only — from launch.** Until launch, v1 is clean slate: a changed word changes everywhere at once (server, SDK, MCP, apps, docs) and the SDK CHANGELOG names every break with a **Breaking:** prefix. From launch, nothing in v1 is removed, renamed, retyped or made required. What CAN arrive is additive — new operations, new optional request fields, new response fields, new values in closed enums — so ignore a field you do not know, and treat an enum value you have not heard of as opaque, never fatal. Breaking changes ship as `/api/v2`, and v1 then keeps answering for at least six months after the announcement, signalled by `Deprecation` and `Sunset` headers. Policy: https://developers.madless.com/deprecation
## Base Types
**Typed IDs.** Every resource id is a prefix and 20 base62 characters — `con_Hz7tA6V6kLm0pQrSt9Xy` — and the prefix is a wire contract you can branch on without a lookup:
`con_` concept · `not_` note · `nvr_` note version · `skt_` sketch · `rel_` relation · `lbl_` label · `vw_` view · `wld_` world · `fil_` file · `prp_` proposal · `itk_` intake (a keep in flight) · `ask_` Ask answer · `tlm_` act-log entry · `trl_` trail row · `sch_` search · `inv_` an invitation waiting for you (a row of `GET /people/invitations`; the Owner's side of an invitation, on `…/invitations`, carries no id and is named by its `uid` or `email`) · `msg_` thread message · `ntf_` notification · `whk_` webhook subscription · `evt_` webhook event · `whm_` webhook message (one event bound for one subscription) · `whd_` webhook delivery (one attempt).
Three identifiers are not typed ids, by design: API keys are `mad_sk_<hex>`; a person is their raw account uid (`uid`, `created_by`, `added_by`) with no prefix; a space is its subdomain slug (the `acme` in `acme.madless.com`).
**A person has one shape.** Wherever a person is embedded — a view's `people[]`, a connection, an invitation — it is `person: { uid, handle, name, photo_url }`, all four keys always present. What the row adds about the relationship (`role`, `permission`, `joined_at`, `status`…) sits BESIDE `person`, never inside it. `name` is `null` when they set none (render `@handle`). `photo_url` is a relative `/api/v1/people/{handle}/avatar?v=…` path for a photo Madless hosts — fetch it with your credential; `v` changes on every upload — or a third party's absolute URL for an avatar they signed in with, or `null`. Two reads ARE a person and spread the same four keys: a profile card (`GET /people/{handle}`, plus `joined_at`) and your own account (`user` on `GET /users/me`, plus what only you may see). A person merely referenced (`owner_uid`, `created_by`, `added_by`, `author_uid`) is a bare uid.
**Timestamps are ISO 8601 UTC strings.** If a field ends in `*_at` it is a string, in requests and responses alike, nested or top-level — `created_at`, `updated_at`, `last_interacted_at`, `client_touched_at`, `happened_at` (the real-world date a concept's subject happened, often auto-set from a photo's EXIF taken-date), `taken_at` (a file's EXIF capture date), and the revisit moments `at` / `next_fire_at` / `fired_at` / `last_fired_at` / `acknowledged_at`.
Send a zone: `2026-07-25T12:00:00Z` or `2026-07-25T13:00:00+01:00`. A bare local stamp like `2026-07-25T12:00:00` is a 400 — it is ambiguous, so it is refused rather than guessed at.
**Note content is an HTML string on the way out, always** — the markup the in-app editor produces, capped at 40,000 characters of YOUR text: the words, not the HTML that stores them, so a paragraph's `<p></p>` and an `&` stored as `&` cost nothing. The stored HTML has its own ceiling of 60,000 characters, which only a note that is mostly markup reaches. Stick to paragraphs, headings, lists, `strong`, `em`, `code`, `a`, `blockquote`.
**Markdown is an accepted INPUT.** One field name, two vocabularies: note create/update takes `content_format: "html" | "markdown"` (default `"html"`); `/keep` takes `content_format: "plain" | "markdown"` (default `"plain"`, since a line someone typed that starts with `#` means a `#`). Either way, send `"markdown"` and the server converts, then sanitizes — in that order. There is no second read format: what comes back is HTML either way. A TABLE — markdown or HTML — is refused with a named 422 (`unsupported_markdown_table`) rather than flattened, and so is an image in either format (`unsupported_image`), rather than dropped — a markdown task list (`- [ ]`) is kept as the editor's own task list, tick and all: silently mangling someone's words is the one unacceptable outcome. `h4`–`h6` normalize to `h3` and `del` to `s`, which is what the editor can actually hold.
**Uploads** are refused at the route if the MIME type is outside the allowlist — images, audio, video, PDF, Office documents, text/CSV/Markdown, JSON/XML. Recorded clips and uploaded files share one per-clip ceiling — how long a single thing can be has one answer per plan, whichever door it came through: Starter 2 minutes, Plus 5, Pro 10. Anything over the cap is refused, never stored-and-left-unreadable — a recording comes back with the limit in the message, and an upload is purged whole once probed. Storage, minutes and image allowances are per-space, move with the plan, and can be lifted for one space, so treat these as today's values. Every meter — AI credits, media minutes, images, storage, views with people — is readable at `GET /api/v1/usage` (no scope required): one `used` / `limit` / `remaining` grammar per meter, `limit: null` meaning unlimited. Check headroom there rather than discovering a cap through a refusal; a refusal still names the limit it enforced.
Full detail: https://developers.madless.com/types and https://developers.madless.com/formats
## Rate Limits
Every response except a `304` carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (a Unix timestamp in **seconds**, not a duration). **Limits are per-endpoint**, so a read endpoint and an AI endpoint have separate budgets — a 429 on one says nothing about the others. Each endpoint publishes its own number AND window on its reference page: a minute for most, an hour for a few — `POST /api/v1/ask` is 30 per hour per person and **15 per hour through an API key**, so a `Retry-After` there can be minutes, not seconds.
On a `429` the body is RFC 9457 with a `retry_after` field (seconds) and a `Retry-After` header is set. Handle it like this:
1. Read `retry_after` from the body (fall back to `Retry-After`, then to `X-RateLimit-Reset − now`).
2. Sleep that long, plus a small random jitter, then retry **the same request**.
3. Do **not** pause your other calls. The budget that ran out belongs to one endpoint; backing off globally throws away throughput you still have.
4. Retry a `429` up to a few times, then surface it. Never retry any other `4xx` — those describe a problem with the request, and repeating it changes nothing.
**One `429` is not a minute bucket.** Its `type` ends `#daily_cap_reached`: the space has made its day's worth of machine requests (every key and connected AI together, over a sliding day measured in UTC days: at 00:00 UTC the previous day's count still weighs in full and fades to nothing by the next 00:00 UTC, so there is no midnight reset and no cliff to time). Seconds of backoff will not help — `retry_after` says how long, and `GET /api/v1/usage` shows the count under `api_requests`. `resets_at` there is the next 00:00 UTC, when today's tally closes — not when the count reaches zero. Branch on `type`, not on the status.
`5xx` is different: safe to retry with exponential backoff.
## Full OpenAPI Specification
The complete OpenAPI 3.1 spec (all endpoints, schemas, examples) is available at:
```
https://developers.madless.com/openapi.json
```
**Always fetch this spec first** to get the latest endpoint details, schemas, and examples.
## What makes this API different
- **Cited, or silent.** `POST /api/v1/ask` takes one question and gives one checked answer, built only from what was kept: every claim is checked against the passage it cites before anything is shown, a claim that does not hold is removed whole, and when nothing holds the answer abstains and says so in a sentence. It refuses to guess. The citations are the receipts — each one carries the exact line the claim was checked against. Recipe 1.
- **Meaning is a field.** `GET /api/v1/concepts/{id}/similar` returns semantically similar concepts with a `similarity` score (0–1, where 0.5 is unrelated — see Recipe 7). `GET /api/v1/search?semantic=true` runs embedding search. Every concept carries `auto_tags` and `topics[]` with salience.
- **The signals are readable.** `GET /api/v1/concepts?sort=` accepts derived signals: `interaction_count_human`, `relation_count`, `last_interacted_at`, `updated_at`, `created_at`. `relation_count` is undirected degree, counted from the live relations on every read (nothing stored to go stale), and needs `graph.read` because the ordering is the graph's shape. On a `?sort=relation_count` page each row carries that number as `relation_count` (`0` for a concept with no connections), so you can read the degree the page was ranked by; rows of every other sort carry no such key. The graph's structure — clusters, hubs, bridges, orphans — is `GET /api/v1/graph/stats`, computed per request; a cluster has no id, because it is identified by its members and stored nowhere. **For "what does this person keep returning to", sort by `interaction_count_human`** — it counts human touches only, and it is the only engagement axis there is. A concept a person created starts at `1` — creation is a touch — so `>= 2` is the real threshold for "returned to"; one created by a key, an MCP client or an import starts at `0`. It needs `history.read`.
- **The lenses are filters, not routes.** One list answers all of them: `GET /api/v1/concepts?placement=unplaced` (the Stack — kept but placed nowhere), `?revisit=scheduled` (the forward agenda) or `?revisit=due` (what is due now), `?lifecycle=trashed` (the bin) — each row there carries `lifecycle.kind: "trashed"` and `trashed_at`. **One lens per request** — each names a different set with its own ordering and its own cursor, so a lens never composes with `q`, `sort` or `concept_ids`; any of those is a `400`. `?concept_ids=` (up to 100) names its own set the same way, so it combines with nothing that ranks, sorts, filters or pages — `q`, `sort`, `label_id`, a lens, `authored_by` or `cursor` beside it is a `400` with code `mutually_exclusive`, never a filter silently dropped. `DELETE /api/v1/concepts/trash` empties the bin and is a separate, irreversible operation (Recipe 12).
- **Machine writes are attributable, and the destructive ones are fenced.** Assistants and tools should write through proposals (`POST /api/v1/proposals`) — a deferred write carrying your `rationale`. **Whether a human sees it before it applies depends on the space's review mode**: under `auto` an auto-acceptable kind applies immediately and returns `status: "accepted"`; under review mode it lands `pending`. Do not tell a person their change is waiting for them unless the response says `pending`. **Four kinds ignore that setting and are always person-only** — `merge`, `edit_empty_trash`, `view_delete` and `world_delete`. A machine credential (API key or MCP OAuth) can file one but can never accept it — and cannot do the act directly either. Accepting one, or calling `POST /api/v1/concepts/merge`, `DELETE /api/v1/concepts/trash`, `DELETE /api/v1/canvas/views/{view_id}` or `DELETE /api/v1/canvas/worlds/{world_id}`, answers a key or a connector `403` with code `human_review_required` and the `proposal_kind` to file instead (`merge`, `edit_empty_trash`, `view_delete`, `world_delete`). Independently of all this, every record a credential can write carries provenance — in your own space concepts, notes, sketches, relations, labels, proposals, views, worlds, files and thread messages, and on a view with people the cards placed on it and the lines drawn between them: `created_by_client` (`{ kind, name }` when a machine made it, `null` when a person did) and `client_touched_at` (when a machine last wrote to it, else `null`).
- **Time is a substrate.** `happened_at` places a thought at its real-world date — the day the thing happened, not the day it was let in.
- **The past is one family: `/api/v1/history`**, and all three reads take `history.read`. `GET /history/changes` is the durable change feed, derived from record modification times — a modification time cannot be dropped, so this is what you poll to reconcile what was created and changed. It carries `concept.created`, `concept.updated` and `relation.created` and nothing else: **a deletion never appears in `/changes`.** Narrow it with `kinds` and `concept_ids` (one comma-joined value; a `relation.created` matches when either end is named). A page can come back shorter than `limit`, even empty, while `next_cursor` is still a string — each page reads a bounded stretch of the window — so follow the cursor until it is `null`; only `null` means done. Going to the bin is a `concept_trashed` row in `/history/events`, a merge a `concept_merged` row there, whose `payload.source_titles` names the folded concepts; a permanent deletion takes the concept's own history with it, so the `concept.deleted` webhook (its `reason` says `deleted`, `purged` or `merged`) is the one announcement of it — without a subscription, a concept you hold that now answers `404` is gone. `GET /history/events` is the act log, the real rows of what was done; filter it with `concept_id`, `subtypes` (the act vocabulary — not webhook event ids), and an ISO `since`/`until` window, paging on `cursor`. `GET /history/trail` is the reach log: what was opened, what was searched for. All three read **newest first**; `?order=asc` reads oldest first, and a `cursor` continues the order it was minted under, so send the same `order` on every page. Thread messages follow the same rule: `GET /api/v1/threads/<anchor_type>/<anchor_key>` is newest first, `?order=asc` oldest first. One concept's own history is `GET /api/v1/history/events?concept_id=con_…`; a concept has no history subresource.
---
## Workflow Recipes
These are step-by-step instructions for common multi-step tasks. Follow them exactly.
### Recipe 1: Ask your mind
One question, one checked answer, built only from what was kept — and it refuses to guess. Everything else in this API exists so this answer can be trusted: the keep door admits only what Madless can read, and the map is the arrangement the answer reads as evidence.
```
POST /api/v1/ask
Content-Type: application/json
Accept: text/event-stream
Idempotency-Key: <generate-a-uuid>
{ "query": "What did I keep about modal jazz?" }
```
Needs `ask.run` and `concepts.read`. The answer is a `text/event-stream`, never one JSON body: read it frame by frame, each frame an `event:` line and a `data:` line of JSON. The frames arrive in one order — `scope → status(searching)* → evidence* → status(reading) → status(checking)* → answer → done` — and only `answer` carries prose. Nothing is shown before it is checked, so there are no token chunks to stitch.
The frame to read is `answer`:
```
event: answer
data: {"id":"ask_7h2k9mRt7Wq2Zx9Kp4Vn","text":"You kept that modal jazz builds on scales rather than chord changes [1], and that Kind of Blue was recorded in two sessions on modal sketches [2].","abstained":false,"citations":[{"n":1,"evidence_id":"e1","id":"con_Hz7tA6V6kLm0pQrSt9Xy","title":"Modal jazz","url":"/?concept=con_Hz7tA6V6kLm0pQrSt9Xy","evidence_class":"user_note","excerpt":"Modal jazz builds on scales rather than chord changes."},{"n":2,"evidence_id":"e2","id":"con_4d6e8f0aAHTd92kLm0Rt","title":"Miles Davis","url":"/?concept=con_4d6e8f0aAHTd92kLm0Rt","evidence_class":"user_note","excerpt":"Released in 1959 — recorded in two sessions on modal sketches."}],"verification":{"claims_examined":2,"outcomes":{"supported":2,"supported_after_repoint":0,"removed":0,"inconclusive":0}},"checked":true}
```
`[N]` in `text` is the citation whose `n` is N, and its `excerpt` is the exact line the claim was checked against. `verification` says what became of every claim: a claim the cited passage did not carry is `removed` — taken out whole, never softened. `id` is the stored answer, readable again at `GET /api/v1/ask/answers/<answer_id>`. `done` closes the stream with what the run spent.
**An abstention is a finished answer, not an error.** When nothing you kept answers the question, `abstained` is `true`, `citations` is empty, `abstain_reason` says why (`no_evidence`, `nothing_kept`, `nothing_survived`, `verifier_unavailable`), and `text` is a sentence to show as it comes — "Nothing you kept says this." Branch on `abstained`, never on the words.
Optional beside `query`: `scope` aims the question at one World or View — `{ "kind": "view", "id": "vw_…" }` — and `locale` picks the language the checked answer is written in. The Ask entry under API Modules has every frame, every reason, and what a view with people changes.
### Recipe 2: Create a Concept (basic)
A concept is the core unit of knowledge in Madless. Body text goes in `notes`; `links` attach URLs. `content_order` says which block leads — the first one is the concept's face. Every concept also carries a read-only `kind` — `note`, `link`, `image`, `video`, `audio` or `file`, the same words `GET /api/v1/activity/summary` uses — derived from its face: a document or any other file is `file`; a drawing, or a concept holding nothing yet, is `note`.
```
POST /api/v1/concepts
Content-Type: application/json
Idempotency-Key: <generate-a-uuid>
{
"title": "Miles Davis",
"notes": [
{ "content": "Trumpeter and bandleader. Kind of Blue (1959) is the best-selling jazz album of all time." }
],
"links": [
{
"url": "https://en.wikipedia.org/wiki/Miles_Davis",
"title": "Miles Davis — Wikipedia"
}
],
"content_order": ["note:0", "https://en.wikipedia.org/wiki/Miles_Davis"]
}
```
**Accepted fields:** `title` (required; up to 500 characters, counted as characters the way a person sees them — an emoji is one, however many code points it is built from, so a family, a flag or a skin-toned thumb is one too — holding something visible, since a title of only spaces or zero-width characters is blank, and no control character but tab and newline, the rule every name and title on the API keeps), `notes` (array of `{ title?, content?, content_format?, order_index? }`, max 10), `links` (array of `{ url, title?, description?, favicon? }`, max 50), `files` (array of `{ file_id }`), `content_order`, `label_ids`, `revisit` (a chosen return — `{ kind: "once", at }` or `{ kind: "cadence", rrule }`, see spec; a cadence is daily at the finest — hourly or finer is refused by name; its optional zone is an IANA name such as Europe/London or UTC, and a cadence is read in it — BYHOUR=9 with Europe/Warsaw is 09:00 on a Warsaw clock, a BYDAY is the weekday there, and a rule sent without a DTSTART comes back at 08:00 there, or at its own BYHOUR (UTC when no zone is given); the name is stored by its canonical spelling, so europe/london is kept as Europe/London, and an unknown one is a 400, never read as UTC), `source_ref`, `last_interacted_at`, `happened_at` (ISO 8601, any instant from `0001-01-01T00:00:00Z` on — the floor is the instant, so `0001-01-01T00:00:00+14:00`, still the year 0 in UTC, is refused). The schema is strict — unknown fields return 400.
A link address is `http://` or `https://` with a host, at every door that stores one — `links[].url` here, `POST /api/v1/concepts/<concept_id>/links`, the keep door's `link` intent and a `text` intent's `source_url`, and a `keep_link` proposal. Any other scheme (`javascript:`, `data:`, `vbscript:`, `file:`…) is a `400` at that field's pointer, with code `invalid_url_scheme`, before anything is stored or staged.
**`content_order` is required whenever you send `notes`, `links` or `files`**, and names exactly those blocks, each once: a file by its `fil_` id, a link by its URL, and a note by its position in your own `notes` array — `"note:0"`, `"note:1"`, … (the server mints the note ids and resolves them in the same write). Leave it off only on a title-only concept.
Response (201):
```json
{
"data": {
"id": "con_abc123",
"title": "Miles Davis",
"labels": [],
"embedding_status": "pending",
...
}
}
```
Save the `data.id` — you'll need it for notes, relations, and file attachments.
**Embedding lifecycle:** the concept appears immediately but isn't semantically searchable until `embedding_status` flips from `"pending"` to `"ready"` (usually seconds). To search a just-created concept, poll `GET /api/v1/concepts/<id>` until it reads `ready`. That read answers a weak `ETag` hashed from the body you got; send it back as `If-None-Match` and an unchanged concept is a bodyless `304`. A 304 carries the `ETag` and cache headers only (no `X-Request-Id`, no `X-RateLimit-*`): the edge strips the rest, so read the request id and the rate-limit headers off a `200`.
### Recipe 3: Keep Something (one call)
`POST /api/v1/keep` is the one door in — send one thing; the door answers the moment it is staged, and the reading runs behind the answer. **Nothing is kept unless Madless can read it:** the material is read and coverage-checked before a concept exists. `Idempotency-Key` is required.
```
POST /api/v1/keep
Content-Type: application/json
Idempotency-Key: <generate-a-uuid>
{
"intent": { "kind": "text", "content": "Modal jazz builds on scales rather than chord changes." },
"source": { "app": "api" }
}
```
`intent` is a discriminated union on `kind`: `text` | `link` | `image` | `audio` | `video` | `document` | `compose` (see spec for each shape; media kinds take a `file_id` from Recipe 5). There is no generic `file` kind — a format with no reader is refused at the door. A `compose` carries one ordered `blocks` array (1–20) and is atomic: every block passes or nothing is kept. (The TypeScript SDK's `keep.file()` uploads a file staged for the keep and picks the kind from its mime — one call.)
Optional alongside `intent`: `source` (`{ "app": "api" }` when omitted; `source.kept_at`, your own clock, must fall between 2000-01-01 and one hour from now, else a 400 at `#/source/kept_at`) and `labels` — up to 50, each exactly one of `{ "id": "lbl_…" }` or `{ "name": "…" }` (matched case-insensitively, up to 200 characters — the same limit `POST /api/v1/labels` holds). A name that matches nothing is minted only when the keep is **admitted** — a refused keep leaves no label behind — and a minted label fires `label.created` like any other. Unknown fields are a 400.
**Text: what the 40,000 means.** `content` is at most 40,000 characters, and they are counted as yours — the note's text, not the HTML that stores it, so a paragraph's `<p></p>` and an `&` stored as `&` cost nothing: 40,000 typed characters are kept. A note whose formatting takes it past 60,000 characters as stored — a page of nothing but `&` or `<`, say — is refused too. Either way it is a synchronous `422` `too_long` whose `detail` gives the numbers — never a `202` that fails later. `GET /api/v1/keep/formats` publishes the ceiling as `max_note_chars` on the `text` row. Any real character is readable — `"42"` is kept; only whitespace, or markdown that converts to no words at all, is `empty_submission`.
Response (202): `{ "data": { "intake_id": "itk_…", "state": "reading", "kind": "text", "name": …, "via": …, "file_id": null, "concept_id": null, "refusal": null, "placement": null, "created_at": …, "expires_at": … } }` — the **intake receipt**, plus a `Location` header naming the status door. There is no concept in the answer, because at Sent it does not exist yet.
Poll `GET /api/v1/keep/intake/<intake_id>` (scope `keep.write`) until `state` is terminal. `"admitted"` carries `concept_id` — the concept the material became. `"refused"` carries `refusal.code` and `refusal.detail` — the exact sentence, safe to show verbatim — and, when a `compose` was refused for one of its blocks (`block_refused`), `refusal.block_ref` naming that block (`#/intent/blocks/2`) so you can take it out and keep the rest; no concept was created and the staged bytes are gone. A terminal receipt stays readable for 24 hours, then `404`. `PATCH /api/v1/keep/intake/<intake_id>` (scope `canvas.write`) with `{ "placement": { "view_id", "x", "y" } }` says where the card will land when admitted — `409` `intake_settled` once the row is terminal.
**A preflight refusal is still a synchronous `422` with a named `code`** (`unsupported_format`, `file_too_large`, `too_long` — a recording past your length cap, or text past `max_note_chars` — `empty_submission`, `blocked_address` — a link pointing at a private, loopback or link-local address, which Madless never fetches — …), the `modality` it was refused for, and a `detail` you can show a person verbatim. The receipt's `refusal.code` uses the same words, so one branch handles a refusal at the door and one on the receipt alike (a link that only redirects to a private address is refused there, as `blocked_address`). **Treat it as final** — retrying an unreadable file cannot help. A note body holding a table or an image is refused the same way, as `unsupported_markdown_table` or `unsupported_image` (inside a `compose`, as that block's `block_refused`). `modality` is on every keep refusal and on no other `422` this door gives — `idempotency_body_mismatch` is about the request, not the material. A refusal after staging arrives ONLY on the receipt, never as an HTTP error. `GET /api/v1/keep/formats` publishes the whole contract per modality, in the same wording — including `max_files`, which on `compose` is how many files one keep may carry in your space (10 unless your space has its own number). Size a multi-file picker by it: one file more is a `400` `too_many_files`, before anything is staged.
### Recipe 4: Add a Note to an Existing Concept
Notes are rich-text blocks attached to a concept. **Up to 10 notes per concept** (`409` with code `note_cap_reached` over the limit — restoring a deleted note counts as adding one).
```
POST /api/v1/concepts/<concept_id>/notes
Content-Type: application/json
Idempotency-Key: <generate-a-uuid>
{
"title": "Kind of Blue",
"content": "<p>Released in 1959 — recorded in two sessions on modal sketches.</p>"
}
```
**Accepted fields:** `title`, `content`, `content_format` (`"html"` default, or `"markdown"` to have the server convert first), `order_index`. Content is sanitized server-side and comes back as HTML regardless of what you sent; word count and read time are computed. The concept is re-embedded in the background so the note becomes searchable.
What a note cannot hold is refused by name with a `422` and `errors[].pointer` `#/content`, never dropped: a table in either format (`unsupported_markdown_table`), and an image in either format — markdown `` or HTML `<img>` (`unsupported_image`) — attach the picture to the concept as a file instead. A markdown task list (`- [ ]` / `- [x]`) is kept: it becomes the editor's own task list, tick and all.
`{}` is a legal body — an empty note is a real note, takes a slot against the 10-note cap, and comes back with an id.
### Recipe 4b: Undo an Edit — a Note's Version Net
Every note keeps its own history, written for you. You never create a checkpoint; three moments make one, and `reason` says which:
- `edit` — the first content change inside a **ten-minute window**, holding the text as it stood *before* that window. A burst of typing is one checkpoint, not forty.
- `delete` — the note as it stood when it was removed.
- `restore` — the state a restore replaced, so undoing an undo is ordinary.
**At most 20 per note**, oldest trimmed as new ones land. The list is always one page (`next_cursor` is present and always `null`; no `cursor` is accepted).
```
GET /api/v1/concepts/<concept_id>/notes/<note_id>/versions
```
Rows carry an `excerpt` — the first 140 characters as plain text — not the body. Read one to get its `content`:
```
GET /api/v1/concepts/<concept_id>/notes/<note_id>/versions/<version_id>
POST /api/v1/concepts/<concept_id>/notes/<note_id>/versions/<version_id>/restore
```
**A deleted note is recoverable, and this is the point.** Both reads answer for a note that no longer exists — the concept is the fence, not the note — so after `DELETE …/notes/<note_id>` you can still list its checkpoints and restore the `delete` one. The note comes back under its **own id**, so the rest of its history still belongs to it.
⚠️ **It returns to the END of `content_order`, not its old position**, and takes an `order_index` one past the highest a live note holds. Deleting a note removes its id from the order and nothing records where it sat; appending is the honest answer rather than a guessed slot. Reorder afterwards if position matters. Bringing a deleted note back is an add: on a concept already holding 10 notes it is the same `409` `note_cap_reached` that adding one is, and nothing changes.
Restore answers the note **as it now stands** — the ordinary note shape, not the version shape. A restore is itself undoable: the words it replaced land in the net first.
The version net is not a bin. Delete the **concept** and the whole net goes with it.
### Recipe 5: Upload a File and Attach to a Concept
File upload is a **three-step process**: sign → upload → register. Then attach the file to a concept. A space has ONE file pool, so there is no container to pick first.
Keeping a file from TypeScript? The SDK does all three steps, the keep and the wait in one call: `await client.keep.file({ name: 'notes.pdf', mime: 'application/pdf', data: bytes })` — the kind is read off the mime (`image/*`, `audio/*`, `video/*`, anything else a document), and a recording still passes `{ duration_sec, origin }`.
**Step 1:** Get a signed upload URL:
```
POST /api/v1/drive/uploads/sign
Content-Type: application/json
Idempotency-Key: <generate-a-uuid>
{
"name": "miles-davis.jpg",
"mime": "image/jpeg",
"size_bytes": 102400
}
```
Response: `{ "data": { "url": "https://<bucket>.r2.cloudflarestorage.com/…?X-Amz-Signature=…", "object_path": "spaces/…", "expires_at": "2026-07-25T12:15:00.000Z" } }` — a signed Cloudflare R2 URL; PUT the bytes to it as given.
Save both `url` and `object_path`. The URL stops accepting a PUT at `expires_at`, fifteen minutes after signing — upload promptly.
**Step 2:** Upload the file directly to the signed URL:
```
PUT <url from step 1>
Content-Type: image/jpeg
<raw file bytes>
```
**Step 3:** Register the uploaded file. `checksum` is REQUIRED — the SHA-256 of the exact bytes you just PUT, 64 lowercase hex characters:
```
POST /api/v1/drive/files
Content-Type: application/json
Idempotency-Key: <generate-a-uuid>
{
"name": "miles-davis.jpg",
"size_bytes": 102400,
"mime": "image/jpeg",
"object_path": "<object_path from step 1>",
"checksum": "<sha256 hex of the bytes>"
}
```
Response: the file itself — the same shape `GET /api/v1/drive/files/{file_id}` returns. Save `data.id`; the field is `id`, the same name it has on every drive read, not `file_id`.
**Read back what Madless read.** `GET /api/v1/drive/files/{file_id}/text` (scope `drive.read`) answers the whole extracted body — a recording's transcript, a PDF's or document's text, the words read off an image — as `{ "text", "language"? }`, one body, no paging; `language` is the BCP 47 tag the reader detected, absent when none was recorded. `404` `text_not_found` while nothing has landed yet (poll the file's `extraction.status`, or the concept's `/understanding`). The file row's `extraction.text` is only the first ~600 characters.
**Registering a file for a keep? Add `"staged_for_keep": true` to this body.** It marks the bytes as belonging to a keep that has not happened yet: a refused keep deletes them, and one that never comes is swept about six hours later — without it, a refused keep leaves the file in Drive. A recording over your plan's length cap that was staged this way is not purged when it is probed: the measured length is stamped on the file and the keep that names it answers `422` `too_long` with both numbers, whatever `duration_sec` the client declared; a keep that never comes leaves it to the same sweep. An image or a short recording is read as soon as it is registered, so the keep answers sooner, and the file ends up exactly as understood as one uploaded without the flag. Leave it off for a file meant to live in Drive on its own.
The object becomes one file. Registering the same `object_path` again with the same `checksum` answers `200` with that file; with a different one it is a `409` (`object_already_registered`). The stored bytes must begin like the declared `mime` — checked here on every register, whatever uploaded them, against the type you declared (a PNG sent as `image/jpeg`, or an HTML page sent as a PDF, is refused): a mismatch is a `400` whose `errors[0].code` is `content_mismatch`, and the object is removed — upload the right bytes, or declare the right type. `size_bytes` is checked too: the register reads the stored object's own size, and one that is not what you sent is a `400` (`size_mismatch`, at `#/size_bytes`, carrying `sent` and `actual`) that removes the object. The checksum is checked against the stored bytes. Up to 25 MB (and for any image or PDF) that happens here, and a disagreement is a `422` (`checksum_mismatch`) that also removes the object. A larger file is checked after it registers; a disagreement quarantines it — nothing reads it, and every route that serves its bytes answers `409` (`file_quarantined`).
**One file grammar throughout:** `name` / `mime` / `size_bytes`. Sign, register and read all take and return those three names for those three facts.
**An alternative to step 3, when the thing doing the PUT should not also compose a registration.** Stash first, upload, then consume a single-use key — two envelopes with nothing to remember in between:
```
POST /api/v1/drive/uploads/register-pending
{ "name": "miles-davis.jpg", "mime": "image/jpeg", "size_bytes": 102400, "object_path": "<object_path from step 1>", "checksum": "<sha256 hex of the bytes>" }
→ { "register_key": "reg_…", "expires_at": "…" }
(PUT the bytes to the signed URL)
PUT /api/v1/drive/uploads/register-pending { "register_key": "reg_…" }
→ the file itself: { "id": "fil_…", "name": …, "mime": …, "size_bytes": …, … }
```
The POST takes the same facts as step 3 — `name` / `mime` / `size_bytes` / `object_path` / `checksum`, the one file grammar. The PUT registers through `POST /drive/files`, so that door's checks apply unchanged and its refusals come back as they are: the object really is in storage, the **true** size is read from the store rather than trusted from the stash, and the bytes must begin like the declared `mime` (`400` `content_mismatch`, object removed).
The PUT answers with the file itself — the same object step 3 returns, so save `data.id`. The key lives one hour (until `expires_at`) and makes ONE file: sending it again, for up to a day, answers with that same file and registers nothing. An expired or unknown key is a `404` (`register_key_unknown`). Finishing before the bytes have landed is a `404` (`upload_not_found`) that leaves the key usable — PUT the bytes and finish again. This path caps at **25 MB**; `POST /drive/files` is the one to use above that.
**Step 4a:** Attach to a NEW concept (at creation time):
```
POST /api/v1/concepts
Content-Type: application/json
Idempotency-Key: <generate-a-uuid>
{
"title": "Miles Davis",
"files": [{ "file_id": "<file_id from step 3>" }],
"content_order": ["<file_id from step 3>"]
}
```
A file is named in `content_order` by its own id — here it is the concept's only block, so it is the face.
**Step 4b:** OR attach to an EXISTING concept:
```
PATCH /api/v1/concepts/<concept_id>
Content-Type: application/json
Idempotency-Key: <generate-a-uuid>
{
"add_files": [{ "file_id": "<file_id from step 3>" }]
}
```
Note the asymmetry: attaching uses objects (`add_files: [{ file_id }]`), detaching uses plain IDs (`remove_file_ids: ["fil_…"]`). An attached file joins the END of `content_order`; send `content_order` in the same patch to put it anywhere else.
### Recipe 6: Connect Two Concepts
A connection is UNTYPED. It says that two things are connected and nothing more — there is no `relation`, `kind` or taxonomy, and `source_id`/`target_id` do not encode a verb. The one thing an edge can carry is an optional `caption`, ≤200 characters, in the PERSON'S words.
```
POST /api/v1/relations
Content-Type: application/json
Idempotency-Key: <generate-a-uuid>
{
"source_id": "<concept_id_1>",
"target_id": "<concept_id_2>",
"caption": "this is where I changed my mind"
}
```
**Rules:** `source_id` and `target_id` must be different (`400` `self_connection` — the same refusal a `connect_add` proposal gets). **The pair is treated as one connection, in either direction** — if the two are already connected, you get a 409 (`already_exists`) whichever way round you send them, and two concurrent creates of one pair draw one edge. The `409` carries `relation_id` — the edge that already connects the pair — and so does an `already_exists` row in `POST /api/v1/relations/batch`. A concept in the bin is a `409` (`concept_in_trash`) — restore it first. `color`, when sent, is `#RRGGBB` or `oklch(…)`. Never write your own reasoning into `caption`; put it on a proposal's `rationale`.
### Recipe 7: Find What a Concept Echoes
```
GET /api/v1/concepts/<concept_id>/similar?limit=8&sensitivity=balanced
```
Returns concepts ranked by embedding similarity, each with a `similarity` score. **Read the scale before reading the number:** `similarity` is `(1 + cosine) / 2`, so `0.5` is unrelated (orthogonal) and `1` identical — unrelated material commonly lands around `0.60–0.70`, and `0.63` is not "63% related". Compare neighbours with each other, never against an absolute bar. `sensitivity` sets the floor: `precise` 0.66 · `balanced` 0.62 (the default) · `broad` 0.55. This is the cheapest way to surface non-obvious connections before proposing relations.
### Recipe 8: Write Through Proposals (for assistants and tools)
Assistants and automations should prefer **proposals** over direct writes — a deferred write the human reviews, with your reasoning attached:
```
POST /api/v1/proposals
Content-Type: application/json
Idempotency-Key: <generate-a-uuid>
{
"payload": { "kind": "connect_add", "source_id": "<concept_id_1>", "target_id": "<concept_id_2>" },
"rationale": "Both concepts discuss modal harmony; the second cites the first."
}
```
- `payload.kind` covers keeps, edits, connections, view changes and more (see the enum in the spec); `payload` is a discriminated union on it, and the kind is named there only — a top-level `kind` on the body is a `400`. The scope the proposal costs is decided by `payload.kind`.
- A floor kind naming something that is not there is refused at filing, never left in the person's inbox where it could not apply: a `view_delete` for a missing View is `404` `view_not_found`, a `world_delete` for a missing World `404` `world_not_found`, a `merge` naming a concept that does not exist `404` `concept_not_found` with `concept_ids`, and an `edit_empty_trash` naming a concept not in your trash the same `404` with `concept_ids`.
- If the space's review mode is `auto` and the kind is auto-acceptable, the response comes back with `status: "accepted"` (and `auto_accepted: true`); otherwise it lands as `pending` for human review.
- `GET /api/v1/proposals` lists **pending by default**; pass `?status=accepted` or `?status=rejected` for the rest (anything else is a `400` `invalid_value`). Narrow with `kind`, `concept_id`, `batch_id` or `mcp_client_id`; they combine. A `?kind=` outside the proposal kinds is the same `400` `invalid_value`.
- Every proposal carries `auto_accepted` — `true` only when it was applied in the same step that filed it.
- Everything you write carries provenance: `created_by_client` and `client_touched_at` are stamped on the records you create or touch.
### Recipe 9: Invite people onto a view (invitation-only)
**Nothing opens to strangers.** There is no public switch, no reader tier, no people-search, no discovery, and no link-only read. A view is private, or it has the people on it who were invited by name — and the only way on is an invitation. Do not look for a `publish` or `audience` field — there isn't one.
A view with people on it is **the same view** — one document, one `vw_` id, whoever is on it. There is no second resource and no separate step: the first invitation on a private view is the act that gives it people.
```
POST /api/v1/canvas/views/<view_id>/invitations
Content-Type: application/json
Idempotency-Key: <generate-a-uuid>
{ "email": "[email protected]", "permission": "full" }
```
Answers `201` with the pending invitation. On a **private** view, one transaction checks how many views with people you already own against your plan (`shared_views.limit` on `GET /api/v1/usage`), writes `shared` onto the view — you as Owner, `people_cap` from your plan — and writes the invitation. On a view that **already has people**, the invitation checks `people_cap` and writes the invitation. The view does not move, is not renamed and is not copied — its cards are already what everyone on it will see. Every view on `GET /api/v1/canvas/views` carries `shared`: `null` on a private view, or `{ owner_uid, permission, can_invite, people_count, people_cap }` — the facts about YOU on that view. `GET /api/v1/canvas/views/shared` lists every view with people on it, yours or one you sit on, each with its `people`.
- **One noun for every invitation.** `POST …/invitations` takes **either** `{ "uid": "…", "permission"? }` (someone you are connected to) **or** `{ "email": "…", "permission"?, "note"? }` (anyone, including someone with no Madless yet) — never both. `permission` is `"full" | "read_only"` and defaults to `full`. The Owner sends invitations, and so does a person the Owner let invite.
- **Two refusals, both named.** `403` `shared_view_cap_reached` — a private view would gain people and you already own as many views with people as your plan holds (Starter 1, Plus 3, Pro any number). `409` `view_full` — the view already seats its `people_cap` (Starter 2, Plus 2, Pro 8), counting everyone: the Owner and every pending invitation included.
- `GET …/invitations` lists a view's pending invitations (Owner, or a person who can invite) — each row named by its `uid` or `email`, never an id. `POST …/invitations/resend` `{ "email": "…" }` mails a fresh link on the same record (the old one dies); `DELETE …/invitations?uid=…` or `?email=…` withdraws either kind (204). All three invitation writes require an `Idempotency-Key`; without one they answer `400` `idempotency_key_required`. **Cancelling the only pending invitation does not make the view private again** — only Remove everyone does.
- **The link never comes back to you.** An address invitation's single-use `/join` link goes to that inbox and nowhere else — no answer (send, resend or list), notification or webhook carries it, because holding it is what proves the address at `POST /people/join`. If the letter does not arrive, resend it.
- **Join is one word with two doors, and there is no separate accept.** An address gets a single-use link that expires in 30 days and creates no account; it is spent at `POST /api/v1/people/join` — the one operation on this API that takes **no credential**, because one click there is consent, a Starter account if the person has none, and their place on the view. An existing account with a pending invitation (`GET /api/v1/people/invitations` lists yours) takes its place with `POST /api/v1/canvas/views/<view_id>/join`, which answers `200` with the view as `GET /canvas/views` lists it. Either way the view lands in the person's **active** World — where they file it is theirs.
- **One role and two switches.** The Owner, and the people they chose. `PATCH /api/v1/canvas/views/<view_id>/people/<uid>` `{ "permission": "full" | "read_only", "can_invite": true | false }` moves either (Owner only). `full` places their own cards, moves any card and draws lines; `read_only` sees everything, talks, asks, and keeps a card into their own Canvas — and touches nothing. A write a switch refuses is `403` `read_only`; an Owner-only act from anyone else is `403` `not_owner`. `GET …/people` lists the faces: each `person` beside `joined_at`, `role` (`owner` | `member`), `can_invite` and `permission`.
- **Building on it is the same route family as a private view.** `POST …/concepts` places one of YOUR concepts, `…/concepts/batch` places several, `DELETE …/concepts/<concept_id>` lifts one, and `PUT /api/v1/canvas/views/<view_id>` saves the whole node map — a replace on a private view, a merge on a view with people, where a node you do not send is never removed. `PATCH /api/v1/canvas/views/<view_id>` renames, and does nothing else. With people on the view: you place only your own concepts, you lift only your own unless you are the Owner, moving needs `full`, and only the Owner renames it — a view has one name for everyone. A card placed by anyone but the Owner carries `author_space_id` on its node (absent means the Owner's space).
- **Who drew a line, and whose cards it joins, decide where it lives.** A line drawn by a person between two of **their own** cards is their own relation — it lives at `/api/v1/relations`, shows wherever both cards are placed, and goes only when they remove it. Every other line — between two people's cards, or drawn between cards that are not the drawer's — is a **board line**, stored under the view, and it goes when the view loses its people or when either card leaves. `POST …/relations` `{ "source_id", "target_id", "caption"? }` routes the line for you and answers `201` with it, saying which it made: `kind: "own"` or `kind: "board"`. `…/relations/batch` takes `{ "relations": [...] }`, and it and `DELETE …/relations/<relation_id>` act on board lines; your own line is removed through `DELETE /api/v1/relations/<relation_id>`. Both batches (`…/concepts/batch` too) answer the house batch shape, `{ "data": { "created": [...], "skipped": [{ "index", "code", "detail" }] } }`. Nothing is copied when a view gains people and nothing is materialised when it loses them.
- **Reading it.** `GET …/graph` is the whole view: `{ view_id, name, owner_uid, owner_space_id, people_cap, people, nodes, relations }`, each node with its card and each line with its `kind`. `GET …/search?q=`, `GET …/files/<file_id>` and `POST …/files/lookup` read what is placed there, on `canvas.read` **and** `people.read`. On your own private view they answer with your own cards and your own lines among them — `…/relations` is `[]`, because a private view has no board lines. A view you are not on is a `404`, never a `403`.
- **Keep** makes a full, independent copy of someone else's card in your own Canvas, through the same admission as anything you let in, on your own capacity, and `read_only` never stops it: `POST …/concepts/<concept_id>/keep` answers `201` `{ "data": { "concept_id": "con_…", "kept_from": { "view_id": "vw_…", "name": "…" } } }` — `name` as the view was called then. You cannot keep your own card back (400). A kept copy is an ordinary concept from then on; there is no refresh and no un-keep.
- **The ways off.** `DELETE …/people/<uid>` is the Owner removing someone (204, and a no-op on a uid that is merely invited — withdraw that through `invitations`). Anyone but the Owner leaves with `DELETE /api/v1/canvas/views/<view_id>` — from where they stand it removes the view from their Madless (204); there is no `…/people/me`. **Only the person leaves**: from an API key or MCP connector, that `DELETE` answers `403` `human_review_required` (`proposal_kind: "view_delete"`) in every state, Leave included — Leave takes their cards off a view other people see, and it shares the route with Delete. **Cards go with the person**: on Remove and on Leave, the cards they placed leave the view with them, and so does every board line touching those cards. `DELETE …/people` is **Remove everyone** (Owner) — immediate and total: everyone else loses the view now, with their cards, every board line, the view's talk and its messages, and pending invitations; it answers `200` with `{ "windows_deleted", "nodes_removed", "board_lines_deleted", "threads_deleted" }`, and the Owner's view is private again with their own cards where they were. Kept copies are untouched — they are the keeper's own concepts. The Owner's `DELETE` of a view with people is Remove everyone, then the trash.
- A person's outward face is a **card**, not a canvas: `GET /api/v1/people/<handle>`. There is no peer-canvas read.
- **Connections are one noun too.** `POST /api/v1/people/connections` `{ "handle": "…" }` asks (201, the pending connection is the resource); `GET /api/v1/people/connections?status=pending|accepted` lists them in one row shape, with `direction` (`incoming` | `outgoing`) present only while pending; `POST …/<connection_id>/accept` and `DELETE …/<connection_id>` close it either way, where `<connection_id>` is the row's `id`. A connection is accepted; a view is joined. A connection governs the pair thread and who you can invite by uid — it is not a read grant.
- **The scopes are the fence around other people's material.** `people.read` covers reading your connections, a person's card and invitations to you, and another person's material on a view you sit on (its cards, lines, files, search, faces), and joining a view you were invited to. `people.write` covers anything another person will see: asking, accepting and removing a connection; inviting; the two switches; removing someone; Remove everyone; renaming a view with people and building on one (placing, moving, connecting) — and it implies `people.read`. `canvas.*` alone never reaches past your own material, and **`people.write` is never granted over OAuth**: a connector can never invite, rename, remove, remove everyone or place a card on a view with people.
### Recipe 10: Search Across Everything
```
GET /api/v1/search?q=jazz&modules=concepts,drive&limit=10
```
Response — the house envelope, one flat ranked array:
```json
{
"data": [
{ "id": "con_…", "title": "Miles Davis", "url": "/?concept=con_…", "module": "concepts", "kind": "concept", "match_strength": "strong", "matched_fields": ["title"] },
{ "id": "fil_…", "title": "liner-notes.pdf", "module": "drive", "kind": "file", "match_strength": "strong" }
],
"next_cursor": null
}
```
There is no `results` object, no per-module nesting, no `total` and no `query` echo — `data` is every hit in rank order across both modules, and you group them yourself on `module` / `kind`. `next_cursor` is present and always `null`: search returns a single ranked top-`limit` page by design, so widen with `limit` rather than paging.
Each hit is `{ id, title, url, module, kind, match_strength, matched_fields }` — a FILE hit carries no `url`, because a file has no page of its own; it is reached through the concept holding it. `match_strength` is a coarse `strong`/`weak` ranking band — deliberately not a raw similarity float, which would be welded to the current embedding model. A title that is exactly your `q` — compared case-folded and Unicode-normalised, so `Słowo` finds a note titled `słowo` — is always `strong`. (`confidence`, wherever it appears, is always a 0–1 number — a different field.) For numerically scored neighbours of a specific concept, use `GET /api/v1/concepts/{id}/similar` instead. `url` is a web deep-link and a PATH — join it to your space origin; never fetch it as an API endpoint.
**Modules:** exactly two — `concepts` and `drive` (comma-separated, default: both). Anything else is a 400; a view with people is searched at `GET /api/v1/canvas/views/<view_id>/search`, not here.
**`q` is up to 500 characters after trimming, and at least 2** — or 1 where one character is a word: a Chinese, Japanese, Korean or Thai character, an emoji or a flag (`q=禅` and `q=🔥` are queries; `q=a` and `q=7` are a `400` `too_small`). A `q` with nothing visible in it — whitespace, zero-width characters, a lone accent, or `q=` sent empty — is the same `400` `too_small`; only a `q` you did not send at all is a missing field.
**Semantic search** (embedding-powered, concepts only):
```
GET /api/v1/search?q=modal harmony in jazz&semantic=true&modules=concepts
```
### Recipe 11: Update a Concept
```
PATCH /api/v1/concepts/<concept_id>
Content-Type: application/json
Idempotency-Key: <generate-a-uuid>
{
"title": "Updated Title",
"add_label_ids": ["lbl_…"]
}
```
**Accepted fields:** `title`, `content_order` (the whole order after this patch — the first block is the face), `revisit` (`{ kind: "once", at }` | `{ kind: "cadence", rrule }` | null to clear), `label_ids` (replaces the whole set), `add_label_ids`, `remove_label_ids` (never together with `label_ids`, and never naming a label the adds also name), `add_files` (objects), `remove_file_ids` (IDs), `add_sketches`, `remove_sketch_ids`, `happened_at` (ISO 8601, any instant from `0001-01-01T00:00:00Z` on; null clears it). The schema is strict — unknown fields return 400. All fields are optional — only include what you want to change. Who sees a concept is not a field on it: a concept reaches other people only by being placed on a view they were invited onto (Recipe 9). The concept is re-embedded in the background after content changes.
A revisit cadence is daily at the finest: a revisit brings something back on a day rather than ringing within one, so an rrule of FREQ=HOURLY (or MINUTELY, SECONDLY) — or a list of times in one day, such as BYHOUR=9,17 or more than one BYMINUTE or BYSECOND value — is a 400 whose code is cadence_too_fine, rather than a promise nothing keeps. A single BYHOUR just pins the hour. The rule is the rule body (FREQ=WEEKLY;BYDAY=SU), or one DTSTART line followed by one RRULE line; a leading RRULE: is the property name and is stripped, so what is stored is the normalised rule, and any other line is a 400. The rule is read in the revisit's `tz`; `next_fire_at` is that moment as an instant. A DTSTART ending in Z is an instant (the stored anchor is written that way, so DTSTART:20261023T060000Z with Europe/Warsaw reads back as 08:00 there); one with no Z and no TZID is a clock time in `tz` (DTSTART:20261027T080000 with Europe/Warsaw is 08:00 in Warsaw, 07:00Z); DTSTART;TZID=… names its own zone, which wins. The tick is hourly: a revisit surfaces within the hour of the time you chose, in your time zone (a cadence sent without `DTSTART` is anchored at 08:00 in its `tz`; a cadence with `BYHOUR` fires at that hour, starting today when it is still ahead).
Sending the same label id in `add_label_ids` and `remove_label_ids` is a `400` whose code is `mutually_exclusive`, at `#/remove_label_ids/<i>` — it used to resolve silently as a removal.
### Recipe 12: Trash vs Delete
**A DELETE asks for a STATE, not an act.** `DELETE /api/v1/concepts/<concept_id>` puts the concept in the bin — the same place `POST …/trash` puts it — and answers `204`. An id that is already gone answers `204` too: the state you asked for is the state it is in, so a retry is never a `404`. (A segment that is not a `con_` id at all names nothing, and is a `404` as on `GET`.) Files behave identically; those two are the only resources with a bin.
**Bin it (recoverable) — `concepts.write`:**
```
DELETE /api/v1/concepts/<concept_id>
Idempotency-Key: <generate-a-uuid>
```
`POST /api/v1/concepts/<concept_id>/trash` is the same act said as a verb, and it keeps its `404` on an id that never existed — a different question ("did you name a real thing?"), which keeps its own answer. Either way the concept's relations go to the bin with it, and restoring brings back the ones that went with it — never one you deleted by hand, and never one whose pair you have connected again since. What is in the bin reads back as a lens on the list: `GET /api/v1/concepts?lifecycle=trashed`. A concept in the bin reads `lifecycle: { "kind": "trashed", "trashed_at": "<ISO>" }` (restorable for 30 days from `trashed_at`); a live one reads `{ "kind": "live" }`. `deleted` is never a concept lifecycle — it is the `concept.deleted` webhook's word for a concept that is gone for good, and a gone concept answers `404`.
**A concept in the bin takes no writes but restore and purge.** Editing it — `PATCH`, its notes (create, edit, delete, reorder, version restore), links, labels, sketches, a block move into or out of it, acknowledging its revisit — is a `409` whose code is `concept_in_trash`, the same answer connecting it gets; `POST /concepts/labels/batch` lists it in `skipped[]` with that code. Restore it first.
**Restore from the bin — `concepts.write`:**
```
POST /api/v1/concepts/<concept_id>/restore
Idempotency-Key: <generate-a-uuid>
```
A restored concept goes back where it sat: on every view it was on when it was trashed, at the same spot. A view deleted while it was in the bin is skipped, and with none left it lands in the Stack. Its relations come back with it.
**Purge (irreversible — concept, relations, and history) — `concepts.delete`:**
```
DELETE /api/v1/concepts/<concept_id>?permanent=true
Idempotency-Key: <generate-a-uuid>
```
`permanent` is `true` or `false`, spelled exactly. Any other value (`yes`, `1`) is a `400` at `#/permanent`, and any other parameter (`?purge=true`) is a `400` `unknown_parameter` — both delete nothing, so a caller who meant to purge never gets a trash instead.
`DELETE /api/v1/concepts/trash` empties the bin on the same scope. The body says what goes and is required: `{"concept_ids": [...]}` purges just those, `{"all": true}` purges everything in the bin. No body, or any query parameter, is a `400` that purges nothing. An API key or MCP connector calling it gets `403` `human_review_required` with `proposal_kind: "edit_empty_trash"` — emptying the bin is the person's to do; file the proposal instead. `DELETE /api/v1/drive/trash` takes the same shape with `file_ids`, on the `drive.manage` scope.
**Irreversible destruction has its own scope.** `?permanent=true` and the empty-bin call both require `concepts.delete`, granted separately from `concepts.write` — a key that can create and edit freely may still be unable to destroy anything. If you get a `403` on `?permanent=true` while your writes succeed, that is the fence, not a bug: ask for the scope, or leave it in the bin.
### Recipe 13: Subscribe to Changes (webhooks)
```
POST /api/v1/webhooks/subscriptions
Content-Type: application/json
Idempotency-Key: <generate-a-uuid>
{
"name": "my-integration",
"url": "https://example.com/hooks/madless",
"events": ["concept.created", "proposal.created"]
}
```
The create response includes the signing `secret` (shown once), a Standard Webhooks secret — `whsec_` + base64(key). Verify with a stock `standardwebhooks` library, or by hand: strip `whsec_`, base64-decode the rest, and HMAC-SHA256 `<webhook-id>.<webhook-timestamp>.<raw body>` with those bytes; the `webhook-signature` header carries one or more space-separated `v1,<base64>` parts and any match is genuine. `webhook-id` is the message id, identical on every retry, so dedupe on it. An event name is `<resource>.<verb>` — `concept.created`, `relation.deleted`, `label.updated`, `view.node_added`, `drive.file.uploaded`. Read the live catalogue with `GET /api/v1/webhooks/events` rather than copying ids out of prose; a subscription naming an event that list doesn't carry is rejected on write, and there are no wildcards.
**Deliveries are queued, then sent**: an event is written to an outbox and sent within about two minutes, retried on failure — three attempts in all, with backoff; answer `429` or `503` with a `Retry-After` (seconds or an HTTP-date) and the next attempt waits at least that long, up to an hour. A subscription whose last 100 events each failed every attempt is turned off (an event that lands resets the count; a failed attempt that is still being retried does not move it): `active: false`, with `deactivated_reason` (one sentence) and `deactivated_at` on `GET /api/v1/webhooks/subscriptions/<subscription_id>` — no event announces it, so check there when deliveries stop; `PATCH { "active": true }` turns it back on and clears both. Each event is one `evt_…` id; it becomes one `whm_…` message per subscription, and every attempt at sending that message is a `whd_…` delivery. `GET /api/v1/webhooks/subscriptions/<subscription_id>/deliveries` is the log — each row's `response_status` is the HTTP status your endpoint answered with, `200` as plainly as `401` — and `POST …/deliveries/<delivery_id>/retry` sends a failed one again.
**Custom headers** — for a receiver (Zapier, n8n, an internal API) that only accepts a request carrying its own auth header, add `"headers": [{ "name": "Authorization", "value": "Bearer <receiver-key>" }]` (up to 5) to the create. They ride on every delivery, test send and replay beside the signature. Values are stored encrypted and follow the secret's rule: the create echoes each `value` once beside its `value_masked`; every later read shows `[{ name, value_masked }]` only (`Bearer ••••xyz`). `PATCH { "headers": [...] }` replaces the whole list (`[]` removes all; leave `headers` out to keep them) — an entry with no `value` — `{ "name": … }`, or the row exactly as a read shows it, `{ "name": …, "value_masked": … }` — keeps the stored value, so the masked list can be re-sent as read to reorder or drop headers (`value` beside `value_masked` is a `400 mutually_exclusive`). Madless's own names (`webhook-*`, `content-type`, `content-length`, `host`, `transfer-encoding`, `connection`, `user-agent`, any case) are refused as `400 reserved_header`.
`POST /api/v1/webhooks/test` `{ "subscription_id": "whk_…" }` sends a signed test envelope to that subscription’s URL straight away — only an event the subscription asked for: the `event_type` you name (it must be one of the subscription’s `events`, else `400 event_not_subscribed`), else `test.ping` if subscribed, else its first event; the response’s `event_type` says which. It is signed exactly as a delivery is, so during a rotation grace window it carries a `v1` signature for the old secret too. It records the attempt in the same deliveries log with `test: true` — it has no event and no message behind it, so it is never retried. The response hands back your endpoint's status, body and the computed signature, and of its response headers only `content-type`, `content-length`, `date`, `server`, `retry-after` and any `webhook-*` header — the rest is not echoed.
Every delivery body is `{ space_id, event_id, event_type, schema_version: 2, occurred_at, data }`, where `occurred_at` is ISO 8601 and `data` is shaped by the event. You do not have to receive one to learn its shape: the spec's top-level `webhooks` object publishes a body component per event id, beside the three Standard Webhooks headers (`webhook-id`, `webhook-timestamp`, `webhook-signature`) the signature is computed over. Deliveries can be dropped by a network you don't control, so **reconcile against `GET /api/v1/history/changes`** — it is derived from record modification times and cannot be lost the way a fire-and-forget announcement can. It reconciles creations and changes only: deletions arrive on `concept.deleted` and in `GET /api/v1/history/events` (`concept_trashed`, `concept_merged`), never in `/changes`.
### Recipe 14: Fold Duplicates Into One (merge — irreversible)
Merging **permanently purges** the sources. They do not go to trash. There is no undo. Read that before you call it.
**An API key or MCP connector cannot merge directly** — the call answers `403` `human_review_required` with `proposal_kind: "merge"`. File a `merge` proposal (`POST /api/v1/proposals`, below) and the person accepts it. The call below is the person's own, from a signed-in session.
```
POST /api/v1/concepts/merge
Content-Type: application/json
Idempotency-Key: <generate-a-uuid>
{ "survivor_id": "con_abc123", "source_ids": ["con_def456", "con_ghi789"] }
```
`source_ids` is a set: an id sent twice is folded in once. The survivor's own id among them is a `400`.
Everything moves to the survivor: notes, links, images and files, sketches. Labels are unioned. The survivor **inherits the sources' relations** — an edge to a third concept is re-pointed, an edge that ran between a source and the survivor is dropped (it would be a self-loop), and a duplicate collapses. The survivor keeps its own title, colour, `happened_at` and revisit, takes each source's **place on the board** — same position — on every view it was not already on (where it already sits, its own position stands), and is re-embedded in the background. Returns the survivor concept. One `concept.merged` webhook fires, naming the survivor, and one `concept.deleted` per purged source with `reason: "merged"` — so a mirror's one deletion handler drops them.
Refusals are pre-flight, write nothing, and carry a `code`: **409** `merge_cap_exceeded` if the combined blocks exceed a per-concept cap (10 notes, 50 links, 50 files, 10 sketches), `merge_source_shared` if a concept being merged **away** sits on a view with people (take it off that view first, or make it the survivor), `concept_in_trash` if one is in the bin; **404** `concept_not_found` if one no longer exists; **403** `not_yours` if you do not own every concept named. A missing `survivor_id`, an empty `source_ids`, or a survivor listed among its own sources is a **400**. An accepted `merge` proposal refuses with exactly the same problem, and its row in `POST /api/v1/proposals/batch` carries the same `code`.
**This is a judgment about what is the same thing, not a cleanup task.** If you are an assistant and you are not certain two concepts are one, say so and let the person decide — a wrong merge cannot be walked back.
An assistant proposes the merge instead, in the same grammar — it waits for the person, always:
```
POST /api/v1/proposals
Content-Type: application/json
Idempotency-Key: <generate-a-uuid>
{ "payload": { "kind": "merge", "survivor_id": "con_abc123", "source_ids": ["con_def456", "con_ghi789"] }, "rationale": "same idea, three weeks apart" }
```
---
## API Modules (public surface)
- **Concepts** (`/api/v1/concepts`) — what you kept: CRUD, bin/restore, **merge** (irreversible, see Recipe 14), notes, links, label assignment, per-concept `similar` + `understanding` (what Madless was able to read out of it — `GET` it, `POST` to re-read it), note **version history** (list / read / restore — see Recipe 4b), and the three lenses, which are FILTERS on the list rather than routes (`?placement=unplaced`, `?revisit=scheduled|due`, `?lifecycle=trashed`)
- **Graph** (`/api/v1/relations`, `/labels`, `/proposals`, `/graph/stats`) — how it connects: relations — untyped, between two concepts — label definitions, proposals (the write path for assistants and tools), and clusters/hubs
- **Canvas** (`/api/v1/canvas/views`, `/api/v1/canvas/worlds`) — where it sits. A view is private or has people on it — one document, one id — and the people on it ride the same family: `invitations`, `people`, `join`, `concepts` (+ `keep`), the board lines at `relations`, and the reads `graph`, `search`, `files` (Recipe 9). Deleting a View or a World answers an API key or MCP connector `403` `human_review_required` (`proposal_kind` `view_delete` / `world_delete`); file the proposal instead
- **History** (`/api/v1/history`) — the past, in one family: `changes` (the durable change feed, derived from modification times — poll this to reconcile creations and changes; deletions are never in it), `events` (the act log — what was done), `trail` (the reach log — what was reached for)
- **Keep** (`/api/v1/keep`) — **the one door in.** One request, one concept, `Idempotency-Key` required. Intent kinds `text | link | image | audio | video | document | compose`. The door answers `202` with an intake receipt the moment the material is staged; `GET /api/v1/keep/intake/{intake_id}` is the one status door, and `PATCH` on it places the pending card. Nothing is admitted until the published understanding contract for that format succeeds: a refusal creates no concept, names its `code`, and deletes the temporary bytes. `GET /api/v1/keep/formats` publishes what each modality promises. (The old capture family was deleted with no alias — if you were built against it, move to this route.)
- **Drive** — the space's one file pool (sign → upload → register, or sign → upload → the `register-pending` pair), download/preview/stream URLs, batch lookup, trash
- **Search** — global search across **concepts and drive** (those two modules and no others; anything else is a 400); optional semantic mode
- **People** (`/api/v1/people`) — who is on a view, **invitation-only, and nothing opens**: no publish, no reader tier, no people-search, no discovery, no link-only read, no request-to-join. A person's outward face is a card (`/people/{handle}`, `/avatar`), never their canvas; `connections` (ask, accept, remove) govern the pair thread and who you can invite by uid — they are not a read grant; `invitations` lists the ones waiting for you; `join` is the token door. Scopes `people.read` / `people.write` — `people.write` is never granted over OAuth
- **Ask** (`/api/v1/ask`) — one question, one **checked** answer built only from what was kept, with its sources shown. `POST /api/v1/ask` is gated on `ask.run` **and** `concepts.read`, takes `{ query, scope?, context?, locale? }` and answers `text/event-stream`, not JSON. The stream carries **status frames only** while it searches, reads and checks, then one checked reveal: `scope → status(searching)* → evidence* → status(reading) → status(checking)* → answer → done` — the whole evidence ledger arrives before `status(reading)`; `scope` is sent TWICE, at once naming only the kind of place (`concept_count: 0` means not yet counted) and again once the pool is read, naming the place and how much it holds — take the last one — with `error` replacing `answer` on a run that could not finish and `done` always last. `status(reading)` carries `reading` — how many distinct kept things are being read closely, the same number the answer's coverage line states. The `answer` frame is `{ id?, text, abstained, abstain_reason?, citations, verification, checked, related? }` — `id` is the stored answer (`ask_…`, the same id the `X-Ask-Answer-Id` header carries), absent only when storing the answer failed. **`abstained` is the one boolean to read**: `true` means nothing was revealed as an answer, and `abstain_reason` says why — `no_evidence` (nothing you kept touched the question), `nothing_kept` (things were read, and none of them says this), `nothing_survived` (claims were written, and none held up under the check) or `verifier_unavailable` (the check could not run, so nothing unchecked is shown). An abstention is a finished answer, not an error, and its `text` is the server's own sentence in the asker's language — show it as it comes. A reply that opens "Nothing you kept says …" is an abstention (`nothing_kept`), with the nearest things on `related`. On a view with people the sentence speaks of that view ("Nothing on this view says this."), never of your own collection. The `evidence` frames are the ledger — what was found — and never receipts: only the `answer` frame's `citations` are, `[N]` in the text is the citation whose `n` is N, and an abstention's `citations` is empty however many evidence cards came before it. Its closest things ride as `related` (on `nothing_kept` and `nothing_survived`), with no `n` and no `[N]` pointing at them. A receipt's passage is `excerpt` on the stream and on `GET /api/v1/ask/answers` alike. A run has four minutes: past that it ends with `error`, then `done`, and nothing unchecked is ever sent. Never raw token chunks, and nothing is shown before it is checked. `scope: { "kind": "view", "id": "vw_…" }` aims a question at one view. On a private view the scope steers: the question still searches everything you kept, reads from the view first, and the coverage line says where it read. On a view with people it binds: it is built only from what the people on it kept there, and it **never widens** into your own collection — there is no `widened_from`, and a thin view ends in an abstention; evidence and citations carry `kept_by`. That question also needs the `people` entitlement and `people.read`, checked in the handler; `read_only` people may ask, and the answer is stored in the asker's space, never posted to the view or its talk. The six frame schemas are published as spec components and mapped from the operation's `x-madless-sse-frames`, so you can generate a decoder rather than hand-write one. `GET /api/v1/ask/answers` (+ `GET`/`DELETE` one) is the record of past answers — history, never evidence; each row carries the same `abstained`, `abstain_reason?` and `related?` the stream's `answer` frame did.
- **Webhooks** — subscriptions, deliveries log, test fire, secret rotation. Every event's delivery body is published in the spec's top-level `webhooks` object, one entry per event id, so you can learn a delivery's shape before you have ever received one
- **Users** (`/api/v1/users/me`) — identity and access only: who you are, what this key may do, which environment it reached; plus your profile
- **Usage** (`/api/v1/usage`) — the plan and every meter in one read: AI credits, media minutes, images, concepts held, storage, the machine day cap, views with people (`shared_views`)
## MCP Server (for AI tools)
Connect Claude, Claude Code, ChatGPT or Grok to your space. The server speaks MCP 2026-07-28 at `https://mcp.madless.com/api/mcp` and still answers the 2025 `initialize` handshake. From Claude Code:
```
claude mcp add --transport http madless https://mcp.madless.com/api/mcp \
--header "Authorization: Bearer mad_sk_..."
```
Leave out the header and Claude Code signs in with OAuth instead. Per-client setup: https://mcp.madless.com/clients/claude, `/clients/claude-code`, `/clients/chatgpt`, `/clients/grok`, `/clients/byo`.
Tools (scope-filtered at connect time): `bearings`, `get`, `recall`, `recollect`, `fetch`, `ask`, `synthesize`, `verify`, `activity`, `keep`, `note`, `edit`, `label`, `connect`, `view`, `propose`, `review`, `upload`, `world`, `people`, `talk` — and, on scopes OAuth never grants (a `mad_sk_*` key only): `developer` (webhooks and the sandbox, `webhooks.manage`), `raw_read` / `raw_write` (any `/api/v1/*` route, `mcp.raw`). `view` also reads the people on a view and lists the views with people on them; `people` reads connections, cards and invitations. Nothing a connector holds can invite, rename, remove, remove everyone or place a card on a view with people.
**`ask` is the one tool that spends a model credit** — one question, one checked answer, charged and stored (`GET /ask/answers`) exactly as asking in the app is; `raw_write` spends one only if you point it at `POST /api/v1/ask`. Every other tool — `synthesize` and `verify` included — reads and writes what the ingestion and nightly passes already computed, so the connected model does the thinking on its own subscription. Letting an image or a recording in through `keep` or `upload` draws the image and media-minute allowances, never credits.
**`verify` is the one worth knowing about — and worth reading exactly.** Hand it an answer you wrote plus the concept ids your `[1]`, `[2]` markers point at; Madless re-reads those concepts server-side and returns a verdict per bracket plus corrected text. No model runs, no credit moves, no cap. It re-reads on the server on purpose — a model cannot verify itself by asserting what its own sources said. What it checks is containment, not truth: the claim's quotes, names and numbers are looked for in the cited document, the document's own title is set aside as the claim's `subject` first, and a claim with nothing testable beyond that comes back `escalated` — left standing, confirmed of nothing. It cannot tell that "recorded in 1975" is wrong when 1975 appears elsewhere in the document, or that a sentence reverses what the document says. `pass` means the receipt points at a document that contains what the claim names; it never means the claim is true.
Call `bearings({ input: { mode: 'help' } })` on connect: it returns the reference at runtime, already filtered to the tools your key can call, so nothing on this page needs pasting. Its workflows come as an index of titles and tags, and its reference sections (`scopes`, `anti_patterns`, `pagination`, `review_modes`, `eventual_consistency`) by name; pass `topic` (a tag, a word from a title, or a section name) for one in full. All of the sections at once are the `madless://help` resource. One argument shape, every tool: `{ input: { … } }`. Every field name, every action and mode value, every result key and every `code` is snake_case — the same dialect the REST API speaks, because it is the same API.
## Documentation
- Full OpenAPI 3.1 spec: https://developers.madless.com/openapi.json
- Interactive API reference: https://developers.madless.com/reference
- Inviting people onto a view: https://developers.madless.com/people
- Product: https://madless.com
Or skip the paste entirely
If the model speaks MCP, it does not need this page. Connect the Madless MCP server and call bearings({input: {mode: 'help'}}) — it returns the same reference at runtime, already filtered to the tools your key can actually call. Nothing to paste, nothing to keep in sync, and no stale copy sitting in someone's prompt six months from now.
Authentication covers minting a key first.