Back

GigaRAG API reference

The REST API reads, writes and searches the buckets, threads and memos in a GigaRAG workspace. Every request carries an API key, and the key decides which workspace it reaches.

Prompt for your agent

Paste into your coding agent with your key, and it reads the markdown version of this page itself. Get a key

Prompt
Find API documentation for GigaRAG here: https://gigarag.com/docs/api.md
API Key: <your-api-key>

Authentication

Create a key on the API Keys page in GigaRAG and send it on every request as a Bearer token. The X-Api-Key header works too.

Example request
curl https://api.gigarag.com/v1/buckets \
  -H "Authorization: Bearer $GIGARAG_API_KEY"

A key belongs to one workspace, so no route takes a workspace id. An admin key reaches every bucket in it, and a restricted key reaches only the buckets it was granted, either to view or to edit. Keep it in an environment variable and out of source code. A key sent in a query string is ignored, because URLs end up in logs and browser history.

Every authentication failure returns the same 401, whether the key is mistyped, revoked or unknown.

Requests and responses

Send bodies as JSON with Content-Type: application/json. Responses are JSON, and each one carries an X-Request-Id header worth quoting when you report a problem.

Buckets, threads and memos each have an id, which is a UUID, and a short ref handle such as B:1, T:4 or N:12 that never changes. All three carry created_at and updated_at as ISO 8601 timestamps.

Each workspace has a per-minute limit on every request and a monthly quota that counts searches and writes. Responses carry X-RateLimit-Limit and X-RateLimit-Remaining for the per-minute limit.

Pagination

A listing returns a page of rows and a cursor. Pass next_cursor back as after to get the next page, and stop when it comes back null. Rows added while you read aren't skipped.

Response to ?limit=1
{
  "items": [
    {
      "id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
      "ref": "B:1",
      "title": "Handbook"
    }
  ],
  "next_cursor": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01"
}

Errors

A failed request returns a readable error message and, for many failures, a code to branch on.

Error body
{
  "error": "slug must be lowercase letters, digits, hyphens or underscores",
  "code": "invalid_request"
}
StatusCodeMeaning
400invalid_requestThe request is malformed. The message names the field.
400invalid_referenceThe body refers to a row that doesn't exist.
401invalid_api_keyThe key is missing, wrong or revoked.
403The key wasn't granted that bucket or was granted it only to view, the id doesn't exist, or the workspace is suspended and refuses writes. A missing id returns 403 so an id can't be used to find out whether a row exists.
403limit_exceededThe write would pass a workspace limit. limit, current and max in the body say which.
404No such route.
409conflictThe slug is already taken where you put it.
423project_frozenThe workspace is moving between servers and refuses writes for a few seconds. Retry after the seconds in Retry-After.
429rate_limitedToo many requests this minute. Retry after the seconds in Retry-After.
429quota_exceededThe monthly request quota is used up.
400bucket_requiredThe workspace has row level security on, so the request has to name one bucket: add the header X-GigaRAG-Bucket: <bucket id> or bucket_id=<bucket id> (over MCP, the bucket_id argument). If you do not know the id, the person copies it in GigaRAG from the bucket menu, Copy bucket ID.
400bucket_mismatchThe header, the query and the route named different buckets. Name one.
422cross_bucket_linkRow level security keeps links inside one bucket, and these two memos are in different buckets. Move one in the app, or the owner turns RLS off in workspace settings. Retrying does not help.

Of these, only 423 and rate_limited are worth retrying as they are. bucket_required and bucket_mismatch succeed once the request names exactly one bucket.

Memos

A memo is a markdown document inside a thread. The API calls it a node, so memo routes live under /v1/nodes.

Create a memo

POST /v1/nodes

Splits the content into sections at its headings, embeds each section and stores the result, so the memo is searchable as soon as the call returns.

Each [label](N:12) link to an existing memo becomes an edge, and links in the response counts them. unresolved_links lists the handles in the content that did not become one. See Links inside memos.

Counts toward the workspace limits on memos and vectors.

Body

thread_id uuid, required
The thread the memo goes in.
slug string, required
Lowercase letters, digits, hyphens and underscores, up to 64 characters, starting and ending with a letter or digit. Unique within the thread.
title string, required
Up to 500 characters.
summary string
Up to 5,000 characters. Searched along with the title.
content string
The markdown body, up to 10,000 characters. Anything longer returns 400.
node_type string
Your own label for the memo, such as decision, up to 64 characters. Defaults to note. Search and listings can filter by it.
properties object
Any JSON object you want kept with the memo.
Request body
{
  "thread_id": "018f2a1c-5d20-7a44-8e1b-6c9f0a2b7d12",
  "slug": "rate-limits",
  "title": "Rate limiting",
  "summary": "How we throttle API clients",
  "content": "# Token buckets\n\nEach client gets 60 requests a minute. See [retries](N:9).",
  "node_type": "note"
}
Response 201
{
  "id": "018f2a1c-6e31-7f55-b2c4-1d8e3f4a9b23",
  "thread_id": "018f2a1c-5d20-7a44-8e1b-6c9f0a2b7d12",
  "ref": "N:12",
  "slug": "rate-limits",
  "title": "Rate limiting",
  "summary": "How we throttle API clients",
  "content": "# Token buckets\n\nEach client gets 60 requests a minute. See [retries](N:9).",
  "node_type": "note",
  "properties": {},
  "created_at": "2026-09-15T10:12:00.000Z",
  "updated_at": "2026-09-15T10:12:00.000Z",
  "sections": 1,
  "links": 1,
  "unresolved_links": []
}

Read a memo

GET /v1/nodes/:id

The memo in full: its body, its sections, the memos linked to it in either direction, and the memos most similar to it.

distance in similar is cosine distance, so lower is closer. similar is empty for a memo with no vector yet, which is normal while a workspace is being re-embedded.

Query parameters

links integer
1 to 100, default 25. How many linked memos to return.
similar integer
0 to 10, default 5. How many similar memos to return. 0 skips the lookup.
Response 200
{
  "id": "018f2a1c-6e31-7f55-b2c4-1d8e3f4a9b23",
  "thread_id": "018f2a1c-5d20-7a44-8e1b-6c9f0a2b7d12",
  "ref": "N:12",
  "slug": "rate-limits",
  "title": "Rate limiting",
  "summary": "How we throttle API clients",
  "content": "# Token buckets\n\nEach client gets 60 requests a minute. See [retries](N:9).",
  "node_type": "note",
  "properties": {},
  "created_at": "2026-09-15T10:12:00.000Z",
  "updated_at": "2026-09-15T10:12:00.000Z",
  "bucket_id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
  "sections": [
    {
      "id": "018f2a1c-8a53-7177-94e6-3fa05b6c1d45",
      "position": 0,
      "heading": "Token buckets",
      "content": "Each client gets 60 requests a minute. See [retries](N:9)."
    }
  ],
  "links": [
    {
      "node_id": "018f2a1c-7f42-7066-a3d5-2e9f4a5b0c34",
      "label": "links_to",
      "description": "retries",
      "weight": 0.5,
      "direction": "out",
      "title": "Retries",
      "summary": "When a client should retry"
    }
  ],
  "similar": [
    {
      "node_id": "018f2a1c-7f42-7066-a3d5-2e9f4a5b0c34",
      "ref": "N:9",
      "title": "Retries",
      "summary": "When a client should retry",
      "node_type": "note",
      "distance": 0.21
    }
  ]
}

List memos

GET /v1/nodes

Memos in the order they were created, without their bodies. See Pagination for reading every page.

Query parameters

limit integer
1 to 200, default 50. A value outside the range is clamped to it.
after uuid
The next_cursor from the previous page.
bucket_id uuid
Only memos in this bucket.
thread_id uuid
Only memos in this thread.
node_type string
Only memos of this type.
Response 200
{
  "items": [
    {
      "id": "018f2a1c-6e31-7f55-b2c4-1d8e3f4a9b23",
      "thread_id": "018f2a1c-5d20-7a44-8e1b-6c9f0a2b7d12",
      "bucket_id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
      "ref": "N:12",
      "slug": "rate-limits",
      "title": "Rate limiting",
      "summary": "How we throttle API clients",
      "node_type": "note",
      "properties": {},
      "created_at": "2026-09-15T10:12:00.000Z",
      "updated_at": "2026-09-15T10:12:00.000Z"
    }
  ],
  "next_cursor": null
}

Find memos by title

GET /v1/nodes/lookup

For a link picker: the memos whose title or summary starts with what has been typed so far, across every bucket the key can read. A title that starts with q comes first. With no q, the most recently edited memos.

Nothing is embedded, so it is cheap enough to call on every keystroke. To find a memo by meaning, use Search instead. Results are ranked, not paged, so there is no cursor.

Query parameters

q string
Up to 200 characters. Each word matches as the start of a word.
limit integer
1 to 25, default 8.
exclude uuid
A memo to leave out, usually the one the link is being written in.
Response 200
{
  "items": [
    {
      "id": "018f2a1c-7f42-7066-a3d5-2e9f4a5b0c34",
      "thread_id": "018f2a1c-5d20-7a44-8e1b-6c9f0a2b7d12",
      "bucket_id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
      "ref": "N:9",
      "slug": "retries",
      "title": "Retries",
      "summary": "When a client should retry",
      "node_type": "note",
      "properties": {},
      "created_at": "2026-09-15T10:12:00.000Z",
      "updated_at": "2026-09-15T10:12:00.000Z"
    }
  ]
}

Read several memos

POST /v1/nodes/fetch

Up to 50 memos with their bodies in one call, in the order you asked for them. Links and similar memos are left out.

An id that doesn't exist, or that sits in a bucket the key can't read, is left out of the result and the call still succeeds.

Body

ids uuid[], required
1 to 50 memo ids.
Request body
{
  "ids": [
    "018f2a1c-6e31-7f55-b2c4-1d8e3f4a9b23",
    "018f2a1c-7f42-7066-a3d5-2e9f4a5b0c34"
  ]
}
Response 200
{
  "nodes": [
    {
      "id": "018f2a1c-6e31-7f55-b2c4-1d8e3f4a9b23",
      "thread_id": "018f2a1c-5d20-7a44-8e1b-6c9f0a2b7d12",
      "ref": "N:12",
      "slug": "rate-limits",
      "title": "Rate limiting",
      "summary": "How we throttle API clients",
      "content": "# Token buckets\n\nEach client gets 60 requests a minute. See [retries](N:9).",
      "node_type": "note",
      "properties": {},
      "created_at": "2026-09-15T10:12:00.000Z",
      "updated_at": "2026-09-15T10:12:00.000Z",
      "bucket_id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01"
    }
  ]
}

Create many memos

POST /v1/nodes/bulk

Up to 100 memos in one call, for indexing a repository or importing an archive. Each one is validated and written on its own.

This does not make writing cheaper. The rate limit counts operations rather than requests, so a batch of 100 costs 100. What it saves is the round trips, and it lifts the 20-message ceiling a batch over MCP runs into.

The status is 201 only when every memo was created. A partial success is 207, and results carries an entry per memo in the order you sent them, so you can retry just the ones that failed. A refusal that applies to the whole call, such as running out of quota, stops the batch, and results is then shorter than what you sent.

Body

nodes object[], required
1 to 100 memos, each taking the same fields as a single create.
Request body
{
  "nodes": [
    {
      "thread_id": "018f2a1c-5d20-7a44-8e1b-6c9f0a2b7d12",
      "slug": "rate-limits",
      "title": "Rate limits",
      "summary": "What the limits are and what happens at them",
      "content": "# Rate limits\n\nRequests are counted per minute."
    }
  ]
}
Response 201
{
  "created": 1,
  "failed": 0,
  "results": [
    {
      "index": 0,
      "ok": true,
      "node": {
        "id": "018f2a1c-6e31-7f55-b2c4-1d8e3f4a9b23",
        "thread_id": "018f2a1c-5d20-7a44-8e1b-6c9f0a2b7d12",
        "ref": "N:12",
        "slug": "rate-limits",
        "title": "Rate limiting",
        "summary": "How we throttle API clients",
        "content": "# Token buckets\n\nEach client gets 60 requests a minute. See [retries](N:9).",
        "node_type": "note",
        "properties": {},
        "created_at": "2026-09-15T10:12:00.000Z",
        "updated_at": "2026-09-15T10:12:00.000Z"
      }
    }
  ]
}

Update a memo

PATCH /v1/nodes/:id

Send only the fields you want to change. A field you leave out keeps its value.

Changing content rebuilds its sections, vectors and links in the same transaction as the update, and the response then carries unresolved_links. Changing only title or summary re-embeds the memo and leaves its sections alone, and a move re-embeds nothing.

Setting thread_id moves the memo. A slug already used in the destination thread returns 409.

Body

title string
Up to 500 characters.
summary string
Up to 5,000 characters.
content string
The new markdown body, up to 10,000 characters. A memo saved before that limit existed can still change its title, summary or thread without being shortened.
thread_id uuid
Moves the memo to this thread.
Request body
{
  "summary": "How we throttle API clients, per key"
}
Response 200
{
  "id": "018f2a1c-6e31-7f55-b2c4-1d8e3f4a9b23",
  "thread_id": "018f2a1c-5d20-7a44-8e1b-6c9f0a2b7d12",
  "ref": "N:12",
  "slug": "rate-limits",
  "title": "Rate limiting",
  "summary": "How we throttle API clients, per key",
  "content": "# Token buckets\n\nEach client gets 60 requests a minute. See [retries](N:9).",
  "node_type": "note",
  "properties": {},
  "created_at": "2026-09-15T10:12:00.000Z",
  "updated_at": "2026-09-15T10:12:00.000Z",
  "sections": 1,
  "links": 1
}

Delete a memo

DELETE /v1/nodes/:id

Deletes the memo with its sections, its vectors and every edge touching it. There is no undo.

Response 204 with no body.

Buckets

Buckets are the top level of a workspace, and each one holds threads.

Create a bucket

POST /v1/buckets

Creates an empty bucket. Needs an admin key. A slug already used in the workspace returns 409.

Body

slug string, required
Lowercase letters, digits, hyphens and underscores, up to 64 characters, starting and ending with a letter or digit.
title string, required
Up to 200 characters.
description string
Up to 2,000 characters.
Request body
{
  "slug": "handbook",
  "title": "Handbook",
  "description": "How the team works"
}
Response 201
{
  "id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
  "ref": "B:1",
  "slug": "handbook",
  "title": "Handbook",
  "description": "How the team works",
  "created_at": "2026-09-15T10:12:00.000Z",
  "updated_at": "2026-09-15T10:12:00.000Z"
}

List buckets

GET /v1/buckets

Buckets the key can read, in the order they were created.

Query parameters

limit integer
1 to 200, default 50. A value outside the range is clamped to it.
after uuid
The next_cursor from the previous page.
q string
Only buckets whose title or slug contains this, up to 200 characters. % and _ match themselves.
Response 200
{
  "items": [
    {
      "id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
      "ref": "B:1",
      "slug": "handbook",
      "title": "Handbook",
      "description": "How the team works",
      "created_at": "2026-09-15T10:12:00.000Z",
      "updated_at": "2026-09-15T10:12:00.000Z"
    }
  ],
  "next_cursor": null
}

Get a bucket

GET /v1/buckets/:id

One bucket by id, with its memory mark (primary or working) and inbox_thread_id, the thread memos saved with no thread land in (null until the first one does).

A bucket the key cannot read answers exactly like one that does not exist.

Response 200
{
  "id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
  "ref": "B:1",
  "slug": "handbook",
  "title": "Handbook",
  "description": "How the team works",
  "created_at": "2026-09-15T10:12:00.000Z",
  "updated_at": "2026-09-15T10:12:00.000Z"
}

Update a bucket

PATCH /v1/buckets/:id

Send only the fields you want to change. A field you leave out keeps its value. A slug already used in the workspace returns 409.

Body

slug string
Lowercase letters, digits, hyphens and underscores, up to 64 characters, starting and ending with a letter or digit.
title string
Up to 200 characters.
description string
Up to 2,000 characters.
Request body
{
  "title": "Team handbook"
}
Response 200
{
  "id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
  "ref": "B:1",
  "slug": "handbook",
  "title": "Team handbook",
  "description": "How the team works",
  "created_at": "2026-09-15T10:12:00.000Z",
  "updated_at": "2026-09-15T10:12:00.000Z"
}

Repository sync state

POST /v1/buckets/:id/sync

Used by the gigarag CLI to keep the memos of a code repository in step with its git history: the file manifest, the last synced commit, a lease so one machine syncs at a time, and stale marks on memos whose source files changed. Reading memos never needs it.

A lease held by another machine returns 409 with the same body, so a client can wait and claim again.

Body

action string, required
state, manifest, claim, release, record, advance or mark_stale.
token string
The lease token, for renewing, releasing and advancing.
commit string
A git commit id, for advance and mark_stale.
Request body
{
  "action": "state"
}
Response 200
{
  "granted": true
}

Delete a bucket

DELETE /v1/buckets/:id

Deletes the bucket with every thread and memo inside it, and everything attached to those memos. Needs an admin key. There is no undo.

Response 204 with no body.

Threads

Threads sit inside a bucket and hold memos.

Create a thread

POST /v1/threads

Creates an empty thread inside a bucket. A slug already used in that bucket returns 409.

Body

bucket_id uuid, required
The bucket the thread goes in.
slug string, required
Lowercase letters, digits, hyphens and underscores, up to 64 characters, starting and ending with a letter or digit.
title string, required
Up to 200 characters.
description string
Up to 2,000 characters.
Request body
{
  "bucket_id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
  "slug": "operations",
  "title": "Operations"
}
Response 201
{
  "id": "018f2a1c-5d20-7a44-8e1b-6c9f0a2b7d12",
  "bucket_id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
  "ref": "T:4",
  "slug": "operations",
  "title": "Operations",
  "description": "",
  "created_at": "2026-09-15T10:12:00.000Z",
  "updated_at": "2026-09-15T10:12:00.000Z"
}

List threads

GET /v1/threads

Threads in the order they were created. node_count is how many memos each one holds.

Query parameters

limit integer
1 to 200, default 50. A value outside the range is clamped to it.
after uuid
The next_cursor from the previous page.
bucket_id uuid
Only threads in this bucket.
Response 200
{
  "items": [
    {
      "id": "018f2a1c-5d20-7a44-8e1b-6c9f0a2b7d12",
      "bucket_id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
      "ref": "T:4",
      "slug": "operations",
      "title": "Operations",
      "description": "",
      "created_at": "2026-09-15T10:12:00.000Z",
      "updated_at": "2026-09-15T10:12:00.000Z",
      "node_count": 3
    }
  ],
  "next_cursor": null
}

Update a thread

PATCH /v1/threads/:id

Send only the fields you want to change. A field you leave out keeps its value.

Setting bucket_id moves the thread and every memo in it. A slug already used in the destination bucket returns 409.

Body

slug string
Lowercase letters, digits, hyphens and underscores, up to 64 characters, starting and ending with a letter or digit.
title string
Up to 200 characters.
description string
Up to 2,000 characters.
bucket_id uuid
Moves the thread to this bucket.
Request body
{
  "title": "Ops"
}
Response 200
{
  "id": "018f2a1c-5d20-7a44-8e1b-6c9f0a2b7d12",
  "bucket_id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
  "ref": "T:4",
  "slug": "operations",
  "title": "Ops",
  "description": "",
  "created_at": "2026-09-15T10:12:00.000Z",
  "updated_at": "2026-09-15T10:12:00.000Z"
}

Delete a thread

DELETE /v1/threads/:id

Deletes the thread with every memo in it. There is no undo.

Response 204 with no body.

Links between memos

An edge joins two memos with a named relationship. Links written inside a memo create edges too, as Links inside memos explains.

List linked memos

GET /v1/nodes/:id/neighbors

Memos one edge away in either direction, strongest edge first. direction says which way each edge points, and label is the edge's rel_type on an out row and its inverse_label on an in row.

Query parameters

limit integer
1 to 200, default 50. A value outside the range is clamped to it.
rel_type string
Only edges of this type.
Response 200
{
  "neighbors": [
    {
      "node_id": "018f2a1c-7f42-7066-a3d5-2e9f4a5b0c34",
      "label": "links_to",
      "description": "retries",
      "weight": 0.5,
      "direction": "out",
      "title": "Retries",
      "summary": "When a client should retry"
    }
  ]
}

Walk the graph

POST /v1/nodes/:id/traverse

Follows edges in both directions from a memo, one hop at a time, up to depth hops away. via is the memo each result was reached from.

Body

depth integer
1 to 6, default 2.
limit integer
1 to 500, default 50.
rel_type string
Only follow edges of this type.
min_weight number
Skip edges weaker than this, 0 to 1, default 0. 0.9 keeps only edges made on purpose.
Request body
{
  "depth": 2,
  "limit": 50,
  "min_weight": 0
}
Response 200
{
  "reached": [
    {
      "node_id": "018f2a1c-7f42-7066-a3d5-2e9f4a5b0c34",
      "title": "Retries",
      "summary": "When a client should retry",
      "node_type": "note",
      "depth": 1,
      "via": "018f2a1c-6e31-7f55-b2c4-1d8e3f4a9b23",
      "weight": 0.5
    }
  ]
}

Read the whole graph

GET /v1/graph

Every memo the key can read, without bodies, and every edge between two of them. Tables the key can read come back in tables, and table_edges joins a memo to a table: mentions when the memo links to it, references when a memo-typed cell of the table points at the memo. For drawing a map of the workspace, so it is not paged: a force layout needs every memo before it can place any of them.

Stops at 5,000 memos, oldest first, and 20,000 edges, strongest first. truncated is true when either cap was hit. An edge is only returned when both of its memos are in nodes.

Query parameters

bucket_id uuid
Only this bucket: its memos, the edges between them and its tables. A bucket the key cannot read returns an empty graph.
Response 200
{
  "nodes": [
    {
      "id": "018f2a1c-6e31-7f55-b2c4-1d8e3f4a9b23",
      "ref": "N:12",
      "thread_id": "018f2a1c-5d20-7a44-8e1b-6c9f0a2b7d12",
      "bucket_id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
      "title": "Rate limiting",
      "summary": "How requests are throttled",
      "node_type": "note"
    },
    {
      "id": "018f2a1c-7f42-7066-a3d5-2e9f4a5b0c34",
      "ref": "N:13",
      "thread_id": "018f2a1c-5d20-7a44-8e1b-6c9f0a2b7d12",
      "bucket_id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
      "title": "Retries",
      "summary": "When a client should retry",
      "node_type": "note"
    }
  ],
  "edges": [
    {
      "id": "018f2a1c-8a53-7177-94e6-3fa05b6c1d45",
      "src_node_id": "018f2a1c-6e31-7f55-b2c4-1d8e3f4a9b23",
      "dst_node_id": "018f2a1c-7f42-7066-a3d5-2e9f4a5b0c34",
      "rel_type": "links_to",
      "weight": 0.5,
      "auto": true
    }
  ],
  "tables": [
    {
      "id": "018f2a1c-9b64-7288-85f7-4ab16c7d2e56",
      "ref": "D:3",
      "title": "Leads",
      "bucket_id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
      "row_count": 2
    }
  ],
  "table_edges": [
    {
      "node_id": "018f2a1c-6e31-7f55-b2c4-1d8e3f4a9b23",
      "table_id": "018f2a1c-9b64-7288-85f7-4ab16c7d2e56",
      "kind": "mentions"
    }
  ],
  "truncated": false
}

Resolve handles

POST /v1/refs/resolve

Turns handles such as N:12 or B:1 into the rows they name, which is what you need to render a [label](N:12) link as a real link.

A table (D:3) or a saved view (V:5) resolves too, with kind set to table or view. A view also carries table_ref, the table it belongs to. A handle that names nothing, or names a row the key can't read, comes back in unresolved.

Body

refs string[], required
1 to 50 handles.
Request body
{
  "refs": [
    "N:12",
    "B:1",
    "D:3",
    "N:99999"
  ]
}
Response 200
{
  "resolved": [
    {
      "ref": "N:12",
      "kind": "node",
      "id": "018f2a1c-6e31-7f55-b2c4-1d8e3f4a9b23",
      "title": "Rate limiting",
      "bucket_id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01"
    },
    {
      "ref": "B:1",
      "kind": "bucket",
      "id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
      "title": "Handbook",
      "bucket_id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01"
    },
    {
      "ref": "D:3",
      "kind": "table",
      "id": "018f2a1c-9b64-7288-85f7-4ab16c7d2e56",
      "title": "Leads",
      "bucket_id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01"
    }
  ],
  "unresolved": [
    "N:99999"
  ]
}

Delete an edge

DELETE /v1/edges/:id

Removes one edge. The two memos it joined stay as they are.

No response returns an edge's id yet, so for now the id has to come from somewhere outside the API.

Response 204 with no body.

Tables

A table holds typed rows you filter, sort and count, where a memo holds prose. Each one has a D: handle, and a memo links to it the way it links to a memo. Saved views are a table plus a filter, and have V: handles. In a path, :table takes the table ref such as D:3 (URL-encoded as D%3A3), its id or its exact title, and :view takes a V: ref or an id.

List tables

GET /v1/tables

Every table the key can read, in creation order, with its row and column counts.

Needs a key that can read the table, which means read access to its bucket.

Query parameters

bucket_id uuid
Only tables in this bucket.
query string
Only tables whose title contains this text.
limit integer
1 to 200, default 50.
after uuid
The next_cursor from the previous page.
Response 200
{
  "items": [
    {
      "id": "018f2a1c-9b64-7288-85f7-4ab16c7d2e56",
      "ref": "D:3",
      "title": "Leads",
      "description": "Inbound leads",
      "link_markdown": "[Leads](D:3)",
      "bucket_id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
      "thread_id": null,
      "row_count": 2,
      "created_at": "2026-10-17T10:12:00.000Z",
      "updated_at": "2026-10-17T10:12:00.000Z",
      "column_count": 3
    }
  ],
  "next_cursor": null
}

Create a table

POST /v1/tables

Creates the table, its columns, any indexes and up to 1,000 first rows in one step. If anything is wrong, nothing is created.

Counts toward the workspace limits: 200 tables, 100 columns per table, 16 indexes per table. A table needs at least one column. A column key is made from its name, such as Email address to email_address, and is what rows are keyed by.

Answers the table as Describe a table shows its summary, columns, indexes and views, plus rows_inserted. When a memo cell in the first rows names no memo the key can read, unresolved_refs lists those refs; the cells are stored as sent.

Needs a key that can edit the table, which means edit access to its bucket.

Body

bucket_id uuid, required
The bucket the table goes in.
thread_id uuid
A thread in that bucket.
title string, required
Up to 200 characters.
description string
Up to 2,000 characters.
columns object[], required
At least one. Each has name, type and optionally options (choices for select and multi_select), required, description and default. A default is applied to new rows that omit the column: a string for text, url and email, a number for number, an integer for integer, true or false for boolean, YYYY-MM-DD or "today" for date, an ISO string or "now" for datetime, one of the choices for select, an array of choices for multi_select, a memo ref for memo, any JSON for json. Types: text, number, integer, boolean, date, datetime, select, multi_select, url, email (stored in lower case), memo, json.
indexes object[]
Each has columns and optionally kind, unique, name. See Create an index.
rows object[]
Up to 1,000 rows, keyed by column key.
Request body
{
  "bucket_id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
  "title": "Leads",
  "description": "Inbound leads",
  "columns": [
    {
      "name": "Email",
      "type": "email",
      "required": true
    },
    {
      "name": "Status",
      "type": "select",
      "options": {
        "choices": [
          "cold",
          "warm",
          "hot"
        ]
      }
    },
    {
      "name": "Score",
      "type": "integer"
    }
  ],
  "indexes": [
    {
      "columns": [
        "email"
      ],
      "unique": true
    }
  ],
  "rows": [
    {
      "email": "[email protected]",
      "status": "hot",
      "score": 92
    }
  ]
}
Response 201
{
  "id": "018f2a1c-9b64-7288-85f7-4ab16c7d2e56",
  "ref": "D:3",
  "title": "Leads",
  "description": "Inbound leads",
  "link_markdown": "[Leads](D:3)",
  "bucket_id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
  "thread_id": null,
  "row_count": 2,
  "created_at": "2026-10-17T10:12:00.000Z",
  "updated_at": "2026-10-17T10:12:00.000Z",
  "columns": [
    {
      "id": "018f2a1c-a075-7399-96a8-5bc27d8e3f67",
      "key": "email",
      "name": "Email",
      "type": "email",
      "options": {},
      "required": true,
      "description": "",
      "position": 1
    },
    {
      "id": "018f2a1c-b186-74aa-a7b9-6cd38e9f4078",
      "key": "status",
      "name": "Status",
      "type": "select",
      "options": {
        "choices": [
          "cold",
          "warm",
          "hot"
        ]
      },
      "required": false,
      "description": "",
      "position": 2
    },
    {
      "id": "018f2a1c-c297-75bb-b8ca-7de49fa05189",
      "key": "score",
      "name": "Score",
      "type": "integer",
      "options": {},
      "required": false,
      "description": "",
      "position": 3
    }
  ],
  "indexes": [
    {
      "name": "leads_email_uq",
      "kind": "btree",
      "columns": [
        "email"
      ],
      "unique": true
    }
  ],
  "views": [
    {
      "ref": "V:5",
      "title": "Hot leads",
      "link_markdown": "[Hot leads](V:5)",
      "where": {
        "status": "hot"
      },
      "sort": [
        {
          "column": "score",
          "direction": "desc"
        }
      ],
      "created_at": "2026-10-17T10:12:00.000Z",
      "updated_at": "2026-10-17T10:12:00.000Z"
    }
  ],
  "rows_inserted": 1
}

Describe a table

GET /v1/tables/:table

The table, its columns, indexes and saved views, the memos that mention it, two example rows and a short note on how to query it. Read this before the first query against a table you did not make.

Each column is addressed by its key. A view lists its filter in the same grammar a query takes, and warnings when a column it names has since been dropped.

Needs a key that can read the table, which means read access to its bucket.

Response 200
{
  "id": "018f2a1c-9b64-7288-85f7-4ab16c7d2e56",
  "ref": "D:3",
  "title": "Leads",
  "description": "Inbound leads",
  "link_markdown": "[Leads](D:3)",
  "bucket_id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
  "thread_id": null,
  "row_count": 2,
  "created_at": "2026-10-17T10:12:00.000Z",
  "updated_at": "2026-10-17T10:12:00.000Z",
  "columns": [
    {
      "id": "018f2a1c-a075-7399-96a8-5bc27d8e3f67",
      "key": "email",
      "name": "Email",
      "type": "email",
      "options": {},
      "required": true,
      "description": "",
      "position": 1
    },
    {
      "id": "018f2a1c-b186-74aa-a7b9-6cd38e9f4078",
      "key": "status",
      "name": "Status",
      "type": "select",
      "options": {
        "choices": [
          "cold",
          "warm",
          "hot"
        ]
      },
      "required": false,
      "description": "",
      "position": 2
    },
    {
      "id": "018f2a1c-c297-75bb-b8ca-7de49fa05189",
      "key": "score",
      "name": "Score",
      "type": "integer",
      "options": {},
      "required": false,
      "description": "",
      "position": 3
    }
  ],
  "indexes": [
    {
      "name": "leads_email_uq",
      "kind": "btree",
      "columns": [
        "email"
      ],
      "unique": true
    }
  ],
  "views": [
    {
      "ref": "V:5",
      "title": "Hot leads",
      "link_markdown": "[Hot leads](V:5)",
      "where": {
        "status": "hot"
      },
      "sort": [
        {
          "column": "score",
          "direction": "desc"
        }
      ],
      "created_at": "2026-10-17T10:12:00.000Z",
      "updated_at": "2026-10-17T10:12:00.000Z"
    }
  ],
  "mentioned_by": [
    {
      "id": "018f2a1c-6e31-7f55-b2c4-1d8e3f4a9b23",
      "ref": "N:12",
      "title": "Rate limiting",
      "mentions": [
        {
          "kind": "table",
          "ref": "D:3"
        }
      ]
    }
  ],
  "examples": [
    {
      "id": 1,
      "email": "[email protected]",
      "status": "hot",
      "score": 92,
      "created_at": "2026-10-17T10:12:00.000Z",
      "updated_at": "2026-10-17T10:12:00.000Z"
    }
  ],
  "how_to_query": "query_rows { \"table\": \"D:3\", \"where\": { \"status\": \"cold\", \"score\": { \"gte\": 10 } }, \"sort\": [\"-score\"], \"limit\": 50 }. ..."
}

Change a table

PATCH /v1/tables/:table

Renames or moves the table and adds, renames, retypes, drops and reorders columns, all in one transaction.

A type change that some cells cannot make is refused with up to five examples. Send force: true to set those cells to null instead. An index that a type change makes unusable is dropped, and listed in dropped_indexes. Dropping a column deletes its data.

Renaming a column changes its display name and nothing else: its key, its data, the saved views, filters and memos that name it all keep working, and the new name is accepted anywhere a key is. Send key with the name to change the key too; warnings then says so. In one call, a column being renamed can be named in change_columns, reorder_columns and drop_columns by the name or key it had or the one it is getting.

Answers the table as Describe a table shows its summary, columns, indexes and views, plus dropped_indexes and warnings. Send only the fields you want to change. Needs a key that can edit the table, which means edit access to its bucket.

Body

title string
Up to 200 characters.
description string
Up to 2,000 characters.
bucket_id uuid
Move the table to this bucket.
thread_id uuid | null
Move it to this thread, or null to take it out of its thread.
add_columns object[]
New columns, shaped as in Create a table.
rename_columns object[]
Each is { "column": "key or name", "name": "New name" }. The display name changes and the key does not. Add "key": "new_key" (lower-case letters, digits and underscores, not used by another column) to change the key as well.
change_columns object[]
Each has column and any of name, key, type, options, required, description, default. A default value sets it, null clears it and leaving it out keeps it. name and key rename as in rename_columns.
drop_columns string[]
Column keys to delete.
reorder_columns string[]
Column keys in the order wanted. Columns not listed follow, in their current order.
force boolean
Null the cells that will not convert, instead of refusing.
Request body
{
  "rename_columns": [
    {
      "column": "score",
      "name": "Lead score"
    }
  ],
  "change_columns": [
    {
      "column": "status",
      "options": {
        "choices": [
          "cold",
          "warm",
          "hot",
          "won"
        ]
      }
    }
  ]
}
Response 200
{
  "id": "018f2a1c-9b64-7288-85f7-4ab16c7d2e56",
  "ref": "D:3",
  "title": "Leads",
  "description": "Inbound leads",
  "link_markdown": "[Leads](D:3)",
  "bucket_id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
  "thread_id": null,
  "row_count": 2,
  "created_at": "2026-10-17T10:12:00.000Z",
  "updated_at": "2026-10-17T10:12:00.000Z",
  "columns": [
    {
      "id": "018f2a1c-a075-7399-96a8-5bc27d8e3f67",
      "key": "email",
      "name": "Email",
      "type": "email",
      "options": {},
      "required": true,
      "description": "",
      "position": 1
    },
    {
      "id": "018f2a1c-b186-74aa-a7b9-6cd38e9f4078",
      "key": "status",
      "name": "Status",
      "type": "select",
      "options": {
        "choices": [
          "cold",
          "warm",
          "hot"
        ]
      },
      "required": false,
      "description": "",
      "position": 2
    },
    {
      "id": "018f2a1c-c297-75bb-b8ca-7de49fa05189",
      "key": "score",
      "name": "Score",
      "type": "integer",
      "options": {},
      "required": false,
      "description": "",
      "position": 3
    }
  ],
  "indexes": [
    {
      "name": "leads_email_uq",
      "kind": "btree",
      "columns": [
        "email"
      ],
      "unique": true
    }
  ],
  "views": [
    {
      "ref": "V:5",
      "title": "Hot leads",
      "link_markdown": "[Hot leads](V:5)",
      "where": {
        "status": "hot"
      },
      "sort": [
        {
          "column": "score",
          "direction": "desc"
        }
      ],
      "created_at": "2026-10-17T10:12:00.000Z",
      "updated_at": "2026-10-17T10:12:00.000Z"
    }
  ],
  "dropped_indexes": [],
  "warnings": []
}

Delete a table

DELETE /v1/tables/:table

Drops the table, every row in it and its saved views. This cannot be undone. Memos that linked to it keep the text, and the link stops resolving.

Needs a key that can edit the table, which means edit access to its bucket.

Response 200
{
  "deleted": true,
  "ref": "D:3",
  "title": "Leads",
  "rows_deleted": 2
}

Insert rows

POST /v1/tables/:table/rows

Adds up to 1,000 rows, keyed by column key. Every row is checked first and the write is all or nothing: a bad batch is refused with up to 20 problems such as rows[3].score: "abc" is not a number.

With upsert, a row whose on values match an existing row updates it instead. on must be exactly the columns of a unique index. Required columns are needed only for the rows an upsert would add: a row that matches an existing one keeps the columns it did not send, and a row that matches nothing and lacks a required column is refused as rows[1].name: is required for new rows; this email matches no existing row.

A value a unique index already holds, or that two rows of the batch share, is refused before anything is written with 409 unique_violation, naming the row, the column, the value and the index. Emails are stored in lower case, so [email protected] and [email protected] are one address. A new value for a select column is added to its choices and reported in choices_added. A memo cell is stored as sent, and unresolved_refs lists any ref that names no memo the key can read.

A table holds at most 250,000 rows. Needs a key that can edit the table, which means edit access to its bucket.

Body

rows object[], required
1 to 1,000 rows.
upsert object
{ "on": ["email"], "update": ["status", "score"] }. update limits which columns an existing row takes; leave it out to update all that were sent.
Request body
{
  "rows": [
    {
      "email": "[email protected]",
      "status": "hot",
      "score": 92
    },
    {
      "email": "[email protected]",
      "status": "warm",
      "score": 54
    }
  ],
  "upsert": {
    "on": [
      "email"
    ]
  }
}
Response 200
{
  "inserted": 1,
  "updated": 1,
  "ids": [
    1,
    2
  ],
  "row_count": 2
}

Update rows

PATCH /v1/tables/:table/rows

Three ways to say which rows. rows is a list of { "id": 1, ...cells }. ids with set gives several rows the same values. where with set changes every row the filter matches, and an empty where is refused unless all is true.

Only the cells you send change. A row sent without an id, or an id that does not exist, refuses the whole call and changes nothing. Breaking a unique index is 409 unique_violation. Like an insert, the answer adds choices_added and unresolved_refs when there are any. Needs a key that can edit the table, which means edit access to its bucket.

Body

rows object[]
Each has an id and the cells to change.
ids integer[]
Row ids. Use with set.
where object
A filter choosing the rows. Use with set.
all boolean
Required to update every row with an empty where.
set object
The cells to write, keyed by column key.
Request body
{
  "where": {
    "status": "warm"
  },
  "set": {
    "status": "hot"
  }
}
Response 200
{
  "updated": 3
}

Delete rows

POST /v1/tables/:table/rows/delete

Deletes rows by ids, by where, or all of them with all: true. A POST, because many proxies drop the body of a DELETE. An id that matches no row deletes nothing and is not an error: deleted says how many rows went.

Needs a key that can edit the table, which means edit access to its bucket.

Body

ids integer[]
Row ids.
where object
A filter choosing the rows. Must not be empty.
all boolean
Delete every row.
Request body
{
  "ids": [
    1,
    2
  ]
}
Response 200
{
  "deleted": 2,
  "row_count": 0
}

Memos that mention a row

GET /v1/tables/:table/rows/:id/mentions

The memos the key can read that mention one row: by D:3/17, by a range that covers it such as D:3/10-25, or by one of its cells such as D:3/17#score. row_lo and row_hi are the span the memo named, and column is set when it named a cell. At most 50, ordered by title.

The row does not have to exist, since a memo may name a row before it is inserted. A row id that is not a whole number from 1 is refused. Needs a key that can read the table, which means read access to its bucket.

Response 200
{
  "table": {
    "id": "018f2a1c-9b64-7288-85f7-4ab16c7d2e56",
    "ref": "D:3",
    "title": "Leads"
  },
  "row_id": 1,
  "mentions": [
    {
      "id": "018f2a1c-6e31-7f55-b2c4-1d8e3f4a9b23",
      "ref": "N:12",
      "title": "Rate limiting",
      "row_lo": 1,
      "row_hi": 1
    }
  ]
}

Query rows

POST /v1/tables/:table/query

Reads rows. A POST, because a filter is structured data of any size. Pages by cursor: send next_cursor back with the same where, search, sort and view, and a cursor used with a different query is refused. Naming a column by its name instead of its key, or listing filter keys in another order, is still the same query.

Nulls sort last in both directions, and id breaks ties. An index on the leading sort column serves ascending and descending alike, and a page deep into the result costs what the first does. With no sort, a search ranks by relevance and anything else by id. view runs a saved view, and a where sent with it is added to the view's own. A limit above 1,000 is cut to 1,000.

The answer carries rows, next_cursor (null on the last page), total when asked, table, and view and warnings when a view ran and a column it used has since been dropped.

Needs a key that can read the table, which means read access to its bucket.

Body

view string
A V: ref or a view id. Its filter, search, sort and columns apply.
where object
A filter. See Table filters for the grammar.
search string
Words matched across every text, select, url and email column, up to 500 characters. Quotes and -word work. A url or email is also matched by its parts, so acme finds [email protected].
sort array
Up to 4 keys: "-score" descending, "name" ascending, or { "column": "score", "direction": "desc" }.
columns string[]
Column keys to return. id, created_at and updated_at always come back.
limit integer
1 to 1,000, default 50.
cursor string
The next_cursor from the previous page.
count boolean
Add an exact total of matching rows.
Request body
{
  "where": {
    "status": "hot",
    "score": {
      "gte": 80
    }
  },
  "sort": [
    "-score"
  ],
  "limit": 50,
  "count": true
}
Response 200
{
  "rows": [
    {
      "id": 1,
      "email": "[email protected]",
      "status": "hot",
      "score": 92,
      "created_at": "2026-10-17T10:12:00.000Z",
      "updated_at": "2026-10-17T10:12:00.000Z"
    }
  ],
  "next_cursor": null,
  "total": 1,
  "table": {
    "id": "018f2a1c-9b64-7288-85f7-4ab16c7d2e56",
    "ref": "D:3",
    "title": "Leads"
  }
}

Aggregate rows

POST /v1/tables/:table/aggregate

Counts, sums and groups, so a total never needs every row read. Each group is one object holding its group_by values and the metrics, named count, sum_score, avg_score and so on.

Grouping by a multi_select column counts per tag: a row with three tags is counted under each of them and a row with none under null, so the counts can add up to more than the number of rows. truncated is true when limit cut the groups short. Needs a key that can read the table, which means read access to its bucket.

Body

where object
A filter. See Table filters for the grammar.
search string
Words matched across every text, select, url and email column, up to 500 characters. Quotes and -word work. A url or email is also matched by its parts, so acme finds [email protected].
group_by array
Up to 3 column keys, or { "column": "created_at", "by": "month" } with by one of day, week, month, quarter, year.
metrics array, required
Up to 10: "count", or { "fn": "sum", "column": "score" } with fn one of count, count_distinct, sum, avg, min, max. Every fn but count needs a column. sum and avg need a number or integer column.
sort array
A group key or metric name, "-count" for descending.
limit integer
1 to 1,000, default 100.
Request body
{
  "group_by": [
    "status"
  ],
  "metrics": [
    "count",
    {
      "fn": "avg",
      "column": "score"
    }
  ],
  "sort": [
    "-count"
  ]
}
Response 200
{
  "rows": [
    {
      "status": "hot",
      "count": 1,
      "avg_score": 92
    },
    {
      "status": "warm",
      "count": 1,
      "avg_score": 54
    }
  ],
  "truncated": false,
  "table": {
    "id": "018f2a1c-9b64-7288-85f7-4ab16c7d2e56",
    "ref": "D:3",
    "title": "Leads"
  }
}

Create an index

POST /v1/tables/:table/indexes

Speeds up a filter or sort on a big table. Building one blocks writes to this table for a few seconds, and nothing else.

kind is auto by default, which picks a btree, or a GIN index for multi_select and json. btree covers 1 to 4 columns in order. search serves the per-column search operator on one text column. contains serves contains, starts_with and ends_with on one text column. unique is btree only, and is what upsert.on needs. A unique index over duplicates is refused with examples.

Answers 201 when this call built the index and 200 when an identical one already existed. Needs a key that can edit the table, which means edit access to its bucket.

Body

columns string[], required
1 to 4 column keys.
kind string
auto, btree, search or contains.
unique boolean
Refuse duplicate values.
name string
Up to 63 characters. Made from the columns if left out.
Request body
{
  "columns": [
    "email"
  ],
  "unique": true
}
Response 201
{
  "created": true,
  "index": {
    "name": "leads_email_uq",
    "kind": "btree",
    "columns": [
      "email"
    ],
    "unique": true
  }
}

Drop an index

DELETE /v1/tables/:table/indexes/:name

Removes an index by name. Queries still work, and may be slower on a big table. Needs a key that can edit the table, which means edit access to its bucket.

Response 200
{
  "dropped": "leads_email_uq"
}

Save a view

POST /v1/tables/:table/views

A view is a name for a filter, search, sort and column set on one table. A memo links to it with [Hot leads](V:5), and view on a query runs it. A table holds up to 50 views.

link_markdown is the text to paste into a memo. Needs a key that can edit the table, which means edit access to its bucket.

Body

title string, required
Up to 200 characters.
where object
A filter. See Table filters for the grammar.
search string
Words matched across every text, select, url and email column, up to 500 characters. Quotes and -word work. A url or email is also matched by its parts, so acme finds [email protected].
sort array
Shaped as in Query rows, as strings or { "column", "direction" } objects. A view always reads back as [{ "column": "score", "direction": "desc" }].
columns string[]
Column keys to show, in order. Leave out to show all.
Request body
{
  "title": "Hot leads",
  "where": {
    "status": "hot"
  },
  "sort": [
    "-score"
  ]
}
Response 201
{
  "created": true,
  "view": {
    "ref": "V:5",
    "title": "Hot leads",
    "link_markdown": "[Hot leads](V:5)",
    "where": {
      "status": "hot"
    },
    "sort": [
      {
        "column": "score",
        "direction": "desc"
      }
    ],
    "created_at": "2026-10-17T10:12:00.000Z",
    "updated_at": "2026-10-17T10:12:00.000Z"
  },
  "link_markdown": "[Hot leads](V:5)",
  "table": {
    "id": "018f2a1c-9b64-7288-85f7-4ab16c7d2e56",
    "ref": "D:3",
    "title": "Leads"
  }
}

Read a view

GET /v1/views/:view

The view and the table it belongs to, so a V: handle in a memo can be followed to its table. :view takes a V: ref (URL-encoded) or an id. The table is shown as Describe a table shows it, without the examples. Needs a key that can read the table, which means read access to its bucket.

Response 200
{
  "view": {
    "ref": "V:5",
    "title": "Hot leads",
    "link_markdown": "[Hot leads](V:5)",
    "where": {
      "status": "hot"
    },
    "sort": [
      {
        "column": "score",
        "direction": "desc"
      }
    ],
    "created_at": "2026-10-17T10:12:00.000Z",
    "updated_at": "2026-10-17T10:12:00.000Z"
  },
  "table": {
    "id": "018f2a1c-9b64-7288-85f7-4ab16c7d2e56",
    "ref": "D:3",
    "title": "Leads",
    "description": "Inbound leads",
    "link_markdown": "[Leads](D:3)",
    "bucket_id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
    "thread_id": null,
    "row_count": 2,
    "created_at": "2026-10-17T10:12:00.000Z",
    "updated_at": "2026-10-17T10:12:00.000Z",
    "columns": [
      {
        "id": "018f2a1c-a075-7399-96a8-5bc27d8e3f67",
        "key": "email",
        "name": "Email",
        "type": "email",
        "options": {},
        "required": true,
        "description": "",
        "position": 1
      },
      {
        "id": "018f2a1c-b186-74aa-a7b9-6cd38e9f4078",
        "key": "status",
        "name": "Status",
        "type": "select",
        "options": {
          "choices": [
            "cold",
            "warm",
            "hot"
          ]
        },
        "required": false,
        "description": "",
        "position": 2
      },
      {
        "id": "018f2a1c-c297-75bb-b8ca-7de49fa05189",
        "key": "score",
        "name": "Score",
        "type": "integer",
        "options": {},
        "required": false,
        "description": "",
        "position": 3
      }
    ],
    "indexes": [
      {
        "name": "leads_email_uq",
        "kind": "btree",
        "columns": [
          "email"
        ],
        "unique": true
      }
    ],
    "views": [
      {
        "ref": "V:5",
        "title": "Hot leads",
        "link_markdown": "[Hot leads](V:5)",
        "where": {
          "status": "hot"
        },
        "sort": [
          {
            "column": "score",
            "direction": "desc"
          }
        ],
        "created_at": "2026-10-17T10:12:00.000Z",
        "updated_at": "2026-10-17T10:12:00.000Z"
      }
    ]
  }
}

Change a view

PATCH /v1/views/:view

Replaces the parts of the view you send. Send null for where, search, sort or columns to clear it. Answers as saving a view does, with created: false. Needs a key that can edit the table, which means edit access to its bucket.

Body

title string
Up to 200 characters.
where object
A filter. See Table filters for the grammar.
search string
Words matched across every text, select, url and email column, up to 500 characters. Quotes and -word work. A url or email is also matched by its parts, so acme finds [email protected].
sort array
Shaped as in Query rows.
columns string[]
Column keys to show, in order.
Request body
{
  "where": {
    "status": {
      "in": [
        "hot",
        "warm"
      ]
    }
  }
}
Response 200
{
  "created": false,
  "view": {
    "ref": "V:5",
    "title": "Hot leads",
    "link_markdown": "[Hot leads](V:5)",
    "where": {
      "status": {
        "in": [
          "hot",
          "warm"
        ]
      }
    },
    "sort": [
      {
        "column": "score",
        "direction": "desc"
      }
    ],
    "created_at": "2026-10-17T10:12:00.000Z",
    "updated_at": "2026-10-17T10:12:00.000Z"
  },
  "link_markdown": "[Hot leads](V:5)",
  "table": {
    "id": "018f2a1c-9b64-7288-85f7-4ab16c7d2e56",
    "ref": "D:3",
    "title": "Leads"
  }
}

Delete a view

DELETE /v1/views/:view

Removes the view and leaves the table and its rows alone. A memo that linked to it keeps the text. Needs a key that can edit the table, which means edit access to its bucket.

Response 200
{
  "deleted": true,
  "ref": "V:5",
  "title": "Hot leads"
}

Imports

Copies of documents from other apps (a connected GitHub repo, a Zendesk Help Center, and so on) that land in the workspace already tagged as import copies. An agent lists the ones waiting, files each into an ordinary memo where it belongs, and marks it filed so it drops off the list.

List pending imports

GET /v1/imports/pending

Copies nobody has filed yet, or whose source changed since it was filed, oldest first. total_pending is the true count even when limit is 0, which is the cheap way to check "is there anything waiting" without paying for a page of rows.

mentioned_by lists up to 10 ordinary memos the caller can read whose text already links to this copy -- the signal that it is already filed in spirit and only needs mark_imports_filed, rather than a fresh memo written around it.

Read access to the Imports bucket is all this needs; nothing here writes.

Query parameters

limit integer
1 to 100, default 20. A value outside the range is clamped to it.
after uuid
The next_cursor from the previous page.
Response 200
{
  "items": [
    {
      "id": "018f2a1c-6e31-7f55-b2c4-1d8e3f4a9b23",
      "ref": "N:12",
      "title": "Q3 pricing doc",
      "thread_id": "018f2a1c-5d20-7a44-8e1b-6c9f0a2b7d12",
      "thread_title": "GitHub: acme/pricing",
      "bucket_id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
      "source_kind": "github",
      "source_url": "https://github.com/acme/pricing/blob/main/README.md",
      "source_changed_at": "2026-09-15T10:12:00.000Z",
      "filed_at": null,
      "state": "new",
      "mentioned_by": []
    }
  ],
  "next_cursor": null,
  "total_pending": 1
}

Mark imports filed

POST /v1/imports/filed

refs takes 1 to 100 refs from list_pending_imports, each one a copy that now has a home -- either written into an ordinary memo's text as a link, or judged not worth filing. Filing sets a timestamp; it never changes the copy's own text.

A ref this workspace does not have, or that is not an import copy, is silently left out of filed and named in unresolved instead of failing the whole call -- one bad ref in a batch of 100 should not undo the other 99.

Refused the same way any other write is on a read-only key or a frozen project: retrying will not help until that changes.

Body

refs string[], required
1 to 100 refs, such as N:12. Sending more than 100 is refused outright, not truncated.
Request body
{
  "refs": [
    "N:12",
    "N:13"
  ]
}
Response 200
{
  "filed": 2,
  "unresolved": []
}

Archive, conflicts and approvals

Raw chat transcripts kept apart from memos, disagreements between memos written as [Title](N:12, conflicts), and agent changes to a primary bucket waiting for a person.

Append to the archive

POST /v1/archive

Appends raw chat messages to one session. The archive is append-only: nothing can edit or remove a message except deleting its bucket.

At most 200 entries a call and 20,000 characters an entry; split longer messages and send the rest with the same session_id. Without bucket_id, the key's only writable bucket is used, or the call is refused with what to send.

Body

bucket_id string
The bucket the chat belongs to.
session_id string
Groups the messages of one chat. Generated when left out.
source string
The client, such as claude-code.
entries object[], required
{ role, content, occurred_at? } each.
Request body
{
  "bucket_id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
  "session_id": "chat-1",
  "source": "claude-code",
  "entries": [
    {
      "role": "user",
      "content": "Move the standup to ten?",
      "occurred_at": "2026-09-15T10:12:00.000Z"
    }
  ]
}
Response 201
{
  "appended": 1,
  "session_id": "chat-1",
  "bucket_id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01"
}

Search the archive

GET /v1/archive

Messages matching q and a date range, oldest first, with the exact total. Each content is a 400-character snippet unless session_id is given, which reads that session in full.

Response 200
{
  "items": [
    {
      "id": "018f2a1c-6e31-7f55-b2c4-1d8e3f4a9b23",
      "bucket_id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
      "session_id": "chat-1",
      "source": "claude-code",
      "role": "user",
      "content": "Move the standup to ten?",
      "occurred_at": "2026-09-15T10:12:00.000Z"
    }
  ],
  "total": 1,
  "next_cursor": null
}

List archived sessions

GET /v1/archive/sessions

Sessions, most recently active first, with their start, end and message count.

Response 200
{
  "items": [
    {
      "session_id": "chat-1",
      "bucket_id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
      "source": "claude-code",
      "started_at": "2026-09-15T10:12:00.000Z",
      "ended_at": "2026-09-15T10:12:00.000Z",
      "entries": 12
    }
  ],
  "next_cursor": null
}

List conflicts

GET /v1/conflicts

Live conflicts whose two memos the caller can read. A conflict ends when one memo supersedes the other, when the marker leaves the text, or when a person clears it.

Response 200
{
  "items": [
    {
      "edge_id": "018f2a1c-6e31-7f55-b2c4-1d8e3f4a9b23",
      "label": "the twenty dollar price",
      "created_at": "2026-09-15T10:12:00.000Z",
      "from": {
        "id": "018f2a1c-6e31-7f55-b2c4-1d8e3f4a9b23",
        "ref": "N:13",
        "title": "Pro costs $25",
        "summary": "Pro is $25 a seat.",
        "bucket_id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
        "thread_id": "018f2a1c-5d20-7a44-8e1b-6c9f0a2b7d12"
      },
      "to": {
        "id": "018f2a1c-6e31-7f55-b2c4-1d8e3f4a9b23",
        "ref": "N:12",
        "title": "Pro costs $20",
        "summary": "Pro is $20 a seat.",
        "bucket_id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
        "thread_id": "018f2a1c-5d20-7a44-8e1b-6c9f0a2b7d12"
      }
    }
  ],
  "next_cursor": null
}

Clear a conflict

POST /v1/conflicts/:id/clear

A person clears a conflict: [T](N:12, conflicts) becomes [T](N:12) in the source memo, and the save removes the mark. The app's own key only; an agent key is told to supersede or edit instead.

Response 200
{
  "cleared": true
}

List proposals

GET /v1/proposals

Agent writes into a primary bucket, waiting for a person. current is the memo as it stands, for an update or a delete, so a reviewer can compare.

Response 200
{
  "items": [
    {
      "id": "018f2a1c-6e31-7f55-b2c4-1d8e3f4a9b23",
      "kind": "update",
      "status": "pending",
      "bucket_id": "018f2a1c-4b7e-7c11-9a3d-2f6b8e1d4c01",
      "node_id": "018f2a1c-6e31-7f55-b2c4-1d8e3f4a9b23",
      "payload": {
        "content": "New text"
      },
      "current": {
        "title": "Refunds",
        "summary": "Thirty days.",
        "content": "Old text"
      },
      "proposed_by": {
        "actor_class": "client"
      },
      "created_at": "2026-09-15T10:12:00.000Z",
      "decided_at": null
    }
  ],
  "next_cursor": null
}

Approve a proposal

POST /v1/proposals/:id/approve

Applies the write through the ordinary path, under the proposer's actor. The app's own key only.

Response 200
{
  "proposal": {
    "id": "018f2a1c-6e31-7f55-b2c4-1d8e3f4a9b23",
    "status": "approved"
  },
  "node": {
    "id": "018f2a1c-6e31-7f55-b2c4-1d8e3f4a9b23",
    "thread_id": "018f2a1c-5d20-7a44-8e1b-6c9f0a2b7d12",
    "ref": "N:12",
    "slug": "rate-limits",
    "title": "Rate limiting",
    "summary": "How we throttle API clients",
    "content": "# Token buckets\n\nEach client gets 60 requests a minute. See [retries](N:9).",
    "node_type": "note",
    "properties": {},
    "created_at": "2026-09-15T10:12:00.000Z",
    "updated_at": "2026-09-15T10:12:00.000Z"
  }
}

Reject a proposal

POST /v1/proposals/:id/reject

Nothing is written. reason is kept for whoever looks later. The app's own key only.

Body

reason string
Why, up to 1,000 characters.
Response 200
{
  "proposal": {
    "id": "018f2a1c-6e31-7f55-b2c4-1d8e3f4a9b23",
    "status": "rejected",
    "reason": "Not our policy."
  }
}

Usage

What the workspace has used this month and what it stores.

Read usage

GET /v1/usage

Operations counted since the start of the month, by kind: search covers searches and traversals, fetch covers reading one memo, and write covers writes. totals is what the workspace stores.

Response 200
{
  "period": "2026-09-01",
  "counters": {
    "search": 120,
    "fetch": 64,
    "write": 8
  },
  "totals": {
    "vectors": 42,
    "documents": 5,
    "storage_bytes": 18432
  }
}

Row level security

A workspace owner on the Team plan can turn on row level security (RLS) in workspace settings. With it on, every request made with an API key has to name exactly one bucket, and the request runs with access to that bucket alone, at the key's own level. An admin key keeps admin on that bucket, and a read-only key stays read-only.

Name the bucket by its id, which is a UUID, never by its ref. Send the header X-GigaRAG-Bucket, or put bucket_id in the query string. Routes that already carry a bucket (/v1/buckets/{id}, and bucket_id in the body of search, thread and table creation) count as naming it. Over MCP, pass the bucket_id argument on each tool call, or set the header once on the connection as the default.

Naming a bucket
curl "https://api.gigarag.com/v1/threads" \
  -H "Authorization: Bearer $GIGARAG_API_KEY" \
  -H "X-GigaRAG-Bucket: 3f0c2d5e-8a41-4c7e-9b52-6d1e0a7f4b18"

Creating a bucket (POST /v1/buckets, save_container with kind: "bucket") and reading usage (GET /v1/usage, browse with level: "usage") name no bucket and are exempt. Listing buckets is refused, so an agent cannot discover another agent's bucket. A key restricted to some buckets that names a different one, and a bucket that does not exist, get the same 403. Moving a thread, memo or table to another bucket is refused.

Links between buckets are not made and not shown while RLS is on. They stay stored, so turning RLS off brings them back. A memo that already links to another bucket keeps that link when you save it.

The id of a bucket is in GigaRAG, in the bucket menu, under Copy bucket ID. The menu has it whether RLS is on or off.

RLS keeps a careful caller inside one bucket. A full workspace key that knows another bucket's id can still name it. For a hard limit, restrict the key to some buckets. The errors are bucket_required, bucket_mismatch and cross_bucket_link, listed under Errors.

Table filters

One filter grammar serves queries, aggregates, bulk updates and deletes, and saved views. A filter is an object whose keys are column keys. id, created_at and updated_at work like columns.

A filter
{
  "status": "hot",
  "score": {
    "gte": 80
  },
  "created_at": {
    "gte": "now-30d"
  },
  "email": {
    "contains": "@acme.com"
  },
  "notes": {
    "search": "pricing objection"
  },
  "tags": {
    "has_any": [
      "enterprise",
      "warm"
    ]
  },
  "or": [
    {
      "owner": "ana"
    },
    {
      "owner": {
        "is_null": true
      }
    }
  ],
  "not": {
    "country": {
      "in": [
        "US",
        "CA"
      ]
    }
  }
}

A bare value means eq, and null means is_null. Several keys in one object are ANDed. and and or take a list of filters and not takes one, nested up to 5 deep and 50 conditions in all. A bare array is in, or has_any on a multi_select column. An empty {} inside or matches every row, so the whole or does; inside and it adds nothing.

OperatorMeaning
eq, neEqual, not equal. ne keeps rows where the cell is empty.
gt, gte, lt, lteGreater or less than. Numbers, dates and text.
betweenA list of two values, both included.
in, not_inA list of up to 500 values. not_in keeps rows where the cell is empty.
contains, not_containsText contains the value, ignoring case.
starts_with, ends_withText begins or ends with the value, ignoring case.
searchWords matched in one text column. Quotes and -word work.
is_nulltrue for an empty cell, false for a filled one.
has_any, has_all, has_noneFor multi_select: any, all or none of the listed values.

Not every operator fits every type, and a wrong one is refused with the list that would work. Dates take ISO strings and relative tokens: now, today, and either plus or minus an amount in h, d, w, mo or y, such as now-30d or today+1w. A relative token is worked out when the query runs, so a saved view stays current. On a datetime column a bare date such as 2026-03-31 is midnight UTC, except as an upper bound (lte, or the second value of between), where it means the end of that day: lte: "2026-03-31" includes everything on March 31. Emails are lower case, and a value you filter an email column by is lower-cased too.

The top-level search field is separate from a filter. It matches words across every text, select, url and email column at once, and a url or email is matched by its parts as well as whole.

Table errors

Every table refusal says what failed, whether the same call can ever work, and what to do next. The code is there to branch on.

StatusCodeMeaning
403table_not_reachableThe key cannot reach that table, or it does not exist. The two are one answer. view_not_reachable is the same for a saved view.
408query_timeoutA statement ran past 10 seconds and was cancelled. Nothing changed. Narrow the filter or add an index.
409table_title_takenA table in that bucket already has the title, ignoring case.
409table_title_ambiguousThe title names several tables the key can read. Name the table by its D: ref instead; the message lists them.
409view_title_taken, column_name_taken, index_name_takenThe name is already used in that table.
409unique_violationA unique index would hold the same value twice. The message names the row, the column, the value and the index.
409duplicate_valuesA unique index cannot be built because the table already holds duplicates. The message shows examples.
413table_row_capThe table is at its 250,000 row limit.
422invalid_rowsSome rows in the batch are wrong. Up to 20 are listed as rows[3].score: .... Nothing was written.
422type_change_lossySome cells would not convert to the new type. Send force: true to empty them.
422table_limit, table_column_cap, imports_bucket, unprocessable_fieldA limit was reached, the Imports bucket cannot hold a table, or a field is wrong. The message says which.
423project_frozenThe workspace is moving between servers. Retry after Retry-After.

Retrying the same call helps only for 423 and for rate_limited.

MCP

Agents that speak the Model Context Protocol use the same key at https://mcp.gigarag.com/mcp, over Streamable HTTP, with the same Authorization header. Every call checks the key again, so a revoked key stops working on the next call.

There are eleven tools, and each takes an optional bucket_id. A write also takes an actor. The route beside each tool is the closest REST equivalent, because one tool covers several: search takes by, when, since, until, as_of and in: "archive"; read takes an id, a ref such as N:12, or lists of either; browse takes a level; save_memo creates or updates (an update sends the version it read); save_container makes buckets and threads and closes a thread or marks a bucket primary or working; save_table, query_table and write_rows take an action or op; delete takes a kind. A write into a primary bucket by an agent key is held for a person to approve and answers pending_approval. The old tool names such as search_nodes and create_node still work for one release but no longer appear in the tool list.

ToolREST route
searchPOST /v1/search
readGET /v1/nodes/:id
browseGET /v1/buckets
graphGET /v1/graph
save_memoPOST /v1/nodes
save_containerPOST /v1/buckets
save_tablePOST /v1/tables
query_tablePOST /v1/tables/:table/query
write_rowsPOST /v1/tables/:table/rows
deleteDELETE /v1/nodes/:id
importsGET /v1/imports/pending

Setup guides for 99 AI tools are at https://gigarag.com/connect.