Skip to content

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).

  • Branch/ref parameter: most endpoints take a branch name in the URL path ({ref_name}) or as a ref query string / body field. Defaults to main.
  • 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. Its commit_id is then the ref’s head, where the value already is.

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:

  1. ?namespace=<ns> query parameter (takes precedence)
  2. X-CTXone-Namespace: <ns> header
  3. Neither present → the default namespace

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.

_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.

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.

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" }

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 snapshot
  • session_tokens_used — sum of tokens actually sent across all sessions
  • session_tokens_saved — sum of (recalls × flat_baseline) - used across all sessions
  • total_graph_size_chars — max observed across sessions (graph size is the whole graph’s, not summable)
  • total_graph_size_tokens — chars ÷ 4
  • cumulative_ratio — (used + saved) / used

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.

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.

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.

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 from cache_read_tokens (see the note below).
  • output_tokens (required) — tokens the model generated
  • cache_read_tokens — tokens served from the prompt cache, default 0
  • cache_create_tokens — tokens written to the prompt cache (Anthropic prompt caching), default 0
  • model — human-readable model identifier for display, optional
  • provider — 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 (its input_tokens excludes cache reads). OpenAI / Codex do not: their input_tokens includes the cached portion (cached_input_tokens is a subset), so a raw copy would count the cached tokens in both input and cache_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 (or 422 Unprocessable Entity, depending on axum’s extractor) when input_tokens or output_tokens are 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.

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 what ctx ingest-session calls after writing a session’s turns, so a re-ingest never inflates the split. Always fast.
  • Whole namespace — omit session to 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, default 20. The walk stops once the budget elapses (after finishing the in-flight session) and returns a cursor. 0 is treated as 1, 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 the next_offset from 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 namespace
  • processed — sessions handled this request
  • sessions_updated — of those, how many had turns to recompute from (a session with no stored turns is left untouched)
  • next_offset — where to resume, or null when finished
  • done — true once every session has been processed

Draining a large namespace. Loop, passing back the cursor, until done is true:

Terminal window
offset=0
while :; 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)
done

Turn-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.

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"
}
}

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-common last_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 a ModelUsage, as in GET /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 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"},
...
]

Recent commit history.

Query params:

  • limit — max commits (default: 20)

Response (200): array of commits. See the log response schema in CLI_REFERENCE.md.

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.

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.


List all branches.

Response (200):

[
{"name": "main", "id": "sg_e762..."},
{"name": "experiment", "id": "sg_a3b1..."}
]

Create a new branch.

Request body:

{
"name": "experiment",
"from": "main",
"if_missing": false,
"git_branch": "feature/experiment"
}
  • name (required) — branch to create
  • from — ref to branch from (default main)
  • if_missing — idempotent ensure: an already-existing branch is success, not an error (default false). 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_branch on the from ref (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).


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.

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.

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 unless namespace is given.
  • remote_url — git remote for detection. Kept in canonical form, host/path lowercased, without scheme, user, password, port, trailing / or .git, so git@github.com:alice/myrepo.git and https://github.com/alice/myrepo are both github.com/alice/myrepo. A remote with no such form (a local path) only loses a trailing .git and /. Host aliases aren’t resolved: an IP address and a DNS name for one server, or an ~/.ssh/config alias, 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 name
  • local_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).

Fetch one project. 404 if the id is unknown.

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.

Run the detection chain for a directory and report which namespace a session started there would land in:

  1. .ctxproject file (first non-empty line = project id) in cwd or any parent
  2. git remote get-url origin looked up in the registry (in the same canonical form as on write, so an ssh clone finds a project registered from https)
  3. the longest registered local_path containing cwd

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:

ParameterMeaning
client_read=trueThe 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:

StatusstatusWhen
500errorThe 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
503busy16 earlier detections are still stuck; refused without starting another
504timeoutDetection 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 — …" }

These are the endpoints CTXone’s memory layer adds on top of the underlying state primitives.

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 store
  • importance — high / medium / low (default medium). Maps to confidence 0.95/0.7/0.4.
  • context — category name; storage path is /memory/<context>/<id>
  • tags — queryable tags stored on the commit
  • ref — branch to write to (default main)

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..."
}

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 (default main)

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.

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
}

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 overwrites
  • pinned — if true, always include in recall; otherwise searchable (default false)
  • sections — parsed markdown sections from the client
  • ref — branch (default main)

Response (200):

{
"status": "ok",
"ref": "main",
"source": "project",
"pinned": true,
"sections_written": 5,
"paths": [
"/memory/pinned/project/the-insight",
"/memory/pinned/project/the-roadmap",
...
]
}

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.

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": [...]
}
]
}

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 absent
  • turns — true to scan raw session turns too (default false)
  • all — true to scan every namespace in the store instead
  • limit — 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.


StatusMeaningExample body
400Malformed request (missing required field, invalid namespace name)"missing field \fact`”`
403Cross-namespace merge denied"cross-namespace merge denied: ..."
404Path, ref, or namespace not found"ref not found: experiment"
409Conflict (duplicate project id / remote_url)"UNIQUE constraint failed: projects.id"
500Internal error (storage, engine)"tree error: ..."

The body is plain text, not JSON. Clients should log and retry on 5xx.


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 Requests
Retry-After: 3
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 0

Configure 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.

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 snapshot
  • GET /api/stats/sessions — all sessions
  • GET /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.

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.

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).

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)

List plans on a branch, optionally filtered by status. Response body is a JSON array of plan objects.

Fetch one plan with its full tasks[] list.

Remove a plan destructively. Use POST /api/plans/{name}/archive for a soft, reversible alternative.

Add a task. Body fields:

FieldTypeRequired
titlestringyes
descriptionstringno
prioritylow/medium/high/criticalno
parent_idstringno (subtask support)
assigned_tostringno — agent id
blocked_bystring[]no
refstringno

Returns the created task on 201.

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).

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" }
  • title keeps the task’s description, and description keeps its title ("" removes the description).
  • blocked_by replaces the whole list ([] clears it).
  • force: true allows 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 without force.

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.


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.

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 }

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.

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>.

Terminal window
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.


Pull-based scheduling. All reminder endpoints live under /api/reminders/*. Reminders are persisted to SQLite (same database as memory).

Create a reminder.

POST /api/reminders
X-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.

List reminders. Accepts query params: status, priority_at_most, created_by, due_before, ref_id, tags.

Return all actionable reminders (due or awaiting_permission), ordered by priority. Lazily promotes pending reminders whose due_at has passed.

Get a single reminder by id.

Defer to a later time. Body: { "until": "<ISO 8601>" }.

Approve a non-autonomous reminder. Body: { "approved_by"?: "..." }.

Cancel permanently.

Mark in-progress. Body: { "agent_id"?: "..." }.

Record execution outcome. Body: { "result": "success|failed|deferred|snoozed|cancelled", "notes"?: [...], "task_id"?: "...", "agent_id"?: "..." }.


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/*.


The HTTP surface (REST and /mcp) is guarded by an optional bearer token:

  • Set it with --auth-token <TOK> or CTXONE_AUTH_TOKEN=<TOK>, or keep tokens in a file with --auth-token-file <PATH> or CTXONE_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/health needs none. Loopback peers are exempt by default, so a hub bound to 127.0.0.1 needs no token and local tools keep working. --require-auth-on-loopback (or CTXONE_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 own ctx (session sync and import) the token as CTX_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 as Authorization: Bearer on 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 /mcp are 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.

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):

Terminal window
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 line disabled <tenant> <sha256 of the token> keeps a token and refuses it. Blank lines and # comments are ignored. Tenant names are 1 to 63 of a-z, 0-9 and -. Only hashes are stored, so the file doesn’t reveal a token.
  • ctxone-hub tenant edits the file (--tenants-file, or CTXONE_TENANTS_FILE): add <name> creates a tenant and prints its token once, list shows the tenants and their tokens, rotate <name> replaces a tenant’s tokens, and disable <name> and enable <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, or DATABASE_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 as ctxone.tenant, never the token.
  • A TenantResolver decides 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/health needs 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, ignore and unignore, /api/sessions/autosync, POST /api/admin/backup and /api/code. Project detection needs client_read=true (a 400 without it). GET /api/help and the MCP help tool 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.