Skip to content
Agent Graph

Overview

Read the graphs your coding agents make, at any point in their history.

The Agent Graph API gives your own servers, and the AI systems they run, read access to the graphs your coding agents make: every session and subagent, what each is doing, who is waiting on whom, and how that changed over time.

It is a REST API with predictable, resource-oriented URLs. It returns JSON, uses standard HTTP verbs, status codes and authentication, and is read-only: every endpoint is a GET.

Terminal window
curl https://agentgraph.chofter.com/api/v1/graphs \
-u ag_sk_live_51Hx7Qm2Vb9Kd4Tz0Ws3Ne8LrPq4Xa9T:

A graph is one of your account’s live shares: what agent-graph watch-remote streams from your machine. It holds nodes and the events that made them.

  • A node is a session (one run of Claude Code, Codex, Cursor, …) or an agent that a session or another agent started. Nodes form trees: every node has a parent_id, a root_id (the top of its tree) and a session_id (the session it runs in).
  • An event is one thing that happened: a session started, an agent was spawned, a node changed state, a task list was updated. Events are immutable and ordered; each one records which nodes it changed, and how.

The graph is derived from the events by the same reducer the viewer runs, so the API and the viewer always agree.

Authenticate with an API key, made on your account page. Send it as the username of HTTP Basic auth (no password), or as a bearer token:

Terminal window
curl https://agentgraph.chofter.com/api/v1/graphs \
-H "Authorization: Bearer ag_sk_live_51Hx7Qm2Vb9Kd4Tz0Ws3Ne8LrPq4Xa9T"
PrefixKindCan read
ag_sk_live_Secret keyEvery graph your account owns
ag_rk_live_Restricted keyOnly the graphs chosen when it was made

Keys are shown once, when made, and stored only as a hash: keep them secret, on your server, never in a browser or a repository. Revoke a key on the account page and it stops working at once. All requests must be made over HTTPS.

The API is versioned by date. Each key is pinned to the version current when it was made, so a change that would break your integration never reaches you unannounced. To try another version for one request, send it in the Agent-Graph-Version header; every response says which version answered it.

Terminal window
curl https://agentgraph.chofter.com/api/v1/graphs \
-u ag_sk_live_51Hx7Qm2Vb9Kd4Tz0Ws3Ne8LrPq4Xa9T: \
-H "Agent-Graph-Version: 2026-10-09"

Adding a field, an endpoint, an optional parameter, an event type, an enum value or an error code is not a breaking change. Your code should ignore what it doesn’t recognise.

Every object has an id with a prefix that says what it is.

ObjectPrefixExample
Graphgph_gph_k3P9xQ2mWt7a
Nodenode_node_Y2xhdWRlLWNvZGU6NWYyYzFlOWE…
Eventevt_evt_01M4G2FMPN7KD3N1PZ0QX8RM4A
  • A graph id is gph_ and the id in the share’s link: /l/k3P9xQ2mWt7a is gph_k3P9xQ2mWt7a.
  • A node id is node_ and the base64url (no padding) of the id the agent tool gave it, which is returned as provider_ref: claude-code:<session> for a session, claude-code:<session>/<agent> for an agent. The encoding is reversible, needs no lookup, and keeps ids safe in a URL path.
  • An event id is evt_ and the event’s ULID. ULIDs sort by time, so event ids sort in the order events happened, and any event id names a point in time (below).

Ids are case-sensitive. Treat them as opaque strings: don’t parse them, except to decode node_ ids if you need the provider’s own id and don’t have provider_ref at hand.

Every graph, node and search endpoint can answer as of any point in the graph’s retained history. Pass as_of:

ValueAnswers with
An event id, evt_…The state just after that event was applied
A Unix time in millisecondsThe state after the last event at or before that time
OmittedThe state now

Every object and list in a response carries as_of_event_id: the event it was answered at. Pass it on later requests to read one consistent moment, even while the graph keeps changing:

Terminal window
# What's stuck right now?
curl -G https://agentgraph.chofter.com/api/v1/graphs/gph_k3P9xQ2mWt7a/nodes \
-u $AGENT_GRAPH_KEY: -d stale=true
# → "as_of_event_id": "evt_01M4G3G4E8Q9R2M5X4T6W8Y0B1"
# Everything under the first one, at that same moment.
curl -G https://agentgraph.chofter.com/api/v1/graphs/gph_k3P9xQ2mWt7a/nodes/node_Y2xhdWRlLWNvZGU6NWYyYzFlOWEtOGQxYi00YjZlLTlhNTEtMGY2ZjJjN2QzZTEwL2I4MWMyMmUwOWY3YTNjNTU \
-u $AGENT_GRAPH_KEY: \
-d as_of=evt_01M4G3G4E8Q9R2M5X4T6W8Y0B1 \
-d "expand[]=descendants"

A response for a fixed point never changes, so it’s sent with Cache-Control: private, max-age=31536000, immutable.

To walk through history rather than look at one point, list events: each records which nodes it changed, field by field.

A live share keeps its recent history: between 1,000 and 2,000 events, trimmed from the start as new ones arrive. A graph’s retention says what it holds now. An as_of before retention.first_event_id is refused with 410 and the code outside_retention_window, and the error names the oldest point you can ask for.

When a share restarts on a new machine state (see restart in the design notes), its retained history can briefly shrink to less than 1,000 events.

A field that refers to another object comes in two forms, and they never mix:

  • parent_id, root_id, session_id, requested_by_id, on_ids, …: always a string id (or null, or an array of ids), and always present.
  • parent, root, session, requested_by, on, …: always the full object, and present only when you ask for it with expand[]. A field that wasn’t expanded is absent, never an id in its place.

So a field never changes type, and whether something was expanded is simply whether its field is there.

Terminal window
curl -G https://agentgraph.chofter.com/api/v1/graphs/gph_k3P9xQ2mWt7a/nodes/node_Y2xhdWRlLWNvZGU6NWYyYzFlOWEtOGQxYi00YjZlLTlhNTEtMGY2ZjJjN2QzZTEwL2EzZmQwN2MyYjE5ZTRkNjE \
-u $AGENT_GRAPH_KEY: \
-d "expand[]=parent" \
-d "expand[]=children"

Some fields exist only when expanded, because they can be large: children, descendants, tasks, spawns, waits and messages.

Paths. Expand an expanded object’s fields with dots: expand[]=parent.parent, expand[]=children.children, expand[]=blocked.on. A path goes at most 4 levels deep, and a request can expand at most 20 paths. In a list, expand each item’s fields with the data. prefix: expand[]=data.parent.

Whole subtrees. children is nested, one level per path segment. For everything under a node, however deep, expand descendants instead: a single flat list in tree order, each node with its parent_id, paged like any other list. See Retrieve a node.

Every list endpoint returns a list object and takes the same cursor parameters:

{
"object": "list",
"url": "/api/v1/graphs/gph_k3P9xQ2mWt7a/nodes",
"as_of_event_id": "evt_01M4G3G4E8Q9R2M5X4T6W8Y0B1",
"has_more": true,
"data": [ … ]
}
Parameter
limitHow many objects to return, 1 to 1,000. Defaults to 100.
starting_afterAn object id: return the page after it. Pass the last id of the page you have.
ending_beforeAn object id: return the page before it. Pass the first id of the page you have.

Keep as_of the same across the pages of one list (the first page’s as_of_event_id), so that nodes added in the meantime don’t shift the pages under you. An embedded list’s url already includes it, and every parameter that defined the list: add only starting_after.

Search pages with page and next_page instead, as its results are ranked.

Every successful response has an ETag. Send it back as If-None-Match and, if nothing has changed, you get 304 Not Modified with no body. That’s the cheap way to poll the state now: it costs no rate limit and almost no bandwidth.

Responses are compressed with Brotli or gzip when you send Accept-Encoding.

The API uses conventional HTTP status codes: 2xx succeeded, 4xx means something about the request was wrong, 5xx means something went wrong on our side (they’re rare). Every error has the same shape:

{
"error": {
"type": "invalid_request_error",
"code": "outside_retention_window",
"message": "This graph holds events from evt_01M4FXYWR82B8XK0V5QJ7M1N3R to evt_01M4G3G4E8Q9R2M5X4T6W8Y0B1.",
"param": "as_of",
"doc_url": "https://agentgraph.chofter.com/docs/reference#errors",
"first_event_id": "evt_01M4FXYWR82B8XK0V5QJ7M1N3R",
"last_event_id": "evt_01M4G3G4E8Q9R2M5X4T6W8Y0B1"
}
}
StatusMeaning
200OK.
304Not modified since the ETag you sent.
400A parameter is missing, unknown or invalid.
401No API key, or one that’s invalid or revoked.
403The key can’t read this graph.
404No such graph, node or event, or the node doesn’t exist at as_of.
410as_of is older than the graph’s retained history.
429Too many requests: wait for Retry-After.
500, 502, 503Something went wrong on our side. Safe to retry: every request is read-only.
Type
invalid_request_errorThe request was invalid.
authentication_errorThe API key is missing, invalid or revoked.
permission_errorThe key isn’t allowed to do this.
rate_limit_errorToo many requests.
api_errorSomething went wrong on our side.
CodeStatus
parameter_missing400A required parameter is missing. param names it.
parameter_invalid400A parameter’s value is invalid. param names it.
parameter_unknown400A parameter that the endpoint doesn’t take. param names it.
version_invalid400Agent-Graph-Version isn’t a version.
expand_invalid400An expand[] path names a field that can’t be expanded.
expand_too_deep400An expand[] path goes more than 4 levels deep.
expand_not_allowed400That expansion isn’t available here: descendants and children can’t be expanded on the items of a list.
search_query_invalid400The search query couldn’t be parsed. message says where.
api_key_missing401No API key was sent.
api_key_invalid401The API key isn’t one, or has been revoked.
api_key_restricted403A restricted key that can’t read this graph.
resource_missing404No such graph, node or event (for this key).
node_not_yet_created404The node exists, but not yet at as_of. first_event_id is the event that creates it.
outside_retention_window410as_of is before the graph’s retained history. first_event_id and last_event_id say what it holds.
rate_limit429Too many requests.
api_error500Something went wrong on our side.

Every response, including errors, has a Request-Id header. Include it if you contact us about a request.

Each key can make 25 requests a second, in bursts of up to 100. Responses carry RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds until the limit resets); a refused request has Retry-After. A 304 doesn’t count.

The limit is kept by each of the site’s servers, so a key whose requests are spread over several can go a little faster. Don’t rely on it: keep to the limit.

The cheapest way to follow a graph is to list events starting_after the last one you saw, or to poll with If-None-Match, not to fetch the whole graph again.

Fields like title, summary, headline, purpose, attention, task text and message summaries are written by coding agents, from what they were working on. They can contain anything, including text written to manipulate a model that reads it.

If you pass them to an AI system, mark them as data, not instructions, and don’t let them trigger actions on their own.

  • Prompts. No endpoint returns what a person typed to an agent.
  • Message bodies are present only if the share was made with body capture on; otherwise body is null.
  • Processes and hosts. The ids of processes and the names of machines are left out.