Inviting people onto a view
A view is private, or it has people on it. It is the same view either way — one document, one id, one vw_ — and the act that puts people on it is an invitation. There is no separate step to make a view ready for people, and nothing opens: every person on a view was invited by name. There are no public views, no reader tier and no search of people or views.
How a view says it has people
Every view on GET /api/v1/canvas/views carries shared: null on a private view, or the facts about you on a view with people.
"shared": { "owner_uid": "Qw8kTRScfbUX0qZ29C0iWqJa", "permission": "full", "can_invite": true, "people_count": 2, "people_cap": 8 }
GET /api/v1/canvas/views/shared is the lens: every view with people on it, yours or one you sit on, each with its people. A view in someone else’s space that you sit on carries your own world_id on both lists — and your own pin and viewport on GET /canvas/views — because where you put it is yours; the view is theirs. A view you are not on answers 404, never 403.
1 · Invite
The Owner, or a person the Owner let invite, sends an invitation: { uid } for someone you are already connected to — a uid needs a connection — or { email, note? } for anyone at all, including someone who has no Madless yet. permission is full unless you say read_only. An Idempotency-Key is required.
curl -X POST 'https://acme.madless.com/api/v1/canvas/views/vw_8f3a2b1cAHTd92kLm0/invitations' \ -H 'Authorization: Bearer mad_sk_abc123...' \ -H 'Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000' \ -H 'Content-Type: application/json' \ -d '{ "email": "[email protected]", "permission": "full" }'
On a private view, the first invitation is the act that gives it people. In one transaction the server checks how many views with people you already own against your plan, makes you the Owner, sets the view’s people_cap from your plan, and writes the pending invitation. The view does not move, is not renamed and is not copied — its cards are already what everyone on it sees. On a view that already has people, an invitation checks people_cap and writes the invitation. Either way the answer is 201 with the pending invitation.
An address invitation’s single-use link goes to that inbox and nowhere else — no answer, list, notification or webhook carries it. Holding it is what proves the address when the person joins, so if the letter does not arrive, send it again rather than handing over a link.
| Refusal | Status | When |
|---|---|---|
shared_view_cap_reached | 403 | A private view would gain people, and you already own as many views with people as your plan holds. |
view_full | 409 | The view already seats its people_cap — everyone counts, the Owner and every pending invitation included. |
| Plan | Views with people you can own | People per view |
|---|---|---|
| Starter | 1 | 2 |
| Plus | 3 | 2 |
| Pro | any number | 8 |
GET …/invitations lists the view’s pending invitations (Owner, or a person who can invite). A row here is named by its uid or email and carries no id; the inv_… id is on the invited person’s side, a row of GET /people/invitations.
2 · Resend or cancel
POST …/invitations/resend with { email } sends the letter again. DELETE …/invitations?uid=… or ?email=… cancels one and answers 204. Cancelling the only pending invitation does not make the view private again — only Remove everyone does.
All three invitation writes — the invitation, the resend and the cancel — require an Idempotency-Key; without one they answer 400 idempotency_key_required and nothing is sent.
3 · Join
One word, two doors, and no separate accept. POST /api/v1/people/join is the token door behind the public /join page: one click is consent, a Starter account if the person has none, and their place on the view. POST /api/v1/canvas/views/{view_id}/join is an existing account with a pending invitation taking its place; it answers 200 with the view as GET /canvas/views lists it.
Either way the view lands in the person’s active World — it is theirs to file where they like. GET /api/v1/people/invitations lists the invitations waiting for you.
4 · The two switches
The Owner sets what each person may do there with PATCH /api/v1/canvas/views/{view_id}/people/{uid} — permission (full or read_only) and can_invite. It answers with the person as GET …/people lists them.
| Permission | May |
|---|---|
| Full | Place their own cards, move any card, draw lines. |
| Read only | See, talk, ask, and keep a card into their own Canvas. Touch nothing. |
A plan says what a person holds; the Owner says what they do on this view. Paying never overrides a switch, and a switch never grants a meter. A write refused by a switch answers 403 with read_only; an Owner-only act from anyone else answers 403 with not_owner.
5 · Remove, leave, remove everyone
| Act | Route | What goes |
|---|---|---|
| Remove (Owner) | DELETE …/people/{uid} | That person, with their cards and every board line touching those cards. 204. |
| Leave (anyone but the Owner) | DELETE /canvas/views/{view_id} | From where they stand it removes the view from their Madless: their place, their cards and every board line touching those cards. 204. |
| Remove everyone (Owner) | DELETE …/people | Everyone else, now. Their cards, every board line, the view’s talk with its messages, pending invitations, presence. 200 with counts. |
Cards go with the person. When someone leaves or is removed, the cards they placed leave the view with them, and so does every board line touching those cards — nobody keeps reading the material of a person who is no longer there.
Remove everyone is immediate and total. The others lose the view at once and the talk goes with it; the Owner’s view is private again, with their own cards where they were. Copies anyone kept are untouched — they are the keeper’s own concepts.
{ "data": { "windows_deleted": 1, "nodes_removed": 6, "board_lines_deleted": 2, "threads_deleted": 3 } }
The Owner’s DELETE /canvas/views/{view_id} on a view with people is Remove everyone, then the trash. Only the person leaves: from an API key or an MCP connector that DELETE answers 403 human_review_required in every state, Leave included — Leave takes their cards off a view other people see, and it shares the route with Delete. There is no …/people/me: leaving is the view’s own DELETE.
Building on a view with people
One route family serves both states. POST …/concepts, …/concepts/batch and DELETE …/concepts/{concept_id} place and lift cards; PUT /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 /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 — the view has one name for everyone. A card placed by anyone but the Owner carries author_space_id on its node.
Who drew a line, and whose cards it joins, decide where it lives.
| The line | Lives | Goes |
|---|---|---|
| Drawn by A between two of A’s cards | A’s own relation (/api/v1/relations), shown wherever both cards are placed | Only when A removes it |
| Drawn by A between A’s card and B’s card | A board line, under the view | With Remove everyone or the view’s delete, or when either card leaves |
| Drawn by A between two of B’s cards, or B’s and C’s | A board line, under the view | With Remove everyone or the view’s delete, or when either card leaves |
POST …/relations with { source_id, target_id, caption? } routes the line for you and says which it made: kind: "own" or kind: "board". …/relations/batch and DELETE …/relations/{relation_id} act on board lines; your own line is removed through DELETE /api/v1/relations/{relation_id} as ever. Nothing is copied when a view gains people and nothing is materialised when it loses them, so nobody’s own graph forks.
Keeping a card
POST …/concepts/{concept_id}/keep makes a full, independent copy of someone else’s card in your own Canvas, through the same admission as anything you let in, and spends your own capacity. Read only never stops it. The answer names the copy and where it came from, as the view was named then:
{ "data": { "concept_id": "con_8f3a2b1cAHTd92kLm0", "kept_from": { "view_id": "vw_8f3a2b1cAHTd92kLm0", "name": "Fermentation" } } }
A kept copy is an ordinary concept from then on. There is no refresh and no un-keep; dropping it is DELETE /api/v1/concepts/{concept_id}.
Reading a view with people
| Route | Answers |
|---|---|
GET …/graph | The whole view: its name, Owner and people_cap, its people, every node with its card, and the lines — each author’s own relations among their placed cards plus the board lines. |
GET …/search?q= | A ranked search over what is placed on this view. |
GET …/files/{file_id} | A file behind a card on this view. |
POST …/files/lookup | Many file ids, one status row each. |
These need canvas.read and people.read. On your own private view they answer with your own cards and your own lines among them; …/relations is [] there, because a private view has no board lines. GET …/people — the faces: each person, with joined_at, role, can_invite and permission — needs people.read alone.
Asking on a view with people
The question goes to the same POST /api/v1/ask, with the view as its scope:
{ "query": "What did we keep about koji?", "scope": { "kind": "view", "id": "vw_8f3a2b1cAHTd92kLm0" } }
A question on a private view steers: it still searches everything you kept, reads from the view first, and the coverage line says where it read. A question on a view with people binds: the answer comes only from what the people on it kept there. It never widens to your private collection — there is no widened_from — and a thin view ends in an abstention (“Nothing on this view says this.”), not an answer from somewhere else. Evidence and citations carry kept_by, and the verb is always kept. Read only people may ask; asking touches nothing. The answer is stored in the asker’s space and is never posted to the view or its talk. Beside ask.run and concepts.read, this question needs the people entitlement and people.read, checked in the handler.
Events
| Event | When | Delivered to |
|---|---|---|
view.shared | The first invitation gives a private view its people | The Owner’s space |
view.unshared | Remove everyone | The Owner’s space |
view.person_joined | Someone takes their place | The Owner’s space and the person’s own — uid and handle, never card content |
view.person_left | Someone leaves or is removed | The Owner’s space and the person’s own — uid and handle, never card content |
view.concept_kept | Someone keeps a card | The space of the card’s author |
No event carries another person’s card through someone else’s subscription. On a view with people, view.node_added and view.node_removed fire only to the acting person’s own space, and only for their own cards. Every payload is on Webhook Events.
Scopes
| Scope | Covers |
|---|---|
people.read | Anything that shows you another person’s material — a view’s cards, lines, files and search when people are on it, and its faces — plus your connections, a person’s card, invitations to you, and joining. |
people.write | 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). Implies people.read. |
canvas.read and canvas.write alone never reach past your own material. people.write is not OAuth-grantable: a connector can never invite, rename, remove, remove everyone or place a card on a view with people. Those stay with the person, or with an API key they minted and chose to give it.