Browse the reference
Portage · /v1/portage
Carry a Concept to disk, and back
A Portage key is what a command-line tool holds in order to move files. It takes a Concept out as Markdown, and brings a folder of Markdown back into a draft. It is a transport: it carries work and decides nothing.
So this surface is narrow on purpose. It cannot read a single document, compare two drafts, delete anything, or ask who it is. An export is how it reads, and an import always lands in a draft that a person then reviews as a proposal.
Coastal atlas
trunk · approved
- Harbour survey
- Tide tables
- Coastal atlas
export
import
~/coastal-atlas
index.md
field-notes/
2026/
harbour-survey.md
tide-tables.md
north-quay.pngSent as a bearer token
Authorization: Bearer arb_live_…
Three keys a person
A laptop, a desktop, a server. A fourth is refused until one is revoked.
Bounded by its owner
A key reaches the Concepts its owner reaches, and no others.
Where an import lands
Every import writes into a draft, so a tool needs to see which drafts exist and to make one. A folder that began life offline has none.
List drafts
GET/v1/portage/c/{identifier}/drafts
Every draft in the Concept that has not been deleted, the trunk first and then newest first. The trunk is in the list because it is a draft like the others, the one everything is eventually published to. It is the one an import will refuse.
Path
- identifierstring · required
- The Concept's address, the short string in its URL. It is generated by the server and never chosen.
Query
- limitinteger · 1 to 200
- How many to return. 50 when absent.
- cursorstring
nextCursorfrom the page before. Paging is by cursor everywhere in this API; there are no offsets, because offsets shift while agents write.
What a draft tells you
- changedDocumentsinteger
- How many documents this draft has changed. The size of its own work, not of the Concept.
- stale, behindByboolean, integer
- Whether the base has moved on since this draft was cut, and by how many published entries.
- approvalStatusapproved · proposed · unapproved
- How far the draft has got: on the trunk, in an open proposal, or not yet proposed.
Refusals
- 404not_foundNo such Concept, or the key's owner cannot reach it.
- 403insufficient_roleThe owner only reads here. Drafts are visible to writers and above.
curl "https://your-server.example/v1/portage/c/k7m2xq9d/drafts?limit=50" \
-H "Authorization: Bearer arb_live_…"{
"items": [
{
"id": "6f0c1c1e-2a4b-4b53-9a57-0f1f5d3f2a10",
"name": "main",
"baseBranch": null,
"isTrunk": true,
"depth": 0,
"changedDocuments": 0,
"newDocuments": 0,
"pendingDeletions": 0,
"stale": false,
"behindBy": 0,
"approvalStatus": "approved",
"proposalNumber": null,
"pinned": false,
"autoUpdate": true,
"owner": { "type": "USER", "id": "…", "name": "Dmitri Okafor", "username": "dmitri" },
"createdAt": "2026-08-02T09:14:00.000Z",
"publishedAt": null
},
{
"id": "b81e77a4-51c9-4f0e-8a3d-6c2f0a9d4e77",
"name": "dmitri/field-notes",
"baseBranch": "main",
"isTrunk": false,
"depth": 1,
"changedDocuments": 4,
"newDocuments": 3,
"pendingDeletions": 0,
"stale": true,
"behindBy": 2,
"approvalStatus": "unapproved",
"proposalNumber": null,
"pinned": false,
"autoUpdate": true,
"owner": { "type": "USER", "id": "…", "name": "Dmitri Okafor", "username": "dmitri" },
"createdAt": "2026-10-01T16:40:12.000Z",
"publishedAt": null
}
],
"nextCursor": null
}Create a draft
POST/v1/portage/c/{identifier}/drafts
Cuts a new draft from the trunk, or from another draft. It starts identical to its base and holds nothing of its own until something is imported into it.
Body
- namestring · 3 to 120 · required
- Written as a namespace, a slash, and a name, in lowercase:
dmitri/field-notes. Letters, digits, dots, hyphens and underscores. - basestring
- The draft to cut from, by name. The trunk when absent. Any draft may be a base.
- pinnedboolean
- Keeps the draft at the top of its owner's list.
Refusals
- 409draft_existsA draft already has that name.
- 409draft_too_deepThe base is already as many drafts from the trunk as the Concept allows.
- 402plan_limitThe person has as many open drafts here as the plan allows.
- 409archivedThe Concept is archived and read-only.
curl -X POST https://your-server.example/v1/portage/c/k7m2xq9d/drafts \
-H "Authorization: Bearer arb_live_…" \
-H "Content-Type: application/json" \
-d '{ "name": "dmitri/field-notes" }'{
"id": "b81e77a4-51c9-4f0e-8a3d-6c2f0a9d4e77",
"name": "dmitri/field-notes",
"baseBranch": "main",
"isTrunk": false,
"depth": 1,
"changedDocuments": 0,
"newDocuments": 0,
"pendingDeletions": 0,
"stale": false,
"behindBy": 0,
"approvalStatus": "unapproved",
"proposalNumber": null,
"pinned": false,
"autoUpdate": true,
"owner": { "type": "API_KEY", "id": "…", "name": "Dmitri Okafor" },
"createdAt": "2026-10-09T11:02:41.000Z",
"publishedAt": null
}What a push will touch
Which documents exist, and at which paths, without taking an export to find out. No bodies come back. Reading text is what an export is for.
List documents
GET/v1/portage/c/{identifier}/documents
One page of a draft's documents, in path order. Each row carries how far that document has been reviewed.
Query
- draftstring
- The draft to list, by name. The trunk when absent.
- pathstring
- A path prefix.
field-notes/lists that folder and everything under it. - limitinteger · 1 to 200
- 50 when absent.
- cursorstring
nextCursorfrom the page before. It is the last path returned.
What comes back
- itemsarray
- A document, or a stub of
id,pathandexcluded: true. Branch onexcluded. - pendingarray
- Documents a recording is still turning into. On the first page only.
curl "https://your-server.example/v1/portage/c/k7m2xq9d/documents?path=field-notes/&limit=2" \
-H "Authorization: Bearer arb_live_…"{
"items": [
{
"id": "0d9a4c5e-7b1f-4c7a-9a10-3f6b2f1c8e21",
"path": "field-notes/2026/harbour-survey.md",
"title": "Harbour survey",
"frontmatter": {},
"version": { "id": "…", "seq": 41, "contentHash": "sha256:9f2c…" },
"provenance": {
"concept": "coastal-atlas",
"draft": "main",
"isTrunk": true,
"approvalStatus": "approved",
"proposal": null,
"stack": null
},
"author": { "type": "USER", "id": "…", "name": "Ana Reyes", "username": "ana" },
"updatedAt": "2026-09-28T13:05:44.000Z",
"wordCount": 1284,
"lineCount": 96,
"aliases": ["Harbour"],
"color": null
},
{
"id": "5a1e0b77-2c3d-4e8f-b6a9-1d2c3e4f5a6b",
"path": "field-notes/2026/private-ledger.md",
"excluded": true
}
],
"nextCursor": "field-notes/2026/private-ledger.md",
"pending": []
}The second row is a document the key's owner has been excluded from. It keeps its place in the list, so paging stays even, and says nothing else.
Somewhere for a new folder to go
A folder written offline has no Concept to be pushed to. This makes one, in the organization the key's owner owns. A Portage key reaches no other organization.
Create a Concept
POST/v1/portage/o/c
The new Concept is private, cannot be adapted, and admits only its members' agents, unless the request says otherwise. Each of those is its own decision and none follows from another.
Body
- slugstring · 2 to 64 · required
- Lowercase letters, digits and single hyphens. Unique within the organization.
- namestring · 1 to 100 · required
- What people see.
- descriptionstring · up to 1,000
- The one line that appears in a list.
- aboutstring · up to 100,000
- The page a reader lands on. Markdown.
- visibilityPRIVATE · PUBLIC
PRIVATEwhen absent.- publicRoleREADER · WRITER
- What the public may do once it is published. Reader unless said.
- adaptableboolean
- Whether others may take a copy to build on. Off when absent.
- agentPolicyMEMBERS_ONLY · OPEN · NONE
- Whose agents may work here.
MEMBERS_ONLYwhen absent. - trunkBranchNamestring · 1 to 100
mainwhen absent.
identifier for one, and the request is refused. The address is drawn by the server so that nobody can choose one.Refusals
- 409conflictThe organization already has a Concept with that slug.
- 400validation_failedA private Concept was asked to be open to outside agents, or the body named an unknown field.
curl -X POST https://your-server.example/v1/portage/o/c \
-H "Authorization: Bearer arb_live_…" \
-H "Content-Type: application/json" \
-d '{
"slug": "coastal-atlas",
"name": "Coastal atlas",
"description": "Survey notes for the northern harbours."
}'{
"id": "3c2b1a09-8f7e-4d6c-b5a4-9e8d7c6b5a41",
"slug": "coastal-atlas",
"identifier": "k7m2xq9d",
"name": "Coastal atlas",
"description": "Survey notes for the northern harbours.",
"about": null,
"owner": { "username": "dmitri", "displayName": "Dmitri Okafor" },
"visibility": "PRIVATE",
"adaptable": false,
"adaptedFrom": null,
"adaptationCount": 0,
"exportCount": 0,
"agentPolicy": "MEMBERS_ONLY",
"trunkBranch": "main",
"archived": false,
"status": "ACTIVE",
"documentCount": 0,
"branchCount": 1,
"openProposals": 0,
"role": "OWNER",
"createdAt": "2026-10-09T11:00:03.000Z",
…
}Keep the identifier. Every other call on this page addresses the Concept by it.
Carrying a Concept out
An export is opened once and then read a page at a time. Opening it is the part that costs: it spends one of the Concept's exports for the day and adds one to the count its owner sees. Paging through what was opened is free.
Three addresses open one: the whole Concept, a folder, or a single document. They answer in the same shape, so a client written for one reads all three.
POST1
Open
Spends one export. Manifest and first page.
GET2
Page 2
Free.
GET3
Page 3
Free.
GET4
Last page
cursor is null. Done.
Open an export
POST/v1/portage/c/{identifier}/export
A POST and not a GET, because it charges. Do not retry it blindly. If an export is already open on the Concept the same one is handed back, with reopened: true, and nothing more is spent.
The Concept must allow adaptation, and the key's owner must reach it. Only what is on the trunk is exported.
Body
- includestring[] · up to 1,000
- Paths to take. A path ending in a slash is a folder. Everything when absent.
- excludestring[] · up to 1,000
- Paths to leave behind.
What comes back
- pathsstring[]
- Every path this export carries, in order. Page the bodies against this list.
- danglingLinksarray
- Links whose target was not included. They are reported, never repaired.
- attachmentsarray
- Each file with a presigned
downloadUrl. The bytes come from storage directly and never pass through the API. - exportId, expiresAtuuid, date-time
- What names this export on every later page, and when it stops working.
- budgetobject
- What is left of the Concept's exports today, shared by everyone in it. Absent when the plan sets no limit.
- documents, cursor, byteSizearray, string, integer
- The first page of bodies, up to 100 documents. A page also stops at 8 MB of text, so
byteSizesays why one came back short.cursoris null on the last page.
Refusals
- 404not_foundNo such Concept, it does not allow adaptation, or the owner cannot reach it.
- 403concept_out_of_scopeThe owner belongs to the organization but was not granted this Concept.
- 409archivedThe Concept is archived. Said only to its owner.
curl -X POST https://your-server.example/v1/portage/c/k7m2xq9d/export \
-H "Authorization: Bearer arb_live_…" \
-H "Content-Type: application/json" \
-d '{ "exclude": ["archive/"] }'{
"concept": {
"identifier": "k7m2xq9d",
"name": "Coastal atlas",
"description": "Survey notes for the northern harbours.",
"about": "# Coastal atlas\n…"
},
"paths": [
"field-notes/2026/harbour-survey.md",
"field-notes/2026/tide-tables.md",
"index.md"
],
"documentCount": 3,
"danglingLinks": [],
"attachments": [
{
"id": "…",
"documentPath": "field-notes/2026/harbour-survey.md",
"filename": "north-quay.png",
"mimeType": "image/png",
"byteSize": 482113,
"contentHash": "sha256:1b7e…",
"downloadUrl": "https://…"
}
],
"exportId": "e4a1d2c3-5b6f-4a7e-8c9d-0e1f2a3b4c5d",
"expiresAt": "2026-10-09T12:00:03.000Z",
"reopened": false,
"budget": { "remaining": 9, "limit": 10 },
"documents": [
{
"path": "field-notes/2026/harbour-survey.md",
"title": "Harbour survey",
"body": "The north quay was resurveyed in…",
"frontmatter": { "tags": ["survey"] },
"contentHash": "sha256:9f2c…"
}
],
"cursor": "field-notes/2026/harbour-survey.md",
"byteSize": 5120
}The manifest and the first page arrive together, so a small Concept is one request.
Export a folder
POST/v1/portage/c/{identifier}/export/{folder}
Everything beneath a folder, named in the address so that somebody typing a command does not have to write a body to say which folder they meant. The folder may be as many segments deep as it is.
It is taken as a folder and never as a prefix of a name: notes does not bring notes-old along with it. The same body as above may be sent to narrow it further.
documents collides with the address below. Reach it with include on the whole-Concept form.curl -X POST \
https://your-server.example/v1/portage/c/k7m2xq9d/export/field-notes/2026 \
-H "Authorization: Bearer arb_live_…"{
"paths": [
"field-notes/2026/harbour-survey.md",
"field-notes/2026/tide-tables.md"
],
"documentCount": 2,
"exportId": "…",
…
}The same shape as opening an export.
Export one document
POST/v1/portage/c/{identifier}/export/documents/{path}
One document, by the path a person actually knows and never by an id. Its body comes back whole, with presigned addresses for its files. There is nothing to page, so cursor is always null.
Refusals
- 404not_foundNo document is at that path on the trunk. A mistyped path is told so and does not come back as an empty export.
- 422validation_failedThe path is longer than 1,024 characters, or contains a `..` segment.
curl -X POST \
https://your-server.example/v1/portage/c/k7m2xq9d/export/documents/field-notes/2026/harbour-survey.md \
-H "Authorization: Bearer arb_live_…"{
"paths": ["field-notes/2026/harbour-survey.md"],
"documentCount": 1,
"attachments": [ { "filename": "north-quay.png", "downloadUrl": "https://…", … } ],
"exportId": "…",
"documents": [
{
"path": "field-notes/2026/harbour-survey.md",
"title": "Harbour survey",
"body": "The north quay was resurveyed in…",
"frontmatter": { "tags": ["survey"] },
"contentHash": "sha256:9f2c…"
}
],
"cursor": null,
"byteSize": 5120
}Fetch the next page
GET/v1/portage/c/{identifier}/export/{exportId}
The next page of bodies. Free, however many there are: the charge was taken when the export was opened, so resuming an interrupted download costs nothing.
Write each page to disk before asking for the next, and stop when cursor is null. Neither side then holds the whole Concept at once.
Query
- cursorstring
- The
cursorfrom the page before. - limitinteger · 1 to 500
- 100 when absent.
Refusals
- 404not_foundNo such export, or it has expired. Open another.
curl "https://your-server.example/v1/portage/c/k7m2xq9d/export/e4a1d2c3-5b6f-4a7e-8c9d-0e1f2a3b4c5d?cursor=field-notes/2026/harbour-survey.md" \
-H "Authorization: Bearer arb_live_…"{
"documents": [
{
"path": "field-notes/2026/tide-tables.md",
"title": "Tide tables",
"body": "| Port | High | Low |\n…",
"frontmatter": {},
"contentHash": "sha256:44ad…"
},
{
"path": "index.md",
"title": "Coastal atlas",
"body": "Start with [[Harbour survey]].",
"frontmatter": {},
"contentHash": "sha256:c01b…"
}
],
"cursor": null,
"byteSize": 2874
}Bringing work back
One request carries the whole folder: every saved step, every path, every file. The server replays all of it or none of it. What arrives is a draft, and a draft reaches the trunk only as a proposal somebody reviews.
Import into a draft
POST/v1/portage/c/{identifier}/imports/d/{draftId}
Replays a folder's history into a draft. Each entry is one saved step with its message, applied in the order given.
Path
- draftIduuid · required
- The draft to write into, by id. Never the trunk.
Body
- entriesarray · 1 to 1,000 · required
- The saved steps, oldest first.
- entries[].messagestring · 1 to 2,000 · required
- What this step did. It is what a reviewer reads.
- entries[].documentsarray · 1 to 1,000 · required
- Each with a
path, abody, and optionallyfrontmatter. A body obeys the same size limit as a document written in the editor. - attachmentsarray · up to 1,000
- The files the documents embed. Each names a document this import writes, and declares its size and hash.
Refusals
- 400validation_failedThe draft named is the trunk. An entry touches no documents, a path is not valid, or an attachment names a document this import does not write.
- 404not_foundNo such draft in this Concept.
- 409archivedThe Concept is archived and read-only.
curl -X POST \
https://your-server.example/v1/portage/c/k7m2xq9d/imports/d/b81e77a4-51c9-4f0e-8a3d-6c2f0a9d4e77 \
-H "Authorization: Bearer arb_live_…" \
-H "Content-Type: application/json" \
-d '{
"entries": [
{
"message": "Resurvey the north quay",
"documents": [
{
"path": "field-notes/2026/harbour-survey.md",
"body": "The north quay was resurveyed in October…",
"frontmatter": { "tags": ["survey"] }
}
]
}
],
"attachments": [
{
"documentPath": "field-notes/2026/harbour-survey.md",
"filename": "north-quay.png",
"mimeType": "image/png",
"byteSize": 482113,
"contentHash": "sha256:1b7e…"
}
]
}'{
"entriesApplied": 1,
"documentsWritten": 1,
"attachments": [
{
"attachmentId": "7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
"documentPath": "field-notes/2026/harbour-survey.md",
"uploadUrl": "https://…"
}
]
}Upload each file to its uploadUrl, then confirm them.
Import under a path
POST/v1/portage/c/{identifier}/imports/d/{draftId}/{path}
The same import, landing somewhere other than where its own paths say. The address decides which of two things happens.
The path
- a folderfield-notes/2027
- Every incoming path is nested beneath it.
- ending .mdfield-notes/recipe.md
- Names one document, which is written at exactly that path.
Only paths move. Bodies, frontmatter, messages and files are left as they were sent, and a file follows the document it belongs to.
Refusals
- 422validation_failedThe path ends in .md and the import carries more than one document. Five documents on one filename would keep only the last.
# Nest every incoming path beneath a folder
curl -X POST \
https://your-server.example/v1/portage/c/k7m2xq9d/imports/d/b81e77a4-…/field-notes/2027 \
-H "Authorization: Bearer arb_live_…" \
-H "Content-Type: application/json" \
-d @manifest.json
# Land one document at exactly this path
curl -X POST \
https://your-server.example/v1/portage/c/k7m2xq9d/imports/d/b81e77a4-…/field-notes/recipe.md \
-H "Authorization: Bearer arb_live_…" \
-H "Content-Type: application/json" \
-d @one-document.json{
"entriesApplied": 3,
"documentsWritten": 12,
"attachments": []
}Confirm uploaded files
POST/v1/portage/c/{identifier}/imports/d/{draftId}/attachments/confirm
After uploading, tell the server which files are there. It checks each against the size and hash the import declared. Until a file is confirmed it is not shown and is not exported.
This second round trip is the one the design keeps. Without it a client could declare one file, upload another, and leave a document describing bytes that were never stored.
Body
- attachmentIdsuuid[] · 1 to 1,000 · required
- The
attachmentIdvalues the import returned.
curl -X POST \
https://your-server.example/v1/portage/c/k7m2xq9d/imports/d/b81e77a4-…/attachments/confirm \
-H "Authorization: Bearer arb_live_…" \
-H "Content-Type: application/json" \
-d '{ "attachmentIds": ["7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f"] }'{
"confirmed": ["7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f"],
"failed": []
}A file that does not match comes back under failed, with an id and a reason. Upload it again and confirm it again.