Container Session API Spec
Container Session API Spec
Scope
- This API is public and unauthenticated for now.
- This spec only covers live session inspection plus CRUD for messages inside existing sessions.
- Workspace file editing, memory-file editing, auth, and network-policy concerns are out of scope for this version.
Current files
The container API should operate directly on the live OpenClaw state already on disk:
- session index:
/data/agents/main/sessions/sessions.json - session transcript files:
/data/agents/main/sessions/<session_id>.jsonl
The current proxy session endpoints read RTDB snapshots. This API should read the local container files directly instead.
Source of truth
Keep the OpenClaw-consumed files simple:
sessions.jsonis the active mapping fromsession_refto the currentsession_id*.jsonlfiles under/data/agents/main/sessions/are the only transcript files OpenClaw consumes
Anything about edits, swaps, or history should live in a parallel directory that OpenClaw ignores.
Recommended sidecar path:
/data/agents/main/session_edits/
Core mutation model
Use one mutation model only: fork and swap.
There should be no public “fork” endpoint and no public “swap” endpoint in v1.
Instead, each message write endpoint should perform the full operation within a single HTTP call.
Every message mutation should:
- read
sessions.json - resolve
session_refto the currentsession_id - parse the current session JSONL
- apply the message-level mutation in memory
- write a new session JSONL with a new
session_id - update the
sessions.jsonentry to pointsession_refat the new file - write an edit record to
/data/agents/main/session_edits/
Important rules:
- do not edit a session JSONL file in place
- do not delete the old session JSONL file during v1
- do not ask OpenClaw to consume the sidecar edit files
The old session JSONL files are the history. The sidecar directory is for edit metadata, not for active runtime state.
Atomicity target
Fork and swap should be a single-call operation from the caller’s perspective.
That means:
- one
POST,PATCH, orDELETErequest does the edit, the fork, and the active-session swap - on success, the session mapping now points at the new fork
- on failure, the old mapping should remain active
Strict cross-file atomicity is not really possible here because the active state spans more than one file. The practical target should be:
- linearized writes under a per-session lock
- atomic rename for file replacement
- a clear commit point
Recommended commit model:
- acquire lock for
session_ref - read the current
sessions.json - resolve the current
active_session_id - build the new forked transcript in memory
- write the new JSONL to a temp file in
/data/agents/main/sessions/ fsyncthe temp file- rename the temp JSONL to its final
<new_session_id>.jsonl - write an updated
sessions.jsontemp file fsyncthat temp file- rename the temp
sessions.jsonover the old one - release lock
The rename of sessions.json is the commit point.
If the process dies before that rename:
- the old session remains active
- an unreferenced forked JSONL may exist on disk, which is acceptable
If the process dies after that rename:
- the new session is active
- sidecar metadata may be missing or incomplete, which is also acceptable
The sidecar edit record is not part of the commit path and should not be treated as authoritative state.
Runtime expectation
This API manages file state only.
It should not promise mid-turn, in-memory mutation of whatever OpenClaw already has loaded. The contract is simpler:
- after a successful mutation, disk state reflects the new active session
- the active
session_refnow points at the new forkedsession_id
If OpenClaw only picks that up on the next turn or after a restart, that is acceptable for this version.
Versioning and concurrency
Use session_id as the version token.
Every mutating request may include:
expected_session_id
If the current active session_id for that session_ref does not match, return 409.
That keeps the concurrency model simple and avoids inventing a second revision system.
Message editing model
The public API should expose normalized messages, not raw JSONL lines.
Each message in the API should have:
record_idparent_idrolecontenttimestampsynthetic
The session JSONL itself still contains non-message records such as:
sessionmodel_changethinking_level_changecustom
Those records should be preserved automatically when a fork is created. The CRUD surface only needs to care about type="message" records.
Supported roles
Read output may include:
userassistanttoolResult
Create support in v1 should be limited to:
userassistant
Creating synthetic toolResult records is out of scope for now.
Delete behavior
Delete should default to removing dependent descendants too.
Why:
- assistant messages may have tool-call content
- later
toolResultmessages may hang off that part of the chain - deleting only one node often leaves an incoherent transcript
Recommended default:
cascade = "dependent"
Sidecar edit records
Each mutation should write one sidecar edit record under:
/data/agents/main/session_edits/<session_ref_safe>/<edit_id>.json
Recommended fields:
edit_idcreated_atoperationsession_refprevious_session_idnew_session_idtarget_record_idactorreason
actor and reason should be optional metadata, not auth.
This write should happen after the main fork-and-swap commit succeeds. It is useful for history, but it should not decide whether the new session becomes active.
Endpoints
Base path:
/v1
GET /health
Basic liveness.
Example response:
{
"ok": true,
"service": "container-session-api"
}
GET /v1/sessions
List active sessions from sessions.json.
Query params:
channel=<substring>optionallimit=100optional
Example response:
{
"sessions": [
{
"session_ref": "agent:main:discord:channel:1482308244964774120",
"active_session_id": "47af0e90-2c57-4d55-9227-fa3dc7e38e9b",
"display_name": "discord:1479164061533863949#scammaster-corleone",
"group_channel": "#scammaster-corleone",
"updated_at": 1773548639042,
"message_count": 160
}
]
}
GET /v1/sessions/{session_ref}
Return one active session mapping plus lightweight metadata.
Example response:
{
"session_ref": "agent:main:discord:channel:1482308244964774120",
"active_session_id": "47af0e90-2c57-4d55-9227-fa3dc7e38e9b",
"session_file": "/data/agents/main/sessions/47af0e90-2c57-4d55-9227-fa3dc7e38e9b.jsonl",
"display_name": "discord:1479164061533863949#scammaster-corleone",
"group_channel": "#scammaster-corleone",
"updated_at": 1773548639042,
"message_count": 160
}
GET /v1/sessions/{session_ref}/messages
Return the normalized message view for the current active session.
Example response:
{
"session_ref": "agent:main:discord:channel:1482308244964774120",
"active_session_id": "47af0e90-2c57-4d55-9227-fa3dc7e38e9b",
"messages": [
{
"record_id": "b00f29db",
"parent_id": "218ccacf",
"role": "user",
"content": "Read HEARTBEAT.md if it exists...",
"timestamp": "2026-03-15T04:23:51.743Z",
"synthetic": false
}
]
}
POST /v1/sessions/{session_ref}/messages
Insert a new synthetic message into the current session, then fork and swap.
Request:
{
"expected_session_id": "47af0e90-2c57-4d55-9227-fa3dc7e38e9b",
"actor": "karl",
"reason": "Inject context for replay",
"insert": {
"position": "after",
"anchor_record_id": "b00f29db"
},
"message": {
"role": "user",
"content": "Editorial note: ignore the deleted detour and continue from the corrected facts."
}
}
Allowed insert positions:
startendbeforeafter
Response:
{
"ok": true,
"session_ref": "agent:main:discord:channel:1482308244964774120",
"previous_session_id": "47af0e90-2c57-4d55-9227-fa3dc7e38e9b",
"active_session_id": "3ebb27e4-2d89-4ddd-b3b7-e56912735e6a",
"created_record_id": "new_msg_001",
"edit_id": "edit_001"
}
PATCH /v1/sessions/{session_ref}/messages/{record_id}
Edit one existing message, then fork and swap.
Request:
{
"expected_session_id": "47af0e90-2c57-4d55-9227-fa3dc7e38e9b",
"actor": "karl",
"reason": "Correct assistant wording",
"content": "Updated message text here."
}
Rules:
roleis immutable in v1record_idstays the same inside the new fork unless there is a strong reason to regenerate it- editing should be limited to text-bearing message content in v1
Response:
{
"ok": true,
"session_ref": "agent:main:discord:channel:1482308244964774120",
"previous_session_id": "47af0e90-2c57-4d55-9227-fa3dc7e38e9b",
"active_session_id": "3ebb27e4-2d89-4ddd-b3b7-e56912735e6a",
"updated_record_id": "81a5f171",
"edit_id": "edit_002"
}
DELETE /v1/sessions/{session_ref}/messages/{record_id}
Delete one message, then fork and swap.
Request:
{
"expected_session_id": "47af0e90-2c57-4d55-9227-fa3dc7e38e9b",
"actor": "karl",
"reason": "Remove bad assistant step from replay branch",
"cascade": "dependent"
}
Allowed cascade values:
dependentdefaultnone
Response:
{
"ok": true,
"session_ref": "agent:main:discord:channel:1482308244964774120",
"previous_session_id": "47af0e90-2c57-4d55-9227-fa3dc7e38e9b",
"active_session_id": "3ebb27e4-2d89-4ddd-b3b7-e56912735e6a",
"deleted_record_ids": ["81a5f171", "0fd02083", "ea7a0c14"],
"edit_id": "edit_003"
}
Mutation algorithm
Every write endpoint should follow the same implementation path:
- lock on
session_ref - read
sessions.json - resolve current
active_session_id - compare with
expected_session_idwhen provided - parse the current JSONL into records
- materialize the editable message view
- apply the message mutation
- rebuild the record list
- write a new JSONL temp file with a new
session_id - rename that temp file to the final
<new_session_id>.jsonl - write an updated
sessions.jsontemp file - rename that temp file over
sessions.json - write a sidecar edit record
- unlock
V1 constraints
- no session-create endpoint yet
- no session-delete endpoint yet
- no workspace or memory file endpoints yet
- no separate apply modes
- no in-place JSONL rewriting
- no hard deletion of old session JSONL files
Open question
One thing still needs to be verified during implementation:
- whether OpenClaw notices the swapped
sessions.jsonmapping immediately, on next turn, or only after restart
That does not change the API contract above. It only affects how quickly the running process picks up the new active fork.
