docs
API

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

MethodPathScopeMCP tool
GET/documentsdocuments:readlist_specs
GET/documents/{id}documents:readnone
GET/documents/{id}/markdowndocuments:readget_document_markdown
GET/documents/{id}/deliverydocuments:readget_spec_delivery
POST/documentsdocuments:writenone
PUT/documents/{id}/markdowndocuments:writenone
PATCH/documents/{id}/spec-metadatadocuments:writenone
PATCH/documents/{id}/epic-metadatadocuments:writenone
POST/documents/{id}/promotedocuments:writenone
DELETE/documents/{id}documents:writenone

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:

  • type defaults to spec, so epics and plain documents are left out unless you pass type. The REST list returns every type when you omit it.
  • pageSize is 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

GET/documents
scopedocuments:readMCP toollist_specs

One 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.

pageinteger
Minimum 1, default 1.
pageSizeinteger
1 to 100, default 20. A larger value is rejected, not clamped.
sortJSON array
Entries of {"field":"updatedAt","direction":"desc"}. Accepted and ignored, because the fixed order above already leaves no ties to break.
typestring
Exact match on type. Omit it and you get every type back, including plain documents.
statusstring
Exact match on the spec or epic status. A plain document never matches one.
searchstring
Case insensitive substring of the title. It does not search the body.
epicIduuid
Matches a top level metadata.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

StatuserrorWhen
400Bad Requestpage below 1, pageSize above 100, or a sort value that is not a list of field and direction
400VALIDATION_ERRORPaging past 3000 rows. details carries maxOffset.

Read one spec

GET/documents/{id}
scopedocuments:read

Returns 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

StatuserrorWhen
404Not FoundNo such document
404RESOURCE_NOT_FOUNDThe document exists, in another workspace
500INTERNAL_SERVER_ERRORThe id is not a UUID. See the note under Errors from the specs API.

Read a spec as markdown

GET/documents/{id}/markdown
scopedocuments:readMCP toolget_document_markdown

The 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.

offsetinteger
Minimum 0.
limitinteger
1 to 12000, default 8000.

Response fields

iduuid
The document.
titlestring
Its current title.
markdownstring
The body, or the requested window of it.
versioninteger
Bumped by every body write. Your window key.
updatedAtISO 8601
Last write, UTC.
statusenum
The spec or epic status. Null for a plain document.
cardIduuid
The ticket it was promoted into, or null.
generationStageenum
researching, deciding, writing or failed, and null when no run owns the document.
paginationobject
Only when a window was requested: offset, limit, totalCharacters, nextOffset.

Errors from the markdown read

StatuserrorWhen
400Bad Requestlimit above 12000, or offset below 0
404Not FoundNo such document
404RESOURCE_NOT_FOUNDThe document exists, in another workspace

Follow a spec to its pull requests

GET/documents/{id}/delivery
scopedocuments:readMCP toolget_spec_delivery

Where 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

documentIduuid
The spec.
titlestring
Its title.
statusenum
The stored status, not recomputed from the ticket.
cardobject
Null until the spec is promoted. Carries id, ticket, title, boardId, column and isDone.
card.ticketstring
For example NW-142.
card.columnstring
The column title as it reads on the board.
card.isDoneboolean
True when the column is flagged as done, or when its title reads like a done column (such as "Done").
pullRequestsobject[]
At most 20, most recently updated first.
latestDriftobject
Deprecated. It is only the newest of the per pull request verdicts. Read pullRequests[].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

StatuserrorWhen
404Not FoundNo such document
404RESOURCE_NOT_FOUNDThe document exists, in another workspace

Create a spec

POST/documents
scopedocuments:write

Creates 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.

titlestringrequired
1 to 500 characters, trimmed. A title of only spaces is rejected.
typestring
Defaults to document. Only spec and epic get their metadata object.
folderIduuid
The folder it lands in. No other route a key can call creates or moves folders.

Errors from the create route

StatuserrorWhen
400Bad RequestMissing or over-long title, or a folderId that is not a UUID
400Bad RequestThe credential resolves to no workspace

Write a spec's markdown back

PUT/documents/{id}/markdown
scopedocuments:write

Replaces 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.

markdownstringrequired
The new body in full.
titlestring
A new title. The old one stays when you leave it out.

This 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

StatuserrorWhen
403ForbiddenNot a member of the workspace holding it
404Not FoundNo such document
409ConflictThe 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

PATCH/documents/{id}/spec-metadata
scopedocuments:write

Every 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.

statusenum
Parses any of the five statuses, but only draft is written. See Spec status values.
priorityenum
p0, p1, p2 or p3.
ownerIduuid
Or null.
effortstring
Any string, or null. No format is enforced.
codebaseFilesstring[]
Any array.
startDatestring
Any string, or null. No date format is enforced.
dueDatestring
Any string, or null. No date format is enforced.
epicIduuid
Or null.

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

StatuserrorWhen
400Bad RequestA field failed its type check, such as a priority outside the four values
400VALIDATION_ERRORA status other than draft. details names the field.
403ForbiddenNot a member of the workspace holding it
404Not FoundNo such document

Patch epic metadata

PATCH/documents/{id}/epic-metadata
scopedocuments:write

The 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.

statusenum
Any of draft, in_review, approved, in_progress, shipped.
priorityenum
p0, p1, p2 or p3.
ownerIduuid
Or null.
startDatestring
Any string, or null.
dueDatestring
Any string, or null.

Errors from the epic metadata patch

StatuserrorWhen
400Bad RequestA field failed its type check
403ForbiddenNot a member of the workspace holding it
404Not FoundNo such document

Promote a spec to a ticket

POST/documents/{id}/promote
scopedocuments:write

Turns 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
The column the ticket lands in. No route an API key or MCP client can call returns column ids: the board and column routes under /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.
priorityenum
low, medium, high or urgent.
assigneeIduuid
Must be a member of the workspace.
dueDateISO 8601
A date or a timestamp.

Errors from the promote route

StatuserrorWhen
400Bad RequestThe document is not a spec, or columnId is missing
400VALIDATION_ERRORassigneeId is not a member of the workspace
403ForbiddenThe document belongs to a different workspace than your credential
404Not FoundNo column with that columnId, or the column's board is not in your workspace
409CONFLICTnima 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.
409ConflictThe 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

DELETE/documents/{id}
scopedocuments:write

A 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

StatuserrorWhen
403ForbiddenNot a member of the workspace holding it
404Not FoundNo such document

The document object fields

Returned in full by create, read and restore.

iduuid
The document.
titlestring
1 to 500 characters.
typeenum
document, spec or epic. Decides which metadata object is present.
blocksobject[]
The body as editor blocks. Large. Read the markdown instead unless you need block ids.
contentHashstring
SHA-256 of the blocks. Unchanged body, unchanged hash.
metadataobject
Everything stored on the document, including specMetadata and epicMetadata.
versioninteger
Starts at 1, increments on every body write.
ownerIduuid
The person who created it.
folderIduuid
Or null. A folder can be set at creation; no other key-reachable route changes it.
createdAtISO 8601
UTC.
updatedAtISO 8601
UTC.
beaconIduuid
The beacon this spec was promoted from. Null for a hand written spec and for a non spec.
beaconKeystring
That beacon's key, for example BCN-21. Null on the same rows.
signalCountinteger
How many signals the spec is grounded in. 0 for a non spec.
specMetadataobject
Present when type is spec.
epicMetadataobject
Present when type is epic.

The specMetadata object

statusenum
priorityenum
p0, p1, p2 or p3. Defaults to p2.
ownerIduuid
Or null.
effortstring
Free text, no format. Or null.
codebaseFilesstring[]
The files the spec was grounded in.
startDatestring
Stored as sent, never parsed. Or null.
dueDatestring
Stored as sent, never parsed. Or null.
epicIduuid
The epic this spec belongs to, or null.
cardIduuid
The ticket it was promoted into. Null until promoted, and cleared again if that ticket is deleted.
repositoryIduuid
The repository it was grounded in, written when generation starts. Null on a hand written spec.
generationobject
Present while a nima generation run owns the spec, including after a failed run (stage failed, 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:

statusenum
Lifted out of the metadata. Null for a plain document.
cardIduuid
The ticket it was promoted into, or null.
ticketstring
That ticket's key, for example NW-142.
prStateenum
open, closed, merged or null: the furthest state any pull request on that ticket reached.
prCountinteger
How many pull requests are linked to that ticket.
positioninteger
Ordering inside its folder. 0 unless it was reordered through a signed-in session route.
startDatestring
The spec or epic metadata date, falling back to the creation time. Null for a plain document.

Spec status values

StatusWhat it meansHow it gets there
draftBeing written, or not reviewed yetSet 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_reviewUnder reviewNothing writes it today, so no spec reaches it. It stays a value the field accepts and the list filters on.
approvedReady to be worked onSet automatically when a draft (or in_review) spec is promoted to a ticket
in_progressWork has startedSet when the first pull request links to its ticket
shippedThe last open pull request on its ticket mergedSet 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.

PathWhat 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/reorderBulk reordering in the sidebar
GET /documents/allAn unpaginated listing. Use GET /documents.
GET /documents/{id}/agent-promptThe ready to paste coding agent prompt built from a spec and its ticket
/document-foldersCreating, 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.

StatuserrorWhen
400Bad RequestA field failed validation. The per field messages are dropped: message reads Bad Request Exception and names nothing.
400VALIDATION_ERRORA 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.
403ForbiddenOn 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.
403INSUFFICIENT_PERMISSIONSWrong scope, or a role that no longer carries the permission
404Not FoundNo such document, or on promote no such column or a column on another workspace's board
404RESOURCE_NOT_FOUNDThe document exists, in another workspace
409ConflictThe spec already has a ticket, or the body changed under a write
409CONFLICTThe spec is still being generated or has no content yet. details.stage says which stage, or is null.
429RATE_LIMITEDOver the limit. See Rate limits.
500INTERNAL_SERVER_ERRORSee 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.