# Inline Context Rendering — what changes for matrx-extend

> Cross-repo handoff. Applies to the matrx-extend Chrome extension, which is
> both the surface that sends `context` and the tool handler that runs
> client-delegated tools. **No required action** — the new behaviour is
> opt-in via existing rich-form fields the wire already supports. But there
> are easy wins on context efficiency you'll want to take.

## What changed (server side, 2026-05-18)

Small context objects are now **rendered inline** in the manifest block on
the user message — the model reads the values directly with no `ctx_get`
round trip. Large objects stay deferred behind `ctx_get` exactly as today.
The decision is per-object, driven by the existing `max_inline_chars`
field on `ContextPolicy` (agent side) and the per-request rich form
(surface side).

The model-visible result on a turn that includes `tab_state` looks like
this in the user message (was a table row + required ctx_get before):

```markdown
## Available Context

The following context is provided inline — read the values directly, do NOT call ctx_get for these.

### `tab_state` — Active tab
_User's currently active browser tab._
```json
{
  "tab_id": 908926921,
  "window_id": 908919117,
  "tab_index": 41,
  "pinned": false,
  "incognito": false,
  "status": "complete"
}
\```
```

This eliminates the "model called `tabs(action='list')` then `read_page(tabId=...)`
to find a tab it already had in context" failure mode.

## The wire shape (no schema break)

The `context` dict you already send works as-is. If a key's value is a
plain string / dict / list, it's treated as content with default settings.
To override behaviour per-key, use the rich form (already supported):

```json
{
  "context": {
    "tab_state": { ...plain object... },           // ← already does the right thing now
    "page_full_content": {                          // ← rich form for explicit control
      "content": { "markdown": "..." },
      "max_inline_chars": 0                         // ← "never inline", always defer
    }
  }
}
```

Recognised rich-form keys: `content` (required when using the rich form),
plus any of: `mutable`, `persist`, `source`, `type`, `label`, `description`,
`max_inline_chars`, `summary_agent_id`.

## `max_inline_chars` — semantics (single source of truth)

| Value | Behaviour |
|---|---|
| `null` / omitted | Use system default (200 chars). Content ≤ 200 → inlined; > 200 → deferred row. |
| positive integer `N` | Override default. Content ≤ min(N, 5000) → inlined; otherwise deferred. |
| `0` | **Never inline.** Always show as a deferred row + require `ctx_get`. Use for known-large slots. |

The hard cap of 5000 chars is enforced regardless of the override — a
misconfigured `max_inline_chars=100000` will be clamped silently.

## Recommended per-key configuration (today's slots)

Based on the keys [v2-bundled.ts](src/lib/chat/context/v2-bundled.ts) currently sends:

| Key | Typical size | Recommended `max_inline_chars` | Why |
|---|---|---|---|
| `tab_state` | ~130 chars | omit (default 200) | Auto-inlines. Model gets `tab_id` for free. |
| `user` | ~100–150 chars | omit (default 200) | Auto-inlines. |
| `client` | ~150–200 chars | omit (default 200) | Auto-inlines most cases. |
| `viewport_state` | ~80 chars | omit (default 200) | Auto-inlines. |
| `page_brief` | ~1 KB | omit (default 200) | Stays deferred (too big for default). |
| `page_meta` | 200–500 chars | `300` | Often borderline — set 300 to inline when small. |
| `page_full_content` | 5K–150K chars | `0` | **Explicit defer.** Avoids any chance of accidentally inlining a giant page. |
| `page_links` | up to 30K chars | `0` | **Explicit defer.** Always pageable. |
| `page_media` | up to 40K chars | `0` | **Explicit defer.** |
| `page_structured_data` | up to a few KB | `0` | **Explicit defer.** |
| `page_seo_audit` | up to a few KB | `0` | **Explicit defer.** |
| `form_elements` | up to ~1 KB | omit (default 200) | Deferred when present; that's fine. |
| `prior_capture` | ~200 chars | omit (default 200) | Auto-inlines. |
| `auth_state` | varies | omit | Whatever fits, fits. |
| `result_list` | varies | omit | Whatever fits, fits. |

Setting `max_inline_chars: 0` explicitly on the known-large keys is
defence-in-depth — even if the agent's slot has a permissive ceiling, the
surface can lock the value out of inline.

## Conflict resolution (surface vs agent)

If both the agent's `context_policies` row AND your rich-form override set
`max_inline_chars`, the **smaller** value wins:

| Context Policy | Surface override | Effective |
|---|---|---|
| 300 | 5000 | 300 (agent ceiling — surface can't raise) |
| 300 | 50 | 50 (surface tightens — allowed) |
| 300 | 0 | 0 (never-inline always wins) |
| 0 | 500 | 0 (agent says never — surface can't override) |
| 300 | not sent | 300 |
| not set | 500 | 500 |
| not set | not sent | 200 (default) |

**Rule of thumb:** the surface can be more restrictive than the agent,
never more permissive.

## What does NOT change

- The deferred-table format for objects that don't inline — same `ctx_get` /
  `ctx_batch` flow you have today.
- The Tier-1 condensed block in the system prompt — still just `Keys: ...`.
  (Was rendering in insertion order; now alphabetised for prompt-cache
  stability. No visible behaviour change for you.)
- The ephemeral lifecycle — inlined values live on the user message for
  the current turn only and are stripped before persistence. They never
  bloat message history or get re-sent on subsequent turns.

## Validation checklist after deployment

- [ ] Open a chat where the extension is sending `tab_state`. Confirm the
      model can reference `tab_id` without calling `ctx_get`.
- [ ] Open a chat on a heavy page. Confirm `page_full_content` is still
      deferred (row in the manifest table), not silently inlined.
- [ ] In `cx_request_snapshot` for a recent turn, find the user message
      and verify the inlined value appears between `EPHEMERAL_CONTEXT_START`
      and `EPHEMERAL_CONTEXT_END` sentinels.
- [ ] Confirm `cx_message` rows for the same turn do NOT contain the
      inlined content (it should be stripped pre-persistence — that's
      the test that history isn't being polluted).
