Skip to content
Takeida
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.

Sent 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
nextCursor from 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.
Request
curl "https://your-server.example/v1/portage/c/k7m2xq9d/drafts?limit=50" \
  -H "Authorization: Bearer arb_live_…"
Response200
{
  "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.
The Concept owner's plan decides how many drafts one person may have open in it. Drafts are counted per person, across every key they hold.

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.
Request
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" }'
Response201
{
  "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
nextCursor from the page before. It is the last path returned.

What comes back

itemsarray
A document, or a stub of id, path and excluded: true. Branch on excluded.
pendingarray
Documents a recording is still turning into. On the first page only.
Request
curl "https://your-server.example/v1/portage/c/k7m2xq9d/documents?path=field-notes/&limit=2" \
  -H "Authorization: Bearer arb_live_…"
Response200
{
  "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
PRIVATE when 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_ONLY when absent.
trunkBranchNamestring · 1 to 100
main when absent.
The body is strict. Send a field that is not listed here, an 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.
Request
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."
  }'
Response201
{
  "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.

  1. POST1

    Open

    Spends one export. Manifest and first page.

  2. GET2

    Page 2

    Free.

  3. GET3

    Page 3

    Free.

  4. 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 byteSize says why one came back short. cursor is 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.
Request
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/"] }'
Response201
{
  "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.

A folder that is itself called documents collides with the address below. Reach it with include on the whole-Concept form.
Request
curl -X POST \
  https://your-server.example/v1/portage/c/k7m2xq9d/export/field-notes/2026 \
  -H "Authorization: Bearer arb_live_…"
Response201
{
  "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.
Request
curl -X POST \
  https://your-server.example/v1/portage/c/k7m2xq9d/export/documents/field-notes/2026/harbour-survey.md \
  -H "Authorization: Bearer arb_live_…"
Response201
{
  "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 cursor from the page before.
limitinteger · 1 to 500
100 when absent.
The export id names the take. It grants nothing. Access is checked again on every page, so an export in progress stops the moment the grant is withdrawn or the Concept stops allowing adaptation.

Refusals

  • 404not_foundNo such export, or it has expired. Open another.
Request
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_…"
Response200
{
  "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, a body, and optionally frontmatter. 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.
Who wrote each step, and when, are never read from the request. The author is the key's owner and the time is the server's. A field claiming otherwise is ignored.

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.
Request
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…"
      }
    ]
  }'
Response201
{
  "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.
Request
# 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
Response201
{
  "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 attachmentId values the import returned.
A file counts against the organization's storage from the moment it is confirmed. Confirming it twice counts it once.
Request
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"] }'
Response200
{
  "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.