Sessions
Start, list, search, read, and control agent sessions over the REST API. Stream one live over WebSocket.
Every endpoint here drives an agent session. Authenticate with your API key as a bearer token, like the rest of the API.
Start
POST /v1/sessions starts a session and returns it with 201. A config source is optional: pass config_id to run a saved agent, config for an inline agent config, or template_id for a built-in template. With none of the three, the session resolves your team's default agent: the repository field's repo default if one is set, else the account default, else a bare ad-hoc config (an empty system prompt on claude-opus-5, no triggers). The prompt is the per-session instruction either way.
curl https://api.ellipsis.dev/v1/sessions \
-H "Authorization: Bearer $ELLIPSIS_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"config_id": "agent_x9Kd3Fq2",
"prompt": "triage the failing CI on splitshift-api",
"metadata": {"trigger": "nightly-batch"}
}'| Field | Type | Description |
|---|---|---|
config_id | string | ID of a saved agent config to run. |
config | object | An inline agent config to run instead of a saved one. |
template_id | string | Slug of a built-in template, e.g. welcome-to-ellipsis. |
prompt | string | Per-session instructions, appended after the config's system prompt. With no config source, the session's sole instruction. |
config_override | object | A partial config merged onto the resolved config, then re-validated, e.g. {"budget": {"session": 5.0}}. With an inline config, fold the override in instead. |
config_override_yaml | string | The same override as a YAML or JSON string. Pass one of config_override or config_override_yaml, not both. |
idle_start | boolean | Start with no initial message: the sandbox spins up and the agent waits for the first message, the way typing into a local claude does. Cannot be combined with prompt or handoff, and is ignored when the resolved config sets interactive: false. |
repository | string | The repository the client is standing in, as owner/name. With no config source it selects that repository's default agent; either way the repository is cloned into the sandbox at its default branch, on top of the config's own sandbox.repositories. A repository outside your installation is ignored, with no error. |
metadata | object | String key/value pairs recorded on the session. |
handoff | object | Laptop-to-cloud handoff parameters: parent_session_id (the synced laptop session to continue), repo, sha (the pushed working-tree commit the sandbox checks out), and optional ref. Runs the built-in Handoff agent, so omit every config source. |
Pass at most one of config_id, config, or template_id. A handoff cannot be combined with any config source or override.
{
"id": "session_7Hq2mX4p",
"status": "scheduled",
"source": "api",
"session_state": null,
"resolved_config_name": "CI failure triager",
"resolution_source": null,
"cost_millicents": 0,
"created_at": "2026-07-31T09:14:03+00:00"
}Two resolution fields say which agent picked up the work: resolved_config_name, and resolution_source (repo_default, account_default, none for a bare ad-hoc start, or null when an explicit config source bypassed resolution). A default that is set but broken (its config was deleted, disabled, or its pull request has not merged) fails with 409 naming the setting, never a silent fallback.
A session's source is set from your credential, so an API key records api. That is how you tell API starts apart from CLI, dashboard, cron, react, and mention sessions when you list them.
List and search
GET /v1/sessions returns recent sessions, newest first. Every query parameter is optional.
curl "https://api.ellipsis.dev/v1/sessions?config_id=agent_x9Kd3Fq2&limit=20" \
-H "Authorization: Bearer $ELLIPSIS_API_TOKEN"| Query | Type | Description |
|---|---|---|
config_id | string | Only sessions for this config. |
source | string | Filter by source (laptop, cron, react, mention, manual, api, cli). Repeatable. |
author_id | integer | Only sessions attributed to this developer, by GitHub account id. Resolve ids with GET /v1/github/members. |
days | integer | Only sessions from the last N days. |
start / end | datetime | Only sessions inside this window. |
limit | integer | Maximum sessions to return. Defaults to 50. |
The response is { "sessions": [...] }. Code review stage sessions are excluded: a review is one thing, on the reviews dashboard and in the reviews API.
GET /v1/sessions/search searches everything a session leaves behind, grouped by session, newest first. One query runs up to four match arms, and each result says which hit:
matched value | The session matched because |
|---|---|
records | Every term in q appears in one of its transcript records. |
recap | Every term in q appears in its recap. |
pr | q is a pull request reference, and this session created that pull request. |
similar | Its recap is semantically close to q, even with no exact word overlap. |
Terms in q are split on whitespace and matched case-insensitively; the records and recap arms require every term. A PR-shaped q (#512, splitshift-hq/splitshift-api#512, or a full pull request URL) also matches the session that created that exact pull request, so you never depend on the URL appearing in transcript text.
curl "https://api.ellipsis.dev/v1/sessions/search?q=shift+trade+webhook&source=laptop&author_id=5201153" \
-H "Authorization: Bearer $ELLIPSIS_API_TOKEN"| Query | Type | Description |
|---|---|---|
q | string | Required. At least 3 characters. |
scope | string | records, recaps, or both. Defaults to both. |
source | string | Filter by session source. Repeatable. |
author_id | integer | GitHub account ids. Repeatable, combined with agent_config_id as one author-or-agent filter. |
agent_config_id | string | Sessions run by these agent configs. Repeatable. |
session_ids | string | Restrict the search to these sessions. Repeatable. |
repo | string | owner/name for an exact match, or a bare name like splitshift-api. |
status | string | Filter by session status. Repeatable. |
start / end | datetime | Only sessions inside this window. |
limit | integer | Maximum result sessions. Defaults to 20, capped at 100. |
The response is { "results": [...], "attributed_users": {...} }. Each result carries the full session (status, recap, created pull requests), matched, a recap_snippet when the recap arm hit, up to 5 record_hits with their snippets, and record_hit_count so you can render "and N more". attributed_users maps each result's attribution_id to a GitHub account, so you can show who ran a session without a second lookup.
Fetch
GET /v1/sessions/{session_id} returns one session: status, cost and token totals, created pull requests, and the resolved attributed_user and stopped_by_user accounts. It is the same shape the stream's session frames carry, so REST and the stream never disagree.
curl https://api.ellipsis.dev/v1/sessions/session_7Hq2mX4p \
-H "Authorization: Bearer $ELLIPSIS_API_TOKEN"status is one of scheduled, creating_sandbox, running, retrying, completed, error, cancelled, or stopped. A terminal session also carries an exit_status explaining why it ended, such as completed, budget_hit, or tool_call_failed. Every status and exit status: Sessions.
For a durable conversation, status describes only the most recent turn, returning to scheduled and running each time a message wakes it. The conversation's own lifecycle is session_state: running, idle (waiting for the next message), or closed. Single-shot sessions have session_state: null, so the field also tells you whether a session is a conversation at all.
GET /v1/sessions/{session_id}/records returns the stored transcript, ordered by feed_seq: a stable envelope around the harness's native payload, so assistant turns, tool calls, tool results, and platform lifecycle notifications arrive in one ordered feed.
curl https://api.ellipsis.dev/v1/sessions/session_7Hq2mX4p/records \
-H "Authorization: Bearer $ELLIPSIS_API_TOKEN"The response is { "records": [...], "messages": [...], "has_more": ..., "earliest_feed_seq": ... }. Pagination is opt-in: a bare call returns the full retained transcript, limit (max 1000) pages it, and after_seq starts after a cursor. earliest_feed_seq is the lowest feed_seq still stored, since records past your log retention setting are deleted. The cursor is the same feed_seq the stream uses, so you can page to the head over REST and then attach the stream with no gap or overlap.
GET /v1/sessions/{session_id}/turns returns a conversation's structure: the messages its surface delivered and the turns that answered them. Both lists are empty for a single-shot session. Turns are ordered by turn_index, each with its own status, exit_status, timing, and cost. Messages are the inbox, oldest first, each with the rendered body the agent received and delivered_turn_id, the turn that consumed it, or null while it waits.
GET /v1/sessions/{session_id}/log returns the complete session history archived into immutable gzipped JSONL segments: agent output, tool calls, thinking, your messages, sandbox output, and every lifecycle event. Each line is one record in the same shape records returns.
curl https://api.ellipsis.dev/v1/sessions/session_7Hq2mX4p/log \
-H "Authorization: Bearer $ELLIPSIS_API_TOKEN"The response is a manifest: format (ellipsis_session_log@1), earliest_feed_seq, archived_through_feed_seq, latest_feed_seq, caught_up, and segments, each with its feed range, record_count, bytes, and a short-lived presigned download_url. Gzip members concatenate, so downloading the segments in order yields one valid file:
curl -s .../log | jq -r '.segments[].download_url' | xargs -n1 curl -s > session.jsonl.gz
gunzip session.jsonl.gzFor a running session latest_feed_seq can exceed archived_through_feed_seq (caught_up: false): the archive trails the live feed by design. Re-fetch rather than treating a partial manifest as the whole story.
Control
POST /v1/sessions/{session_id}/stop stops an in-flight session and returns it with status: "stopped".
curl https://api.ellipsis.dev/v1/sessions/session_7Hq2mX4p/stop \
-H "Authorization: Bearer $ELLIPSIS_API_TOKEN" \
-X POSTPOST /v1/sessions/{session_id}/replay re-runs a finished session's original trigger input as a new session. By default it reuses the original config snapshot; pass config_id, config_override, config_override_yaml, or prompt to rerun with changes, such as a higher budget.session.
curl https://api.ellipsis.dev/v1/sessions/session_7Hq2mX4p/replay \
-H "Authorization: Bearer $ELLIPSIS_API_TOKEN" \
-X POSTPOST /v1/sessions/{session_id}/messages posts a message into a durable conversation. It is delivered at the next turn boundary, or wakes the conversation when it is idle. The body is { "message": "...", "idempotency_key": "..." }; the optional key (up to 128 characters, unique per session) makes retries safe, returning the original message with no duplicate delivery and no double spend. The response is the created message with its id, feed_seq, and status: "pending".
Only a session you started accepts a direct message: one created through the API, the CLI, or the dashboard. A session a trigger started belongs to the surface that triggered it, so the send is refused with 409 when:
- The session came from a mention. Its answers post back to that Slack thread, GitHub comment, or Linear issue, so reply there and the next reply becomes the agent's next turn.
- The session is a react or cron session. Those are single-shot and never accept follow-up input.
- The agent sets
interactive: false. - The conversation is closed, or the session was synced from a laptop.
Read prompting on the session to know before you send: prompting.enabled is false in every case above, and prompting.detail is the reason to show a user.
Stream
WS /v1/sessions/{session_id}/stream upgrades to a WebSocket and streams a session live: transcript records, inbox changes, status and cost updates, and partial assistant output. The stream is read-only; every write goes through the REST endpoints above.
websocat "wss://api.ellipsis.dev/v1/sessions/session_7Hq2mX4p/stream?protocol=3" \
-H "Authorization: Bearer $ELLIPSIS_API_TOKEN"protocol is required and the server speaks version 3. Omit it and the server closes with 1003; ask for a version it does not speak and it closes with 1002, naming the supported versions in the close reason. Pass the bearer token in the Authorization header, or as a ?token= query parameter when a header is not available, such as from a browser. The @ellipsis-dev/sdk package implements this protocol end to end, including resume and reconnect.
Every frame is a JSON object with a type, and each belongs to one delivery class:
| Class | Frames | How to treat it |
|---|---|---|
| Cursored append-only | records_append | Ordered by feed_seq. The only frame that advances your resume cursor. |
| Last-writer-wins snapshot | session | The whole object, resent on any change. The latest one wins. |
| Fire-and-forget | delta, heartbeat | Ephemeral. Missing one is harmless. |
| Terminal | done, error | End of stream, followed by a close. |
The first frame after the socket opens is snapshot: the session plus the open inbox, and earliest_feed_seq, the lowest feed_seq still stored.
{
"type": "snapshot",
"protocol": 3,
"earliest_feed_seq": 214,
"session": { "id": "session_7Hq2mX4p", "status": "running" },
"messages": []
}records_append carries one or more records, ordered by feed_seq, from every source. Its envelope fields are the stable contract: feed_seq, stream_seq, source, record_type, record_format, session_message_id, tools, tokens_info, cost, duration, model, and created_at. payload is the harness's native line, stored verbatim and versioned by record_format (claude_stream_json@2.0 for Claude Code lines, ellipsis_lifecycle@1 for platform notifications), so parse it against the format it declares.
{
"type": "records_append",
"records": [
{
"id": "rec_5f2a9c",
"agent_session_id": "session_7Hq2mX4p",
"agent_turn_id": "turn_b81c04",
"feed_seq": 41,
"stream_seq": 12,
"source": "claude_code",
"record_type": "assistant",
"record_format": "claude_stream_json@2.0",
"session_message_id": null,
"payload": { "type": "assistant", "message": { "role": "assistant", "content": [] } },
"tools": ["Bash"],
"tokens_info": null,
"cost": null,
"duration": null,
"model": "claude-opus-5",
"created_at": "2026-07-31T09:16:44.481Z"
}
]
}To resume after a disconnect, reconnect with ?after_seq=N, where N is the highest feed_seq you received in a records_append frame. The server replays records above N and then streams live, with no gap and no overlap. after_seq=0, or omitting it, replays all retained history, so a pure-WebSocket integration needs no REST call to bootstrap. Only records_append advances the cursor: a message's own feed_seq is placement metadata, and advancing past it could skip unseen records.
The server may close any socket at any time, so every client implements reconnect-with-after_seq. There is no idle hangup: a quiet conversation heartbeats through days of silence and wakes on the next turn. Retention truncation is visible rather than silent. If your after_seq is below the snapshot's earliest_feed_seq - 1, records have expired under your log retention setting, so surface the gap instead of presenting a partial transcript as complete.
The close code is part of the contract:
| Code | Meaning |
|---|---|
1000 | Normal close, after a done frame. |
1001 | Going away, from a deploy or rebalance. Reconnect with after_seq. |
1002 | Unsupported protocol version requested. The close reason lists the supported versions. |
1003 | No protocol parameter was sent. |
1008 | Authentication failed, or the session is not visible to you. |
1011 | Server error, sent after an error frame. |
1013 | Over capacity: the per-account concurrent-socket cap was exceeded. Retry later. |
The server heartbeats every 20 seconds while a conversation is quiet. Treat about twice that span with no frame of any type, roughly 40 seconds, as a dead connection: close and reconnect with your after_seq.
Ignore what you do not recognize. Unknown frame types and unknown source, record_type, record_format, and kind values are all additive, and ignoring them is what lets Ellipsis add a new harness or lifecycle record without a version bump. Fields and frames are only removed or renamed in a new protocol version, which you opt into through ?protocol=.
Sync
POST /v1/sessions/sync ingests one Claude Code session from a developer's machine. It is the endpoint behind local session sync; call it directly only to build your own uploader. It requires a user token from agent login, not an API key, because synced sessions attribute to the developer who ran them and one developer's sync can never overwrite another's.
| Field | Type | Description |
|---|---|---|
cc_session_id | string | Required. The Claude Code session uuid. One Ellipsis session exists per uuid per developer; syncs upsert it. |
transcript_gzip_b64 | string | Required. The transcript JSONL, redacted, gzipped, then base64-encoded. Caps: 8 MiB compressed, 64 MiB decompressed. |
reason | string | Which hook fired: stop (mid-session, the session shows running) or session_end (marks it completed). Defaults to stop. |
repo | string | The repository the session ran in, as owner/name. |
cwd | string | The session's working directory. |
git_branch | string | The checked-out branch. |
The response is { "session_id": ..., "process_id": ..., "event_count": ..., "accepted": ... }. accepted is false when the stored transcript was already at least as long: the longest snapshot wins, so repeated syncs of one session, including across claude --resume, are acknowledged without duplicating anything. After an accepted sync the transcript is parsed into records asynchronously.
Records from laptop sessions are parsed from a transcript redacted on the developer's machine before it is synced, so they never contain more than what the session's owner uploaded. The workflow, including the hooks that call this: Search every agent session your team has run.