docs
API

API conventions

The rules that hold across every nima endpoint: vocabulary, ids, casing, null, timestamps, enums, query arrays, the filter DSL and the base path of each resource.

Everything under https://api.getnima.io takes JSON and returns JSON. Almost every route authenticates with one Authorization: Bearer header; the exceptions are listed under Base paths and versioning below. See Authentication for how to get a token. This page is the part that does not change from one resource to the next.

Vocabulary of the nima API

The API is the pipeline, so its nouns are the pipeline's nouns, plus the nouns of the watches, reminders, agent operations and outbound actions built on top of it.

TermWhat it isWhere it lives
SignalOne piece of raw evidence: a Slack thread, a GitHub comment, a posted webhook payload, a note someone typed. Classified, de-duplicated, and tagged with an origin./v1/signals
BeaconA ranked collection of related signals. The unit a PM decides on./v1/beacons
SpecA document whose type is spec: the grounded write-up a coding agent can execute. Its lifecycle is specMetadata.status, not a top-level field./documents
CardThe ticket a spec is promoted into. It is what links a spec to its pull requests. A spec has at most one.POST /documents/{id}/promote, GET /documents/{id}/delivery
EventA recorded change to a signal, beacon, spec or pull request. Events are always recorded; a watch decides which ones reach a person./v1/events
WatchA saved rule, usually owned by one person: when an event happens about a subject, run saved instructions and deliver the answer./v1/watches
Inbox itemOne delivery a watch made to a person, readable with GET /v1/inbox./v1/inbox
Scheduled messageA one-time reminder delivered to the creator's own linked Slack DM at a set time./v1/scheduled-messages
OperationOne unit of agent work, usually on a spec: a handoff to your own coding agent or a run on a hosted VM./v1/operations
ActionA draft that nima holds for approval before it writes outside nima, such as a reply on a ticket or a Slack DM./v1/actions
PolicyThe rule for one kind of outbound action: never, ask, or auto./v1/dispatch/policies
IdentityThe link between a person in an external system, such as a Slack or Linear account, and a nima workspace member./v1/identities

A spec is a document, so it comes back from the same endpoints as any other document. Ask for ?type=spec if you only want specs; a call with no type returns every document type.

Ids in the nima API

Every id in every path and body is a UUID string. Treat it as opaque. Do not parse it, do not sort on it, do not assume a UUID version.

Two human-readable keys sit beside ids and are not ids:

  • A beacon's key, shaped BCN-12, sequential per workspace and never reused.
  • A card's ticketPrefix plus ticketNumber.

Both are for display. Every path segment takes the UUID.

On signals, beacons and specs, path ids are not checked for shape. A well-formed UUID that does not exist gives a clean 404, but a string that is not a UUID at all can reach the database and come back as a 500 instead of a 400. Newer resources (watches, scheduled messages, operations, actions, API keys) answer a malformed id with 400. Validate ids on your side before sending them.

Field name casing in nima requests and responses

Request and response bodies use camelCase. The exceptions are the OAuth endpoints under /oauth, which follow the OAuth specs and use snake_case both ways: the token endpoint takes grant_type and refresh_token and answers with access_token, refresh_token, token_type and expires_in, and client registration uses redirect_uris, client_name, client_id and the other RFC 7591 names. /oauth/userinfo answers in camelCase apart from sub.

A signal's metadata is an open object carried through from the source integration. Its keys are not nima's and not normalised. Do not assume any particular key is present.

Null, absent and unchanged values in nima requests and responses

In the nima API, null, an absent key and an unchanged field are three different things, and the difference is load-bearing.

In responses, a field that is always present but may have no value is typed as nullable and comes back as null. classification and impact on a signal are null until the classifier has run. A field that is absent means something else: a key missing from a beacon's sourceCounts map means zero for that producer, and specMetadata.generation is present while a spec-generation run owns the document. It is removed when the run succeeds; a run that gives up leaves it in place with stage: "failed".

In PATCH bodies, a key you omit is left alone and a key you send as null is written as null. Clearing a spec's due date and leaving it alone are two different requests:

{ "dueDate": null }
{ "priority": "p1" }

The first clears the date. The second leaves it exactly as it was.

Never send "" to mean "no value". It is stored as an empty string.

Unknown fields in a request body

How nima checks a request body depends on the resource.

Signals, beacons, specs and the other older resources drop fields they do not declare, silently, before the handler runs. A typo in a field name looks like a successful write that did nothing, so read the response back. A bad value there comes back as 400 with "error": "Bad Request" and "message": "Bad Request Exception", and the per-field reasons are dropped. PUT /documents/{id}/markdown declares no schema at all, so nothing is stripped and nothing is validated.

Watches reject unknown fields with a 400 whose details.issues names each problem.

Action policies, identities and the Jira and Linear writer connections check the body against a schema. A bad value comes back as a 400 whose details.issues lists each problem with its path.

Agent compute settings, operations and spec dispatch also check the body against a schema, and agent compute settings reject unknown fields. Their 400 carries only a message (Invalid agent compute settings, Invalid operation request, Invalid spec dispatch request) and no details, so compare your body with the resource page.

See Errors.

Timestamps and calendar dates

Timestamps are ISO 8601 in UTC, always with the Z suffix and milliseconds:

"createdAt": "2026-09-14T09:12:44.018Z"

Everything that names a moment works this way: createdAt, updatedAt, formedAt, mergedAt, autoPromotedAt, timestamp on an error. Convert to local time in your own layer, never in the query.

Calendar dates are a plain YYYY-MM-DD string with no time and no zone. They appear on a spec's startDate and dueDate, and on the daily buckets of timeline responses. Parse them by hand:

const [y, m, d] = "2026-04-15".split("-").map(Number);
const local = new Date(y, m - 1, d);

new Date("2026-04-15") parses as midnight UTC and shifts the day for anyone west of Greenwich.

The two write paths onto a spec's dates disagree today: POST /documents/{id}/promote validates dueDate as an ISO 8601 date, while PATCH /documents/{id}/spec-metadata accepts any string and stores it verbatim. Send YYYY-MM-DD to both.

Enum values in the nima API

Enum values on the wire are lowercase, with snake_case for multiple words: feature_request, in_review, spec_drift, not_addressed.

Treat every enum as open. Values are added and retired without a version bump. Handle a value you do not recognise instead of throwing. Retirement is real and quiet: waiting_for_response is a signal status that is still declared but that nothing sets any more; expect it to be removed without notice.

Unknown values behave differently depending on how you pass them:

WhereAn unrecognised enum value does this
filter DSL (signals, beacons)400, with the failing condition's path in details.issues
?status= on /v1/signalsPassed to the query, matches nothing, empty page
?status= on /v1/beaconsDropped. If every value you sent is dropped, the filter disappears and you get the unfiltered list
?slice= on /v1/beacons400 listing the allowed values

Only the DSL and ?slice= tell you; the two ?status= parameters fail quietly.

Query string arrays and booleans

Query strings are parsed in the bracket-aware style, so a list can be written either way and both arrive as a list:

?status[]=ready&status[]=forming
?status=ready&status=forming

GET /v1/beacons and GET /v1/beacons/facets read status as a list. GET /v1/signals and GET /v1/signals/facets read status and impact as comma-separated strings, and a repeated or bracketed value is joined with commas before it is read, so all three forms work there too. Every other flat parameter takes one value: repeat classification or source on /v1/signals and the values are joined into one string that matches nothing. Prefer the comma form everywhere: ?status=ready,classified.

Send the filter parameter below exactly once, as a plain filter= with no brackets. A repeated filter is joined with commas and a bracketed one arrives as the text [object Object], so either one fails as a 400.

Boolean query parameters are read as the literal strings true and false. Anything else, including 1 and yes, is treated as not set.

Page size is named inconsistently: /v1/signals, /v1/beacons, /v1/inbox and /v1/actions take page and limit, while /documents and /v1/operations take page and pageSize. See Pagination.

Filter DSL for signals and beacons

GET /v1/signals and GET /v1/beacons accept a structured filter as a single JSON-encoded filter query parameter. Their facet endpoints, /v1/signals/facets and /v1/beacons/facets, take the same parameter. No other resource has one.

Shape of a filter expression

A filter is one group. A group has a combinator and a list of conditions, and any condition may itself be a group:

{
  "combinator": "and",
  "conditions": [
    { "field": "classification", "operator": "in", "value": ["bug", "performance"] },
    { "field": "createdAt", "operator": "in_last", "value": { "amount": 14, "unit": "day" } }
  ]
}

combinator is and or or. An empty conditions array matches everything, so a filter you built by clearing every condition returns the unfiltered list rather than nothing.

Filterable fields on signals and beacons

Signals:

FieldType
status, classification, impact, source, originenum
searchsearch
content, pmNotestext
productArea, stakeholderstring_array
priorityScore, sourceCount, reactionCount, confidencenumber
createdAt, updatedAtdate
inBeacon, hasSpecrelation

Beacons:

FieldType
status, sliceenum
title, pmNotestext
rankScore, evidenceCountnumber
createdAt, formedAt, updatedAtdate
hasSpecrelation

Filter operators by field type

The field's type decides which operators it accepts. Anything else is a 400.

TypeOperatorsvalue
enumin, not_inarray of allowed values
enum, text, date, string_arrayis_set, is_not_setomit it
textcontains, not_containsstring
searchmatches, not_matchesstring
numbereq, neq, gt, gte, lt, ltenumber
number, datebetween[from, to]
dategt, gte, lt, lteISO 8601 timestamp string
datein_last{ "amount": 14, "unit": "day" | "week" | "month" }
string_arrayhas_any, has_allarray of strings
relationexists, not_existsomit it
booleaneqboolean

Notes on behaviour you cannot guess from the names:

  • For is_set, is_not_set, exists and not_exists, leave value out entirely. Sending "value": null is a 400.
  • contains is a case-insensitive substring match, and %, _ and \ in your string are matched literally rather than as wildcards.
  • matches is word-boundary full-text search, not a substring match: your string is split into words, each word is matched whole and case-insensitively, and the words are ANDed. There is no stemming, so export does not match exports, and a fragment like v2. or 500 is only reachable through content contains. The flat ?search= parameter on GET /v1/signals works the same way, with searchMode=prefix as the option that matches word beginnings.
  • in_last counts a month as 30 days.

No field declares the boolean type on either resource today, so that row is the DSL's, not something you can use yet.

Filter size limits

LimitValue
Nesting depth3
Conditions in the whole tree32
Values in one in, not_in, has_any or has_all list50
Characters in one text value200
in_last amount3650, in whatever unit the condition names

Filter request example

curl -sS --get https://api.getnima.io/v1/signals \
  -H "Authorization: Bearer $NIMA_API_KEY" \
  --data-urlencode 'filter={"combinator":"and","conditions":[{"field":"classification","operator":"in","value":["bug","performance"]},{"field":"createdAt","operator":"in_last","value":{"amount":14,"unit":"day"}}]}' \
  --data 'limit=1'
{
  "data": [
    {
      "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
      "source": "slack",
      "status": "ready",
      "classification": "bug",
      "impact": "high",
      "origin": "customer",
      "preview": "Bulk export times out on large workspaces",
      "extractedIntent": "Make bulk export finish for workspaces over 40k rows",
      "extractedProductArea": ["export"],
      "pmNotes": null,
      "sourceCount": 3,
      "createdAt": "2026-09-14T09:12:44.018Z",
      "updatedAt": "2026-09-18T16:03:10.774Z"
    }
  ],
  "meta": { "page": 1, "pageSize": 1, "totalItems": 37, "totalPages": 37 }
}

Fields are trimmed here. Signals has the full response shape.

The flat parameters and the DSL both apply: ?status=ready&filter=... narrows by both. Flat parameters are the older surface and cover less.

The MCP tools for signals and beacons take the flat parameters only, not filter; see Pipeline tools. Over HTTP you get both.

How nima rejects a malformed filter

A malformed filter is always a 400, never a predicate that is quietly ignored, because ignoring one returns more rows than you asked for. The message tells you which of two things went wrong.

messageCause
filter must be valid JSONThe string did not parse, including a filter you sent twice or with brackets. details.filter echoes your first 200 characters.
filter is not a valid filter expressionIt parsed, but the expression is wrong: unknown field, operator the field does not accept, enum value outside the list, wrong value type, or a limit above. details.issues lists each problem with its path.

Every limit in the table above surfaces as the second one:

{
  "statusCode": 400,
  "error": "VALIDATION_ERROR",
  "message": "filter is not a valid filter expression",
  "details": {
    "issues": [
      { "path": "conditions", "message": "Filter is nested deeper than 3 levels (4)." }
    ]
  },
  "timestamp": "2026-09-22T11:04:18.220Z",
  "path": "/v1/signals"
}

An empty filter= is not an error. It is read as no filter at all.

Base paths and versioning

/v1 is a literal part of most paths and absent from others. It is not version negotiation: there is no /v2, no version header, and no way to ask for an older response shape.

ResourceBase pathCredential
Signals/v1/signalskey or OAuth token
Beacons/v1/beaconskey or OAuth token
Specs and documents/documentskey or OAuth token
Spec dispatch to an agent/v1/documents/{id}/dispatchkey with operations:write (minted from Slack)
Spec sections/prdkey or OAuth token
Event history/v1/eventskey or OAuth token
Watches and event catalog/v1/watches, /v1/triggerskey or OAuth token
Inbox/v1/inboxkey or OAuth token
Scheduled messages/v1/scheduled-messageskey or OAuth token
Agent operations/v1/operationskey with operations:write for writes (minted from Slack; see Scopes)
Hosted agent compute settings/v1/agent-computekey with integrations:write (minted from Slack)
Outbound action policies and project audience/v1/dispatchkey with integrations:write (minted from Slack)
Outbound actions awaiting approval/v1/actions (list and read also at /v1/dispatch/actions; approve and discard only under /v1/actions/{id})actions:approve, which no key or OAuth client can hold today; approve on the Slack card
Identities/v1/identitieskey with integrations:write (minted from Slack); link-me needs identities:write, which no key can hold today
GitHub writer connection/v1/github-writerkey with integrations:write (minted from Slack)
Jira and Linear writer connections/v1/integrations/jira/writer, /v1/integrations/linear/writerkey with integrations:write (minted from Slack)
Knowledge graph/v1/knowledge-graphkey or OAuth token
Code search/v1/code-intelkey or OAuth token
Workspace settings/organizations/settingssigned-in session only
API keys/v1/api-keyssigned-in session only
Inbound signal webhook ingest/v1/webhooks/signals/{id}the webhook's secret, no key; see Webhooks
Inbound signal webhook endpoints/v1/signal-webhookssigned-in session only
OAuth, including userinfo/oauthnone, or any scope for userinfo
Remote MCP/mcpkey or OAuth token

Copy the path from the resource page. Do not add or remove /v1 by analogy with a neighbouring resource. A route that carries no scope, such as /organizations/settings, answers 403 to a key or OAuth token; see Scopes for what each scope unlocks.

There is no deprecation mechanism on the wire. Responses carry no Deprecation or Sunset header, and nothing in a payload marks a field as going away. latestDrift on the spec delivery response, for example, is already superseded by the newest entry of pullRequests[].drift and will stop being filled with no warning in the response itself. Write clients that tolerate a field disappearing.

Request ids for support

Every response carries an X-Request-Id header. On a 5xx the same value comes back in the body as incidentId. Quote it when you report a problem: it is the one string that finds the whole request.

On this page