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 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.
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).
Simple liveness check.
Response (200):
{
"status": "ok",
"service": "ctxone-hub"
}Used by ctx status and ctx doctor.
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 process-global, not summable)total_graph_size_tokens—chars ÷ 4cumulative_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.
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) — tokens the model consumed as inputoutput_tokens(required) — tokens the model generatedcache_read_tokens— tokens served from the prompt cache (Anthropic), default0cache_create_tokens— tokens written to the prompt cache (Anthropic), 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.
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.
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"
}
}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.
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", ...]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 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).
A project maps a code repo to an ASG namespace. The registry lives
in the Hub's sqlite database (projects and project_paths tables) —
memory/postgres backends have 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/user/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/user/myrepo"
}id(required) — project id (kebab-case). Doubles as the namespace name unlessnamespaceis given.remote_url— git remote for detection. Normalized on write: trailing.gitand/are stripped.namespace— explicit namespace name (default: the id)display_name— human-readable namelocal_path— checkout path to bind
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.
Fetch one project. 404 if the id is unknown.
Bind another local checkout to the project.
Request body: { "local_path": "/home/user/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:
.ctxprojectfile (first non-empty line = project id) incwdor any parentgit remote get-url originlooked up in the registry (URLs normalized the same way as on write)- the longest registered
local_pathcontainingcwd
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" }Non-sqlite backends report { "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 the Hub may not read it (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 on file access, e.g. 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 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)
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..."
}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.
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 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",
...
]
}List all pinned memories.
Response (200):
[
{"path": "/memory/pinned/project/the-insight/title", "value": "The Insight"},
{"path": "/memory/pinned/project/the-insight/body", "value": "..."},
...
]Clients typically group these by /memory/pinned/<source>/<slug> and pair
the /title and /body children to reconstruct structured sections.
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
}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.
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": [...]
}
]
}| 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.
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).
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 snapshotGET /api/stats/sessions— all sessionsGET /api/stats/tokens— cross-session aggregate (backward-compat)
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 (which is what ctx init wires into the
generated .mcp.json / .cursor/mcp.json etc).
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:
| 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.
List tasks in a plan, flat.
Fetch a single task.
Transition pending → in_progress. Returns the updated task. Returns
409 Conflict if blockers aren't done.
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.
Body: { "reason": "superseded" }. Reason is required (empty
reasons return 400).
Soft-archive a plan.
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.
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.md). The namespace query parameter selects
the project namespace exactly like the REST endpoints. The list of tools
served here is documented in MCP_TOOLS.md. 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>orCTXONE_AUTH_TOKEN=<TOK>. - When set, non-loopback requests must send
Authorization: Bearer <TOK>; requests are rejected with 401 otherwise. Loopback peers are always exempt, so a hub bound to127.0.0.1needs no token and local tools keep working. - 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 always passes; a foreign Origin is rejected unless allow-listed
via --allowed-origin / CTXONE_ALLOWED_ORIGINS (comma-separated).
For multi-tenant / keyed isolation beyond a single shared token, the engine's
agentstategraph-mcp binary supports --auth and --keys-file; CTXone Hub's
bearer token is a single-secret gate over the whole surface.
- 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