# Implementation Report: cli-api-requirements.md

Analysis of all requirements against the current codebase, with implementation strategy and rationale.
§7 (per-message turn stats) has been dropped per owner decision.

---

## Pre-check: What Already Exists

| Req | Feature | DB layer | Route | Verdict |
|-----|---------|----------|-------|---------|
| §1 | PATCH session | `updateSession()` exists (database.js:206) | **None** | Route only |
| §2 | Trim messages after ID | None | None | DB fn + route |
| §3 | PATCH message content | None | None | DB fn + route |
| §4 | DELETE single message | None | None | DB fn + route |
| §5 | Fork session | None | None | DB fn + route |
| §6 | `continue` flag | N/A | `message` hard-required (chat.js:19) | chat.js + router.js |
| §7 | Thinking content storage | No columns | None | Client + migration + loop |
| §8 | Attachments (image/audio/video/file) | N/A | `message: string` only | chat.js + router.js |

---

## §1 — PATCH /sessions/:id

### Current state

`db.updateSession(sessionId, updates)` (database.js:206) already does exactly what we need:
it takes a camelCase object, converts keys via `camelToSnake()`, and runs
`UPDATE sessions SET ... WHERE id = ?`. No DB changes needed — this is a route-only addition.

### Implementation

New route in `api/routes/sessions.js`.

**Whitelist of patchable fields:**

```js
const PATCHABLE = new Set([
  'title', 'model', 'model_thinking',
  'compact_enabled', 'compact_auto_threshold', 'compact_count',
  'compact_model', 'compact_custom_instructions'
]);
```

Why a whitelist instead of pass-through? `updateSession` is generic — without one, a caller
could overwrite `status`, `compact_summary`, `compact_size`, `created_at`, or any other field.
The whitelist is the only thing that makes this endpoint safe.

**Key edge cases:**

- `model_thinking` — stored as a JSON string in DB. The route must call `JSON.stringify()` on
  the value before passing to `updateSession`.
- `compact_enabled` — must coerce boolean → integer (`true → 1`, `false → 0`). The DB column
  is `INTEGER NOT NULL DEFAULT 1` and the loop checks `=== 0`, so storing a JS boolean would
  silently break auto-compact.
- `model` — if changed, `context_size_limit` should also be re-derived via `getContextLimit(model)`
  and updated in the same call. If not done, the auto-compact threshold math (`context_size /
  context_size_limit * 100`) will compare against the old model's limit.
- Unknown fields in the request body → `400 VALIDATION_ERROR`. Do not silently ignore them;
  the client is likely sending the wrong field name.
- Session must be `active` → `400 SESSION_CLOSED` if not.

**Response:** full updated session object (re-fetch after update).

---

## §2 — DELETE /sessions/:id/messages/after/:messageId

### Current state

Nothing exists. `resetSession` wipes all messages; there is no targeted trim.

### Implementation

New DB function + route.

**DB function:**

```js
function deleteMessagesAfter(sessionId, messageId) {
  const db = getDb();
  // Verify the anchor message exists in this session
  const anchor = db.prepare('SELECT id FROM messages WHERE id = ? AND session_id = ?')
    .get(messageId, sessionId);
  if (!anchor) return null; // caller handles 404

  const result = db.prepare('DELETE FROM messages WHERE session_id = ? AND id > ?')
    .run(sessionId, messageId);

  const newCount = db.prepare(
    'SELECT COUNT(*) as c FROM messages WHERE session_id = ?'
  ).get(sessionId).c;

  const nonSystemCount = db.prepare(
    "SELECT COUNT(*) as c FROM messages WHERE session_id = ? AND role != 'system'"
  ).get(sessionId).c;

  const session = db.prepare('SELECT compact_size FROM sessions WHERE id = ?').get(sessionId);
  const newCompactSize = Math.min(session?.compact_size ?? 0, nonSystemCount);

  db.prepare(
    'UPDATE sessions SET message_count = ?, compact_size = ?, updated_at = ? WHERE id = ?'
  ).run(newCount, newCompactSize, new Date().toISOString(), sessionId);

  return { deletedCount: result.changes, lastRemainingMessageId: messageId };
}
```

**Why cap `compact_size`?**

`buildMessagesWithSummary` does `nonSystemMsgs.slice(compactSize)` to decide which messages
to include in the LLM context. If `compact_size = 50` but only 10 non-system messages remain
after a trim, `.slice(50)` returns nothing — the LLM receives only the old summary and none of
the surviving messages. Capping at `nonSystemCount` prevents this. The summary text itself
remains valid; it captured those messages' content at compaction time and does not reference
DB row IDs.

**Route:**

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

Also accept `DELETE /sessions/:id/messages?after=:messageId` as a query-param alias
(the requirements note this as acceptable, and it's easy to add).

**Guards:** session active, anchor message must exist in this session.

---

## §3 — PATCH /sessions/:id/messages/:messageId

### Current state

Nothing exists. Messages are append-only.

### Implementation

New DB function + route.

**DB function:**

```js
function updateMessage(messageId, updates) {
  const db = getDb();
  const allowed = ['content', 'thinking_content', 'thinking_tokens'];
  const fields = Object.keys(updates)
    .filter(k => allowed.includes(k))
    .map(k => `${k} = ?`).join(', ');
  if (!fields) return;
  const values = Object.keys(updates).filter(k => allowed.includes(k)).map(k => updates[k]);
  db.prepare(`UPDATE messages SET ${fields} WHERE id = ?`).run(...values, messageId);
}
```

This function is also used internally by §7 (thinking content) to persist thinking data.

**Route guards:**

- Session active
- Message must exist and belong to this session: `SELECT id, role FROM messages WHERE id = ? AND session_id = ?`
- `system` messages → `400 CANNOT_EDIT_SYSTEM_MESSAGE`
- `tool` result messages → `400 CANNOT_EDIT_TOOL_MESSAGE`

Why disallow editing `tool` messages? Tool results are linked to tool calls via `tool_call_id`.
Editing them makes the conversation semantically broken (the LLM believes it executed a tool
and got result X, but the stored result is now Y). The UI's "Edit & Resend" flow is for
`user` and `assistant` messages only; tool messages should never be editable in the UI either.

- Only `content` accepted in the public request body. Other fields (`thinking_content` etc.)
  are internal-only. Silently ignore any extra fields from the client.

---

## §4 — DELETE /sessions/:id/messages/:messageId

### Current state

Nothing exists.

### Implementation

New DB function + route. Shares the same `compact_size` recalculation logic as §2.

**DB function:**

```js
function deleteMessage(sessionId, messageId) {
  const db = getDb();
  const result = db.prepare(
    'DELETE FROM messages WHERE id = ? AND session_id = ?'
  ).run(messageId, sessionId);
  if (result.changes === 0) return false;

  const newCount = db.prepare(
    'SELECT COUNT(*) as c FROM messages WHERE session_id = ?'
  ).get(sessionId).c;
  const nonSystemCount = db.prepare(
    "SELECT COUNT(*) as c FROM messages WHERE session_id = ? AND role != 'system'"
  ).get(sessionId).c;
  const session = db.prepare('SELECT compact_size FROM sessions WHERE id = ?').get(sessionId);
  const newCompactSize = Math.min(session?.compact_size ?? 0, nonSystemCount);

  db.prepare(
    'UPDATE sessions SET message_count = ?, compact_size = ?, updated_at = ? WHERE id = ?'
  ).run(newCount, newCompactSize, new Date().toISOString(), sessionId);
  return true;
}
```

**Route guards:**

- Session active
- Message must exist in this session
- `system` messages → `400 CANNOT_DELETE_SYSTEM_MESSAGE` (requirement calls this out explicitly)
- `tool` messages: no server-side guard. The server cannot know if the UI warned the user.
  The requirement says the Studio will warn — trust the caller.

---

## §5 — POST /sessions/:id/fork

### Current state

Nothing exists. `POST /sessions` creates a fresh empty session; there is no way to clone
message history.

### Implementation

New DB function + route.

**Fork title logic:**

The forked session title is set to `"Fork from: <source title>"` if the source session has a
title, otherwise `null` (the Studio will auto-title it). This gives the user immediate context
in the session list without requiring a manual rename.

```js
const forkTitle = source.title ? `Fork from: ${source.title}` : null;
```

**DB function:**

```js
function forkSession(sourceSessionId, upToMessageId) {
  const db = getDb();
  const source = db.prepare('SELECT * FROM sessions WHERE id = ?').get(sourceSessionId);
  if (!source) return null;

  // Load messages up to (and including) the anchor
  const messages = db.prepare(
    'SELECT * FROM messages WHERE session_id = ? AND id <= ? ORDER BY id ASC'
  ).all(sourceSessionId, upToMessageId);

  // Verify anchor exists in this session
  if (!messages.find(m => m.id === upToMessageId)) return { error: 'MESSAGE_NOT_FOUND' };

  const newId = generateId('sess_');
  const now = new Date().toISOString();
  const forkTitle = source.title ? `Fork from: ${source.title}` : null;

  db.prepare(`
    INSERT INTO sessions (
      id, agent_name, mode, instance_folder, status, model, model_thinking,
      context_size_limit, compact_count, compact_auto_threshold, compact_enabled,
      compact_model, compact_custom_instructions, title, message_count, created_at, updated_at
    ) VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  `).run(
    newId, source.agent_name, source.mode, source.instance_folder,
    source.model, source.model_thinking, source.context_size_limit,
    source.compact_count, source.compact_auto_threshold, source.compact_enabled,
    source.compact_model, source.compact_custom_instructions,
    forkTitle, messages.length, now, now
  );

  // Copy messages (include thinking_content + thinking_tokens if columns exist)
  const insertMsg = db.prepare(`
    INSERT INTO messages (
      session_id, role, content, tool_calls, tool_call_id,
      model_key, input_tokens, output_tokens, cache_tokens, cost,
      thinking_content, thinking_tokens, created_at
    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  `);
  for (const m of messages) {
    insertMsg.run(
      newId, m.role, m.content, m.tool_calls, m.tool_call_id,
      m.model_key, m.input_tokens, m.output_tokens, m.cache_tokens, m.cost,
      m.thinking_content ?? null, m.thinking_tokens ?? null, m.created_at
    );
  }

  // Copy compact state only if all compacted messages are within the fork range
  const nonSystemInFork = messages.filter(m => m.role !== 'system').length;
  if (source.compact_summary && (source.compact_size ?? 0) <= nonSystemInFork) {
    db.prepare('UPDATE sessions SET compact_summary = ?, compact_size = ? WHERE id = ?')
      .run(source.compact_summary, source.compact_size, newId);
  }

  return { sessionId: newId };
}
```

**Why copy compaction state conditionally?**

If the fork point is before `compact_size`, the summary references messages that are not
included in the fork. Injecting it would tell the LLM "you previously did X" where X is
context from outside the fork window — factually wrong. When the fork includes all compacted
messages, the summary is still valid and avoids re-compacting from scratch.

**Route response** `201 Created`:

```json
{
  "sessionId": "sess_newid",
  "session": { "title": "Fork from: My session", ... },
  "forkedFrom": { "sessionId": "sess_original", "upToMessageId": 18 }
}
```

---

## §6 — `continue: true` in POST /agents/:name/chat

### Current state

`chat.js:19` hard-requires `message`. `runChat` in `router.js:82-84` always appends the user
message and saves it to DB.

### Implementation

Two files: `api/routes/chat.js` and `core/router.js`.

**chat.js:**

```js
const { message, sessionId, sse = false, continue: continueFlag = false } = req.body;

if (!continueFlag && (!message || typeof message !== 'string')) {
  return sendError(res, 400, 'VALIDATION_ERROR',
    'message is required and must be a string (or pass continue:true to resume without a new message)');
}
if (continueFlag && !sessionId) {
  return sendError(res, 400, 'VALIDATION_ERROR',
    'sessionId is required when continue:true');
}
```

Pass `continueFlag` and `message` (possibly undefined) through to `runChat`.

**router.js `runChat`:**

```js
async function runChat({ agentName, message, sessionId, continueFlag = false, ... }) {
  // ...session loading unchanged...

  if (!continueFlag) {
    messages.push({ role: 'user', content: message });
    db.addMessage({ sessionId: sid, role: 'user', content: message });
    eventBus.emit('event', { type: 'chat.user_message', ... });
  }
  // Loop runs from current session state either way
}
```

**Why require `sessionId` when `continue: true`?**

Without an existing session there is no prior context to resume. A fresh session with no
user message would enter `runLoop` with only a system message and immediately return an
empty response. This is a client bug, not a valid use case.

**Can you pass both `message` and `continue: true`?**

Yes — the implementation allows it. The message is added, then the loop runs. This is a valid
pattern: inject one final user note, then let the agent continue. Do not prohibit it.

---

## §7 — Thinking content in assistant messages

### Current state

`extractMessage` in `llm/client.js:178` does `content: msg.content || null`. When extended
thinking is active, providers return `msg.content` as an array of typed content blocks
(`{ type: "thinking", thinking: "..." }` and `{ type: "text", text: "..." }`), or put thinking
in `msg.reasoning_content`. Neither case is handled. The streaming client accumulates only
`delta.content` strings. No DB columns exist for thinking content.

### Implementation

Four files: `llm/client.js`, migration, `infrastructure/database.js`, `core/loop.js`.

**llm/client.js — `extractMessage`:**

```js
function extractMessage(response) {
  const choice = response.choices[0];
  if (!choice) throw new Error('LLM response had no choices');
  const msg = choice.message;

  let content = null;
  let thinkingContent = null;

  if (Array.isArray(msg.content)) {
    // Anthropic-style content blocks via OpenRouter
    content = msg.content.filter(b => b.type === 'text').map(b => b.text).join('') || null;
    thinkingContent = msg.content.filter(b => b.type === 'thinking').map(b => b.thinking).join('') || null;
  } else {
    content = msg.content || null;
    thinkingContent = msg.reasoning_content || null; // some OpenRouter models use this field
  }

  return {
    content,
    thinkingContent,
    audio: msg.audio || null,
    toolCalls: msg.tool_calls?.length > 0 ? msg.tool_calls : null,
    finishReason: choice.finish_reason || 'stop',
  };
}
```

**llm/client.js — `callLLMStreaming` delta loop:**

Track a separate `thinkingContent` accumulator alongside `fullContent`:

```js
let thinkingContent = '';

// Inside the delta processing loop:
if (delta.reasoning_content) {
  thinkingContent += delta.reasoning_content;
}
if (typeof delta.content === 'string') {
  fullContent += delta.content;
  onChunk(delta.content);
} else if (Array.isArray(delta.content)) {
  for (const block of delta.content) {
    if (block.type === 'text' && block.text) { fullContent += block.text; onChunk(block.text); }
    if (block.type === 'thinking' && block.thinking) thinkingContent += block.thinking;
  }
}
```

Include `thinkingContent: thinkingContent || null` in the returned response object.

**llm/client.js — `extractUsage`:**

Extract `reasoning_tokens` for the `thinking_tokens` field:

```js
function extractUsage(response) {
  const usage = response.usage || {};
  return {
    input:          usage.prompt_tokens || 0,
    output:         usage.completion_tokens || 0,
    cache:          usage.prompt_tokens_details?.cached_tokens || 0,
    thinkingTokens: usage.completion_tokens_details?.reasoning_tokens || 0,
    cost:           usage.cost || 0,
  };
}
```

**Migration (009):**

```sql
ALTER TABLE messages ADD COLUMN thinking_content TEXT;
ALTER TABLE messages ADD COLUMN thinking_tokens INTEGER;
INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (9, datetime('now'));
```

**database.js:** Two `ensureColumn` calls. Extend `addMessage` to accept `thinkingContent` and
`thinkingTokens` parameters and insert them.

**core/loop.js:** `extractMessage` now returns `thinkingContent`; `extractUsage` now returns
`thinkingTokens`. Pass both to `addMessage` when saving the assistant message (loop.js:385-386).

**Why per-message and not session-level?**

Each assistant turn has independent thinking content. The studio requirement is to show a
collapsible "Thinking (N tokens)" block above each assistant message. Session-level would
only allow showing the last turn's thinking.

**Why `thinking_tokens` separately from `output_tokens`?**

Some providers report them as `completion_tokens_details.reasoning_tokens` and bill them at
the output-token rate but separately from the response. Storing them separately allows the
studio to show "thinking: 1200 tokens, response: 300 tokens" and compute accurate cost
breakdowns without mixing the two.

---

## §8 — Multimodal attachments in POST /agents/:name/chat

### Current state

`chat.js:15-16` extracts only `{ message, sessionId, sse }`. `runChat` always appends
`{ role: 'user', content: message }` as a plain string. The LLM client sends `content`
unchanged to the API.

### OpenRouter content block formats (verified from docs)

OpenRouter supports four attachment types in user message content arrays:

| Attachment type | OpenRouter block type | URL support | Base64 support |
|-----------------|----------------------|-------------|----------------|
| Image (png, jpg, webp, gif) | `image_url` | ✅ | ✅ `data:image/png;base64,...` |
| Audio (wav, mp3, aiff, aac, ogg, flac, m4a) | `input_audio` | ❌ | ✅ raw base64 + `format` field |
| Video (mp4, mpeg, mov, webm) | `video_url` | ✅ | ✅ `data:video/mp4;base64,...` |
| File / PDF | `document` | ✅ | ✅ in `source.data` |

```js
// Image — URL or base64 data URL
{ type: "image_url", image_url: { url: "https://..." } }
{ type: "image_url", image_url: { url: "data:image/png;base64,..." } }

// Audio — base64 ONLY, no URL support
{ type: "input_audio", input_audio: { data: "<base64>", format: "wav" } }

// Video — URL or base64 data URL
{ type: "video_url", video_url: { url: "https://..." } }
{ type: "video_url", video_url: { url: "data:video/mp4;base64,..." } }

// File / PDF — Anthropic document format (most compatible via OpenRouter)
{ type: "document", source: { type: "base64", media_type: "application/pdf", data: "<base64>" } }
// OR URL:
{ type: "document", source: { type: "url", url: "https://..." } }
```

### VeilCLI request contract

The `attachments` array accepted by `POST /agents/:name/chat`:

```json
{
  "message": "What's in this image?",
  "sessionId": "sess_abc",
  "sse": true,
  "attachments": [
    {
      "type": "image",
      "mediaType": "image/png",
      "data": "<base64>"
    },
    {
      "type": "image",
      "url": "https://example.com/photo.jpg"
    },
    {
      "type": "audio",
      "format": "wav",
      "data": "<base64>"
    },
    {
      "type": "video",
      "mediaType": "video/mp4",
      "data": "<base64>"
    },
    {
      "type": "video",
      "url": "https://example.com/clip.mp4"
    },
    {
      "type": "file",
      "filename": "report.pdf",
      "mediaType": "application/pdf",
      "data": "<base64>"
    },
    {
      "type": "file",
      "filename": "report.pdf",
      "url": "https://example.com/report.pdf",
      "mediaType": "application/pdf"
    }
  ]
}
```

Each attachment field:

| Field | Required | Description |
|-------|----------|-------------|
| `type` | ✅ | `"image"`, `"audio"`, `"video"`, or `"file"` |
| `data` | one of `data`/`url` | Base64-encoded content. Audio requires this; others accept either. |
| `url` | one of `data`/`url` | Public URL. Not supported for audio. |
| `mediaType` | for `image`, `video`, `file` with `data` | MIME type: `image/png`, `video/mp4`, `application/pdf` etc. |
| `format` | for `audio` | Audio format string: `"wav"`, `"mp3"`, `"aac"`, `"flac"`, `"ogg"`, `"m4a"` |
| `filename` | for `file` | Original filename, passed to the document block |

### Implementation

Two files: `api/routes/chat.js` and `core/router.js`.

**`chat.js`:** Extract and forward `attachments`:

```js
const { message, sessionId, sse = false, continue: continueFlag = false, attachments } = req.body;
```

Validate attachments array if present:
```js
if (attachments !== undefined && !Array.isArray(attachments)) {
  return sendError(res, 400, 'VALIDATION_ERROR', '"attachments" must be an array');
}
```

Pass `attachments` to `runChat`.

**`core/router.js` — `buildAttachmentBlock` helper:**

```js
const VALID_AUDIO_FORMATS = new Set(['wav', 'mp3', 'aiff', 'aac', 'ogg', 'flac', 'm4a', 'pcm16', 'pcm24']);

function buildAttachmentBlock(a) {
  switch (a.type) {
    case 'image': {
      const url = a.url || `data:${a.mediaType};base64,${a.data}`;
      return { type: 'image_url', image_url: { url } };
    }
    case 'audio': {
      if (!a.data) return null; // audio requires base64, skip if only URL provided
      const format = VALID_AUDIO_FORMATS.has(a.format) ? a.format : 'wav';
      return { type: 'input_audio', input_audio: { data: a.data, format } };
    }
    case 'video': {
      const url = a.url || `data:${a.mediaType};base64,${a.data}`;
      return { type: 'video_url', video_url: { url } };
    }
    case 'file': {
      if (a.url) {
        return { type: 'document', source: { type: 'url', url: a.url } };
      }
      return { type: 'document', source: { type: 'base64', media_type: a.mediaType || 'application/pdf', data: a.data } };
    }
    default:
      return null;
  }
}

function buildUserContent(message, attachments) {
  if (!attachments || attachments.length === 0) return message;
  const parts = [{ type: 'text', text: message }];
  for (const a of attachments) {
    const block = buildAttachmentBlock(a);
    if (block) parts.push(block);
  }
  return parts;
}
```

**`runChat` usage:**

```js
const userContent = buildUserContent(message, attachments);
messages.push({ role: 'user', content: userContent }); // multi-part for LLM
db.addMessage({ sessionId: sid, role: 'user', content: message });  // plain text to DB
```

**Why store only plain text in DB?**

Base64-encoded images/video/audio can be megabytes each. Storing them in SQLite would bloat
the messages table and make `GET /sessions/:id/messages` responses enormous for any UI
replaying history. The DB stores the user's text description; the multi-part format is
assembled in-memory for LLM calls only. This is the same trade-off that all major AI chat
platforms make. If history replay with attachment thumbnails is ever needed, that is a future
concern with a dedicated blob store — not SQLite.

**Why null-check audio `data`?**

Audio is the only type that does NOT support URL input (confirmed by OpenRouter docs). If a
caller sends `{ type: "audio", url: "..." }` without `data`, we silently skip the block rather
than sending a malformed request to the LLM API. The text message still goes through.

**No model validation:** The server does not check whether the current agent's model supports
multimodal input. Pass through and let the LLM API return an error if not — the caller is
responsible for knowing their model's capabilities.

**Streaming + attachments:** No special handling needed. The attachment content is only in the
user message turn; LLM streaming responses are text only. The existing `onChunk` callback and
streaming infrastructure are unchanged.

---

## Recommended Build Order

| Order | Req | Rationale |
|-------|-----|-----------|
| 1 | §3 PATCH message | `updateMessage()` DB fn is also needed internally by §7 |
| 2 | §4 DELETE single message | Shares `compact_size` recalc pattern; unblocks UI delete action |
| 3 | §2 Trim after messageId | Shares same pattern; unblocks Rewind |
| 4 | §1 PATCH session | Self-contained route; no new DB fns needed |
| 5 | §6 continue flag | Two-file change, self-contained |
| 6 | §7 Thinking content | Migration + client.js changes; test with thinking-capable models |
| 7 | §5 Fork session | Lower priority; no dependencies on above |
| 8 | §8 Attachments | Most complex; touches chat.js, router.js, and all four content types |
