# Sessions

A session is created automatically for every chat run. It holds the full conversation history (system prompt, user messages, assistant replies, tool calls, tool results).

---

## POST /sessions

Create a new session without sending a message. Useful when you want to pre-create a session and pass it as `sessionId` to the chat endpoint, or to attach a stream listener before the first message.

> **System prompt (May 2026)**: System prompts are **no longer persisted** in the messages table — they are rebuilt fresh from the agent on every chat turn (engine-blind). New sessions therefore have **zero rows** in the `messages` table at creation time. This lets `PATCH /sessions/:id { agent_name }` and edits to `agent.json` flow into the next turn without restart.

**Request body**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `agentName` | string | ✓ | Agent to create the session for |
| `mode` | string | | `chat` (the only mode) |
| `model` | string | | Override model for this session. Defaults to the agent's configured model. |
| `reasoning` | object\|null | | Engine-blind reasoning config: `{ effort, max_tokens? }`. `effort` accepts `none`, `auto`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, or any custom string (passed through to openai providers; ignored on claude-cli). Defaults to agent's `reasoning` if set. Pass `null` to clear and fall through to agent default. |

**Request example**
```json
{
  "agentName": "assistant",
  "mode": "chat"
}
```

**Response** `201 Created`
```json
{
  "sessionId": "sess_4f3a1b9c2d8e7f01",
  "session": {
    "id": "sess_4f3a1b9c2d8e7f01",
    "agent_name": "assistant",
    "mode": "chat",
    "status": "active",
    "model": "anthropic/claude-sonnet-4-5",
    "created_at": "2025-03-02T10:00:00.000Z",
    "updated_at": "2025-03-02T10:00:00.000Z"
  }
}
```

**Error responses**

| Code | Condition |
|------|-----------|
| `404 AGENT_NOT_FOUND` | No agent with that name |
| `400 VALIDATION_ERROR` | `agentName` missing or `mode` is invalid |

**Example**
```bash
curl -X POST http://localhost:5050/sessions \
  -H "Content-Type: application/json" \
  -d '{"agentName": "assistant", "mode": "chat"}'
```

---

## GET /sessions

List sessions for the current workspace.

**Query parameters**

| Param | Type | Description |
|-------|------|-------------|
| `agentName` | string | Filter by agent name |
| `status` | string | `active`, `suspended`, or `closed` |
| `limit` | integer | Max results (default: 20) |
| `cursor` | string | Pagination cursor (last session ID from previous page) |

**Response**

```json
{
  "sessions": [
    {
      "id": "sess_4f3a1b9c2d8e7f01",
      "agent_name": "assistant",
      "mode": "chat",
      "status": "active",
      "model": "moonshotai/kimi-k2.6",
      "message_count": 6,
      "instance_folder": "/home/user/workspace",
      "created_at": "2025-03-02T10:00:00.000Z",
      "updated_at": "2025-03-02T10:05:00.000Z"
    }
  ]
}
```

**Session object fields**

| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Unique session ID |
| `agent_name` | string | Agent this session belongs to |
| `mode` | string | `chat` |
| `status` | string | `active`, `suspended`, or `closed` (see [suspend](#post-sessionsidsuspend) / [resume](#post-sessionsidresume)) |
| `model` | string | LLM model used |
| `title` | string\|null | Optional session title |
| `message_count` | integer | Total messages in this session |
| `total_input_tokens` | integer | Accumulated prompt tokens for the session (includes deleted messages and resets) |
| `total_output_tokens` | integer | Accumulated completion tokens for the session |
| `total_cache_tokens` | integer | Accumulated cached prompt tokens for the session |
| `context_size` | integer | Prompt + completion tokens of the **last** LLM turn (current context window usage) |
| `context_size_limit` | integer\|null | Model context limit in tokens (if known) |
| `cost` | number | Accumulated cost in USD for the session |
| `reasoning` | string\|null | Engine-blind reasoning config for this session, JSON-encoded in DB (e.g. `'{"effort":"high","max_tokens":5000}'`). Parse on read. |
| `compact_enabled` | integer | `1` if compaction is enabled for this session, `0` if disabled |
| `compact_count` | integer | Percentage of uncompacted context to summarize per compaction run |
| `compact_auto_threshold` | integer\|null | Auto-compact trigger percentage (`context_size / context_size_limit × 100`). Null if auto-compact is disabled. |
| `compact_model` | string | Model used for compaction LLM calls. `"default"` means the session's own model. |
| `compact_custom_instructions` | string\|null | Extra instructions appended to the compaction system prompt |
| `compact_size` | integer | Number of non-system messages already covered by the compact summary |
| `compact_summary` | string\|null | The current accumulated compaction summary text |
| `claude_session_id` | string\|null | The Claude CLI / Agent SDK session ID, if this session is backed by a claude-cli runtime. `null` for standard sessions. |
| `instance_folder` | string | Workspace that owns this session |
| `created_at` | ISO string | When session was created |
| `updated_at` | ISO string | Last activity timestamp |

---

## GET /sessions/streaming

List sessions that currently have an active chat loop running. Useful for dashboards that want to render a "currently streaming" indicator without polling each session individually.

**Note on path matching:** registered before `GET /:id` so Express matches the literal `streaming` segment (instead of treating it as a session ID).

**Response**

```json
{
  "streams": [
    {
      "sessionId": "sess_4f3a1b9c2d8e7f01",
      "agentName": "assistant",
      "startedAt": "2025-03-02T10:00:01.000Z"
    }
  ]
}
```

| Field | Type | Description |
|-------|------|-------------|
| `streams[]` | array | One entry per active chat loop |
| `streams[].sessionId` | string | Session that is currently running |
| `streams[].agentName` | string | Agent that owns the running session |
| `streams[].startedAt` | ISO string | When the current loop started |

Sorted ascending by `startedAt`. Internal fields (engine handle, cancel handle) are stripped — they never leave the process.

**Example**
```bash
curl http://localhost:5050/sessions/streaming
```

---

## GET /sessions/:id

Get details for a single session.

> **Context-limit self-heal:** on read, if the session's `model` resolves to a different context limit than the stored `context_size_limit` (e.g. the model definition changed after the session was created), the row is silently updated to the model's current limit before responding. Sessions created before a model rename therefore stop falling back to the 128k default.

**Path parameters**

| Param | Description |
|-------|-------------|
| `id` | Session ID |

**Response**

```json
{
  "session": {
    "id": "sess_4f3a1b9c2d8e7f01",
    "agent_name": "assistant",
    "mode": "chat",
    "status": "active",
    "model": "moonshotai/kimi-k2.6",
    "message_count": 6,
    "instance_folder": "/home/user/workspace",
    "created_at": "2025-03-02T10:00:00.000Z",
    "updated_at": "2025-03-02T10:05:00.000Z"
  }
}
```

**Error responses**

| Code | Condition |
|------|-----------|
| `404 SESSION_NOT_FOUND` | No session with that ID |

---

## GET /sessions/:id/config

Return the effective LLM parameters for a session, broken down into three layers: per-session overrides, agent defaults, and the resolved effective values. This is the readonly counterpart to `PATCH /sessions/:id` (which writes the override layer).

The resolver precedence is `perCall > session > agent`. Since this endpoint reflects state on disk only, `perCall` is treated as empty.

**Path parameters**

| Param | Description |
|-------|-------------|
| `id` | Session ID |

**Response**

```json
{
  "effective": {
    "model": "anthropic/claude-sonnet-4-5",
    "temperature": 0.7,
    "max_tokens": 4096,
    "reasoning": { "effort": "high", "max_tokens": 5000 }
  },
  "overrides": {
    "temperature": 0.7,
    "reasoning": { "effort": "high", "max_tokens": 5000 }
  },
  "agentDefaults": {
    "model": "anthropic/claude-sonnet-4-5",
    "temperature": null,
    "max_tokens": 4096,
    "reasoning": { "effort": "medium" }
  }
}
```

| Field | Type | Description |
|-------|------|-------------|
| `effective` | object | Resolved values used on the next LLM turn |
| `overrides` | object | Only fields explicitly set on the session row (everything else is unset). `reasoning` is parsed from JSON if set. |
| `agentDefaults` | object | The agent's `agent.json` defaults — always 4 keys (`model`, `temperature`, `max_tokens`, `reasoning`), `null` when unset |

If the agent has been deleted since the session was created, `agentDefaults` returns all-null values and `effective` falls back to whatever was snapshotted onto the session row.

> **Reasoning unification (May 2026)**: The legacy `effort` (string), `thinking` (object), and bare-string `reasoning` fields are unified under one engine-blind shape `reasoning: { effort, max_tokens? }`. Per-engine routing happens at LLM-call time:
> - `effort: 'none'` → openai omits `reasoning`; claude-cli sets `thinking.type='disabled'`.
> - `effort: 'auto'` → openai omits `reasoning`; claude-cli sets `thinking.type='adaptive'`.
> - `effort: 'minimal' | 'low' | 'medium' | 'high'` → both pass through.
> - `effort: 'xhigh' | 'max'` → openai caps to `'high'`; claude-cli passes through.
> - `effort: <custom string>` → openai pass-through; claude-cli warns and omits.
> - `max_tokens` (when set, with non-`'none'`/`'auto'` effort) → openai includes `body.reasoning.max_tokens`; claude-cli sets `thinking.budgetTokens` + `thinking.type='enabled'`.

**Error responses**

| Code | Condition |
|------|-----------|
| `404 SESSION_NOT_FOUND` | No session with that ID |

**Example**
```bash
curl http://localhost:5050/sessions/sess_4f3a1b9c2d8e7f01/config
```

---

## POST /sessions/:id/cancel

Cooperatively cancel an active chat loop on this session. Idempotent — returns `200` whether or not a loop is currently running.

The cancel signal is checked at iteration boundaries (between LLM calls and between tool calls). In-flight tool calls finish naturally; the next iteration check picks up the signal and stops the loop. Any dangling tool calls (assistant turn emitted `tool_use` blocks but cancellation arrived before all results were written) get a `[cancelled — no result]` placeholder so the session stays valid for inspection or resume.

**Path parameters**

| Param | Description |
|-------|-------------|
| `id` | Session ID |

**Request body** — none

**Response** `200 OK`

```json
{
  "sessionId": "sess_4f3a1b9c2d8e7f01",
  "status": "canceled"
}
```

The same `200 { status: "canceled" }` response is returned even when there is no active loop — calling `cancel` on an idle session is a no-op rather than an error.

**SSE side effects:** if a chat SSE stream is open on this session, it emits an `error` event with `code: "CANCELLED"` and closes; the eventual chat-API response (HTTP JSON path) sets `cancelled: true` on the `done` payload.

**Error responses**

| Code | Condition |
|------|-----------|
| `404 SESSION_NOT_FOUND` | No session with that ID |

**Example**
```bash
curl -X POST http://localhost:5050/sessions/sess_4f3a1b9c2d8e7f01/cancel
```

---

## POST /sessions/:id/suspend

Park a session. Any running turn is cancelled, and until the session is resumed it **rejects new chat turns** (the chat/router path throws `SESSION_SUSPENDED`) and **skips scheduled wake-ups** (an armed `schedule_wakeup` timer keeps ticking but doesn't deliver). Queued inter-agent `agent_messages` stay `pending` and are delivered on resume.

**Path parameters**

| Param | Description |
|-------|-------------|
| `id` | Session ID |

**Request body** — none

**Response** `200 OK`
```json
{
  "sessionId": "sess_4f3a1b9c2d8e7f01",
  "status": "suspended",
  "cancelledTurn": true
}
```

| Field | Description |
|-------|-------------|
| `cancelledTurn` | `true` when a running chat loop was cancelled by the suspend, `false` when the session was already idle. |

Idempotent: suspending an already-suspended session returns `200 { status: "suspended", cancelledTurn: false }`. Emits a `session.status` event with `status: "suspended"`.

**Error responses**

| Code | Condition |
|------|-----------|
| `404 SESSION_NOT_FOUND` | No session with that ID |
| `409 SESSION_CLOSED` | The session is closed |

**Example**
```bash
curl -X POST http://localhost:5050/sessions/sess_4f3a1b9c2d8e7f01/suspend
```

---

## POST /sessions/:id/resume

Reactivate a suspended session. The status returns to `active` and any inter-agent messages that queued up while it was parked are drained (delivered as a new turn if the session is idle).

**Path parameters**

| Param | Description |
|-------|-------------|
| `id` | Session ID |

**Request body** — none

**Response** `200 OK`
```json
{
  "sessionId": "sess_4f3a1b9c2d8e7f01",
  "status": "active"
}
```

Emits a `session.status` event with `status: "active"`.

**Error responses**

| Code | Condition |
|------|-----------|
| `404 SESSION_NOT_FOUND` | No session with that ID |
| `409 NOT_SUSPENDED` | The session is not currently suspended (`details`-free; the message includes the current status) |

**Example**
```bash
curl -X POST http://localhost:5050/sessions/sess_4f3a1b9c2d8e7f01/resume
```

---

## GET /sessions/:id/todos

Return the session's todo list — the state written by the `todo_write` tool and read by `todo_read`.

**Path parameters**

| Param | Description |
|-------|-------------|
| `id` | Session ID |

**Response** `200 OK`
```json
{
  "sessionId": "sess_4f3a1b9c2d8e7f01",
  "todos": [
    { "id": "1", "content": "Read the config file", "status": "completed", "priority": "high" },
    { "id": "2", "content": "Draft the summary", "status": "in_progress", "priority": "medium" }
  ]
}
```

**Error responses**

| Code | Condition |
|------|-----------|
| `404 SESSION_NOT_FOUND` | No session with that ID |

---

## Wake-up schedule — GET / POST / DELETE /sessions/:id/wakeup

Read and control a session's self-scheduled wake-up (the state behind the [`schedule_wakeup`](../guide/06-tools.md#schedule_wakeup) tool). Manual control lets a UI arm, inspect, or cancel a wake-up without the agent doing it.

### GET /sessions/:id/wakeup

Return the current schedule.

```json
{
  "sessionId": "sess_4f3a1b9c2d8e7f01",
  "active": true,
  "message": "Check the build status",
  "intervalMs": 1800000,
  "loop": true,
  "nextFireAt": 1751884800000
}
```

| Field | Type | Description |
|-------|------|-------------|
| `active` | boolean | Whether a wake-up is currently armed. When `false`, the other fields are omitted. |
| `message` | string | Text injected as a new user turn when the timer fires. |
| `intervalMs` | integer | Delay until the next fire, in milliseconds. |
| `loop` | boolean | Whether it re-arms and repeats every interval. |
| `nextFireAt` | integer | Epoch-ms timestamp of the next fire. |

### POST /sessions/:id/wakeup

Arm (or replace) the schedule.

**Request body**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `time` | string | ✓ | Delay: `"30m"`, `"90s"`, `"2h"`, or a bare number (= minutes). `"0"` disables/removes the schedule. |
| `message` | string | required when `time` > 0 | Text delivered when the timer fires. |
| `loop` | boolean | | Re-arm and fire every `time` until cancelled. Default `false`. |

Returns the resulting schedule (same shape as `GET`). Replaces any existing schedule for the session.

**Error responses**

| Code | Condition |
|------|-----------|
| `400 VALIDATION_ERROR` | Bad `time` format, or `message` missing when `time` > 0 |
| `404 SESSION_NOT_FOUND` | No session with that ID |
| `409 SESSION_CLOSED` | The session is closed |

### DELETE /sessions/:id/wakeup

Cancel any armed wake-up.

```json
{ "sessionId": "sess_4f3a1b9c2d8e7f01", "active": false }
```

Returns `404 SESSION_NOT_FOUND` if the session doesn't exist.

**Example**
```bash
# Arm a repeating 30-minute wake-up
curl -X POST http://localhost:5050/sessions/sess_4f3a1b9c2d8e7f01/wakeup \
  -H "Content-Type: application/json" \
  -d '{"time": "30m", "message": "Check the build status", "loop": true}'

# Inspect it
curl http://localhost:5050/sessions/sess_4f3a1b9c2d8e7f01/wakeup

# Cancel it
curl -X DELETE http://localhost:5050/sessions/sess_4f3a1b9c2d8e7f01/wakeup
```

---

## GET /sessions/:id/messages

Retrieve the message history for a session.

**Path parameters**

| Param | Description |
|-------|-------------|
| `id` | Session ID |

**Query parameters**

| Param | Type | Description |
|-------|------|-------------|
| `limit` | integer | Max messages to return (default: 100) |
| `offset` | integer | Skip first N messages (default: 0) |

**Response**

```json
{
  "sessionId": "sess_4f3a1b9c2d8e7f01",
  "messages": [
    {
      "id": 1,
      "session_id": "sess_4f3a1b9c2d8e7f01",
      "role": "system",
      "content": "You are Assistant, a helpful agent...",
      "tool_calls": null,
      "tool_call_id": null,
      "created_at": "2025-03-02T10:00:00.000Z"
    },
    {
      "id": 2,
      "role": "user",
      "content": "What files are in the current directory?",
      "tool_calls": null,
      "tool_call_id": null,
      "created_at": "2025-03-02T10:00:01.000Z"
    },
    {
      "id": 3,
      "role": "assistant",
      "content": null,
      "tool_calls": "[{\"id\":\"call_abc\",\"type\":\"function\",\"function\":{\"name\":\"list_dir\",\"arguments\":\"{\\\"dir\\\":\\\".\\\"}\"}}]",
      "tool_call_id": null,
      "created_at": "2025-03-02T10:00:02.000Z"
    },
    {
      "id": 4,
      "role": "tool",
      "content": "README.md (1.2KB)\npackage.json (544B)\n...",
      "tool_calls": null,
      "tool_call_id": "call_abc",
      "created_at": "2025-03-02T10:00:03.000Z"
    },
    {
      "id": 5,
      "role": "assistant",
      "content": "Here are the files in the current directory:\n- README.md\n- package.json",
      "tool_calls": null,
      "tool_call_id": null,
      "created_at": "2025-03-02T10:00:04.000Z"
    }
  ]
}
```

**Message object fields**

| Field | Type | Description |
|-------|------|-------------|
| `id` | integer | Auto-incremented message ID |
| `session_id` | string | Parent session |
| `role` | string | `system`, `user`, `assistant`, or `tool` |
| `content` | string\|null | Text content (null for tool-call-only assistant messages) |
| `tool_calls` | array\|null | OpenAI tool call objects (parsed from JSON) — present on `assistant` messages that call tools |
| `tool_call_id` | string\|null | Links a `tool` result back to the originating tool call |
| `model_key` | string\|null | Model used for this message (only set on `assistant` messages) |
| `input_tokens` | integer\|null | Prompt tokens for this LLM call (assistant messages only) |
| `output_tokens` | integer\|null | Completion tokens for this LLM call (assistant messages only) |
| `cache_tokens` | integer\|null | Cached prompt tokens for this LLM call (assistant messages only) |
| `cost` | number\|null | Cost in USD for this LLM call — from API response, null if not reported |
| `thinking_content` | string\|null | Extended thinking / reasoning text from the model (assistant messages only, null if not enabled) |
| `thinking_tokens` | integer\|null | Tokens used for extended thinking (assistant messages only) |
| `created_at` | ISO string | When the message was created |

**Message roles**

| Role | Description |
|------|-------------|
| `system` | System prompt (first message, set once per session) |
| `user` | User input message |
| `assistant` | Agent reply — may have `tool_calls` instead of `content` when using tools |
| `tool` | Tool execution result — `tool_call_id` links it to the assistant's tool call |

**Note on `tool_calls`**: The field is a JSON string (not a nested object) containing an array of OpenAI function-call objects. Parse it with `JSON.parse()`.

**Error responses**

| Code | Condition |
|------|-----------|
| `404 SESSION_NOT_FOUND` | No session with that ID |

---

## GET /sessions/:id/stream

Real-time SSE (Server-Sent Events) stream for a session. Streams all runtime events scoped to this session — tool calls, LLM responses, iteration starts, and more.

**Path parameters**

| Param | Description |
|-------|-------------|
| `id` | Session ID |

**Response** — `text/event-stream`

The stream opens with a `session` event showing the current session state, then forwards all matching eventBus events in real time.

**`event: session`** — sent immediately on connection:
```
event: session
data: {"sessionId":"sess_...","status":"active","agentName":"assistant","mode":"chat"}
```

**Live events** — one SSE event per bus event, named by event type:
```
event: chat.tool
data: {"sessionId":"sess_...","agentName":"assistant","event":{"type":"tool.start","toolName":"read_file","toolInput":{"file":"/home/user/workspace/package.json"}},"timestamp":1234567890}

event: chat.response
data: {"sessionId":"sess_...","agentName":"assistant","event":{"content":"Here are the files...","toolCount":2},"timestamp":1234567890}

event: chat.event
data: {"sessionId":"sess_...","agentName":"assistant","event":{"type":"iteration.start","iteration":1},"timestamp":1234567890}
```

**Event types by session mode**

| Type | Mode | Description |
|------|------|-------------|
| `chat.event` | chat | Iteration start, LLM errors |
| `chat.tool` | chat | Tool call start/end |
| `chat.response` | chat | Final agent reply |

A keepalive comment is sent every 15 seconds:
```
: keepalive
```

The stream stays open until the client disconnects. There is no automatic close.

**Error responses**

| Code | Condition |
|------|-----------|
| `404 SESSION_NOT_FOUND` | No session with that ID |

**JavaScript example**
```js
// Pre-create a session, then open the stream before sending the first message
const { sessionId } = await fetch('http://localhost:5050/sessions', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ agentName: 'assistant' }),
}).then(r => r.json());

const source = new EventSource(`http://localhost:5050/sessions/${sessionId}/stream`);

source.addEventListener('chat.tool', (e) => {
  const { event } = JSON.parse(e.data);
  if (event.type === 'tool.start') console.log('Tool:', event.toolName);
  if (event.type === 'tool.end') console.log('Done in', event.durationMs, 'ms');
});

source.addEventListener('chat.response', (e) => {
  const { event } = JSON.parse(e.data);
  console.log('Reply:', event.content);
});

// Now send a chat message using the pre-created sessionId
await fetch(`http://localhost:5050/agents/assistant/chat`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ message: 'List files in the current dir', sessionId }),
});
```

---

## GET /sessions/:id/context

Returns the full message history for debugging. Unlike `/messages`, this endpoint formats messages for quick context inspection.

**Response**

```json
{
  "sessionId": "sess_4f3a1b9c2d8e7f01",
  "agentName": "assistant",
  "mode": "chat",
  "messageCount": 5,
  "messages": [
    { "id": 1, "role": "system", "content": "You are...", "created_at": "..." },
    { "id": 2, "role": "user", "content": "What files...", "created_at": "..." },
    { "id": 3, "role": "assistant", "content": null, "tool_calls": [...], "created_at": "..." }
  ]
}
```

**Error responses**

| Code | Condition |
|------|-----------|
| `404 SESSION_NOT_FOUND` | No session with that ID |

---

## PATCH /sessions/:id

Update session metadata. Useful for renaming sessions, changing the active model, or adjusting compaction settings without restarting.

**Path parameters**

| Param | Description |
|-------|-------------|
| `id` | Session ID |

**Request body** — all fields optional, at least one required

| Field | Type | Description |
|-------|------|-------------|
| `title` | string\|null | Session label. Pass `null` to clear. |
| `instance_name` | string\|null | Caller-unique label for this session under its parent (the same field set at spawn time by `agent_spawn`). Pass `null` or `""` to clear. Subject to the partial UNIQUE index on `(parent_session_id, instance_name)` — see "instance_name collisions" below. |
| `model` | string | Override the LLM model for subsequent turns. Also re-derives `context_size_limit`. |
| `temperature` | number\|null | Per-session temperature override (0–2). `null` clears the override and falls back to the agent default. |
| `max_tokens` | integer\|null | Per-session max-output-tokens override (positive integer). `null` clears the override. |
| `reasoning` | object\|null | Engine-blind reasoning config: `{ effort, max_tokens? }`. See "Reasoning unification" note above for full enum + cross-engine semantics. Pass `null` to clear. |
| `agent_name` | string | **Swap the agent for this session.** Validated: agent must exist, have `modes.chat.enabled`, and produce the same engine type as the current agent (cross-engine swap rejected — see below). Subsequent turns rebuild the system prompt from the new agent + reload tools/MCP/permissions. |
| `compact_enabled` | boolean | Enable or disable compaction for this session. |
| `compact_auto_threshold` | integer\|null | Auto-compact trigger percentage. Pass `null` to disable auto-compact. |
| `compact_count` | integer | Percentage of uncompacted context to summarize per run (1–99). |
| `compact_model` | string | Model used for compaction LLM calls. `"default"` uses the session's model. |
| `compact_custom_instructions` | string\|null | Extra instructions for the compaction prompt. |

**LLM-parameter overrides:** `temperature`, `max_tokens`, and `reasoning` are validated against the same whitelist as the per-call `overrides` field on `POST /agents/:name/chat`. Unknown fields, agent-identity fields (`tools`, `permissions`, `mcpServers`, etc.), and `top_p` are rejected with `400 VALIDATION_ERROR`. Use `GET /sessions/:id/config` to inspect the resolved values.

**`agent_name` swap rules:**
- Active session (a chat turn is running): `409 SESSION_BUSY` (mirrors `/trim` and `/reset`).
- Agent doesn't exist: `404 AGENT_NOT_FOUND`.
- Agent has `modes.chat.enabled !== true`: `400 VALIDATION_ERROR`.
- Cross-engine swap (new agent's resolved engine differs from current): `400 VALIDATION_ERROR` with `details.currentEngine` + `details.requestedEngine`. Cross-engine swaps would invalidate `claude_session_id` and the SDK transcript; create a new session for that case.
- The chat HTTP route (`POST /agents/:name/chat`) automatically resolves the agent from the session row when `sessionId` is provided — Studio can leave the URL `:name` unchanged after a swap.
- Pending `agent_messages` rows targeted at the old agent name on this session are repointed to the new name (so sub-agent reply drains continue working).

**`instance_name` collisions.** A partial UNIQUE index on `(parent_session_id, instance_name)` prevents two sibling sessions under the same parent from sharing a name (NULL rows are unconstrained). When a PATCH would violate that constraint, the response is `409` with the **same envelope shape** that `agent_spawn` returns on initial-spawn collision:

```json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "instance_name \"worker_one\" already used in this caller's session",
    "details": { "field": "instance_name", "reason": "duplicate_in_caller_session" }
  }
}
```

Empty string and `null` both clear the field. Sessions with no parent (top-level chat sessions) are unaffected by the constraint — the partial index excludes rows where `parent_session_id IS NULL`.

**Response** `200 OK`
```json
{
  "session": { "id": "sess_...", "title": "My session", ... }
}
```

**Error responses**

| Code | Condition |
|------|-----------|
| `400 SESSION_CLOSED` | Cannot modify a closed session |
| `400 VALIDATION_ERROR` | Unknown field, no fields provided, or LLM-param override fails validation (`details.field` identifies which) |
| `404 AGENT_NOT_FOUND` | `agent_name` references an agent that doesn't exist |
| `404 SESSION_NOT_FOUND` | No session with that ID |
| `409 SESSION_BUSY` | A chat turn is currently running on this session — `agent_name` swaps require an idle session (mirrors `/trim` and `/reset`). |
| `409 VALIDATION_ERROR` | `instance_name` collides with a sibling session under the same `parent_session_id`. Same `code` body as `agent_spawn`'s spawn-time collision; `details.reason: "duplicate_in_caller_session"`. |

**Note:** For claude-cli sessions (where `claude_session_id` is set), changes to `model` are proxied live to the Claude SDK runtime — the model switch takes effect immediately on the next LLM turn. `reasoning` changes apply on the next turn (resolver re-reads from session row at runChat entry); no live proxy needed.

**Example**
```bash
curl -X PATCH http://localhost:5050/sessions/sess_4f3a1b9c2d8e7f01 \
  -H "Content-Type: application/json" \
  -d '{"title": "Debug run", "model": "anthropic/claude-opus-4"}'
```

---

## PATCH /sessions/:id/messages/:msgId

Edit the text content of a single message. Useful for correcting user inputs before re-running a turn.

**Path parameters**

| Param | Description |
|-------|-------------|
| `id` | Session ID |
| `msgId` | Integer message ID |

**Request body**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `content` | string | ✓ | New message content |

**Response** `200 OK`
```json
{
  "message": { "id": 5, "role": "user", "content": "Updated content", ... }
}
```

**Error responses**

| Code | Condition |
|------|-----------|
| `400 SESSION_CLOSED` | Cannot modify a closed session |
| `400 CANNOT_EDIT_SYSTEM_MESSAGE` | System messages cannot be edited |
| `400 CANNOT_EDIT_TOOL_MESSAGE` | Tool result messages cannot be edited |
| `400 VALIDATION_ERROR` | `content` missing or not a string |
| `404 MESSAGE_NOT_FOUND` | No message with that ID in this session |
| `404 SESSION_NOT_FOUND` | No session with that ID |

---

## DELETE /sessions/:id/messages

Trim all messages after a given message ID. The anchor message is kept; all messages after it are deleted. Also caps `compact_size` to prevent compaction summary from referencing deleted messages.

Two forms are supported: query param and path param (both do the same thing).

**Query param form:**

```
DELETE /sessions/:id/messages?after=:messageId
```

**Path param form:**

```
DELETE /sessions/:id/messages/after/:messageId
```

**Parameters**

| Param | Type | Description |
|-------|------|-------------|
| `after` | integer | ID of the message to keep. All messages after this ID are deleted. |

**Response** `200 OK`
```json
{
  "sessionId": "sess_4f3a1b9c2d8e7f01",
  "deletedCount": 3,
  "lastRemainingMessageId": 5
}
```

**Error responses**

| Code | Condition |
|------|-----------|
| `400 SESSION_CLOSED` | Cannot modify a closed session |
| `400 VALIDATION_ERROR` | `after` param missing (query form) |
| `404 MESSAGE_NOT_FOUND` | No message with that ID in this session |
| `404 SESSION_NOT_FOUND` | No session with that ID |
| `409 SESSION_BUSY` | A chat turn is currently running on this session — wait for it to complete and retry |
| `500 FORK_FAILED` | Internal trim/fork operation failed |

**Example — rewind to message 5**
```bash
curl -X DELETE "http://localhost:5050/sessions/sess_4f3a1b9c2d8e7f01/messages/after/5"
```

---

## DELETE /sessions/:id/messages/:msgId

Delete a single message by ID. System messages cannot be deleted. Also adjusts `compact_size` if needed.

**Path parameters**

| Param | Description |
|-------|-------------|
| `id` | Session ID |
| `msgId` | Integer message ID |

**Response** `200 OK`
```json
{
  "sessionId": "sess_4f3a1b9c2d8e7f01",
  "deletedMessageId": 7
}
```

**Error responses**

| Code | Condition |
|------|-----------|
| `400 SESSION_CLOSED` | Cannot modify a closed session |
| `400 CANNOT_DELETE_SYSTEM_MESSAGE` | System messages cannot be deleted |
| `404 MESSAGE_NOT_FOUND` | No message with that ID in this session |
| `404 SESSION_NOT_FOUND` | No session with that ID |

---

## POST /sessions/:id/fork

Fork a session up to (and including) a specific message. Creates a new independent session that copies the source's messages, model + LLM overrides (`model`, `temperature`, `max_tokens`, `reasoning`, `context_size_limit`) and compaction *configuration* (`compact_count`, `compact_auto_threshold`, `compact_enabled`, `compact_model`, `compact_custom_instructions`).

The forked session has a fresh `parent_session_id = NULL` (graph-root), regardless of the source's parentage — forking is a user-initiated action, not a spawn. The compaction *summary state* (`compact_summary`, `compact_size`) is NOT copied; the forked session re-summarizes from scratch on its next compaction.

**Path parameters**

| Param | Description |
|-------|-------------|
| `id` | Source session ID |

**Request body**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `upToMessageId` | integer | ✓ | Copy messages from the source session up to and including this message ID |

**Response** `201 Created`
```json
{
  "sessionId": "sess_newforkedsessionid",
  "session": { "id": "sess_newforkedsessionid", "title": "Fork from: Original Title", ... },
  "forkedFrom": {
    "sessionId": "sess_4f3a1b9c2d8e7f01",
    "upToMessageId": 10
  }
}
```

**Error responses**

| Code | Condition |
|------|-----------|
| `400 VALIDATION_ERROR` | `upToMessageId` missing or not a number |
| `404 MESSAGE_NOT_FOUND` | No message with that ID in the source session |
| `404 SESSION_NOT_FOUND` | No session with that ID |
| `500 FORK_FAILED` | Internal fork operation failed |

> **Note**: Fork is safe to run while a chat turn is active on the source. New messages from the running turn have IDs > `upToMessageId` and are excluded from the snapshot.

**Example**
```bash
curl -X POST http://localhost:5050/sessions/sess_4f3a1b9c2d8e7f01/fork \
  -H "Content-Type: application/json" \
  -d '{"upToMessageId": 10}'
```

---

## POST /sessions/:id/compact

Run default compaction on a session. Summarizes the next batch of uncompacted messages and stores the result as `compact_summary` on the session. On all subsequent LLM turns, the summary is automatically injected after the system prompt, and messages already captured in the summary are excluded from the context sent to the model.

Requires `defaultCompaction.enabled` to be `true` (the default). If the agent was configured with `enabled: false`, this endpoint returns `400 COMPACTION_DISABLED`.

If the session has an active loop running, the endpoint returns `409 SESSION_BUSY` — compacting under a running turn would move the summary boundary out from under the loop's context rebuild. Wait for the turn to finish and retry.

**Path parameters**

| Param | Description |
|-------|-------------|
| `id` | Session ID |

**Response** `200 OK`
```json
{
  "sessionId": "sess_4f3a1b9c2d8e7f01",
  "compactedCount": 47,
  "newSize": 47,
  "alreadyUpToDate": false,
  "session": { ... }
}
```

| Field | Description |
|-------|-------------|
| `compactedCount` | Number of messages summarized in this run |
| `newSize` | Total messages covered by the summary after this run (`compact_size`) |
| `alreadyUpToDate` | `true` when there were no new messages to compact — the session is already up-to-date |
| `session` | Full updated session object |

**Error responses**

| Code | Condition |
|------|-----------|
| `400 COMPACTION_DISABLED` | `defaultCompaction.enabled` is `false` for this agent |
| `400 SESSION_CLOSED` | Session is already closed |
| `404 SESSION_NOT_FOUND` | No session with that ID |

**Note:** For claude-cli sessions (where `claude_session_id` is set), `/compact` is sent as a user message through the chat flow instead of running the default compaction engine. The Claude SDK runtime handles compaction internally.

**Example**
```bash
curl -X POST http://localhost:5050/sessions/sess_4f3a1b9c2d8e7f01/compact
```

See [Memory & Compaction](../guide/08-memory.md) for full explanation of how the summary is built and injected.

---

## POST /sessions/:id/reset

Clear all messages from a session but keep the session alive. The system prompt is **not** persisted — every chat turn rebuilds it fresh from the agent's current configuration (since May-2026 unification). The agent starts the next turn with a clean transcript.

**Response**

```json
{
  "sessionId": "sess_4f3a1b9c2d8e7f01",
  "status": "reset",
  "messageCount": 0
}
```

> **Breaking change (May 2026)**: `messageCount` is now `0` after reset (was `1` when the system row was persisted). The system prompt is rebuilt per turn instead of being stored in the messages table.

**Error responses**

| Code | Condition |
|------|-----------|
| `400 SESSION_CLOSED` | Cannot reset a closed session |
| `404 SESSION_NOT_FOUND` | No session with that ID |
| `409 SESSION_BUSY` | A chat turn is currently running on this session |

---

## DELETE /sessions/:id

Close or delete a session.

**Query parameters**

| Param | Type | Description |
|-------|------|-------------|
| `hard` | string | Set to `"true"` to permanently delete the session and all messages. Default: soft close only. |

**Path parameters**

| Param | Description |
|-------|-------------|
| `id` | Session ID |

**Response (soft close — default)**

```json
{
  "sessionId": "sess_4f3a1b9c2d8e7f01",
  "status": "closed"
}
```

**Response (hard delete — `?hard=true`)**

```json
{
  "sessionId": "sess_4f3a1b9c2d8e7f01",
  "status": "deleted"
}
```

**Error responses**

| Code | Condition |
|------|-----------|
| `404 SESSION_NOT_FOUND` | No session with that ID |

---

## Notes

- Sessions are scoped to the workspace (`instance_folder`). You cannot access sessions from a different server instance using the same ID.
- Chat sessions (`mode: "chat"`) stay `active` until explicitly closed or the server restarts (active sessions are left open; they can be resumed).
