HTTP API
The CTXone Hub exposes a REST API over HTTP when run with --http. This
doc lists every endpoint, its request format, response format, and any
query parameters.
All endpoints live under http://<host>:<port>/api/. Default host and port:
0.0.0.0:3001. The same daemon also serves MCP over HTTP at /mcp and,
with --lens, the web UI — one process, all three surfaces (see
MCP over HTTP and Authentication below).
Cross-origin requests are guarded, not wide open: same-origin is always
allowed, and any request carrying a foreign Origin header is rejected unless
that origin is allow-listed (--allowed-origin / CTXONE_ALLOWED_ORIGINS).
Conventions
Section titled “Conventions”- Branch/ref parameter: most endpoints take a branch name in the URL
path (
{ref_name}) or as arefquery string / body field. Defaults tomain. - Namespace parameter: every ref-touching endpoint also accepts a
namespace (see Namespaces below). Defaults to
default. - Content type: requests and responses use
application/json. - Error responses: HTTP 4xx for bad input, 5xx for server errors. Body is plain text with a human-readable message.
- Unchanged writes: a write that would leave its path holding what it
already holds makes no commit:
remember,prime, imports, session turns, titles, meta, provenance, burn and analysis, summaries, plan and session links, and doc registrations. Itscommit_idis then the ref’s head, where the value already is.
Namespaces
Section titled “Namespaces”A namespace isolates everything ref-scoped: branches, plans, memory
(facts / pinned / primed / sessions), taints, reminders, and history.
One namespace typically maps to one code repo via the
project registry. Pre-existing data lives in the
reserved default namespace; nothing migrates.
Every ref-touching endpoint (state/paths/search, log, blame,
diff, merge, branches, memory/*, plans/*, taint/*,
reminders/*, session turns) resolves its namespace the same way:
?namespace=<ns>query parameter (takes precedence)X-CTXone-Namespace: <ns>header- Neither present → the
defaultnamespace
Rules:
- Namespace names are ASCII
[A-Za-z0-9_-], 1–64 bytes. An invalid name returns 400. - Namespaces are created by registering a project
(
POST /api/projects); ref operations in a namespace that doesn’t exist return 404. - Cross-namespace merge is deny-by-default: attempting it returns 403 (per AgentStateGraph’s isolation rules).
- Names beginning with
_are reserved for CTXone; a project can’t use one.
The personal namespace
Section titled “The personal namespace”_personal holds what belongs to the person rather than to a project: the
agent rules ctx agents install primes, and preferences remembered with
ctx remember --personal. Any request may name it with
?namespace=_personal, and the first one creates it. It has only main.
Recall reads it as well as the request’s namespace, and so does
GET /api/memory/pinned. Each result names the namespace it came from in
namespace, and where both hold the same path, the workspace’s own copy
wins. On a multi-tenant Hub, each tenant has its own. It will sync only to
the user’s own space (the Hub sync design).
Over MCP, where the namespace is fixed for the connection, remember,
forget and prime take personal: true instead. A personal write isn’t
refused when a session fell back to default, since it names where it goes.
Health
Section titled “Health”GET /api/health
Section titled “GET /api/health”Simple liveness check. It needs no token on any Hub, so load balancers and container health checks can call it, and it isn’t rate limited. A token sent with it is still checked: a wrong one is a 401.
Response (200):
{ "status": "ok", "service": "ctxone-hub"}Used by ctx status and ctx doctor.
GET /api/tenant
Section titled “GET /api/tenant”The tenant the request’s bearer token belongs to, so a client can check a
token (see Multi-tenant mode). null on a single-tenant
Hub.
Response (200):
{ "tenant": "alice" }GET /api/stats/tokens
Section titled “GET /api/stats/tokens”Cumulative token savings aggregated across every session.
Response (200):
{ "session_id": "_aggregate", "session_tokens_used": 98, "session_tokens_saved": 1706, "total_graph_size_chars": 1804, "total_graph_size_tokens": 451, "cumulative_ratio": 18.43}session_id— always"_aggregate"to signal this is a roll-up, not a single-session snapshotsession_tokens_used— sum of tokens actually sent across all sessionssession_tokens_saved— sum of(recalls × flat_baseline) - usedacross all sessionstotal_graph_size_chars— max observed across sessions (graph size is the whole graph’s, not summable)total_graph_size_tokens—chars ÷ 4cumulative_ratio—(used + saved) / used
GET /api/stats/tokens/{session_id}
Section titled “GET /api/stats/tokens/{session_id}”Stats for a single logical session. session_id is whatever clients
pass in the X-CTXone-Session header; absent clients roll up under
"default".
Response (200):
{ "session_id": "alice@example.com", "session_tokens_used": 42, "session_tokens_saved": 658, "total_graph_size_chars": 1804, "total_graph_size_tokens": 451, "cumulative_ratio": 16.67}Returns 404 if the session ID has never been seen. Sessions are
created lazily the first time a read endpoint (recall, context)
records token usage for them.
GET /api/stats/sessions
Section titled “GET /api/stats/sessions”List every known session with its current stats.
Response (200):
[ { "session_id": "alice@example.com", "session_tokens_used": 42, "session_tokens_saved": 658, "total_graph_size_chars": 1804, "total_graph_size_tokens": 451, "cumulative_ratio": 16.67 }, { "session_id": "bob@example.com", "session_tokens_used": 120, "session_tokens_saved": 1200, "total_graph_size_chars": 1804, "total_graph_size_tokens": 451, "cumulative_ratio": 11.00 }, { "session_id": "default", "session_tokens_used": 0, "session_tokens_saved": 0, "total_graph_size_chars": 1804, "total_graph_size_tokens": 451, "cumulative_ratio": 0.0 }]Sorted by session_id. The "default" session is always present even
on a fresh Hub.
PUT /api/sessions/{sid}/usage
Section titled “PUT /api/sessions/{sid}/usage”Replace a session’s per-model LLM usage, and keep it in the session’s
envelope at /sessions/{sid}/usage. ctx ingest-session and
ctx session import send it, worked out from the whole transcript, so a Hub
that gets no turns still has per-model usage. Sending the same usage again
writes nothing, and a grown session’s usage replaces the old rather than adding
to it.
Request body:
{ "by_model": { "claude-opus-4-8": { "input_tokens": 119, "output_tokens": 29518, "cache_read_tokens": 3342296, "cache_create_tokens": 95623, "call_count": 5 } }, "last_model": "claude-opus-4-8"}For OpenAI-family models, input_tokens leaves out the cached tokens, as
everywhere else on the Hub. At most 256 models, each name 1 to 200 bytes; an
empty by_model leaves the session’s split as it is.
The envelope holds the same split plus the session’s tokens_sent and
tokens_saved as of the call. It travels with the workspace (export and
import), so a Hub with no statistics for a session lists it in
GET /api/stats/sessions and costs it in GET /api/stats/plan/{plan}/cost
from the envelope. Dollar costs aren’t stored: Lens prices the tokens when it
reads them.
Response (200): {"status": "ok", "ref", "path", "commit_id"}, or
{"status": "unchanged", "ref", "path"} when the envelope already held it.
POST /api/stats/llm_usage
Section titled “POST /api/stats/llm_usage”Record one LLM turn’s token usage against the caller’s session.
Agents call this after each significant LLM turn with numbers copied
straight from the provider response’s usage field. Returns the
updated SessionSnapshot so callers see running totals in one round
trip.
The session is resolved via X-CTXone-Session (same mechanism as
every other endpoint). Unknown sessions are auto-created.
Request body:
{ "input_tokens": 2400, "output_tokens": 450, "cache_read_tokens": 1800, "cache_create_tokens": 600, "model": "claude-sonnet-4.5", "provider": "anthropic"}input_tokens(required) — fresh input tokens the model consumed, NOT counting anything served from cache. Report input disjoint fromcache_read_tokens(see the note below).output_tokens(required) — tokens the model generatedcache_read_tokens— tokens served from the prompt cache, default0cache_create_tokens— tokens written to the prompt cache (Anthropic prompt caching), default0model— human-readable model identifier for display, optionalprovider— provider identifier (anthropic,openai,gemini, …), optional
All token fields are u64; negative or malformed values are rejected
by the JSON parser.
Cached tokens must not be double-counted. CTXone treats the four fields as disjoint — the total for a call is
input + output + cache_read + cache_create. Anthropic already reports input this way (itsinput_tokensexcludes cache reads). OpenAI / Codex do not: theirinput_tokensincludes the cached portion (cached_input_tokensis a subset), so a raw copy would count the cached tokens in bothinputandcache_read. Subtract the cached count from input before reporting:input = input_tokens − cached_input_tokens. Getting this wrong inflates the token total (~2× on cache-heavy sessions) and overcharges cost, since the cached tokens would be priced at the full input rate instead of the cache-read rate.
Response (200):
{ "session_id": "alice@example.com", "session_tokens_used": 12, "session_tokens_saved": 340, "total_graph_size_chars": 1804, "total_graph_size_tokens": 451, "cumulative_ratio": 29.33, "llm_input_tokens": 2400, "llm_output_tokens": 450, "llm_cache_read_tokens": 1800, "llm_cache_create_tokens": 600, "llm_call_count": 1, "last_model": "claude-sonnet-4.5", "last_provider": "anthropic"}Error responses:
400 Bad Request(or422 Unprocessable Entity, depending on axum’s extractor) wheninput_tokensoroutput_tokensare missing, non-numeric, or negative.
Recall integration: once a session has reported LLM usage at
least once, every subsequent GET /api/memory/recall from the same
session carries a session_llm_stats sub-object so agents see the
running totals alongside the results:
{ "results": [...], "ctx_tokens_sent": 300, "ctx_tokens_estimated_flat": 1500, "ctx_savings_ratio": 5.0, "pinned_count": 2, "topic_matches": 3, "session_llm_stats": { "input_tokens_total": 12500, "output_tokens_total": 3200, "cache_read_tokens_total": 8900, "cache_create_tokens_total": 450, "call_count": 17 }}The field is only present for sessions that have reported usage — sessions that haven’t see the same shape they’ve always seen.
POST /api/stats/backfill_by_model
Section titled “POST /api/stats/backfill_by_model”Recompute each session’s per-model token split from its stored
per-turn snapshots, and replace the stored split with the result.
Every turn at /sessions/{id}/turns/{idx} already carries its own
model and tokens, so this recomputes the per-model breakdown
without re-ingesting (no double-counting) and without touching the
graph (no memory loss). It is idempotent — running it again yields
the same replacement. Scoped to the request namespace
(X-CTXone-Namespace).
Use it after importing sessions recorded before the per-model split existed, or any time a session’s per-model numbers look wrong.
Cached-token normalization. As it recomputes, the backfill enforces
the disjoint-fields rule above: for OpenAI / Codex-family models (gpt-*,
codex*, o1/o3/o4) it subtracts the cached count from input
(input = input_tokens − cache_read), since those providers fold cached
tokens into input_tokens. Anthropic turns are left as-is. This is why
recomputing is the right fix for historical Codex sessions whose totals
were inflated — it happens at aggregation, so stored turns keep their
provider-native shape and re-running stays idempotent.
Two modes:
- One session — pass
?session=<id>to recompute just that session. This is whatctx ingest-sessioncalls after writing a session’s turns, so a re-ingest never inflates the split. Always fast. - Whole namespace — omit
sessionto walk every session. The walk is bounded per request so it can never exceed the HTTP timeout, no matter how large the namespace’s database is.
Query parameters (whole-namespace mode):
max_secs— soft wall-clock budget in seconds, default20. The walk stops once the budget elapses (after finishing the in-flight session) and returns a cursor.0is treated as1, and at least one session is always processed so a spent budget can’t stall.limit— optional hard cap on how many sessions this request processes, applied on top of the time budget.offset— resume cursor: skip this many sessions (in sorted order) before processing. Pass back thenext_offsetfrom the previous response.
Response (200):
{ "status": "ok", "namespace": "default", "sessions_updated": 25, "turns_scanned": 4820, "models": ["claude-opus-4-8", "gpt-5.2-codex"], "total": 60, "processed": 25, "next_offset": 25, "done": false}total— sessions in the namespaceprocessed— sessions handled this requestsessions_updated— of those, how many had turns to recompute from (a session with no stored turns is left untouched)next_offset— where to resume, ornullwhen finisheddone—trueonce every session has been processed
Draining a large namespace. Loop, passing back the cursor, until
done is true:
offset=0while :; do resp=$(curl -s -X POST \ "http://localhost:3001/api/stats/backfill_by_model?offset=$offset" \ -H "X-CTXone-Namespace: default") echo "$resp" [ "$(echo "$resp" | jq -r .done)" = "true" ] && break offset=$(echo "$resp" | jq -r .next_offset)doneTurn-less sessions. Backfill only replaces a session’s split when it finds stored turns to recompute from — a session with none is left as-is. If such a session’s stored numbers are wrong, backfill cannot fix them; delete the session (
DELETE /api/sessions/{sid}) instead.
GET /api/stats/{ref_name}
Section titled “GET /api/stats/{ref_name}”Structural stats for a branch.
Response (200):
{ "commit_count": 27, "path_count": 21, "branch_count": 2, "epoch_count": 0, "agents": ["ctxone", "ctxone-prime"], "categories": ["Checkpoint", "Custom(\"Observe\")"], "latest_commit": { "id": "sg_e762325fed96", "timestamp": "2026-04-14T17:47:43Z", "agent": "ctxone", "intent": "fact description" }}GET /api/namespaces/summary
Section titled “GET /api/namespaces/summary”A hub-global rollup with one entry per workspace (namespace) — the data behind the Hub Home overview. One call returns token and graph aggregates for every workspace, so the dashboard needs no per-workspace fan-out. Short-TTL cached (~15s) since it walks every namespace.
Response (200):
{ "workspaces": [ { "namespace": "default", "session_count": 153, "representative_model": "gpt-5.2", "tokens": { "used": 1700, "saved": 5200, "llm_input": 5100000000, "llm_output": 27100000, "llm_cache_read": 7500000000, "llm_cache_create": 69500000, "by_model": { "claude-opus-4-8": { "input_tokens": 126025, "output_tokens": 1781988, "cache_read_tokens": 754047192, "cache_create_tokens": 36456103, "call_count": 214 }, "gpt-5.4": { "input_tokens": 2497100792, "output_tokens": 6201704, "cache_read_tokens": 2373367552, "cache_create_tokens": 0, "call_count": 1412 } } }, "graph": { "commit_count": 10000, "path_count": 10000, "branch_count": 41, "epoch_count": 0 } } ]}representative_model— the workspace’s most-commonlast_model, used only as a label.tokens.llm_*— the four token classes summed across the workspace’s sessions.tokens.by_model— the same totals split per model (each entry is aModelUsage, as inGET /api/stats/sessions).
Cost is priced per model. The Hub Home cost tile sums each model’s
tokens at that model’s own rate (input + output + cache-read +
cache-write), then adds the per-model costs — the same basis as the
workspace LLM-usage panel. Pricing the whole workspace at a single
representative model would mis-price a mixed-model workspace: one that is
mostly Opus but whose most-common model is a cheaper model would read far
too low. by_model is what makes the accurate, per-model figure
possible; representative_model is kept only for the label. (Older hubs
that predate by_model fall back to the single-model estimate.)
Read endpoints (state)
Section titled “Read endpoints (state)”GET /api/state/{ref_name}?path=<path>
Section titled “GET /api/state/{ref_name}?path=<path>”Read a value at a specific path.
Query params:
path— JSON path to read (default:/)
Response (200): the value at that path, pretty-printed JSON.
GET /api/state/{ref_name}/paths?prefix=<prefix>&max_depth=<n>
Section titled “GET /api/state/{ref_name}/paths?prefix=<prefix>&max_depth=<n>”List all paths under a prefix.
Query params:
prefix— path prefix (default:/)max_depth— max tree depth (default: 50)
Response (200): array of path strings.
["/memory/licensing/abc", "/memory/architecture/def", ...]GET /api/state/{ref_name}/search?query=<q>&max_results=<n>
Section titled “GET /api/state/{ref_name}/search?query=<q>&max_results=<n>”Literal substring search across values and keys.
Query params:
query— substring to match (case-insensitive)max_results— max results (default: 50)
Response (200):
[ {"path": "/memory/licensing/abc", "value": "CTXone uses BSL-1.1"}, ...]Log and blame
Section titled “Log and blame”GET /api/log/{ref_name}?limit=<n>
Section titled “GET /api/log/{ref_name}?limit=<n>”Recent commit history.
Query params:
limit— max commits (default: 20)
Response (200): array of commits. See the log response schema in
CLI_REFERENCE.md.
GET /api/blame/{ref_name}?path=<path>
Section titled “GET /api/blame/{ref_name}?path=<path>”Provenance chain for a specific path.
Query params:
path— path to blame
Response (200): array of blame entries with commit id, agent, timestamp, intent, and confidence.
GET /api/diff?ref_a=<a>&ref_b=<b>
Section titled “GET /api/diff?ref_a=<a>&ref_b=<b>”Diff two refs.
Query params:
ref_a— first ref (usually older / base)ref_b— second ref (usually newer / target)
Response (200):
{ "ref_a": "main", "ref_b": "experiment", "ops": [ {"op": "AddKey", "path": "/memory/test", "key": "abc", "value": "..."}, {"op": "SetValue", "path": "/...", "old": {...}, "new": {...}}, {"op": "RemoveKey", "path": "/...", "key": "..."} ]}Op tags: SetValue, AddKey, RemoveKey, AppendItem, RemoveItem.
Branches
Section titled “Branches”GET /api/branches
Section titled “GET /api/branches”List all branches.
Response (200):
[ {"name": "main", "id": "sg_e762..."}, {"name": "experiment", "id": "sg_a3b1..."}]POST /api/branches
Section titled “POST /api/branches”Create a new branch.
Request body:
{ "name": "experiment", "from": "main", "if_missing": false, "git_branch": "feature/experiment"}name(required) — branch to createfrom— ref to branch from (defaultmain)if_missing— idempotent ensure: an already-existing branch is success, not an error (defaultfalse). Branch mirroring re-ensures on every CLI invocation with this flag.git_branch— optional raw git branch this ASG branch mirrors. Recorded once, on actual creation, as metadata at/ctxone/branches/<name>/git_branchon thefromref (sanitization is lossy:feature/x→feature-x).
Response (200):
{ "status": "ok", "name": "experiment", "from": "main", "existed": false, "commit_id": "sg_a3b1..."}existed is true when if_missing was set and the branch already
existed (in which case commit_id is omitted).
Project endpoints
Section titled “Project endpoints”A project maps a code repo to an ASG namespace. The registry lives
in the Hub’s database: the sqlite file’s projects and project_paths
tables, or on Postgres the ctxone_projects and ctxone_project_paths
tables, where every row carries the Hub’s tenant, so Hubs sharing a
database never see each other’s projects. The memory backend has no
registry, so project endpoints return 400 there. Registered projects
are what the CLI and MCP server use to auto-detect the namespace from a
working directory.
GET /api/projects
Section titled “GET /api/projects”List registered projects.
Response (200):
[ { "id": "myrepo", "remote_url": "https://github.com/alice/myrepo", "namespace": "myrepo", "display_name": "My Repo", "created_at": "2026-07-01T12:00:00Z", "local_paths": ["/home/alice/myrepo"], "asd_repos": ["myrepo"] }]asd_repos lists the pool-managed ASD repos whose db path lives under
one of the project’s local_paths. The binding is derived, not stored —
registering an ASD repo under a project’s path is what binds it.
POST /api/projects
Section titled “POST /api/projects”Register a project and create + initialize its ASG namespace
(idempotent — the namespace gets an initialized main branch so ref
operations work immediately).
Request body:
{ "id": "myrepo", "remote_url": "https://github.com/alice/myrepo", "namespace": "myrepo", "display_name": "My Repo", "local_path": "/home/alice/myrepo"}id(required) — project id (kebab-case). Doubles as the namespace name unlessnamespaceis given.remote_url— git remote for detection. Kept in canonical form,host/pathlowercased, without scheme, user, password, port, trailing/or.git, sogit@github.com:alice/myrepo.gitandhttps://github.com/alice/myrepoare bothgithub.com/alice/myrepo. A remote with no such form (a local path) only loses a trailing.gitand/. Host aliases aren’t resolved: an IP address and a DNS name for one server, or an~/.ssh/configalias, are different remotes. The Hub rewrites remotes registered before this in canonical form when it starts and when it opens a tenant; ids and workspaces don’t change. When two projects’ remotes turn out to be one repository, the older one gets the canonical form, and the other keeps its remote and is found by its path or.ctxproject(the Hub logs it).namespace— explicit namespace name (default: the id)display_name— human-readable namelocal_path— checkout path to bind
The Hub also keeps the project’s namespace, remote and display name in the
personal namespace at /ctxone/projects/<id>, so
the registry can follow the person to other machines. Local paths stay on
this machine. At every start (and tenant open) the Hub writes any record
that’s missing or differs, and registers a project for a record it has no
row for, without local paths.
Response (200): the project object (same shape as GET /api/projects
entries). Returns 409 on a duplicate id or remote_url, 400
on an invalid namespace name or an id or namespace beginning with _
(reserved for CTXone).
GET /api/projects/{id}
Section titled “GET /api/projects/{id}”Fetch one project. 404 if the id is unknown.
POST /api/projects/{id}/paths
Section titled “POST /api/projects/{id}/paths”Bind another local checkout to the project.
Request body: { "local_path": "/home/alice/myrepo-worktree" }
Response (200): the updated project object. 404 if the id is unknown.
GET /api/projects/detect?cwd=/abs/path
Section titled “GET /api/projects/detect?cwd=/abs/path”Run the detection chain for a directory and report which namespace a session started there would land in:
.ctxprojectfile (first non-empty line = project id) incwdor any parentgit remote get-url originlooked up in the registry (in the same canonical form as on write, so an ssh clone finds a project registered from https)- the longest registered
local_pathcontainingcwd
Steps 1 and 2 read the directory, which only works on the machine it is
on. So ctx reads it itself and sends what it found; the Hub then reads
nothing and only looks it up, which also works for a Hub on another
machine:
| Parameter | Meaning |
|---|---|
client_read=true | The caller read cwd; use the parameters below instead of reading it |
ctxproject=<id> | The id in the nearest .ctxproject |
ctxproject_error=<msg> | The nearest .ctxproject exists but couldn’t be read |
remote_url=<url> | git remote get-url origin as git prints it (the Hub normalizes it) |
Without client_read (older clients), the Hub reads cwd on its own
machine. A multi-tenant Hub reads nothing of its own
and answers 400.
Response (200):
{ "status": "found", "via": "ctxproject", "project_id": "myrepo", "namespace": "myrepo" }via is "ctxproject", "remote" (also carries remote_url) or
"local_path" (also carries local_path). No match:
{ "status": "not_found", "namespace": "default" }The memory backend reports { "status": "registry_unavailable", "namespace": "default" }.
Only those three 200 answers say where a session belongs. Anything else
means detection did not finish, and the directory may well have a project —
callers must not treat it as not_found:
| Status | status | When |
|---|---|---|
| 500 | error | The registry query failed, or a .ctxproject exists but couldn’t be read (ctxproject_error, or the Hub’s own read; on macOS, a denied Files & Folders prompt) and no later step matched |
| 503 | busy | 16 earlier detections are still stuck; refused without starting another |
| 504 | timeout | Detection ran past 3 s — typically the Hub blocked reading cwd for an older client, e.g. on a pending macOS privacy prompt |
{ "status": "timeout", "error": "project detection did not finish within 3s — …" }Memory endpoints (the high-level API)
Section titled “Memory endpoints (the high-level API)”These are the endpoints CTXone’s memory layer adds on top of the underlying state primitives.
POST /api/memory/remember
Section titled “POST /api/memory/remember”Store a fact.
Request body:
{ "fact": "CTXone uses BSL-1.1 licensing", "importance": "high", "context": "licensing", "tags": ["legal", "decision"], "ref": "main"}fact(required) — the string to storeimportance—high/medium/low(defaultmedium). Maps to confidence 0.95/0.7/0.4.context— category name; storage path is/memory/<context>/<id>tags— queryable tags stored on the commitref— branch to write to (defaultmain)
To remember something personal, send it to the personal namespace:
POST /api/memory/remember?namespace=_personal with ref main
(The personal namespace).
Response (200):
{ "status": "ok", "ref": "main", "path": "/memory/licensing/18a6...", "commit_id": "sg_e762..."}POST /api/memory/forget
Section titled “POST /api/memory/forget”Delete a memory at a specific path.
Request body:
{ "path": "/memory/licensing/18a6...", "reason": "superseded by new policy", "ref": "main"}Marked in blame as a Rollback intent with the given reason.
Response (200):
{ "status": "ok", "ref": "main", "path": "/memory/licensing/18a6...", "commit_id": "sg_next..."}GET /api/memory/recall?topic=<t>&budget=<n>&ref=<r>
Section titled “GET /api/memory/recall?topic=<t>&budget=<n>&ref=<r>”Retrieve memories for a topic. Pinned-first, token-scored, budget-capped.
Query params:
topic— query string (tokenized, multi-word supported)budget— max token budget (default 1500)ref— branch (defaultmain)
Response (200): see the recall response schema in
CLI_REFERENCE.md.
Recall reads the personal namespace’s main as
well, and each result carries the namespace it came from.
Every recall updates the session token counters — each call’s sent
contributes to session_tokens_used on GET /api/stats/tokens.
GET /api/memory/context/{project}?ref=<r>
Section titled “GET /api/memory/context/{project}?ref=<r>”Load the full context tree for a project.
Response (200):
{ "project": "myproject", "ref": "main", "context": { "status": "active", "decisions": {...} }, "ctx_tokens_sent": 234, "ctx_tokens_estimated_flat": 1191}POST /api/memory/prime
Section titled “POST /api/memory/prime”Load structured sections as pinned or searchable memory.
Request body:
{ "source": "project", "pinned": true, "sections": [ {"title": "The Insight", "body": "..."}, {"title": "The Roadmap", "body": "..."} ], "ref": "main"}source(required) — group name; re-priming the same source overwritespinned— if true, always include in recall; otherwise searchable (default false)sections— parsed markdown sections from the clientref— branch (defaultmain)
Response (200):
{ "status": "ok", "ref": "main", "source": "project", "pinned": true, "sections_written": 5, "paths": [ "/memory/pinned/project/the-insight", "/memory/pinned/project/the-roadmap", ... ]}GET /api/memory/pinned
Section titled “GET /api/memory/pinned”List all pinned memories.
Response (200):
[ {"path": "/memory/pinned/project/the-insight/title", "value": "The Insight", "namespace": "myrepo"}, {"path": "/memory/pinned/project/the-insight/body", "value": "...", "namespace": "myrepo"}, {"path": "/memory/pinned/ctxone-agents/standing-rules/title", "value": "Standing rules", "namespace": "_personal"}, ...]Clients typically group these by /memory/pinned/<source>/<slug> and pair
the /title and /body children to reconstruct structured sections.
The workspace’s own leaves come first, then the
personal namespace’s, leaving out any path the
workspace also holds. namespace says which one each came from.
Returns an empty array (not 404) when no pinned memories exist.
POST /api/memory/summarize_session
Section titled “POST /api/memory/summarize_session”End-of-session commit capturing what was learned.
Request body:
{ "session_id": "2026-04-14-afternoon", "key_points": ["Shipped Postgres backend", "Built auth middleware"], "decisions": ["SaaS as on-ramp", "agent memory is top priority"]}Response (200):
{ "status": "ok", "session_id": "2026-04-14-afternoon", "key_points": 2, "decisions": 2}GET /api/memory/what_changed_since?since=<iso>
Section titled “GET /api/memory/what_changed_since?since=<iso>”Recent commits filtered to those after a timestamp.
Query params:
since— ISO 8601 timestamp (e.g.,2026-04-12T00:00:00Z)
Response (200): array of commit summaries.
GET /api/memory/why_did_we?decision=<text>
Section titled “GET /api/memory/why_did_we?decision=<text>”Search for a decision and return its blame chain.
Query params:
decision— substring of the decision to look up
Response (200):
{ "decision": "use BSL-1.1", "traces": [ { "path": "/memory/licensing/abc", "blame": [...] } ]}GET /api/secrets/scan
Section titled “GET /api/secrets/scan”Credential-shaped strings in what would sync: every branch of the request’s
namespace and of the personal namespace. Raw session turns are skipped
unless asked for. This is what ctx secrets scan calls. A scan reads the
personal namespace but doesn’t create it.
Query params:
ref— scan one branch; every branch when absentturns—trueto scan raw session turns too (defaultfalse)all—trueto scan every namespace in the store insteadlimit— stop after this many findings (default 1000, at most 10000)
Response (200):
{ "scanned": [{ "namespace": "repo-a", "ref": "main" }, { "namespace": "_personal", "ref": "main" }], "turns": false, "strings": 4182, "findings": [ { "namespace": "repo-a", "ref": "main", "path": "/memory/ci/18e45270505dc388", "rule": "github-token", "name": "GitHub token", "confidence": "high", "masked": "ghp_…(40 chars)", "forget": "/memory/ci/18e45270505dc388" } ], "truncated": false}masked never holds the secret. forget is the record to pass to
POST /api/memory/forget (a memory entry, or one part of a session’s
envelope), or null where there’s none, such as a plan task. strings
counts each string read once; content shared by several branches is read
once for all of them. confidence is high for a provider’s own token
format or a private key, and medium for shapes ordinary text sometimes
takes.
Error responses
Section titled “Error responses”| Status | Meaning | Example body |
|---|---|---|
| 400 | Malformed request (missing required field, invalid namespace name) | "missing field \fact`”` |
| 403 | Cross-namespace merge denied | "cross-namespace merge denied: ..." |
| 404 | Path, ref, or namespace not found | "ref not found: experiment" |
| 409 | Conflict (duplicate project id / remote_url) | "UNIQUE constraint failed: projects.id" |
| 500 | Internal error (storage, engine) | "tree error: ..." |
The body is plain text, not JSON. Clients should log and retry on 5xx.
Rate limiting
Section titled “Rate limiting”The Hub enforces a per-peer-IP token-bucket rate limit in HTTP mode. Default: 600 requests/minute per IP (permissive — catches runaway loops without bothering real agents). Loopback peers aren’t limited. In multi-tenant mode the limit is per tenant instead, wherever its requests come from, loopback included.
Clients that exceed the bucket get:
HTTP/1.1 429 Too Many RequestsRetry-After: 3X-RateLimit-Limit: 600X-RateLimit-Remaining: 0Configure via --rate-limit-rpm <N> or the CTXONE_RATE_LIMIT_RPM env
var. 0 disables rate limiting entirely. See
docs/TROUBLESHOOTING.md#rate-limiting
for details.
Per-session token tracking
Section titled “Per-session token tracking”Send X-CTXone-Session: <id> on any request to have its token usage
counted under that session. Absent the header, usage rolls up under
the "default" session. Per-session stats are exposed via:
GET /api/stats/tokens/{session_id}— single-session snapshotGET /api/stats/sessions— all sessionsGET /api/stats/tokens— cross-session aggregate (backward-compat)
The Hub saves session stats every 30 seconds and on shutdown, and loads
them at startup: to the ctxone_sessions table of its sqlite file, or of
its Postgres database, one row set per tenant. The memory backend keeps
them only while it runs.
The Python client accepts a session_id constructor arg or reads
CTX_SESSION_ID from the environment.
Per-tool agent IDs
Section titled “Per-tool agent IDs”Send X-CTXone-Agent: <name> on any write request
(remember/forget/prime/summarize_session/merge) to stamp
the commit with that agent ID. ctx blame and /api/log/{ref}
responses surface this as agent_id, so you can tell which tool
wrote each fact.
Absent the header, commits are attributed to "ctxone". The Python
client accepts an agent_id constructor arg or reads CTX_AGENT_ID
from the environment; the Hub binary accepts --agent-id <name>
for MCP stdio mode.
MCP over HTTP (/mcp) reads the agent ID the same way, from
X-CTXone-Agent or, for clients that can’t send headers, ?agent=<name>
(which wins). ctx init writes the tool’s ID into each entry it generates
(.mcp.json, .cursor/mcp.json and so on: a header, an mcp-remote
--header, or Codex’s query string). An MCP request that names none, or
one longer than 128 bytes or with a control character, is attributed to
the Hub’s --agent-id.
See docs/TROUBLESHOOTING.md#per-tool-agent-ids for the full resolution order and examples.
Plan endpoints
Section titled “Plan endpoints”All plan endpoints live under /api/plans/* and honor
X-CTXone-Agent for blame attribution + X-CTXone-Session for stats.
A ref query parameter selects the branch (default main).
POST /api/plans
Section titled “POST /api/plans”Create a plan.
POST /api/plans{ "name": "website-v2", "description": "Brand pivot", "ref": "main"}→ 201 Created{ "name": "website-v2", "description": "Brand pivot", "status": "active", "created_by": "claude-code", "created_at": "2026-04-16T…", "task_counts": { "pending": 0, "in_progress": 0, "done": 0, "abandoned": 0, "total": 0 }}→ 409 Conflict (plan already exists)GET /api/plans?ref=main&status=active
Section titled “GET /api/plans?ref=main&status=active”List plans on a branch, optionally filtered by status. Response body is a JSON array of plan objects.
GET /api/plans/{name}?ref=main
Section titled “GET /api/plans/{name}?ref=main”Fetch one plan with its full tasks[] list.
DELETE /api/plans/{name}?ref=main
Section titled “DELETE /api/plans/{name}?ref=main”Remove a plan destructively. Use POST /api/plans/{name}/archive for
a soft, reversible alternative.
POST /api/plans/{name}/tasks
Section titled “POST /api/plans/{name}/tasks”Add a task. Body fields:
| Field | Type | Required |
|---|---|---|
title | string | yes |
description | string | no |
priority | low/medium/high/critical | no |
parent_id | string | no (subtask support) |
assigned_to | string | no — agent id |
blocked_by | string[] | no |
ref | string | no |
Returns the created task on 201.
GET /api/plans/{name}/tasks?ref=main
Section titled “GET /api/plans/{name}/tasks?ref=main”List tasks in a plan, flat.
GET /api/plans/{name}/tasks/{task_id}?ref=main
Section titled “GET /api/plans/{name}/tasks/{task_id}?ref=main”Fetch a single task.
POST /api/plans/{name}/tasks/{task_id}/start
Section titled “POST /api/plans/{name}/tasks/{task_id}/start”Transition pending → in_progress. Returns the updated task. Returns
409 Conflict if blockers aren’t done.
POST /api/plans/{name}/tasks/{task_id}/complete
Section titled “POST /api/plans/{name}/tasks/{task_id}/complete”Transition in_progress → done with a proof:
{ "proof": { "kind": "commit", "value": "ef6ce63" } }Proof kind is one of commit / file / test / text. Returns
400 Bad Request when the proof value is empty or the kind is
unknown.
POST /api/plans/{name}/tasks/{task_id}/abandon
Section titled “POST /api/plans/{name}/tasks/{task_id}/abandon”Body: { "reason": "superseded" }. Reason is required (empty
reasons return 400).
PATCH /api/plans/{name}/tasks/{task_id}
Section titled “PATCH /api/plans/{name}/tasks/{task_id}”Change a task’s title, description, priority or blockers. Send only the fields to change:
{ "title": "Rewrite the hero", "blocked_by": ["t-009"], "ref": "main" }titlekeeps the task’s description, anddescriptionkeeps its title (""removes the description).blocked_byreplaces the whole list ([]clears it).force: trueallows editing a done or abandoned task.
Status, proof, assignment and timestamps never change. Returns the task
with changed, the fields that changed (empty when nothing did).
Errors:
400: an empty edit, a blank title, or an unknown priority.404: an unknown task, or a blocker that isn’t in the plan.409: blockers that would form a cycle, or a finished task withoutforce.
POST /api/plans/{name}/archive
Section titled “POST /api/plans/{name}/archive”Soft-archive a plan.
GET /api/plans/{name}/next?ref=main&assigned_to=me&include_unassigned=true&assigned_only=false
Section titled “GET /api/plans/{name}/next?ref=main&assigned_to=me&include_unassigned=true&assigned_only=false”Return the highest-priority pickable task wrapped as
{ "task": { … } } or { "task": null }. Pass assigned_to=me to
filter to tasks assigned to the agent carried by X-CTXone-Agent —
this is the state-driven orchestration primitive.
Epoch endpoints (sealed checkpoints)
Section titled “Epoch endpoints (sealed checkpoints)”Completing a plan automatically seals a per-workspace epoch — a tamper-evident, point-in-time snapshot of the workspace’s memory graph at plan close. These endpoints view and download them.
POST /api/plans/backfill_epochs
Section titled “POST /api/plans/backfill_epochs”One-time retroactive seal of an epoch for every already-Completed plan in
the workspace, so existing workspaces show checkpoints without waiting to
re-close plans. Idempotent (skips plans already sealed); safe to re-run.
Scoped to the request namespace (X-CTXone-Namespace).
Response (200):
{ "status": "ok", "namespace": "default", "sealed": 12, "already_sealed": 0, "failed": 0 }GET /api/epochs
Section titled “GET /api/epochs”The sealed checkpoints for this workspace, newest-sealed first. Pass
?all=true for the hub-wide view (every workspace, each row tagged with its
namespace).
Response (200):
{ "epochs": [ { "id": "plan:default:website-v2", "namespace": "default", "plan": "website-v2", "status": "Sealed", "created_at": "2026-08-25T04:44:52Z", "sealed_at": "2026-08-25T19:30:43Z", "commit_count": 14577 } ]}id— the epoch id,plan:<namespace>:<plan-id>.plan— the plan whose completion sealed it.commit_count— the number of commits captured in the seal (sealed_commits).
ASG epochs live in one global table, so the Hub attributes each to its workspace by the
plan:<namespace>:id prefix.
GET /api/epochs/{id}/export
Section titled “GET /api/epochs/{id}/export”Download one sealed epoch’s audit bundle (epoch metadata + the sealed-commit
manifest) as a JSON attachment. Pass the epoch’s own namespace via
?namespace=<ns>.
curl -OJ "http://localhost:3001/api/epochs/plan:default:website-v2/export?namespace=default"Returns Content-Disposition: attachment so a browser download works; the
id must be a plan: epoch, or the request is rejected (400), and an
unknown id returns 404.
Reminder endpoints
Section titled “Reminder endpoints”Pull-based scheduling. All reminder endpoints live under /api/reminders/*.
Reminders are persisted to SQLite (same database as memory).
POST /api/reminders
Section titled “POST /api/reminders”Create a reminder.
POST /api/remindersX-CTXone-Agent: claude-code
{ "title": "Check feature flag rollout", "instructions": "Query the metrics dashboard and report adoption %.", "due_at": "2026-05-10T09:00:00Z", "priority": "medium", "autonomous": false}→ 201 Created with the reminder JSON.
GET /api/reminders
Section titled “GET /api/reminders”List reminders. Accepts query params: status, priority_at_most,
created_by, due_before, ref_id, tags.
GET /api/reminders/due
Section titled “GET /api/reminders/due”Return all actionable reminders (due or awaiting_permission),
ordered by priority. Lazily promotes pending reminders whose due_at
has passed.
GET /api/reminders/{id}
Section titled “GET /api/reminders/{id}”Get a single reminder by id.
POST /api/reminders/{id}/snooze
Section titled “POST /api/reminders/{id}/snooze”Defer to a later time. Body: { "until": "<ISO 8601>" }.
POST /api/reminders/{id}/approve
Section titled “POST /api/reminders/{id}/approve”Approve a non-autonomous reminder. Body: { "approved_by"?: "..." }.
POST /api/reminders/{id}/cancel
Section titled “POST /api/reminders/{id}/cancel”Cancel permanently.
POST /api/reminders/{id}/start
Section titled “POST /api/reminders/{id}/start”Mark in-progress. Body: { "agent_id"?: "..." }.
POST /api/reminders/{id}/record
Section titled “POST /api/reminders/{id}/record”Record execution outcome. Body: { "result": "success|failed|deferred|snoozed|cancelled", "notes"?: [...], "task_id"?: "...", "agent_id"?: "..." }.
MCP over HTTP
Section titled “MCP over HTTP”The same daemon that serves this REST API also serves MCP over HTTP (Streamable HTTP) at:
POST /mcp?namespace=<ns>This is what ctx init --transport http points AI tools at, and it’s the
standard setup (one process covers MCP + REST + Lens; see
Architecture). The namespace query parameter selects
the project namespace exactly like the REST endpoints. The list of tools
served here is documented in MCP tools. The bearer token and
Origin guard below apply to /mcp just as they do to /api/*.
Authentication
Section titled “Authentication”The HTTP surface (REST and /mcp) is guarded by an optional bearer
token:
- Set it with
--auth-token <TOK>orCTXONE_AUTH_TOKEN=<TOK>, or keep tokens in a file with--auth-token-file <PATH>orCTXONE_AUTH_TOKEN_FILE(not both). The file holds one token per line (blank lines and#comments are ignored), every listed token is accepted, and the hub re-reads it every second, so tokens rotate without a restart: add the new token, move clients to it, then remove the old line. An empty file accepts no token. If a later read fails, the tokens last read stay in force; a file that can’t be read at startup stops the hub. - When set, non-loopback requests must send
Authorization: Bearer <TOK>; requests are rejected with 401 otherwise.GET /api/healthneeds none. Loopback peers are exempt by default, so a hub bound to127.0.0.1needs no token and local tools keep working.--require-auth-on-loopback(orCTXONE_REQUIRE_AUTH_ON_LOOPBACK=1) ends the exemption, for a hub fronted by a proxy or sidecar on 127.0.0.1, where every request arrives from loopback. It needs a token, and with it the hub hands its ownctx(session sync and import) the token asCTX_TOKEN. - Lens sends a token once the hub asks for one. On a 401 it asks for
the token, checks it with
GET /api/tenant, keeps it in the browser’s storage for the hub’s origin, and sends it asAuthorization: Beareron every call. A refused token is forgotten and asked for again; Forget token in the sidebar removes it. Its own files need no token. - When unset, there is no authentication. If the hub is bound to a non-loopback
interface, it logs a warning that REST and
/mcpare reachable unauthenticated — set a token before exposing it beyond loopback.
Cross-origin requests are additionally guarded (see the intro):
same-origin passes; a foreign Origin is rejected unless allow-listed via
--allowed-origin / CTXONE_ALLOWED_ORIGINS (comma-separated). With
--strict-origin (or CTXONE_STRICT_ORIGIN=1) only the listed origins pass:
an Origin matching the request’s Host isn’t enough, since behind an ingress
or under DNS rebinding Host is whatever the caller sent. A hosted hub lists
its Lens origin and sets this.
Multi-tenant mode
Section titled “Multi-tenant mode”One Hub can serve many tenants, each with its own data, from one Postgres database (the multi-tenant Hub design; how to run one: Deployment):
ctxone-hub --http --tenants-file /etc/ctxone/tenants \ --storage postgres --database-url "$DATABASE_URL"--tenants-file <PATH> (or CTXONE_TENANTS_FILE) turns it on. It needs --http
and Postgres; --storage memory keeps the tenants in memory, to try it out. It
replaces --auth-token, --auth-token-file and --tenant. Every tenant shares
one connection pool of --pg-pool-size connections (or CTXONE_PG_POOL_SIZE;
default 32). With --lens, Lens asks each browser for a tenant’s token, as
above, and shows that tenant’s data, leaving out the Code views, session import
and transcript sync, which are off. With --strict-origin too, list the Hub’s
own origin with --allowed-origin, since that’s where Lens calls from; with no
--allowed-origin the Hub refuses to start.
- The tenants file has a line per token:
<tenant> <sha256 of the token>, the hash as 64 hex digits. A tenant may have several lines. A linedisabled <tenant> <sha256 of the token>keeps a token and refuses it. Blank lines and#comments are ignored. Tenant names are 1 to 63 ofa-z,0-9and-. Only hashes are stored, so the file doesn’t reveal a token. ctxone-hub tenantedits the file (--tenants-file, orCTXONE_TENANTS_FILE):add <name>creates a tenant and prints its token once,listshows the tenants and their tokens,rotate <name>replaces a tenant’s tokens, anddisable <name>andenable <name>refuse or accept them, keeping the tenant’s data.purge <name>deletes the data of a tenant the file lists as disabled from the database (--database-url, orDATABASE_URL), and then its tokens. It asks for the tenant’s name first unless given--yes, and waits a minute so that every Hub has closed the tenant.- It’s re-read every second, so adding a tenant or rotating a token needs no restart. A file with a bad line stops the hub at startup. Later, a bad line is skipped with a warning and the rest apply, so a typo locks out only that line. A hash listed for two tenants is refused for both. If the file can’t be read, the tenants last read stay in force; an empty file refuses every token.
- Every request needs a token that belongs to a tenant, loopback included,
since there’s no local user. The exception is
GET /api/health, which needs none. An unknown token is a 401. The tenant is attached to the request, rate limits are per tenant, and request log lines carry it asctxone.tenant, never the token. - A
TenantResolverdecides the tenant. The Hub’s reads the tenants file; an account service can supply its own. One that can’t answer gives a 503. - Each tenant has its own data: memory, plans, workspaces, the project
registry, session statistics and MCP sessions. Tenants can use the same
workspace names, project ids and session ids without meeting, and an MCP
session belongs to the tenant that started it. A tenant opens on its first
request that needs its data, which creates the tenant if it’s new and brings
its schema up to date. If it can’t be opened, the request gets a 503 and
the next one tries again.
GET /api/healthneeds no data and opens nothing. - A tenant closes when it’s idle, 30 minutes after its last request, and within 30 seconds once none of its tokens is accepted. Its session statistics are saved every 30 seconds while it’s open, when it closes and when the Hub stops.
- Nothing acts on the Hub’s own machine, since the tenants are elsewhere.
These answer 404:
POST /api/sessions/sync,GET /api/sessions/discoverable,POST /api/sessions/import,ignoreandunignore,/api/sessions/autosync,POST /api/admin/backupand/api/code. Project detection needsclient_read=true(a 400 without it).GET /api/helpand the MCPhelptool answer from the Hub’s own docs and ask no other program. The MCP code tools say there’s nothing to reach. The flags for those features (--asd-url,--asd-path,--asd-serve-binary,--asd-idle-timeout,--ctx-binary) are refused at startup, and the Hub logs what’s off.
See also
Section titled “See also”- CLI_REFERENCE.md — the
ctxCLI, which wraps this API - MCP_TOOLS.md — the MCP tools, which wrap the same underlying logic
- ARCHITECTURE.md — how recall ranks, how the graph is structured