Skip to content

API Reference

Rahil Pirani edited this page Sep 26, 2026 · 19 revisions

All endpoints require an Authorization: Bearer YOUR_TOKEN header except CORS preflight and the dashboard shell at GET /. OAuth clients exchange an authorization for the same bearer identity. Query-string token authentication was removed in v3.

On a team brain, YOUR_TOKEN is either the owner's AUTH_TOKEN or an individual member token. Every read is limited to that identity's personal workspace plus its company workspaces. Administrative routes additionally require the admin role.

Error responses are standardized as { "ok": false, "error": "..." } across all routes (e.g. 401 { "ok": false, "error": "Unauthorized" }).

Authentication failures also include a stable code:

HTTP Code Meaning
401 invalid_token Missing, malformed, or unknown credential
401 suspended This member exists but an admin suspended access
401 removed This member was removed from the team
403 — The identity is valid but lacks the required role or memory permission

Team-aware access rules

  • Reads return only the caller's personal workspace and company workspaces. An ID outside that scope returns 404, not evidence that another person's memory exists.
  • Company memories include author attribution. Personal memories never expose an author to another identity.
  • Mutating a company memory requires its author or an admin. Other members receive 403 from append, update, forget, status, and un-share operations.
  • Links must stay inside one workspace. A cross-workspace POST /link returns 400 with code cross_workspace_link; share both memories or keep both private before linking them.
  • Imports always restore into the caller's personal workspace. A restore never publishes memories to the company layer.

POST /capture

Store an entry. Duplicate detection runs synchronously. Embedding happens in the background so the response is instant.

Request body:

{
  "content": "your note here",
  "tags": ["work", "idea"],
  "source": "api"
}
Field Required? Default Description
content Required — The text to store. Rejected if missing or empty.
tags Optional [] Array of tags to attach to the entry
source Optional "api" Free-text label for where the note came from
workspace Optional your effective capture default personal or company — which layer the entry lands in. An explicit value wins over member and organization defaults.
team Optional primary team When workspace is company on a multi-team brain, which team workspace (id from list_teams)
volatility Optional (omitted) durable, state, or volatile: how likely the fact is to stop being true. Folded into the entry's tags.
project Optional (none) Slug of a project this entry belongs to. Unions project:<slug> into tags. An unknown slug is silently auto-created; an invalid slug is rejected the same way as a bad literal project: tag (400). See Projects.

Responses:

Stored successfully:

{ "ok": true, "id": "uuid-v4", "tags": ["work", "pricing"] }

tags is what actually landed on the entry, which is not always what you sent: hashtags found in the content are added to it. A hashtag that is only digits — #4821, a ticket or PR reference — is left in the text and is not treated as a tag.

Stored but similar entry exists (tagged duplicate-candidate):

{
  "ok": true,
  "id": "uuid-v4",
  "warning": "similar",
  "matchId": "existing-uuid",
  "score": 88.5,
  "message": "Stored but similar entry exists — tagged as duplicate-candidate"
}

Blocked — near-exact duplicate:

{
  "ok": false,
  "duplicate": true,
  "matchId": "existing-uuid",
  "score": 97.2,
  "message": "Near-exact duplicate detected — not stored"
}

Merged into an existing entry (no new row created):

{ "ok": true, "id": "existing-uuid", "action": "merged", "message": "Memories merged into a single combined entry" }

Replaced an outdated existing entry:

{ "ok": true, "id": "existing-uuid", "action": "replaced", "message": "New memory replaced an outdated existing entry" }

Resolved a contradiction (the conflicting entry is deprecated — dropped from recall but kept for audit — and the new note is stored tagged contradiction-resolved):

{ "ok": true, "id": "uuid-v4", "resolved_conflict": "deprecated-entry-uuid", "reason": "The new note says the meeting moved to Wednesday, superseding the Tuesday entry." }

Contradiction with a canonical entry — the canonical version is protected, so the new note is kept as a draft instead of overwriting it:

{ "ok": true, "id": "uuid-v4", "status": "draft", "kept_canonical": "canonical-entry-uuid", "reason": "Conflicts with a confirmed canonical memory." }

See How It Works → Duplicate detection, contradictions, and smart merging for when each of these outcomes (merged/replaced/contradiction/flagged/blocked) gets triggered, and Contradiction-aware scoring for how a contradiction's outcome affects future recall ranking.

Status Meaning
200 ok:true Stored, merged, replaced, contradiction resolved, or kept as a draft (canonical protected)
200 ok:false duplicate:true Blocked — near-exact duplicate
400 Missing or invalid content
401 Invalid auth token

POST /append

Append new information to an existing entry. The original content is preserved and the update is added with a timestamp.

Request body:

{
  "id": "existing-entry-uuid",
  "addition": "New information to append"
}
Field Required? Description
id Required UUID of the entry to append to. Rejected (400) if missing/empty.
addition Required Text to append. Rejected (400) if missing/empty.
volatility Optional durable, state, or volatile: how likely the fact is to stop being true. Folded into the entry's tags.

Responses:

Success:

{
  "ok": true,
  "id": "existing-entry-uuid",
  "message": "Update appended successfully with timestamp"
}

Entry not found:

{
  "ok": false,
  "error": "No entry found with ID: existing-entry-uuid"
}

Append failed:

{
  "ok": false,
  "error": "Append failed: <error details>"
}
Status Meaning
200 ok:true Update appended successfully
404 ok:false Entry not found
409 ok:false The entry is a managed mirror — see below
500 ok:false Append operation failed
400 Missing or invalid id or addition, or invalid JSON body
401 Invalid auth token

Note: entries synced in from an external system (for example Notion Integration) are managed mirrors and cannot be edited through the API — editing them here would be overwritten by the next sync. Those return 409 with an error explaining where to make the change instead.


GET /list

List recent entries in reverse chronological order, with optional tag and time-range filters.

curl "https://<your-worker-url>/list?n=20&tag=work&after=1700000000000&before=1750000000000" \
  -H "Authorization: Bearer YOUR_TOKEN"

All query parameters are optional.

Query param Required? Default Max Description
n Optional 20 100 Number of entries to return
tag Optional — (no filter) — Only return entries with this exact tag
after Optional — (no filter) — Only return entries created at/after this Unix ms timestamp
before Optional — (no filter) — Only return entries created at/before this Unix ms timestamp
workspace Optional — (both layers) — Team brains only. personal or company — restrict the list to one layer
team Optional primary team (none) When workspace=company on a multi-team brain, restrict to one team (id from list_teams)
actor Optional — (all authors) — Restrict to a roster name, user ID, or me. Commonly paired with workspace=company; me can cover the caller's personal and company memories.
project Optional — (no filter) — Restrict to one project — matches its project:<slug> tag plus any of its aliases. Unknown slug returns 404 with known_projects.

Returns a JSON array of entry objects (same filtering behavior as the list_recent MCP tool).

Each entry also carries workspace (personal or company), can_edit, and actor_name. The author name is populated for company memories and null for personal memories, so a client can render permissions and attribution without a second request.


GET /recall

Semantic search over your memories: vector search with reranking (recency, importance, and contradiction history, plus an optional cross-encoder step controlled by RERANK_MODE, see GET /config), chunk dedup, and an LLM-synthesized insight. Mirrors the recall MCP tool. See How It Works for how results are ranked.

curl "https://<your-worker-url>/recall?query=what+did+I+decide+about+pricing&topK=5&tag=work" \
  -H "Authorization: Bearer YOUR_TOKEN"
Query param Required? Default Range Description
query Required — — Natural-language search query. Returns 400 if missing/empty.
topK Optional 5 clamped to 1–20 Max number of results to return
tag Optional — (no filter) — Restrict results to entries carrying this exact tag
after Optional — (no filter) — Only consider entries created at/after this Unix ms timestamp
before Optional — (no filter) — Only consider entries created at/before this Unix ms timestamp
kind Optional — (no filter) episodic | semantic Restrict to events (episodic) or facts/knowledge (semantic). An unrecognised value is ignored rather than rejected.
hops Optional 0 clamped to 0–3 Graph expansion depth. 0 returns direct matches only; 1–2 also surface memories linked to the matches.
full Optional 0 1 | true | yes Return each memory's complete content instead of a bounded snippet (see below)
workspace Optional — (both readable layers) personal | company Restrict recall to one layer.
team Optional primary team (none) When workspace=company on a multi-team brain, restrict to one team (id from list_teams)
project Optional — (no filter) — Restrict to one project — matches its project:<slug> tag plus any of its aliases, both for candidate selection and evidence-slot hydration. Unknown slug returns 404 with known_projects.

Response size

By default content is a snippet, not the whole memory. Short memories are returned complete; long ones are cut on a sentence boundary where possible.

  • The first 2 results are allowed up to 4,000 characters, but only if they are also strong — scores are normalized so the top match is 100, and a leading result must score at least 75 to earn the larger allowance. Rank alone is not enough: when the second result is much weaker than the first, spending thousands of characters on it would starve the rest.
  • Every other result is allowed 400 characters.
  • Entries built up by /append are cut as head + most recent update, so the newest information survives truncation.

Every result carries truncated and full_length so you can tell a snippet from a complete memory. To get the rest, either re-request with full=1 or fetch that one entry with the get MCP tool.

This matters most for agents: treating a cut snippet as the complete memory and answering confidently from partial text is the exact failure this design guards against. Check truncated before quoting.

Responses:

Matches found:

{
  "ok": true,
  "query_used": "what did I decide about pricing",
  "results": [
    {
      "id": "entry-uuid",
      "content": "Decided to go with usage-based pricing...",
      "truncated": false,
      "full_length": 412,
      "score": 91.4,
      "tags": ["work", "pricing"],
      "source": "api",
      "created_at": 1718000000000,
      "updated": false,
      "hop": 0,
      "workspace": "company",
      "actor_name": "Ada",
      "via_provenance": null,
      "via_type": null,
      "linked_at": null,
      "related_to": null
    }
  ],
  "insight": "You've been leaning toward usage-based pricing models.",
  "semantic_unavailable": false
}
Field Description
query_used The query actually embedded and searched on. Any tag: syntax is parsed out and the remainder is distilled to its rarest terms, so this often differs from what you sent.
truncated / full_length Whether content was cut, and the full character count of the stored memory
score Relevance, normalized so the top match scores 100
updated Whether the chunk that matched was an appended update block rather than the entry's original text
workspace personal or company on an identity-aware brain
actor_name Author attribution for company memories; null for personal memories
insight LLM-synthesized summary across the matches, or null
semantic_unavailable true when the Vectorize index is missing and these are keyword matches only

The hop, via_*, and related_to fields describe how a result was reached and are only meaningful when hops is greater than 0:

Field Description
hop Distance from a direct match — 0 for direct hits, 1+ for graph-expanded ones
via_type / via_provenance Type of the edge traversed to reach this memory, and whether it was explicit, inferred, or system
linked_at Unix ms timestamp of when that edge was formed
related_to ID of the memory this one was reached from

Nothing found:

{
  "ok": true,
  "results": [],
  "query_used": "what did I decide about pricing",
  "semantic_unavailable": false,
  "message": "Nothing found matching that query."
}

If the Vectorize index is missing, semantic_unavailable is true and message instead explains that semantic search is unavailable and how to fix it. Recall still returns keyword matches in that state rather than failing.

Two categories of entry are always excluded from recall regardless of score: those marked status:deprecated (see POST /status) and unconfirmed auto-insight entries awaiting review (auto-pattern is the same exclusion, kept only for entries written before the rename).

Status Meaning
200 ok:true Search executed (results may be empty)
400 ok:false Missing or empty query
401 Invalid auth token

POST /forget

Permanently delete an entry and all of its associated vectors (original + chunked updates) from D1 and Vectorize. Mirrors the forget MCP tool.

Request body:

{ "id": "existing-entry-uuid" }
Field Required? Description
id Required UUID of the entry to delete. Rejected (400) if missing/empty. Whitespace is trimmed before lookup.

Responses:

Success:

{ "ok": true, "id": "existing-entry-uuid", "deletedVectors": 2 }

Entry not found:

{ "ok": false, "error": "No entry found with ID: existing-entry-uuid" }
Status Meaning
200 ok:true Entry and vectors deleted
400 ok:false Missing or invalid id, or invalid JSON body
404 ok:false No entry found with that ID
401 Invalid auth token

Note: Vectorize cleanup is non-fatal — if vector deletion fails, the D1 row is still removed and the response still reports success.


POST /update

Replace an entry's content (re-extracts hashtags as tags and re-embeds it). Unlike /append, this overwrites rather than preserving the original text.

Request body:

{ "id": "existing-entry-uuid", "content": "the corrected note text" }
Field Required? Description
id Required UUID of the entry to overwrite. Rejected (400) if missing/empty.
content Required New content. Replaces the existing text entirely and the entry is re-embedded. Any #hashtags are stripped out of the text and merged into the entry's existing tags. Rejected (400) if missing/empty.
tags Optional Array of strings. Omit to leave existing tags alone (hashtags still merge in). Pass an array to replace the entry's tag set outright, including removing tags not in the array.
volatility Optional durable, state, or volatile: folded into the entry's tags the same way as /capture.

Responses:

Success — vectors is the number of vectors the new content was embedded into:

{ "ok": true, "id": "existing-entry-uuid", "vectors": 1 }

Entry not found:

{ "ok": false, "error": "No entry found with ID: existing-entry-uuid" }

Re-index failed — the entry is left completely unchanged:

{ "ok": false, "error": "Couldn't update: search re-index failed. Your memory is unchanged — please try again." }
Status Meaning
200 ok:true Entry updated and re-embedded
400 ok:false Missing id/content, or invalid JSON body
404 ok:false No entry found with that ID
409 ok:false The entry is a managed mirror and cannot be edited through the API
500 ok:false Re-embedding failed — content and vectors are unchanged
401 Invalid auth token

Notes:

  • Content is only committed after re-embedding succeeds. A failed re-index leaves the memory exactly as it was rather than saving new text that could not be indexed, which would leave the entry silently unsearchable.
  • Omit tags and the existing set is only ever added to via hashtags found in the new content. Pass tags explicitly to replace the set outright, including removals.
  • Cleanup of the old vectors is non-fatal — if it fails, the update still succeeds.

POST /share

Move one memory between the caller's personal workspace and the company workspace. This is a move, not a copy: the ID, relationships, and history stay with the one canonical row. Mirrors the MCP share tool.

{ "id": "existing-entry-uuid", "workspace": "company" }
Field Required? Default Description
id Required — Memory to move
workspace Optional company Target layer: personal or company
team Optional primary team When workspace is company on a multi-team brain, which team workspace (id from list_teams)

Successful moves return status: "shared" or status: "unshared". A memory already in the requested layer returns status: "no_change".

Any member can share a memory they can mutate. Only the memory's author or an admin can move a company memory back to Personal. The move is audited and its Vectorize metadata is updated in the background.

Status Meaning
200 ok:true Moved or already in the target workspace
400 ok:false Missing ID, invalid workspace, or invalid JSON
403 ok:false Caller cannot un-share this memory
404 ok:false Memory is outside the caller's readable scope or does not exist
401 ok:false Invalid or revoked credential

POST /status

Set an entry's lifecycle status. Mirrors the set_status MCP tool.

Status Meaning
canonical Confirmed and authoritative — protected from auto-overwrite
draft Tentative, not yet confirmed
deprecated No longer accurate — removed from recall results, kept for audit

Request body:

{ "id": "existing-entry-uuid", "status": "canonical" }
Field Required? Description
id Required UUID of the entry. Rejected (400) if missing/empty. Whitespace is trimmed before lookup.
status Required One of canonical, draft, deprecated. Any other value is rejected (400).

Responses:

Success:

{ "ok": true, "id": "existing-entry-uuid", "status": "canonical" }

Entry not found:

{ "ok": false, "error": "No entry found with ID: existing-entry-uuid" }
Status Meaning
200 ok:true Status applied
400 ok:false Missing id, invalid status, or invalid JSON body
404 ok:false No entry found with that ID
401 Invalid auth token

Note: status is stored as a status:<value> tag on the entry, so it appears in /tags and can be filtered on with ?tag=status:canonical. Setting a new status replaces any previous one.


POST /link

Create an explicit relationship edge between two memories. Mirrors the link MCP tool.

Request body:

{ "source_id": "entry-uuid-a", "target_id": "entry-uuid-b", "type": "supersedes" }
Field Required? Default Description
source_id Required — UUID of the source entry
target_id Required — UUID of the target entry
type Optional relates_to Relationship type — see the table below. An unknown type is rejected (400).

Relationship types:

Type Direction Label
relates_to Undirected Related to
supersedes Directed Supersedes
caused_by Directed Caused by
decided Directed Decided
about_person Directed About person
part_of_project Directed Part of project
follows Directed Follows

Responses:

Success:

{ "ok": true, "source_id": "entry-uuid-a", "target_id": "entry-uuid-b", "type": "supersedes" }

Self-link:

{ "ok": false, "error": "Cannot link an entry to itself" }
Status Meaning
200 ok:true Edge created, or an existing identical edge was kept
400 ok:false Missing source_id/target_id, unknown type, self-link, or invalid JSON body
400 ok:false code:cross_workspace_link The two entries are in different workspaces
400 ok:false code:kind_not_allowed decided or follows requires both entries classified episodic
401 Invalid auth token

Notes:

  • Edges created here are stored with provenance: "explicit" and weight 1.0, which distinguishes them from the inferred edges the Worker creates automatically when similar memories are written.
  • Linking is idempotent — re-posting the same source/target/type updates the existing edge rather than duplicating it.
  • relates_to is undirected, so its pair is stored in a canonical order. The response may return source_id and target_id swapped relative to your request; this is expected.
  • Entry IDs are not checked against the entries table. Linking a non-existent ID succeeds and yields an edge that never resolves to a memory.

POST /unlink

Remove relationship edges between two memories. Mirrors the unlink MCP tool. Uses POST rather than DELETE because the CORS policy allows only GET, POST, and OPTIONS.

Request body:

{ "source_id": "entry-uuid-a", "target_id": "entry-uuid-b", "type": "supersedes" }
Field Required? Default Description
source_id Required — UUID of one end of the link
target_id Required — UUID of the other end
type Optional — (all types) Only remove edges of this type; omit to remove every link between the pair

Responses:

Success:

{ "ok": true, "deleted": 1 }
Status Meaning
200 ok:true Request processed — deleted is the number of edges removed, and may be 0
400 ok:false Missing source_id/target_id, unknown type, or invalid JSON body
401 Invalid auth token

Note: deletion is direction-agnostic — edges are removed in both directions between the pair, so you do not need to know which entry was stored as the source. Removing a link that does not exist is not an error; it returns 200 with "deleted": 0.


GET /connections

List the memories directly linked to an entry — its 1-hop neighbors in the relationship graph. Mirrors the connections MCP tool.

curl "https://<your-worker-url>/connections?id=entry-uuid&type=supersedes" \
  -H "Authorization: Bearer YOUR_TOKEN"
Query param Required? Default Description
id Required — UUID of the entry whose neighbors you want. Returns 400 if missing/empty.
type Optional — (no filter) Restrict to a single relationship type (see POST /link for the list)

Responses:

Success:

{
  "ok": true,
  "id": "entry-uuid",
  "connections": [
    {
      "id": "neighbour-uuid",
      "content": "The earlier pricing decision this one replaces...",
      "tags": ["work", "pricing"],
      "source": "api",
      "created_at": 1718000000000,
      "type": "supersedes",
      "label": "Supersedes",
      "weight": 1,
      "provenance": "explicit",
      "linkedAt": 1718600000000
    }
  ]
}

Each connection carries both the neighboring entry and the edge that reached it:

Field Description
type / label Relationship type and its human-readable label
weight Edge strength, 0–1. Explicit links are 1.0; inferred links carry their similarity score
provenance explicit (you created the link), inferred (auto-created on write), or system
linkedAt Unix ms timestamp of when the edge was formed — not when either memory was created
Status Meaning
200 ok:true Lookup executed (connections may be empty)
400 ok:false Missing or empty id
401 Invalid auth token

Notes:

  • At most 8 neighbors are returned, highest-weight edges first.
  • Entries marked deprecated are excluded.
  • The type filter is applied after the 8-edge cap, so a type-filtered call on a densely linked entry can return fewer results than actually exist for that type. Omit type and filter client-side if you need completeness.

GET /entry

Returns one entry in full, with everything the pipeline has decided about it. Used by the dashboard when you open a memory.

curl "https://<your-worker-url>/entry?id=uuid-v4" -H "Authorization: Bearer YOUR_TOKEN"
Parameter Required? Description
id Required The entry id

Response:

{
  "ok": true,
  "entry": {
    "id": "uuid-v4",
    "content": "The pricing floor is $6k per project.",
    "tags": ["work", "pricing", "kind:semantic", "status:canonical"],
    "source": "claude-desktop",
    "created_at": 1767225600000,
    "updated_at": 1769817600000,
    "importance_score": 4,
    "recall_count": 7,
    "contradiction_wins": 1,
    "contradiction_losses": 0,
    "indexed": true,
    "workspace": "personal",
    "actor_name": "Ada",
    "can_edit": true,
    "timeline": [
      { "event": "created", "created_at": 1767225600000, "actor_name": "Ada", "payload": {} }
    ]
  }
}
Field Description
updated_at When the entry was last changed. Equals created_at for entries never edited, and for rows written before the column existed
importance_score 0–5, assigned at capture
recall_count How many times this entry has been returned by a recall
contradiction_wins / contradiction_losses How often this entry has superseded another, and been superseded. A non-zero losses means something newer disagreed with it
indexed Whether the entry has vectors. false means recall cannot find it — POST /vectorize-pending fixes that
workspace personal or company
actor_name Who wrote it, resolved from the roster
can_edit Whether the caller may edit or forget this entry (the same check the mutation routes enforce)
timeline Array of audit events: { event, created_at, actor_name, payload }

kind:, status: and volatility: travel inside tags; the dashboard renders them as plain language rather than showing the tags.

Returns 404 if no entry has that id.


GET /brief

A summary of what the brain has been doing, assembled in one request. The dashboard calls this when it opens; it exists so a client does not have to make five calls to build the same picture. Reads only — no AI, no Vectorize, and a fixed number of database queries regardless of brain size.

curl "https://<your-worker-url>/brief" -H "Authorization: Bearer YOUR_TOKEN"

Response:

{
  "ok": true,
  "window_hours": 48,
  "captured": 44,
  "sources": [
    { "source": "claude-desktop", "count": 31 },
    { "source": "email-gmail", "count": 13 }
  ],
  "patterns": [
    { "id": "uuid-v4", "content": "Across your memories, a recurring theme of…" }
  ],
  "resurface": {
    "id": "uuid-v4",
    "content": "The pricing floor is $6k per project.",
    "created_at": 1767225600000
  },
  "activity": [
    { "day": 20670, "count": 0 },
    { "day": 20671, "count": 12 }
  ],
  "topics": [{ "tag": "signpath", "count": 9 }],
  "total": 1878,
  "attention": { "unindexed": 1, "stale": 0, "patterns": 1 }
}
Field Description
captured / sources Entries written in the last window_hours, and where they came from
patterns Up to 3 auto-derived patterns awaiting confirmation. These are excluded from recall until confirmed — see POST /patterns/resolve
resurface One older, important memory worth re-reading, or null. The choice is stable for a day and changes the next
activity Capture counts for the last 14 days, one entry per day including days with none. day is days since the Unix epoch
topics The most-used tags from the last 7 days, excluding the Worker's own bookkeeping
total Every entry in the brain
attention unindexed: memories recall cannot see. stale: memories the nightly staleness pass has flagged as possibly out of date. patterns: same count as the array above

Reminders, due dates, and proactive push

Time anchors

POST /capture accepts these additional optional fields:

Field Description
when A plain date or datetime. Date-only and offsetless values use the configured TIMEZONE.
when_kind due, event, or wake. Requires when; defaults to wake.

The MCP remember and append tools also accept optional when and when_kind fields. append keeps its required id and addition fields.

Due and open-loop routes

Method and path Parameters Description
GET /due none Returns overdue and upcoming items, with upcoming limited to the next 48 hours, plus counts.
POST /due/snooze JSON: id, until Moves one time anchor to a future date or datetime.
POST /due/clear JSON: id Removes one time anchor and marks it cleared.
GET /loops limit 1 to 100, default 50; offset 0 or greater, default 0 Lists open task-tagged commitments.
POST /loops/resolve JSON: id, action (done or not-task) Marks a loop done or removes its task classification.
GET /extract/dry-run limit 1 to 10, default 5; admin only Previews nightly extraction verdicts without persisting them.

Push routes

Method and path Parameters Description
GET /push/vapid-public-key none Returns the deployment's generated VAPID public key.
POST /push/subscribe JSON: subscription with endpoint and keys.p256dh plus keys.auth; optional content_free Registers or replaces one device subscription.
POST /push/unsubscribe JSON: endpoint Removes a device subscription.
POST /push/run none; admin only Runs the due sender and returns aggregate counts plus capped per-device results.
POST /push/test none; admin only Sends a fixed test notification and returns per-device results.

All routes above require a bearer token. The push sender runs hourly, dedupes by entry and when_at, and sends again when that timestamp changes.


GET /count

Returns the total number of stored entries. No parameters.

curl "https://<your-worker-url>/count" -H "Authorization: Bearer YOUR_TOKEN"

Response:

{ "count": 142 }

GET /tags

Returns every distinct tag currently in use, alphabetically sorted.

curl "https://<your-worker-url>/tags" -H "Authorization: Bearer YOUR_TOKEN"

Response:

["idea", "pricing", "work"]
Query param Required? Default Description
counts Optional 0 1 to return { "tag", "count" } objects instead of bare names

With counts=1 the same tags come back in the same order, each with a memory count:

[ { "tag": "idea", "count": 4 }, { "tag": "pricing", "count": 12 }, { "tag": "work", "count": 31 } ]

Counts come from one statement that scans up to 5,000 entries in scope. If that limit is hit, the response carries an X-Counts-Approximate: 1 header and the numbers are lower bounds.


GET /stats

Returns summary statistics about your second brain — entry count, average importance score, top tags, and tags that are candidates for digest synthesis (tags with enough compressible entries and no recent digest; the count reflects only compression-eligible entries, not the tag's total). No parameters.

curl "https://<your-worker-url>/stats" -H "Authorization: Bearer YOUR_TOKEN"

Response:

{
  "count": 142,
  "avg_importance": 2.3,
  "top_tags": ["work", "idea", "pricing", "personal", "claude-response"],
  "digest_candidates": [
    { "tag": "work", "count": 27 }
  ],
  "unvectorized": 0,
  "vectorize_grace_ms": 300000,
  "unclassified": 4
}
Field Description
count Total entries
avg_importance Mean importance score, rounded to one decimal, or null on an empty brain
top_tags The 5 most-used tags, most frequent first. Excludes the Worker's own bookkeeping — the reserved namespaces (kind:, status:, volatility:, stale:), pipeline markers such as synthesized and rolled-up, and numeric tags left by older hashtag extraction. Without that filter kind:episodic outranks every real topic on any brain that has been classified.
digest_candidates Up to 10 tags eligible for compression — see the note above
unvectorized Entries with no vectors that are older than the grace window. A non-zero value means those memories are missing from semantic search; POST /vectorize-pending backfills them.
vectorize_grace_ms How long after creation an entry is allowed to have no vectors before it counts as unvectorized. Embedding is asynchronous, so freshly captured entries are excluded from that count.
unclassified Entries carrying neither a status: nor a kind: tag — typically ones predating those features

POST /chat

Streams an LLM-generated answer to a question, grounded in a set of memories you provide (typically the results of a prior /recall call). Used by the web dashboard's chat feature.

Request body:

Field Required? Description
query Required The question to answer. Rejected (400) if missing/empty.
memories Optional A text blob of relevant memories to ground the answer in (e.g. formatted /recall results)

Response: a text/event-stream of the LLM's streamed response (Server-Sent Events) — not a single JSON payload.

Status Meaning
200 Streaming response started
400 ok:false Missing query, or invalid JSON body
401 Invalid auth token

GET /digest

Synthesizes a high-volume tag's or project's entries into a single condensed summary entry (tagged synthesized), to keep recall results from being dominated by repetitive low-value notes. This is the same compression the nightly cron job runs automatically — /digest lets you trigger it on demand for a specific tag or project.

Frequently-recalled, recent, high-importance, and contradiction-surviving memories are protected from compression — only genuinely low-signal entries are rolled up. See How It Works → Semantic compression for the exact eligibility rules.

Query param Required? Description
tag Required, unless project is given The tag to compress.
project Required, unless tag is given The project slug to compress — uses its expanded project:<slug> + aliases OR-group as both the eligibility pool and the synthesis input. Unknown slug returns 404 with known_projects.

tag and project are mutually exclusive: passing both, or neither, returns 400.

Responses:

Digest created:

{
  "tag": "work",
  "synthesis": "Condensed summary text...",
  "entry_id": "new-synthesized-entry-uuid",
  "source_count": 23
}

A project digest returns project instead of tag, and its entry_id lands tagged synthesized + project:<slug> so it appears back inside the project:

{
  "project": "signpath",
  "synthesis": "Condensed summary text...",
  "entry_id": "new-synthesized-entry-uuid",
  "source_count": 23
}

Not enough entries, or compressed too recently:

{
  "tag": "work",
  "error": "Could not create digest — tag may have fewer than 10 eligible entries or was recently compressed",
  "source_count": 8
}
Status Meaning
200 Either a digest was created, or a reason is returned for why one wasn't (check for the error field)
400 ok:false Missing both tag and project, or both were given
404 ok:false project given but no project with that slug is in scope
401 Invalid auth token

GET /graph

Return visualization nodes and edges from the caller's readable workspaces. This is a dashboard endpoint and has no MCP twin.

Query param Required? Default Description
seed Optional — Center the graph on one readable memory
limit Optional implementation ceiling Maximum nodes; must be at least 1
workspace Optional both readable layers personal or company
team Optional primary team When workspace=company on a multi-team brain, restrict to one team (id from list_teams)
project Optional — (no filter) Restrict seed selection to one project's members — its project:<slug> tag plus any aliases. Unknown slug returns 404 with known_projects.

Company nodes carry workspace: "company" and actor_name. Edges are scoped with their workspace, so graph traversal cannot cross into another person's private layer.


GET /export

Full backup of every entry, edge, and project the caller can read (their personal workspace plus their company workspaces). No parameters. Entries are listed oldest first, so a restore inserts them in the order they were written. Existing backups taken with the older ordering still restore correctly.

curl "https://<your-worker-url>/export" -H "Authorization: Bearer YOUR_TOKEN"

Response:

{
  "ok": true,
  "exported_at": 1718000000000,
  "version": 3,
  "entries": [
    {
      "id": "uuid-v4",
      "content": "The pricing floor is $6k per project.",
      "tags": ["work", "pricing"],
      "source": "api",
      "created_at": 1718000000000,
      "updated_at": 1718000000000,
      "recall_count": 0,
      "importance_score": 0,
      "contradiction_wins": 0,
      "contradiction_losses": 0
    }
  ],
  "edges": [
    { "source_id": "uuid-a", "target_id": "uuid-b", "type": "supersedes", "weight": 1, "provenance": "explicit", "created_at": 1718000000000 }
  ]
}

Vectors are not included; an import re-embeds on the way back in.


POST /import

Round-trip counterpart to GET /export. Restores entries and edges from a previously exported payload, in either order: entries are re-sorted oldest first on the way in, so a restored brain keeps the same search behavior as one built by saving memories one at a time. Always lands in the caller's own personal workspace. A restore is not a share, and it never publishes memories to the company layer.

Paged: one call processes entries[offset .. offset+limit), then, once entries are exhausted, edges[edge_offset .. edge_offset+limit). Resend the same payload with the returned next_offset/next_edge_offset until both remaining_entries and remaining_edges are 0. Existing ids and edges are skipped on a re-run, so paging is safe to repeat.

curl -X POST "https://<your-worker-url>/import?limit=50&offset=0&edge_offset=0" \
  -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
  -d '{"entries": [...], "edges": [...]}'
Query param Required? Default Description
limit Optional 40 Rows to process per call, capped at 1000
offset Optional 0 Entry offset to resume from
edge_offset Optional 0 Edge offset to resume from (only advances once entries are done)

Response:

{
  "ok": true,
  "imported": 50,
  "skipped": 0,
  "failed": 0,
  "edges_imported": 0,
  "edges_skipped": 0,
  "edges_failed": 0,
  "remaining_entries": 12,
  "remaining_edges": 3,
  "next_offset": 50,
  "next_edge_offset": 0,
  "results": [],
  "vectorize_hint": "POST /vectorize-pending until remaining is 0"
}

Vectors are not restored during import. Call POST /vectorize-pending after a restore completes to backfill them.

Status Meaning
200 ok:true Page processed; check remaining_entries/remaining_edges for whether more pages remain
400 ok:false Invalid JSON body or malformed payload
401 Invalid auth token

GET /config, PATCH /config, DELETE /config/:key

Runtime settings for ranking, recall, capture, models, and team defaults. Each setting has a shipped default; the Worker stores only the values you change, so a default improved in a later release still reaches a setting you never touched. Reads are per request, so a change applies to the next call.

Route Who What it does
GET /config any signed-in member Returns { "ok": true, "config": {...}, "overrides": {...}, "defaults": {...} }: the effective values, only the ones you changed, and the shipped defaults. Every setting is listed here.
PATCH /config admin Sparse update: send an object of setting to value. The whole patch is rejected with 400 and a message naming the setting if any value is invalid, so nothing is half written. Returns the new effective config.
DELETE /config/:key admin Resets one setting to its default. 404 for an unknown key.
curl -X PATCH "https://<your-worker-url>/config" \
  -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
  -d '{"RERANK_MODE": "off"}'

The settings that shape recall:

Key Default Description
RERANK_MODE auto The cross-encoder answer reranker. auto runs it only when the top two candidates score within 15% of each other and never for a search containing an identifier; on runs it for every eligible search; off never calls the model and restores the previous ranking exactly. Any other value is rejected. Whatever the mode, the model runs only after its self-check passes, and any failure or timeout returns the un-reranked order. See How It Works.
KEYWORD_CANDIDATE_LIMIT 500 The most candidate memories the keyword side returns to the ranking.
SUBSTRING_MATCH_WEIGHT 0.25 The share of a word's weight a memory earns when the word appears only inside longer words ("cat" in "concatenate").
MMR_LAMBDA 0.7 Balance between relevance and diversity in the diversity pass.
RECENCY_FLOOR 0.6 Lowest recency multiplier for an ordinary memory; RECENCY_FLOOR_DURABLE (0.9) and RECENCY_FLOOR_VOLATILE (0.15) cover durable and volatile ones.
DEFAULT_HOPS 0 Graph expansion depth when a caller does not send hops; an explicit value, including 0, always wins.

Other keys cover duplicate detection thresholds, recall output size, compression, the language models, team defaults, and the time zone for date-only reminders; GET /config returns all of them with their defaults.


GET /projects

List projects the caller can see. See Projects for the concept.

curl "https://<your-worker-url>/projects?include_archived=1&counts=1" \
  -H "Authorization: Bearer YOUR_TOKEN"
Query param Required? Default Description
workspace Optional both readable layers personal or company — restrict to one layer
team Optional primary team When workspace=company on a multi-team brain, restrict to one team (id from list_teams)
include_archived Optional 0 1 to include archived projects
counts Optional 0 1 to include a count per project (see below)

Response:

{
  "projects": [
    {
      "id": "signpath",
      "name": "SignPath",
      "description": "Code signing for the Windows installer",
      "aliases": ["signpath"],
      "status": "active",
      "workspace_id": "b2f4c0e8-6a1d-4e2b-9c3f-d5a7e9f1b3c5",
      "layer": "personal",
      "created_at": 1718000000000,
      "updated_at": null,
      "count": 9
    }
  ]
}

Ordered by name. count is only present when counts=1. It comes from one statement that scans up to 5,000 tagged entries in scope and tallies project:<slug> occurrences in JS, so it reflects explicitly tagged memories only — alias-matched entries are not counted. If the scan hits that limit, the response also carries "counts_approximate": true.

Status Meaning
200 List returned (may be empty)
401 Invalid auth token

POST /projects

Create a project.

Request body:

{ "name": "SignPath", "description": "Code signing for the Windows installer" }
Field Required? Default Description
id Optional derived from name Slug: ^[a-z0-9][a-z0-9_-]{0,63}$. Omit it and Second Brain lowercases the name, turns spaces into hyphens, and strips anything else illegal; 400 if what's left doesn't form a valid slug.
name Required — Display name, up to 120 characters
description Optional "" Up to 1,000 characters
aliases Optional [] Plain topic tags this project claims retroactively, up to 16
workspace Optional your effective capture default personal or company
team Optional primary team When workspace is company on a multi-team brain

Responses:

Created (status 201):

{
  "ok": true,
  "project": {
    "id": "signpath",
    "name": "SignPath",
    "description": "Code signing for the Windows installer",
    "aliases": [],
    "status": "active",
    "workspace_id": "b2f4c0e8-6a1d-4e2b-9c3f-d5a7e9f1b3c5",
    "layer": "personal",
    "created_at": 1718000000000,
    "updated_at": null
  }
}

Slug already taken in that workspace:

{ "ok": false, "error": "project \"signpath\" already exists" }
Status Meaning
201 ok:true Project created; the body carries the full project
400 ok:false Missing name, an invalid id, or an alias that fails validation
409 ok:false The slug already exists in the target workspace
401 Invalid auth token

Note: an alias may not start with a reserved prefix (kind:, status:, volatility:, stale:, capsule:, capsule-slot:, project:) — those namespaces are never plain topic tags.


PATCH /projects/:slug

Partially update a project's name, description, aliases, or status.

Request body (all fields optional; only the ones present are changed):

{ "description": "Updated description", "status": "archived" }
Field Description
name New display name
description New description
aliases Replaces the full alias list — same validation as POST /projects
status active or archived

Query params workspace and team select the project the same way they scope GET /projects.

Response:

{ "ok": true, "project": { "id": "signpath", "name": "SignPath", "description": "Updated description", "aliases": ["signpath"], "status": "archived", "workspace_id": "b2f4c0e8-6a1d-4e2b-9c3f-d5a7e9f1b3c5", "layer": "personal", "created_at": 1718000000000, "updated_at": 1718100000000 } }
Status Meaning
200 ok:true Project updated; the body carries the full updated project
400 ok:false Invalid field value (bad alias, bad status, etc.), or the slug exists in more than one workspace and no workspace (and team) narrowed it
404 ok:false No project with that slug in scope
401 Invalid auth token

DELETE /projects/:slug

Remove a project's registry row. Entries keep their project:<slug> tag — see Projects → Archiving and deleting a project for what this does and doesn't do.

Query params workspace and team select the project the same way they scope GET /projects.

Response:

{ "ok": true, "deleted": true }
Status Meaning
200 ok:true Project deleted
400 ok:false The slug exists in more than one workspace and no workspace (and team) narrowed it
404 ok:false No project with that slug in scope
401 Invalid auth token

The project parameter on other endpoints

project=<slug> is also accepted on POST /capture, GET /list, GET /recall, GET /digest, and GET /graph (see each endpoint above and below for its exact placement). On POST /capture an unknown slug is silently auto-created, the same as remember. On the read endpoints (GET /list, GET /recall, GET /digest, GET /graph) an unknown slug returns 404:

{
  "ok": false,
  "error": "unknown project \"nope\"",
  "known_projects": ["signpath", "second-brain"]
}

known_projects lists up to 10 active project slugs in scope, so a caller can recover from a typo instead of silently searching nothing.


Team identity and administration

These routes back the Team tab. Routes marked admin return 403 to an authenticated member without the admin role.

Method and path Access Purpose / principal fields
GET /team/me member Caller profile, owner flag, member/org capture defaults, and effective default
GET /team/roster member Team names plus member names and roles; no emails, counts, or suspension state
GET /team/workspaces member Company workspaces the caller belongs to
POST /team/me/default-share member Set own default to personal, company, or inherit
POST /team/profile member Update own name/email; an admin may include another member id
GET /team/members admin Full administration list and caller ID
POST /team/members admin Create { name, email?, role? }; returns the one-time plaintext token
POST /team/members/token admin Rotate { id }; returns the replacement token once
POST /team/members/suspend admin Set { id, suspended } without deleting data
POST /team/members/default-share admin Set { id, default } to personal, company, or inherit
POST /team/members/remove admin Remove { id }, delete that personal workspace, and retain company memories
POST /team/workspaces/rename admin Rename { name, id? }; ID defaults to the caller's primary team
GET /team/activity admin Paged audit feed; limit defaults to 50/max 100, with offset

Creation and token rotation are the only calls that return a plaintext member token. Store or transmit it immediately; only its hash remains afterward.

The activity feed combines administrative events with readable share, un-share, and insight decisions. It returns names rather than credential hashes or private memory content.


GET+POST /mcp

MCP server endpoint using the Streamable HTTP transport. Authentication is required — either:

  • an Authorization: Bearer <your-token> header, using the owner credential or an individual member token; or
  • a full OAuth flow discovered from the base /mcp URL.

Query-string tokens are rejected in v3.

Connect any MCP-compatible client using:

https://<your-worker-url>/mcp

See Connect to AI Clients for client-specific setup instructions and config examples.

MCP tools

? after a parameter name means it's optional. All others are required.

Tool Parameters Description
remember content, tags? (array), source?, volatility?, workspace? (personal | company), team?, project? Store a note with duplicate detection and optional explicit layer
append id, addition, volatility? Append new info to an existing entry, preserving the original
update id, content, tags? (array, replaces the set), volatility? Replace an entry's full content (overwrites, re-embeds)
set_status id, status (canonical | draft | deprecated) Set an entry's lifecycle status. deprecated removes it from recall but keeps it for audit
recall query, topK? (default 5, range 1–20), tag?, after? (Unix ms), before? (Unix ms), kind? (episodic | semantic), hops? (default 0, range 0–3), workspace?, team?, project? Semantic search with optional personal/company restriction
list_recent n? (default 10, max 50), tag?, after? (Unix ms), before? (Unix ms), workspace?, team?, actor?, project? Chronological listing; actor accepts a company author name or ID
get id Fetch one memory in full — the follow-up when a recall or list_recent result came back [truncated]
forget id Delete entry and all chunks from D1 and Vectorize
link source_id, target_id, type? (default relates_to) Create an explicit relationship between two memories
unlink source_id, target_id, type? Remove links between two memories; omit type to remove all of them
connections id, type? List an entry's 1-hop neighbors in the relationship graph
share id, workspace? (personal | company, default company), team? Move one canonical memory between personal and company layers
list_teams none List the shared teams the caller belongs to, with names and workspace ids. Call before remember/share/recall with workspace: "company" when the team is not named.
list_projects workspace?, team?, include_archived? List active projects (slug, name, workspace, description) the caller can see. Call before passing project on remember/recall/list_recent when the project is not already known. See Projects.

Notes:

  • recall and list_recent return bounded output. Long memories come back shortened and marked with a [truncated …] note carrying the entry's ID, and the whole response is capped at roughly 12,000 characters — extra results are dropped with a count of how many. A result without that marker is complete. Call get on anything marked truncated before quoting or acting on it.
  • recall's REST counterpart (GET /recall) clamps topK to 1–20 with a default of 5; list_recent's REST counterpart (GET /list) defaults n to 20 with a max of 100 (the MCP tool itself defaults to 10/max 50).
  • remember defaults source to claude, whereas POST /capture defaults it to api.
  • team (id from list_teams) targets one company workspace on a multi-team brain, used together with workspace: "company". Omit it on a single-team brain. The REST equivalents (POST /capture, POST /share, GET /recall, GET /list, GET /graph) accept the same team parameter.
  • project (slug) on remember auto-creates an unknown project, exactly like POST /capture. On recall and list_recent an unknown slug instead returns the near-miss error text with known project slugs, the same as the REST read endpoints — see The project parameter on other endpoints.

GET /

Returns the web dashboard (single-page HTML app). No auth required — auth is handled client-side via localStorage.

Clone this wiki locally