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.json is the active mapping from session_ref to the current session_id
  • *.jsonl files 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:

  1. read sessions.json
  2. resolve session_ref to the current session_id
  3. parse the current session JSONL
  4. apply the message-level mutation in memory
  5. write a new session JSONL with a new session_id
  6. update the sessions.json entry to point session_ref at the new file
  7. 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, or DELETE request 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:

  1. acquire lock for session_ref
  2. read the current sessions.json
  3. resolve the current active_session_id
  4. build the new forked transcript in memory
  5. write the new JSONL to a temp file in /data/agents/main/sessions/
  6. fsync the temp file
  7. rename the temp JSONL to its final <new_session_id>.jsonl
  8. write an updated sessions.json temp file
  9. fsync that temp file
  10. rename the temp sessions.json over the old one
  11. 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_ref now points at the new forked session_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_id
  • parent_id
  • role
  • content
  • timestamp
  • synthetic

The session JSONL itself still contains non-message records such as:

  • session
  • model_change
  • thinking_level_change
  • custom

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:

  • user
  • assistant
  • toolResult

Create support in v1 should be limited to:

  • user
  • assistant

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 toolResult messages 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_id
  • created_at
  • operation
  • session_ref
  • previous_session_id
  • new_session_id
  • target_record_id
  • actor
  • reason

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> optional
  • limit=100 optional

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:

  • start
  • end
  • before
  • after

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:

  • role is immutable in v1
  • record_id stays 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:

  • dependent default
  • none

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:

  1. lock on session_ref
  2. read sessions.json
  3. resolve current active_session_id
  4. compare with expected_session_id when provided
  5. parse the current JSONL into records
  6. materialize the editable message view
  7. apply the message mutation
  8. rebuild the record list
  9. write a new JSONL temp file with a new session_id
  10. rename that temp file to the final <new_session_id>.jsonl
  11. write an updated sessions.json temp file
  12. rename that temp file over sessions.json
  13. write a sidecar edit record
  14. 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.json mapping 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.