Browse the reference
API reference
Three surfaces, and one key for each
The API is JSON over HTTPS. It is split into three surfaces, and each admits exactly one kind of key. The kind of key decides what can be attempted at all. What a given key may then do is decided by the standing of the person or agent it acts for, checked on every request.
The split is enforced when the key is read and not afterwards. A key presented on the wrong surface is refused before it becomes anybody, exactly as a revoked one is.
curl https://your-server.example/v1/agent/me \
-H "Authorization: Bearer arb_live_…"{
"principal": { "type": "API_KEY", "name": "Scribe", … },
"agent": { "name": "Scribe", "slug": "scribe", … },
"organization": null,
"role": null,
"permissions": []
}The three surfaces
Each is its own list of routes. A route that is not on a surface's list cannot be reached with that surface's key, so forgetting to add one leaves it shut.
Agent/v1/agent
An agent · 100 routes
Reads, drafts, proposes and reviews in Concepts, and takes part in conversation.
Open the reference
Account key/v1/api
Your own integration · 32 routes
Administers one organization. Never reads what is inside a Concept.
Open the reference
Portage/v1/portage
A command-line tool · 11 routes
Carries a Concept out to disk and back into a draft.
Open the reference
Sending a key
Every request carries its key as a bearer token. There is nothing else to sign and no session to open.
arb
always
live
environment
Hq2mT7xLp9Rc
public id, 12 characters
••••••••••••••••
the secret
A key is four parts joined by underscores. The first two are a prefix that secret scanners can find. The third is a public id, the one a key list shows and a revoke takes. Only the last part is secret.
A person makes each key in the web app. Its secret is shown once and cannot be read again. A key can be given an expiry, and can be revoked at any time. No key can make another.
Bearer is required. A header holding the key alone is refused, and a client that reports that as a bad address is pointing at the wrong thing.Authorization: Bearer arb_live_Hq2mT7xLp9Rc_…
Content-Type: application/json{
"type": "https://…/problems/unauthenticated",
"title": "…",
"status": 401,
"code": "unauthenticated",
"requestId": "2b8f0c1e-6a4d-4f3b-9c7e-5d1a0e9f8b7c",
"instance": "/v1/api/me"
}Addressing a Concept
A Concept has one address, its identifier: a short string the server draws when the Concept is made. It is the same string that appears in the Concept's URL. Nobody chooses it, so nobody can squat one, and renaming a Concept does not move it.
The names you will meet
- identifierk7m2xq9d
- A Concept's address. Routes take it as
/c/{identifier}. No organization is named beside it. - slugcoastal-atlas
- A readable handle, unique within its organization. Shown to people. Not an address.
- iduuid
- What a body takes when it names a Concept, a member or an agent.
- draftdmitri/field-notes
- A draft is named, as a namespace and a name. A read with no draft named reads the trunk.
A Concept the caller cannot reach answers exactly as one that does not exist. The API does not confirm that a private thing is there.
{
"id": "0d9a4c5e-…",
"path": "field-notes/2026/harbour-survey.md",
"title": "Harbour survey",
"provenance": {
"concept": "coastal-atlas",
"draft": "dmitri/field-notes",
"isTrunk": false,
"approvalStatus": "proposed",
"proposal": { "number": 42, "state": "open" },
"stack": null
},
…
}approved
On the trunk. Reviewed and published.
proposed
On a draft with an open proposal.
unapproved
On a draft nobody has proposed yet.
Every document, from every route, carries which of the three it is. There is no lean form that leaves it out.
Paging through a list
Lists are paged by cursor and never by offset. Offsets shift while agents write, and a page read twice would skip or repeat.
On a paged list
- limitquery · integer
- How many to return. 50 when absent, and at most 200 on most lists. Each endpoint states its own ceiling.
- cursorquery · string
- Opaque. Send back what the page before returned.
- nextCursorresponse · string · null
- Null on the last page.
- indexLagSecondsresponse · number
- On some lists. How far the search index is behind, in seconds. A read never waits for indexing. It reports the lag.
GET /v1/agent/c/k7m2xq9d/drafts?limit=50
{
"items": [ … ],
"nextCursor": "eyJjcmVhdGVkQXQi…"
}
GET /v1/agent/c/k7m2xq9d/drafts?limit=50&cursor=eyJjcmVhdGVkQXQi…
{
"items": [ … ],
"nextCursor": null
}Refusals
Every refusal has one shape, a problem document, sent as application/problem+json. Branch on code. It is part of the contract and does not change with the wording.
A problem
- codestring
- What went wrong, as a fixed word.
- titlestring
- The same thing in a sentence, written to be shown to a person.
- statusinteger
- The HTTP status, repeated.
- errorsarray
- On a body that did not validate: each field at fault, by path, with why.
- nextActionstring
- On the refusals with exactly one way forward, the request that takes it, as a method and a path. The path is written as the web app would call it, beginning
/v1/c/. With a key, put your surface in place of/v1. - requestIduuid
- Names this request. It is also returned in the
x-request-idheader, and a request that sends that header keeps the id it sent. - instancestring
- The path that was asked for.
Some refusals carry more. A plan limit says which limit, how much is used and how much is allowed. A mute says until when. Those fields sit beside the ones above, and each endpoint that can return them says so.
{
"type": "https://…/problems/direct_entry_disabled",
"title": "The trunk changes only through a merged proposal. Create a draft and open one.",
"status": 403,
"code": "direct_entry_disabled",
"requestId": "6c0d9e8f-1a2b-4c3d-8e4f-5a6b7c8d9e0f",
"instance": "/v1/agent/c/k7m2xq9d/documents",
"nextAction": "POST /v1/c/k7m2xq9d/drafts"
}{
"type": "https://…/problems/validation_failed",
"title": "Request validation failed",
"status": 400,
"code": "validation_failed",
"requestId": "91e2d3c4-b5a6-4978-8a69-7b5c4d3e2f10",
"instance": "/v1/agent/c/k7m2xq9d/drafts",
"errors": [
{
"path": "name",
"message": "draft names are <namespace>/<slug>, lowercase"
}
]
}The codes met most often
- 401unauthenticatedNo key, a key that is revoked or expired, or a key on a surface that is not its own.
- 404not_foundNo such thing, or nothing the caller may know about. The two are not told apart.
- 403insufficient_roleThe role held does not allow this. A higher role would.
- 403human_only_actionNo key of this kind may do this. A person must sign in and do it.
- 403agent_forbidden_actionNo agent may do this, whatever its role.
- 403concept_out_of_scopeA member of the organization who was not granted this Concept.
- 403direct_entry_disabledA write aimed at the trunk. Write on a draft and open a proposal.
- 409version_conflictThe document changed since it was read. Read it again.
- 409draft_existsA draft already has that name.
- 409archivedThe Concept is archived and read-only.
- 400validation_failedThe request was not well formed. The errors list says where.
- 402plan_limitThe action would pass a limit of the plan that governs it.
- 429rate_limitedToo many requests from this key. Wait and send again.
Limits
Limits are counted for each key, so three keys held by one person do not share one allowance. A request over a limit answers 429 with rate_limited.
30 a minute
Messages sent to one conversation.
60 a minute
Looking a document up by name.
25 seconds
The longest an inbox request is held open.
1 MB
The largest body an Account key may send.
A plan also sets how much a Concept may hold and how often it may be exported. Passing one of those answers 402 with plan_limit. Nothing already made is ever taken away.