Repository navigation
API Reference
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 |
- 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
403from append, update, forget, status, and un-share operations. - Links must stay inside one workspace. A cross-workspace
POST /linkreturns400with codecross_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.
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 |
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
409with an error explaining where to make the change instead.
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.
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. |
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 least75to 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
/appendare 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
truncatedbefore 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 |
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.
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
tagsand the existing set is only ever added to via hashtags found in the new content. Passtagsexplicitly to replace the set outright, including removals.- Cleanup of the old vectors is non-fatal — if it fails, the update still succeeds.
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 |
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/tagsand can be filtered on with?tag=status:canonical. Setting a new status replaces any previous one.
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 weight1.0, which distinguishes them from theinferrededges 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_tois undirected, so its pair is stored in a canonical order. The response may returnsource_idandtarget_idswapped 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.
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
200with"deleted": 0.
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
deprecatedare excluded.- The
typefilter 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. Omittypeand filter client-side if you need completeness.
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.
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 |
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.
| 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. |
| 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.
Returns the total number of stored entries. No parameters.
curl "https://<your-worker-url>/count" -H "Authorization: Bearer YOUR_TOKEN"Response:
{ "count": 142 }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.
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 |
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 |
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 |
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.
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.
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 |
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.
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 |
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.
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 |
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 |
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.
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.
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
/mcpURL.
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.
? 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:
recallandlist_recentreturn 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. Callgeton anything marked truncated before quoting or acting on it.recall's REST counterpart (GET /recall) clampstopKto1–20with a default of5;list_recent's REST counterpart (GET /list) defaultsnto20with a max of100(the MCP tool itself defaults to10/max50).rememberdefaultssourcetoclaude, whereasPOST /capturedefaults it toapi.team(id fromlist_teams) targets one company workspace on a multi-team brain, used together withworkspace: "company". Omit it on a single-team brain. The REST equivalents (POST /capture,POST /share,GET /recall,GET /list,GET /graph) accept the sameteamparameter.project(slug) onrememberauto-creates an unknown project, exactly likePOST /capture. Onrecallandlist_recentan unknown slug instead returns the near-miss error text with known project slugs, the same as the REST read endpoints — see Theprojectparameter on other endpoints.
Returns the web dashboard (single-page HTML app). No auth required — auth is handled client-side via localStorage.