Specs
How to list specs, read one as markdown, write markdown back, promote one to a ticket, delete one, and follow it to its pull requests over the REST API.
A spec is stage three of the pipeline: the document a beacon is promoted into,
grounded in your codebase, that a coding agent executes. Specs, epics and plain
documents are one resource with a type field, so every operation here works on
all three unless it says otherwise. Saved copies of one body live on
Spec versions, and the reads inside one body on
Spec sections.
On for every workspace. The four reads work with any API key that holds
documents:read. The six writes need documents:write, which only a workspace
owner or admin can put on a key. No MCP tool performs a write, so the scope
does nothing over MCP. These paths
carry no version segment. It is /documents, not /v1/documents.
Other parts of the API do carry /v1. Every path printed on this page is the
real one.
Specs API operations
| Method | Path | Scope | MCP tool |
|---|---|---|---|
GET | /documents | documents:read | list_specs |
GET | /documents/{id} | documents:read | none |
GET | /documents/{id}/markdown | documents:read | get_document_markdown |
GET | /documents/{id}/delivery | documents:read | get_spec_delivery |
POST | /documents | documents:write | none |
PUT | /documents/{id}/markdown | documents:write | none |
PATCH | /documents/{id}/spec-metadata | documents:write | none |
PATCH | /documents/{id}/epic-metadata | documents:write | none |
POST | /documents/{id}/promote | documents:write | none |
DELETE | /documents/{id} | documents:write | none |
One route that acts on a spec lives on another page: POST /v1/documents/{id}/dispatch
hands a spec to a coding agent as an operation, needs the operations:write scope
and does carry /v1. It is documented under
Operations.
Paths are relative to https://api.getnima.io, and every call carries
Authorization: Bearer <your key>. documents:read opens the four reads and
documents:write the six writes. Granting documents:write takes the delete
permission alongside create and update, which only a workspace owner or an admin
holds, so a member cannot put it on a key at all. documents:read is open to any
member. documents:write is not on the MCP consent screen, and no MCP tool calls any of
the six writes, so they are reachable only over REST. See
Scopes and permissions.
Four operations bind themselves to the workspace your credential belongs to:
the list, the read, the markdown read and the delivery read. A document in
another workspace answers 404 there. Every write on this page checks only
that you own the document or belong to the workspace holding it, so a
credential whose owner is a member of two workspaces can write, patch and
delete in both. Promotion is the exception: it compares the two workspaces and
answers 403 when they differ.
Specs over MCP
Three of the reads are MCP tools: list_specs, get_document_markdown and
get_spec_delivery. They call the same routes, so scopes, workspace binding and
plan checks are the REST ones. The tools are documented on
Pipeline tools. list_specs differs from the
REST list in three ways:
typedefaults tospec, so epics and plain documents are left out unless you passtype. The REST list returns every type when you omit it.pageSizeis 1 to 50, default 20. The REST list allows up to 100.- Each row is a fixed subset of the REST row. It keeps the id, title, type,
status, ticket, pull request and beacon fields, and a subset of
specMetadata(generation, status, priority, effort, ownerId, dueDate).
get_document_markdown takes id, offset and limit (1 to 12000, default
8000). Because limit always has a value over MCP, every call returns a window
and pagination; follow pagination.nextOffset until it is null. No MCP tool creates, writes, promotes or
deletes a spec.
Spec operations reference
Each heading below is one route, with its parameters, errors and a request and response example.
List specs
/documentsdocuments:readMCP toollist_specsOne page of the workspace's documents, as summaries without the body. Follow a
row with the markdown read to get the text. This is also the route behind the MCP tool list_specs, with the differences listed under Specs over MCP.
Rows come back ordered by position ascending, then most recently updated, then
id. That last key is unique, so the order is already total: the sort parameter
is parsed and accepted, and never changes where a row lands.
Paging is capped at 3000 rows. page multiplied by pageSize above that
answers 400 with details.maxOffset. Narrow the filter instead of paging
deeper.
pageintegerpageSizeintegersortJSON array{"field":"updatedAt","direction":"desc"}. Accepted and ignored, because the fixed order above already leaves no ties to break.typestringtype. Omit it and you get every type back, including plain documents.statusstringsearchstringepicIduuidmetadata.epicId, which nothing in nima writes. The spec to epic link lives at specMetadata.epicId, so filtering by epicId returns an empty page.Errors from the list specs route
| Status | error | When |
|---|---|---|
400 | Bad Request | page below 1, pageSize above 100, or a sort value that is not a list of field and direction |
400 | VALIDATION_ERROR | Paging past 3000 rows. details carries maxOffset. |
Read one spec
/documents/{id}documents:readReturns the full document object, blocks included. For a spec that means the
whole body as editor blocks, which is usually far larger than the markdown. Read
the markdown instead unless you need block ids.
Errors from the read spec route
| Status | error | When |
|---|---|---|
404 | Not Found | No such document |
404 | RESOURCE_NOT_FOUND | The document exists, in another workspace |
500 | INTERNAL_SERVER_ERROR | The id is not a UUID. See the note under Errors from the specs API. |
Read a spec as markdown
/documents/{id}/markdowndocuments:readMCP toolget_document_markdownThe body as one markdown string. Omit both parameters and you get the whole body
and no pagination object. Supply either one and you get a window plus
pagination.
Offsets count UTF-16 units, and a window may return one unit more than you asked
for rather than splitting an emoji. Follow pagination.nextOffset until it is
null, and keep version unchanged across windows: if it changes, the document
was rewritten under you, so start again at offset 0.
The response is JSON. markdown is a string field inside it. There is no
text/markdown representation of a document.
offsetintegerlimitintegerResponse fields
iduuidtitlestringmarkdownstringversionintegerupdatedAtISO 8601statusenumcardIduuidgenerationStageenumresearching, deciding, writing or failed, and null when no run owns the document.paginationobjectoffset, limit, totalCharacters, nextOffset.Errors from the markdown read
| Status | error | When |
|---|---|---|
400 | Bad Request | limit above 12000, or offset below 0 |
404 | Not Found | No such document |
404 | RESOURCE_NOT_FOUND | The document exists, in another workspace |
Follow a spec to its pull requests
/documents/{id}/deliverydocuments:readMCP toolget_spec_deliveryWhere one spec is in Spec to PR to Closed: the ticket it became, the pull
requests linked to that ticket, and the drift verdict on each. A spec that was
never promoted answers 200 with card: null and an empty pullRequests, not
an error.
Response fields
documentIduuidtitlestringstatusenumcardobjectid, ticket, title, boardId, column and isDone.card.ticketstringNW-142.card.columnstringcard.isDonebooleanpullRequestsobject[]latestDriftobjectpullRequests[].drift.Each pull request carries id, number, title, state (open, closed or
merged), authorLogin, repoFullName, mergedAt, updatedAt and drift.
drift is null when that pull request was never checked. When present it carries
pullRequestId, status (pending, analyzing, completed or failed),
hasDrift, sections, matchStrategy (card_link or file_path),
commitSha and checkedAt. Each section carries sectionRef, verdict
(aligned, drifted or not_addressed), summary, prdExcerpt and
relevantFiles. sections is empty until the check completes.
Errors from the delivery read
| Status | error | When |
|---|---|---|
404 | Not Found | No such document |
404 | RESOURCE_NOT_FOUND | The document exists, in another workspace |
Create a spec
/documentsdocuments:writeCreates an empty document and answers 201 with the full object. Write the body
with the markdown write.
The workspace comes from your credential. There is no workspace parameter here
or anywhere else in the API, and a credential that resolves to no workspace
answers 400. type is not checked against the three known values: a value like
Spec is stored verbatim, gets no metadata object, and matches only type=Spec,
not type=spec.
titlestringrequiredtypestringdocument. Only spec and epic get their metadata object.folderIduuidErrors from the create route
| Status | error | When |
|---|---|---|
400 | Bad Request | Missing or over-long title, or a folderId that is not a UUID |
400 | Bad Request | The credential resolves to no workspace |
Write a spec's markdown back
/documents/{id}/markdowndocuments:writeReplaces the whole body from a markdown string, and the title when you send one.
version increments, and the answer is 204 with an empty body.
It does not snapshot first. Call the snapshot before this if you want the old body kept.
markdownstringrequiredtitlestringThis body is not validated. Omitting markdown, or sending null, replaces
the document's content with nothing and still answers 204. Sending a number
answers 500. Send a string, and send the whole document.
Errors from the markdown write
| Status | error | When |
|---|---|---|
403 | Forbidden | Not a member of the workspace holding it |
404 | Not Found | No such document |
409 | Conflict | The document changed between the read this write is based on and the write itself. There is no version parameter to send, so recover by reading the markdown again and reapplying your change. |
Patch spec metadata
/documents/{id}/spec-metadatadocuments:writeEvery field is optional and merges into specMetadata. null clears any field except status, which accepts only draft,
unknown fields are dropped without an error, and the answer is 200 with an
empty body.
statusenumdraft is written. See Spec status values.priorityenump0, p1, p2 or p3.ownerIduuidnull.effortstringnull. No format is enforced.codebaseFilesstring[]startDatestringnull. No date format is enforced.dueDatestringnull. No date format is enforced.epicIduuidnull.Status on a spec belongs to the pipeline. The four other values pass the field check and are then refused:
{
"statusCode": 400,
"error": "VALIDATION_ERROR",
"message": "Spec status is set by the pipeline; only \"draft\" can be written by hand",
"details": { "field": "status", "allowed": ["draft"] },
"timestamp": "2026-09-22T09:33:51.410Z",
"path": "/documents/7c3a9d21-5e84-4f60-b1d7-2ae0f9c84b13/spec-metadata"
}Errors from the spec metadata patch
| Status | error | When |
|---|---|---|
400 | Bad Request | A field failed its type check, such as a priority outside the four values |
400 | VALIDATION_ERROR | A status other than draft. details names the field. |
403 | Forbidden | Not a member of the workspace holding it |
404 | Not Found | No such document |
Patch epic metadata
/documents/{id}/epic-metadatadocuments:writeThe same shape for an epic, minus effort, codebaseFiles and epicId. Here
all five statuses are written as sent, because an epic's status is yours to set.
The answer is 200 with an empty body.
The route does not check that the document is an epic, so patching a spec with it writes a field nothing reads.
statusenumdraft, in_review, approved, in_progress, shipped.priorityenump0, p1, p2 or p3.ownerIduuidnull.startDatestringnull.dueDatestringnull.Errors from the epic metadata patch
| Status | error | When |
|---|---|---|
400 | Bad Request | A field failed its type check |
403 | Forbidden | Not a member of the workspace holding it |
404 | Not Found | No such document |
Promote a spec to a ticket
/documents/{id}/promotedocuments:writeTurns a spec into a ticket on a board column, for a caller that already holds a columnId, and answers 201 with the ticket.
This is the Spec to PR transition: from here nima links pull requests to the
ticket, moves it across columns and drift checks every push against the spec.
What you send wins over the spec's own metadata. Leave assigneeId out and the
spec's ownerId is used; leave dueDate out and the spec's dueDate is used;
leave priority out and the spec's priority is mapped across, defaulting to
medium. Promotion also writes specMetadata.cardId, moves a draft (or in_review) spec to
approved without ever walking a further status back, and enrols the spec in
drift detection.
columnIduuidrequired/kanban accept only a signed-in session and answer 403 to a key, and neither the promote response nor the delivery read carries a column id. Ask the nima team for the column id of the board you promote into.priorityenumlow, medium, high or urgent.assigneeIduuiddueDateISO 8601Errors from the promote route
| Status | error | When |
|---|---|---|
400 | Bad Request | The document is not a spec, or columnId is missing |
400 | VALIDATION_ERROR | assigneeId is not a member of the workspace |
403 | Forbidden | The document belongs to a different workspace than your credential |
404 | Not Found | No column with that columnId, or the column's board is not in your workspace |
409 | CONFLICT | nima is still generating the spec (any stage but failed), or the spec has no content yet. details.stage holds the generation stage, or null for a spec nima never generated. |
409 | Conflict | The spec already has a ticket. A ticket that was deleted does not count, so a spec whose ticket is gone can be promoted again. |
Delete a spec
/documents/{id}documents:writeA soft delete, answered 200 with an empty body: the document stops being
listed and stops being readable, and nothing about it is recoverable through
this API.
Deleting a spec that came from a beacon puts the beacon back in play. One nima promoted by itself goes to held, with a reason naming the deleted spec, so it is not picked up again on the next pass. One you promoted by hand returns to ready.
Errors from the delete route
| Status | error | When |
|---|---|---|
403 | Forbidden | Not a member of the workspace holding it |
404 | Not Found | No such document |
The document object fields
Returned in full by create, read and restore.
iduuidtitlestringtypeenumdocument, spec or epic. Decides which metadata object is present.blocksobject[]contentHashstringmetadataobjectspecMetadata and epicMetadata.versionintegerownerIduuidfolderIduuidcreatedAtISO 8601updatedAtISO 8601beaconIduuidbeaconKeystringBCN-21. Null on the same rows.signalCountintegerspecMetadataobjecttype is spec.epicMetadataobjecttype is epic.The specMetadata object
statusenumpriorityenump0, p1, p2 or p3. Defaults to p2.ownerIduuideffortstringcodebaseFilesstring[]startDatestringdueDatestringepicIduuidcardIduuidrepositoryIduuidgenerationobjectfailed, with error). Absent on a hand written spec and once a run finishes. Carries stage, notificationId and beaconId.epicMetadata carries status, priority, ownerId, startDate and
dueDate, and nothing else.
Fields on a document list row
List rows are a different shape. They drop blocks, contentHash, metadata,
version and ownerId, keep beaconId, beaconKey, signalCount,
specMetadata and epicMetadata, and add:
statusenumcardIduuidticketstringNW-142.prStateenumopen, closed, merged or null: the furthest state any pull request on that ticket reached.prCountintegerpositionintegerstartDatestringSpec status values
| Status | What it means | How it gets there |
|---|---|---|
draft | Being written, or not reviewed yet | Set when the document is created, and written back if an approved spec's ticket is deleted. The only value you can write by hand. |
in_review | Under review | Nothing writes it today, so no spec reaches it. It stays a value the field accepts and the list filters on. |
approved | Ready to be worked on | Set automatically when a draft (or in_review) spec is promoted to a ticket |
in_progress | Work has started | Set when the first pull request links to its ticket |
shipped | The last open pull request on its ticket merged | Set by nima when that happens |
Status lives at metadata.specMetadata.status and is echoed as status by the
list, the markdown read and the delivery read. It is stored, never recomputed at
read time, so a ticket sitting in a done column with a stale status reads stale.
While nima is generating a spec, specMetadata.generation.stage is one of
researching, deciding or writing, and the body is empty or partial rather
than abandoned. failed means the run gave up and the body will not change on
its own. No generation key at all means no run owns the document.
Document routes an API key cannot reach
These paths exist and belong to this resource, but they declare no scope, so an
API key or an OAuth token gets 403 with the message
This endpoint does not accept OAuth access tokens. They serve
signed-in sessions only, and none of them has a public substitute except where
noted.
| Path | What it is |
|---|---|
PUT /documents/{id} | The block level save the editor uses, with its own version check. Use PUT /documents/{id}/markdown. |
PATCH /documents/{id} | Title, folder and position. Title is settable through the markdown write. |
PATCH /documents/reorder | Bulk reordering in the sidebar |
GET /documents/all | An unpaginated listing. Use GET /documents. |
GET /documents/{id}/agent-prompt | The ready to paste coding agent prompt built from a spec and its ticket |
/document-folders | Creating, listing, renaming, reordering and deleting folders. You can read a document's folderId and set one at creation. |
Plan feature gate on the documents routes
Every operation on this page is declared under the plan feature documents. A
workspace whose plan lacks that feature can get a 403 with
error: INSUFFICIENT_PERMISSIONS and details.feature set to documents:
{
"statusCode": 403,
"error": "INSUFFICIENT_PERMISSIONS",
"message": "Feature \"documents\" is not available on your current plan",
"details": { "feature": "documents", "plan": "free" }
}Handle that response. details.feature tells it apart from a missing scope or a
role that no longer carries the permission. Nobody is billed today.
Errors from the specs API
Errors use nima's standard envelope, described in
Errors. What is specific to this resource is
that the error field is not dependable here.
| Status | error | When |
|---|---|---|
400 | Bad Request | A field failed validation. The per field messages are dropped: message reads Bad Request Exception and names nothing. |
400 | VALIDATION_ERROR | A rule the API checks itself, such as writing a spec status other than draft, an assignee who is not a member, or paging past 3000 rows. details names the field. |
403 | Forbidden | On promote and on the writes: the document is in a workspace you are not a member of, or on promote in a different workspace than your credential. A removed member gets 403 INSUFFICIENT_PERMISSIONS instead. |
403 | INSUFFICIENT_PERMISSIONS | Wrong scope, or a role that no longer carries the permission |
404 | Not Found | No such document, or on promote no such column or a column on another workspace's board |
404 | RESOURCE_NOT_FOUND | The document exists, in another workspace |
409 | Conflict | The spec already has a ticket, or the body changed under a write |
409 | CONFLICT | The spec is still being generated or has no content yet. details.stage says which stage, or is null. |
429 | RATE_LIMITED | Over the limit. See Rate limits. |
500 | INTERNAL_SERVER_ERROR | See below |
The same failure can arrive under two different error values: a missing
document is Not Found and the same document in another workspace is
RESOURCE_NOT_FOUND; a spec that is already promoted is Conflict and one that
is still generating or empty is CONFLICT. Branch on statusCode, and on details when
it is there. Do not branch on error for these paths.
An id is not checked for shape before the lookup runs, so
GET /documents/not-a-uuid answers 500, not 404. The body carries an
incidentId and nothing else useful. Validate that you are holding a UUID
before you send it.