# Raxol: Full Documentation

This file concatenates the Raxol documentation for single-fetch ingestion by
an agent. The curated, linked index is at https://raxol.io/llms.txt. Sources
live at https://github.com/DROOdotFOO/raxol/tree/master/docs.

<!-- docs/getting-started/BUILD_AN_AGENT.md -->

# Build Your First Agent

The [Quickstart](QUICKSTART.md) builds a terminal app. This one builds an autonomous
agent: a program an LLM drives, with tools, memory, and a learning loop. Same TEA model,
same OTP supervision. The "user" is a model issuing tool calls instead of a keyboard.

By the end you will have an agent that reasons on a real model, calls tools you define,
remembers across sessions, and improves itself after each turn.

## 1. A message-driven agent

An agent is a `use Raxol.Agent` module. At its simplest it processes messages and returns
commands, with no LLM at all:

```elixir
defmodule MyAgent do
  use Raxol.Agent

  def init(_ctx), do: %{findings: []}

  def update({:agent_message, _from, {:analyze, file}}, model) do
    {model, [shell("wc -l #{file}")]}
  end

  def update({:command_result, {:shell_result, %{output: out}}}, model) do
    {%{model | findings: [out | model.findings]}, []}
  end
end

{:ok, _} = Raxol.Agent.Session.start_link(app_module: MyAgent, id: :my_agent)
Raxol.Agent.Session.send_message(:my_agent, {:analyze, "lib/raxol.ex"})
```

This is a headless OTP process (`view/1` defaults to `nil`). See the
[Agent Framework](../features/AGENT_FRAMEWORK.md) for sessions, teams, and messaging.

## 2. Give it tools

A tool is a `use Raxol.Agent.Action` module: a name, a description, a validated input
schema, and a `run/2`. The framework turns it into an LLM tool definition and dispatches
calls to it.

```elixir
defmodule Tools.CountLines do
  use Raxol.Agent.Action,
    name: "count_lines",
    description: "Count the lines in a file.",
    schema: [input: [path: [type: :string, required: true, description: "File path"]]]

  def run(%{path: path}, _context) do
    {:ok, %{lines: path |> File.read!() |> String.split("\n") |> length()}}
  end
end
```

Declare which tools an agent may call with `available_actions/0`. Raxol ships a full
toolset already (file read/write, shell, grep, glob, memory, skills); the
[Tool Catalog](../reference/TOOL_CATALOG.md) lists every built-in tool and its
authorization tier.

```elixir
defmodule MyAgent do
  use Raxol.Agent

  def available_actions do
    [Tools.CountLines | Raxol.Agent.Actions.Fs.all()]
  end
end
```

Sensitive tools (writing files, running shell commands) are denied by default and gated
through the [ALLOW/ASK/DENY authorization engine](../features/AGENT_FRAMEWORK.md#authorization-allowaskdeny).
The [Coding Agent](../features/CODING_AGENT.md) shows the interactive approval UX.

## 3. Reason on a real model

Pick a backend with `ExecutorConfig` and `Backend.Selector`, then drive a turn with
`Raxol.Agent.Turn`, which runs the reasoning loop and records the conversation:

```elixir
{:ok, log} = Raxol.Agent.Conversation.Log.start_link(name: MyLog)

{:ok, backend, backend_opts} =
  Raxol.Agent.Backend.Selector.select(
    Raxol.Agent.ExecutorConfig.new(
      harness: :anthropic,
      model: "claude-sonnet-5",
      auth: %{api_key: System.fetch_env!("ANTHROPIC_API_KEY")}
    )
  )

{:ok, items} =
  Raxol.Agent.Turn.run(MyAgent, "how many lines are in mix.exs?",
    backend: backend,
    backend_opts: backend_opts,
    log: MyLog,
    conversation_id: "session-1",
    agent_id: "my-agent"
  )
```

The harness atom selects the provider: `:anthropic`, `:openai`, `:ollama`, `:lm_studio`,
`:claude_native` (Claude Code), `:cursor`, and more. See
[Agent Framework](../features/AGENT_FRAMEWORK.md#native-multi-vendor-harness).

## 4. Memory, skills, and self-improvement

These are opt-in callbacks on the agent module. Turn wires them into every turn:

```elixir
defmodule MyAgent do
  use Raxol.Agent

  # Recall facts across sessions, and search prior conversation history.
  def memory_providers, do: [Raxol.Agent.Memory.Store.Ets]

  # Author and reuse SKILL.md procedures.
  def skills_provider, do: Raxol.Agent.Skills.Store

  # After each successful turn, review it on a cheap model and write down
  # what was learned (durable memory + new skills).
  def self_improve, do: %{enabled: true, model: "claude-haiku-4-5", min_tool_calls: 5}

  def available_actions, do: Raxol.Agent.Actions.Fs.all()
end
```

Setting `memory_providers/0` and `skills_provider/0` auto-exposes the memory and skills
tools. `self_improve/0` runs a background reviewer after each turn (isolated, so a review
crash never touches the turn). Nothing else changes: the same `Turn.run/3` call now
recalls memory, offers skills, and learns.

- [Memory](../features/MEMORY.md): the provider stack, session search, and user model.
- [Self-Improvement](../features/SELF_IMPROVEMENT.md): the after-turn loop and the Curator.
- [Skill Authoring](../guides/SKILL_AUTHORING.md): how to write a `SKILL.md`.

## 5. Run a complete example

A full, runnable tool-using agent (with a Mock backend so it works without an API key):

```bash
cd packages/raxol_agent
mix run examples/agents/react_agent.exs                        # Mock backend
ANTHROPIC_API_KEY=sk-ant-... mix run examples/agents/react_agent.exs   # real model
```

For an interactive coding agent you can talk to right now, see the
[Coding Agent](../features/CODING_AGENT.md) (`mix raxol.code`).

## Where next

- [Agent Framework](../features/AGENT_FRAMEWORK.md): teams, the native harness, the item-log, the tunnel.
- [Agentic Commerce](../features/AGENTIC_COMMERCE.md): give the agent a wallet and spending limits.
- [Why Raxol](../WHY_RAXOL.md): why an OTP runtime is the right substrate for agents.


<!-- docs/getting-started/COMPONENT_GALLERY.md -->

# Component Gallery

All Components are available via the View DSL after `use Raxol.Core.Runtime.Application`. Layout containers (`column`, `row`, `box`) use `do` block syntax. Everything else is a plain function call.

To see them all running: `mix raxol.playground` (interactive demos across all categories).

---

## Layout

Layout Components arrange children on screen. These are the skeleton of every Raxol UI.

### column

Vertical stack. Children are arranged top-to-bottom.

```elixir
column style: %{gap: 1, padding: 1, align_items: :center} do
  [
    text("Header", style: [:bold]),
    divider(),
    text("Body content"),
    spacer(),
    text("Footer", style: [:dim])
  ]
end
```

Style options: `gap`, `padding`, `align_items` (`:start`, `:center`, `:end`, `:stretch`), `flex`, `width`, `height`.

### row

Horizontal stack. Children are arranged left-to-right.

```elixir
row style: %{gap: 2, align_items: :center} do
  [
    text("Status:"),
    text("Online", style: %{fg: :green, bold: true}),
    spacer(),
    button("Refresh", on_click: :refresh)
  ]
end
```

Same style options as `column`. Use `spacer()` to push items apart.

### box

Container with optional border and padding. Good for grouping related content.

```elixir
box style: %{border: :single, padding: 1, width: 40} do
  column style: %{gap: 1} do
    [
      text("User Profile", style: [:bold]),
      text("Name: #{model.name}"),
      text("Email: #{model.email}")
    ]
  end
end
```

Border styles: `:none`, `:single`, `:double`, `:rounded`, `:bold`, `:dashed`.

### spacer

Flexible space that fills available room. Useful for pushing items to opposite ends of a row or column.

```elixir
row do
  [text("Left"), spacer(), text("Right")]
end
```

Options: `size` (integer, default 1), `direction` (`:vertical` or `:horizontal`).

### divider

Horizontal line separator.

```elixir
column do
  [text("Section A"), divider(), text("Section B")]
end
```

Options: `char` (string, default `"-"`), `style`.

### split_pane

Resizable split layout with two panes.

```elixir
split_pane(
  direction: :horizontal,
  ratio: {1, 2},
  min_size: 10,
  children: [left_panel, right_panel]
)
```

Options: `direction` (`:horizontal` or `:vertical`), `ratio` (tuple, default `{1, 1}`), `min_size` (integer, default 5).

---

## Text & Display

Components for showing information to the user. These are all display-only, with no user interaction.

### text

Styled text content. The most basic Component.

```elixir
# Style atoms
text("Bold text", style: [:bold])
text("Dimmed", style: [:dim])
text("Warning", style: [:bold, :underline])

# Color via style map
text("Error", style: %{fg: :red, bold: true})
text("Success", style: %{fg: :green, bg: :black})

# fg/bg also work as keyword opts
text("Custom", fg: :cyan, bg: :blue)
```

Style atoms: `:bold`, `:dim`, `:italic`, `:underline`, `:strikethrough`, `:reverse`.

Colors: `:black`, `:red`, `:green`, `:yellow`, `:blue`, `:magenta`, `:cyan`, `:white`, plus RGB tuples `{r, g, b}` and hex strings `"#ff6600"`. Auto-downsampled to whatever the terminal supports.

### label

Alias for text with an explicit `content` key.

```elixir
label(content: "Field name:", style: %{bold: true})
```

### progress

Progress bar indicator.

```elixir
progress(value: 65, max: 100)
```

The underlying component module (`Raxol.UI.Components.Display.Progress`) supports more options when used directly: `show_percentage`, `label`, `animated`, `width`.

### list

Render a list of items, with optional selection highlighting.

```elixir
list(items: ["Elixir", "Rust", "Go", "Zig"])
list(items: model.todos, selected: model.selected_index)
```

### table

Tabular data display with headers.

```elixir
table(
  headers: ["Name", "Role", "Status"],
  rows: [
    ["Alice", "Admin", "Active"],
    ["Bob", "User", "Idle"],
    ["Carol", "User", "Active"]
  ]
)
```

The component module (`Raxol.UI.Components.Table`) supports much more when used directly: `column_widths` (`:auto` or explicit list), `border_style`, `sortable`, `filterable`, `selectable`, `striped`, `alignments`. Handles keyboard navigation for row selection.

### tree

Hierarchical tree view with expand/collapse. Component module only (not available as a View DSL function).

```elixir
alias Raxol.UI.Components.Display.Tree

nodes = [
  %{id: "src", label: "src/", children: [
    %{id: "lib", label: "lib/", children: [
      %{id: "app", label: "app.ex", children: []}
    ]},
    %{id: "test", label: "test/", children: []}
  ]}
]

# In init/1:
{:ok, tree_state} = Tree.init(%{id: "file_tree", nodes: nodes})

# In view/1 (render the tree state):
Tree.render(model.tree_state, context)
```

Keyboard: Up/Down (navigate), Right (expand), Left (collapse/go to parent), Enter/Space (select), Home/End (jump).

Options: `indent_size`, `on_select`, `on_expand`, `on_collapse`.

### viewport

Scrollable container for content larger than the visible area. Component module only.

```elixir
alias Raxol.UI.Components.Display.Viewport

{:ok, vp} = Viewport.init(%{
  id: "log_view",
  children: log_lines,
  visible_height: 20,
  show_scrollbar: true
})

# Scroll programmatically:
vp = Viewport.update({:scroll_by, 5}, vp)
vp = Viewport.update({:scroll_to, 0}, vp)
```

### status_bar

Fixed status bar for key-value display. Component module only.

```elixir
alias Raxol.UI.Components.Display.StatusBar

{:ok, bar} = StatusBar.init(%{
  id: "status",
  items: [
    %{key: "branch", label: "main"},
    %{key: "tests", label: "5484 passing"},
    %{key: "mode", label: "INSERT"}
  ],
  separator: " | "
})
```

### code_block

Syntax-highlighted code display. Uses Makeup for Elixir highlighting, falls back to plain text for other languages. Component module only.

```elixir
alias Raxol.UI.Components.CodeBlock

{:ok, block} = CodeBlock.init(%{
  content: ~s|defmodule Hello do\n  def world, do: :ok\nend|,
  language: "elixir"
})
```

### markdown_renderer

Renders markdown text with terminal formatting. Supports headings, bold, italic, inline code, lists, and blockquotes. Component module only.

```elixir
alias Raxol.UI.Components.MarkdownRenderer

{:ok, md} = MarkdownRenderer.init(%{
  markdown_text: "# Hello\n\nThis is **bold** and *italic*.\n\n- Item one\n- Item two",
  width: 60
})
```

Uses EarmarkParser when available, falls back to regex-based parsing.

### image

Inline terminal image display. Supports Kitty, iTerm2, and Sixel protocols.

```elixir
image(src: "logo.png", width: 30, height: 15)
image(src: raw_png_binary, protocol: :kitty, preserve_aspect: true)
```

Options: `protocol` (`:kitty`, `:iterm2`, `:sixel`, auto-detected if omitted), `preserve_aspect` (default true).

---

## Input

Components that accept user interaction. These handle keyboard events and fire callbacks.

### button

Clickable button that sends a message to `update/2` on press.

```elixir
button("Save", on_click: :save)
button("Delete", on_click: {:delete, item.id})
```

The component module (`Raxol.UI.Components.Input.Button`) supports: `role` (`:primary`, `:secondary`, `:danger`, `:success`), `disabled`, `shortcut`, `tooltip`.

Keyboard: Enter or Space to activate.

### text_input

Single-line text input field.

```elixir
text_input(value: model.name, placeholder: "Enter name...")
```

The component module (`Raxol.UI.Components.Input.TextInput`) supports: `on_change`, `on_submit`, `on_cancel`, `mask_char` (for passwords), `max_length`, `validator` (function).

### textarea

Multi-line text area.

```elixir
textarea(
  value: model.notes,
  placeholder: "Write something...",
  rows: 8
)
```

For the full-featured editor with undo/redo, selection, and text wrapping, use the `MultiLineInput` component module directly.

### checkbox

Toggle checkbox.

```elixir
checkbox(checked: model.agreed, label: "I agree to the terms")
```

The component module (`Raxol.UI.Components.Input.Checkbox`) supports: `on_toggle`, `disabled`, `required`, `tooltip`.

Keyboard: Space or Enter to toggle.

### radio_group

Radio button group for single selection from a set of options.

```elixir
radio_group(
  options: ["Small", "Medium", "Large"],
  selected: model.size
)
```

Options: `on_change`.

### select / select_list

Dropdown selection. The DSL `select/1` creates a simple dropdown. The component module (`Raxol.UI.Components.Input.SelectList`) is a full-featured scrollable list with search, pagination, and multi-select.

```elixir
# Simple DSL dropdown
select(
  options: ["Elixir", "Rust", "Go"],
  selected: model.language,
  placeholder: "Pick a language..."
)
```

```elixir
# Full component with search and multi-select
alias Raxol.UI.Components.Input.SelectList

{:ok, sl} = SelectList.init(%{
  id: "lang_picker",
  options: [{"Elixir", :elixir}, {"Rust", :rust}, {"Go", :go}],
  enable_search: true,
  multiple: true,
  max_height: 10,
  on_select: :language_selected
})
```

Keyboard: Up/Down (navigate), Enter (select), PageUp/PageDown, Home/End, type to search.

### tabs

Tab navigation bar.

```elixir
tabs(tabs: ["Overview", "Details", "Settings"], active: model.active_tab)
```

The component module (`Raxol.UI.Components.Input.Tabs`) supports: `on_change`, keyboard Left/Right (with wrap), Home/End, 1-9 (direct select).

### menu

Nested dropdown/context menu with submenus. Component module only.

```elixir
alias Raxol.UI.Components.Input.Menu

items = [
  %{id: :file, label: "File", children: [
    %{id: :new, label: "New", shortcut: "Ctrl+N"},
    %{id: :open, label: "Open", shortcut: "Ctrl+O"},
    %{id: :save, label: "Save", shortcut: "Ctrl+S", disabled: true}
  ]},
  %{id: :edit, label: "Edit", children: [
    %{id: :undo, label: "Undo", shortcut: "Ctrl+Z"},
    %{id: :redo, label: "Redo", shortcut: "Ctrl+Y"}
  ]}
]

{:ok, menu} = Menu.init(%{id: "main_menu", items: items, on_select: :menu_action})
```

Keyboard: Up/Down (skip disabled items), Right (open submenu), Left (close submenu), Enter (select), Escape (close).

### multi_line_input

Full text editor with undo/redo, selection, and word wrapping. Component module only.

```elixir
alias Raxol.UI.Components.Input.MultiLineInput

{:ok, editor} = MultiLineInput.init(%{
  id: "code_editor",
  value: "defmodule Hello do\n  def world, do: :ok\nend",
  width: 60,
  height: 20,
  wrap: :word
})
```

Options: `wrap` (`:none`, `:char`, `:word`), `on_change`, `on_submit`.

Features: cursor movement, shift-select, undo/redo history, line wrapping.

---

## Overlay

Components that float above the main content.

### modal

Modal dialog that overlays the current view.

```elixir
# Simple alert
modal(visible: model.show_confirm, title: "Confirm", content: text("Delete this item?"))
```

The component module (`Raxol.UI.Components.Modal`) supports multiple types:

```elixir
alias Raxol.UI.Components.Modal

# Alert with buttons
{:ok, m} = Modal.init(%{
  id: "confirm",
  type: :alert,
  title: "Delete Item",
  content: "This cannot be undone.",
  buttons: [
    %{label: "Cancel", action: :cancel},
    %{label: "Delete", action: :delete}
  ]
})

# Prompt with input
{:ok, m} = Modal.init(%{
  id: "rename",
  type: :prompt,
  title: "Rename",
  input_value: model.current_name
})

# Form with validation
{:ok, m} = Modal.init(%{
  id: "settings",
  type: :form,
  title: "Settings",
  fields: [%{name: "timeout", label: "Timeout (ms)"}],
  validate: &validate_settings/1
})
```

---

## Progress indicators

Show how far along something is, or that work is happening.

### progress (DSL)

Standard horizontal progress bar. This is the DSL entry point.

```elixir
progress(value: 65, max: 100)
```

### Progress.Bar, Progress.Spinner, Progress.Circular

Component modules for more control. Not separate DSL functions.

```elixir
# Animated bar with percentage label
alias Raxol.UI.Components.Display.Progress

{:ok, bar} = Progress.init(%{
  id: "upload",
  progress: 0.65,
  width: 40,
  show_percentage: true,
  label: "Uploading...",
  animated: true
})
```

```elixir
# Spinner (stateless utility, call each frame)
alias Raxol.UI.Components.Progress.Spinner

# Available styles: :dots, :line, :circle, :arrow, :bounce,
#                   :pulse, :wave, :dots3, :square, :flip
frame = Spinner.spinner("Loading...", 0, type: :dots)
```

| Module              | Use case                                                  |
| ------------------- | --------------------------------------------------------- |
| `Progress.Bar`      | Determinate progress with known completion                |
| `Progress.Spinner`  | Indeterminate: something is happening, unknown duration   |
| `Progress.Circular` | Circular/ring-style progress indicator                    |

---

## Charts

Streaming data visualization. All chart functions render braille or block characters and compose naturally in `view/1`.

### sparkline

Minimal inline chart: a line with no axes or legend.

```elixir
sparkline(data: model.cpu_history, width: 30, height: 3, color: :green)
```

### line_chart

Braille line chart with multi-series support.

```elixir
line_chart(
  series: [
    %{name: "CPU", data: model.cpu_history, color: :cyan},
    %{name: "Memory", data: model.mem_history, color: :magenta}
  ],
  width: 60,
  height: 15,
  show_axes: true,
  show_legend: true
)
```

### bar_chart

Block-character bar chart. Vertical or horizontal, grouped multi-series.

```elixir
bar_chart(
  series: [
    %{name: "Q1", data: [42, 67, 55], color: :blue},
    %{name: "Q2", data: [50, 72, 61], color: :green}
  ],
  width: 50,
  height: 12,
  orientation: :vertical,
  show_values: true,
  show_legend: true
)
```

Options: `bar_gap` (gap within group), `group_gap` (gap between groups).

### scatter_chart

Braille 2D scatter plot.

```elixir
scatter_chart(
  series: [
    %{name: "Cluster A", data: [{1.2, 3.4}, {2.1, 4.5}, {1.8, 3.9}], color: :cyan},
    %{name: "Cluster B", data: [{5.0, 1.2}, {4.8, 1.5}, {5.3, 0.9}], color: :yellow}
  ],
  width: 50,
  height: 15,
  show_axes: true,
  x_range: {0, 7},
  y_range: {0, 6}
)
```

### heatmap

2D grid with color intensity.

```elixir
heatmap(
  data: [
    [0.1, 0.4, 0.9, 0.6],
    [0.3, 0.8, 0.5, 0.2],
    [0.7, 0.2, 0.3, 0.8]
  ],
  width: 40,
  height: 6,
  color_scale: :warm,
  show_values: true
)
```

Color scales: `:warm` (yellow -> red), `:cool` (cyan -> blue), `:diverging` (blue -> white -> red), or a custom `fn(value, min, max) -> {r, g, b}`.

---

## Advanced

### process_component

Run any component in its own supervised process for crash isolation. If it crashes, it restarts automatically without affecting the rest of the app.

```elixir
# In your view:
process_component(MyExpensiveComponent, %{path: "/var/log"})
```

### focus_ring

Visual focus indicator for accessibility. Highlights the currently focused component.

Component module: `Raxol.UI.Components.FocusRing`

Options: `color`, `width`, `offset`, `style` (`:solid`), `components` (list of component IDs to track).

---

## Using components directly

The View DSL functions cover most needs. When you need full control (handling events, managing component state, accessing all options) use the component modules directly:

```elixir
alias Raxol.UI.Components.Input.TextInput

# Initialize with full options
{:ok, state} = TextInput.init(%{
  id: "search",
  value: "",
  placeholder: "Search...",
  max_length: 100,
  on_submit: :do_search
})

# Handle an event
state = TextInput.handle_event(event, state, context)

# Render
rendered = TextInput.render(state, context)
```

All component modules follow the same pattern: `init/1` -> `handle_event/3` -> `render/2`.

---

## Quick reference

| Component     | DSL function      | Module path            | Interactive? |
| ------------- | ----------------- | ---------------------- | ------------ |
| column        | `column do`       | --                     | No           |
| row           | `row do`          | --                     | No           |
| box           | `box do`          | --                     | No           |
| spacer        | `spacer/1`        | --                     | No           |
| divider       | `divider/1`       | --                     | No           |
| split_pane    | `split_pane/1`    | `UI.Layout.SplitPane`  | No           |
| text          | `text/1`          | --                     | No           |
| label         | `label/1`         | --                     | No           |
| list          | `list/1`          | --                     | No           |
| progress      | `progress/1`      | `Display.Progress`     | No           |
| table         | `table/1`         | `Table`                | Yes          |
| tree          | --                | `Display.Tree`         | Yes          |
| viewport      | --                | `Display.Viewport`     | Yes          |
| status_bar    | --                | `Display.StatusBar`    | No           |
| code_block    | --                | `CodeBlock`            | No           |
| markdown      | --                | `MarkdownRenderer`     | No           |
| image         | `image/1`         | --                     | No           |
| button        | `button/1`        | `Input.Button`         | Yes          |
| text_input    | `text_input/1`    | `Input.TextInput`      | Yes          |
| textarea      | `textarea/1`      | `Input.MultiLineInput` | Yes          |
| checkbox      | `checkbox/1`      | `Input.Checkbox`       | Yes          |
| radio_group   | `radio_group/1`   | --                     | Yes          |
| select        | `select/1`        | `Input.SelectList`     | Yes          |
| tabs          | `tabs/1`          | `Input.Tabs`           | Yes          |
| menu          | --                | `Input.Menu`           | Yes          |
| modal         | `modal/1`         | `Modal`                | Yes          |
| sparkline     | `sparkline/1`     | --                     | No           |
| line_chart    | `line_chart/1`    | --                     | No           |
| bar_chart     | `bar_chart/1`     | --                     | No           |
| scatter_chart | `scatter_chart/1` | --                     | No           |
| heatmap       | `heatmap/1`       | --                     | No           |
| spinner       | --                | `Progress.Spinner`     | No           |
| focus_ring    | --                | `FocusRing`            | No           |

All component module paths are under `Raxol.UI.Components.*`.

---

## Running examples

```bash
# Interactive playground with all demos
mix raxol.playground

# Flagship demo (dashboard, sparklines, live stats)
mix run examples/demo.exs

# Simple starting point
mix run examples/getting_started/counter.exs

# Full Component showcase
mix run examples/apps/showcase_app.exs
```


<!-- docs/getting-started/CORE_CONCEPTS.md -->

# Core Concepts

## The Elm Architecture (TEA)

Most Raxol apps use TEA, four callbacks that form a loop:

- **`init/1`**: Set up your initial state (the "model")
- **`update/2`**: Handle messages: keyboard events, button clicks, timers. Returns `{new_model, commands}`
- **`view/1`**: Build the UI from state. Called after every update
- **`subscribe/1`**: Set up recurring events (timers, data feeds)

State flows one direction. Views are pure functions of the model. Commands are how you request side effects (quitting, async work). If you've used Elm, Redux, or Bubble Tea, this will feel familiar.

Everything that arrives in `update/2` is a "message." That includes application atoms like `:increment`, timer ticks like `:tick`, and Raxol events like `%Event{type: :key, data: %{key: :enter}}`. They're all just inputs to the same function.

See the [Quickstart](QUICKSTART.md) for a full walkthrough, or browse the [Examples Learning Path](../../examples/README.md) for annotated examples from beginner to advanced.

---

## Buffers: The canvas underneath

Most Raxol apps never touch buffers directly. The View DSL and layout engine handle all of this for you. But understanding the layer underneath helps when debugging, optimizing, or building custom renderers.

A buffer is a 2D grid of cells representing terminal content, a canvas for text.

### Buffer structure

```elixir
%{
  width: 80,
  height: 24,
  lines: [
    %{cells: [
      %{char: "H", style: %{fg_color: :cyan, bold: true}},
      %{char: "e", style: %{}},
      %{char: "l", style: %{}},
      # ... more cells
    ]},
    # ... more lines
  ]
}
```

Each buffer has width x height dimensions in characters. Lines are rows top to bottom. Each cell contains a `char` (single grapheme) and a `style` map (colors, bold, etc.).

### Immutable and functional

```elixir
# Each operation returns a NEW buffer
new_buffer = Buffer.write_at(old_buffer, 5, 3, "Text")
# old_buffer is unchanged
```

These are pure data structure operations, with no server process behind them,
which is what makes diffing and caching cheap.

### Cell coordinates

Buffers use **(x, y)** coordinates, both 0-indexed:

```
(0,0) ────────────────> x (width)
  |
  |  (5,3) = Column 5, Row 3
  |
  v
  y (height)
```

```elixir
# Write "Hello" starting at column 10, row 5
buffer = Buffer.write_at(buffer, 10, 5, "Hello")
```

---

## The rendering pipeline

### Stage 1: Buffer construction

Build the buffer by combining operations:

```elixir
buffer = Buffer.create_blank_buffer(80, 24)
  |> Box.draw_box(0, 0, 80, 24, :double)
  |> Buffer.write_at(10, 5, "Title", %{bold: true})
  |> Buffer.write_at(10, 7, "Content goes here")
```

Pure data transformation. No I/O, no side effects.

### Stage 2: Diffing

Calculate minimal changes between frames:

```elixir
diff = Renderer.render_diff(old_buffer, new_buffer)
# => [
#   {:move, 10, 7},
#   {:write, "Updated text", %{}},
# ]
```

Without diffing you'd clear and redraw everything. With diffing, only changed cells are written, bringing typical updates to ~2ms.

### Stage 3: Output generation

```elixir
# Full output (for debugging)
IO.puts(Buffer.to_string(buffer))

# Diff output (for efficiency)
IO.write(Renderer.apply_diff(diff))

# HTML output (for web)
html = TerminalBridge.buffer_to_html(buffer)
```

### The complete pipeline

```
[User Code]
    |
    v
[Create Buffer] ────> Immutable data structure
    |
    v
[Apply Operations] ──> write_at, draw_box, fill_area
    |
    v
[Calculate Diff] ────> Compare with previous frame
    |
    v
[Generate Output] ───> ANSI codes / HTML / String
    |
    v
[Display] ───────────> Terminal / Browser / File
```

---

## State management

**TEA is the canonical app model.** A Raxol application is a single module with `init/1`, `update/2`, and `view/1`, started via `Raxol.start_link/2`. One model, one update function, one view. Don't reach for alternatives unless TEA genuinely doesn't fit.

```elixir
defmodule MyApp do
  use Raxol.Core.Runtime.Application

  def init(_ctx), do: %{count: 0}

  def update(:inc, model), do: {%{model | count: model.count + 1}, []}

  def view(model) do
    column do
      [text("Count: #{model.count}"), button("+", on_click: :inc)]
    end
  end

  def subscribe(_model), do: []
end

Raxol.start_link(MyApp)
```

The same module renders to terminal, browser (LiveView via `Raxol.LiveView.TEALive`), SSH, and the MCP agent surface without changes. That's the whole point: one source of truth, four projections.

### When you don't need TEA

Two narrow cases call for something else:

**Pure rendering from a script.** You have data, you want a string. No loop, no input, no process. Use the buffer API directly:

```elixir
Buffer.create_blank_buffer(80, 24)
|> Box.draw_box(0, 0, 80, 24, :single)
|> Buffer.write_at(10, 5, "Count: #{data.count}")
|> Buffer.to_string()
|> IO.puts()
```

**You're embedding rendering inside an existing OTP process.** If you already have a GenServer or LiveView mount where Raxol is just one Component surface among many, you can call buffer ops directly from `handle_call` / `handle_event`. But if the *application* is the UI, wrap it in TEA and use a [`Raxol.LiveView.TEALive`](../cookbook/LIVEVIEW_INTEGRATION.md) mount or [`Raxol.SSH.serve/2`](../cookbook/SSH_DEPLOYMENT.md) instead: you'll get crash isolation, hot reload, and the agent surface for free.

Avoid hand-rolling your own `loop(state)` recursive function. That was a pre-Raxol pattern; OTP supervision and TEA's update loop subsume it.

---

## Performance model

### Targets

| Operation         | Target  | Typical |
| ----------------- | ------- | ------- |
| Buffer create     | < 1ms   | 0.3ms   |
| write_at (single) | < 100us | 50us    |
| draw_box          | < 500us | 240us   |
| render_diff       | < 2ms   | 1.2ms   |
| Full render       | < 16ms  | 8ms     |

60 FPS = 16ms frame budget.

### Optimization tips

**Pipeline operations** instead of intermediate variables. Elixir optimizes pipelines better.

**Use diff rendering.** Typical updates drop to ~2ms.

**Reuse style references.** Avoid allocating duplicate style maps.

**Use `fill_area`** instead of looping `set_cell`. Much faster for area fills.

### Memory

- Each cell: ~100 bytes (character + style)
- 80x24 buffer: ~192KB
- 200x50 buffer: ~1MB

Keep buffers reasonably sized. Don't hold references to old buffers you no longer need.

---

## Design principles

Buffer operations return new buffers and never mutate, so they compose by
piping:

```elixir
def create_dashboard(buffer, data) do
  buffer
  |> draw_header(data.title)
  |> draw_sidebar(data.menu)
  |> draw_content(data.body)
  |> draw_footer(data.status)
end
```

`Raxol.Core` depends only on telemetry at runtime, so it runs anywhere Elixir
does. Adoption is incremental: buffers and rendering for scripts, the View DSL
for interactive apps, the full framework when you want LiveView and SSH.

The design law behind all of this is in [Philosophy](../PHILOSOPHY.md).

---

## Common questions

### Why not just write ANSI codes directly?

Buffers enable diffing. Holding the full state lets the renderer calculate
minimal updates instead of redrawing everything.

### Can I skip buffers entirely?

Yes, they are optional. You give up automatic diffing, state inspection, HTML
rendering, and the testing utilities that read cells.

### How does Raxol compare to ncurses, Bubble Tea, and Ratatui?

See [Why OTP](../WHY_OTP.md) for the TUI framework comparison, and
[Why Raxol](../WHY_RAXOL.md) for the agent runtime one.

### Can I use Raxol alongside other libraries?

Yes. `Raxol.Core` is just data structures:

```elixir
buffer = Buffer.create_blank_buffer(80, 24)
  |> Buffer.write_at(10, 5, "Generated by Raxol")

output = Buffer.to_string(buffer)
MyCustomRenderer.render(output)
```

---

## Next steps

- [Quickstart](QUICKSTART.md): build your first app
- [Cookbook](../cookbook/README.md): practical patterns and recipes
- [Buffer API](../core/BUFFER_API.md): complete function documentation
- [Architecture](../core/ARCHITECTURE.md): implementation details

`examples/demo.exs` is a full working app with dashboard layout and live stats.


<!-- docs/getting-started/QUICKSTART.md -->

# Quickstart

A counter app, running in your terminal, using the four callbacks every Raxol
app implements.

## Install

Generate a new project:

```bash
mix raxol.new my_app
cd my_app
mix deps.get
```

Or add to an existing project:

```elixir
# mix.exs
def deps do
  [{:raxol, "~> 2.6"}]
end
```

## Headless / CI setup

The tutorial app below needs a real terminal, but building and testing Raxol
does not. Prerequisites: Elixir/OTP (versions in the repo's `mise.toml`)
and a C toolchain for the termbox2 NIF (`make` + `cc`; on Debian/Ubuntu,
`apt-get install build-essential`). From a fresh clone:

```bash
mix local.hex --force        # fresh machines and CI: install Hex without a prompt
mix deps.get
mix compile                  # builds the termbox2 NIF
SKIP_TERMBOX2_TESTS=true MIX_ENV=test mix test --exclude slow --exclude integration --exclude docker
MIX_ENV=test mix raxol.rate  # RATE: render-determinism golden suite
```

`SKIP_TERMBOX2_TESTS=true` excludes the tests that need a real local terminal;
CI sets the same variable. Plain `mix test` without the exclude flags also
runs integration suites that need external services (PostgreSQL for the
workflow checkpoint tests), so stick to the command above. If `HOME` is
read-only in your sandbox, point `MIX_HOME` and `HEX_HOME` at a writable
directory first.

## Your first app

Every Raxol app follows The Elm Architecture (TEA) with four callbacks:

```elixir
defmodule MyApp do
  use Raxol.Core.Runtime.Application

  # 1. Initialize state
  @impl true
  def init(_context) do
    %{count: 0}
  end

  # 2. Handle messages
  @impl true
  def update(message, model) do
    case message do
      :increment ->
        {%{model | count: model.count + 1}, []}

      :decrement ->
        {%{model | count: model.count - 1}, []}

      # Keyboard events
      %Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: "="}} ->
        {%{model | count: model.count + 1}, []}

      %Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: "-"}} ->
        {%{model | count: model.count - 1}, []}

      %Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: "q"}} ->
        {model, [Directive.stop()]}

      _ ->
        {model, []}
    end
  end

  # 3. Render UI from state
  @impl true
  def view(model) do
    column style: %{padding: 1, gap: 1, align_items: :center} do
      [
        text("My Counter", style: [:bold]),
        box style: %{border: :single, padding: 1, width: 20, justify_content: :center} do
          text("Count: #{model.count}", style: [:bold])
        end,
        row style: %{gap: 1} do
          [
            button("=", on_click: :increment),
            button("-", on_click: :decrement)
          ]
        end,
        text("Press =/- or click buttons. q to quit.", style: [:dim])
      ]
    end
  end

  # 4. Subscriptions (optional)
  @impl true
  def subscribe(_model), do: []
end

# Start the app
{:ok, pid} = Raxol.start_link(MyApp, [])
ref = Process.monitor(pid)
receive do
  {:DOWN, ^ref, :process, ^pid, _reason} -> :ok
end
```

- `init/1` returns a plain map, which is your entire app state
- `update/2` pattern-matches on messages and returns `{new_state, commands}`. The empty list `[]` means "no side effects"
- `view/1` builds the UI from state using the View DSL macros (`column`, `row`, `box`)
- `Directive.stop()` tells the runtime to shut down (the `Directive` alias comes from `use Raxol.Core.Runtime.Application`)

Save as `lib/my_app.ex` and run:

```bash
mix run lib/my_app.ex
```

## How it works

```
                +---> view(model) ---> Terminal
                |
init(context) --+--> model
                |
                +---> update(message, model) --+
                      ^                        |
                      |    {new_model, cmds}   |
                      +------------------------+
```

1. `init/1` sets up your initial state (the "model")
2. `view/1` renders the UI; it's called after every state change
3. `update/2` handles messages (keyboard events, button clicks, timers)
4. `subscribe/1` sets up recurring events (timers, external data)

State flows in one direction. Views are pure functions of state. Side effects go through commands.

## View DSL

The View DSL provides macros for building layouts:

```elixir
# Layout containers
column style: %{gap: 1} do ... end    # Vertical stack
row style: %{gap: 2} do ... end        # Horizontal stack

# Components
text("Hello", style: [:bold])          # Text with styling
button("Click", on_click: :msg)        # Clickable button
text_input(value: v, placeholder: "")  # Text input
progress(value: 65, max: 100)          # Progress bar

# Containers
box style: %{border: :single, padding: 1} do ... end  # Bordered box

# Utilities
divider()                              # Horizontal line
spacer()                               # Flexible space
```

## Adding live updates

Use `subscribe/1` to get periodic messages:

```elixir
@impl true
def subscribe(_model) do
  [subscribe_interval(1000, :tick)]  # Send :tick every second
end

@impl true
def update(:tick, model) do
  {%{model | uptime: model.uptime + 1}, []}
end
```

## OTP Supervision

Use `--sup` when generating to get a proper OTP application:

```bash
mix raxol.new my_app --sup
```

This generates an Application module with a supervision tree. Run with:

```bash
mix run --no-halt
```

## If the terminal is left in a bad state

A Raxol app puts your terminal into raw mode, and full-screen apps also switch to
the alternate screen. Both get restored on the way out. But if the app dies hard,
a `kill -9` or a VM crash, nothing runs that restore and you land back in a shell
with no echo and no line editing.

Nothing is broken. Reset it:

```bash
reset
```

If you cannot see what you are typing, that still works blind. Type it and hit
Enter. `stty sane` is the lighter version, and it fixes echo without clearing the
screen.

vim and tmux leave the same mess when you SIGKILL them. It comes with the
territory for full-screen terminal programs. [Why OTP](../WHY_OTP.md#crash-isolation)
covers what can take the VM down that way in the first place.

## Where to go next

That counter is a complete Raxol app. `init/update/view` is the whole API, and
everything else builds on this loop.

- [Component Gallery](COMPONENT_GALLERY.md): all Components with examples
- [Core Concepts](CORE_CONCEPTS.md): buffers, the rendering pipeline, and how they fit together
- [Building Apps](../cookbook/BUILDING_APPS.md): state machines, scrollable lists, keyboard shortcuts

`mix raxol.playground` browses 40 Component demos interactively, with search and
filtering.

### Things to try

**SSH serving.** Serve your app over SSH. Each connection gets its own process:

```bash
mix run examples/ssh/ssh_counter.exs
# Then: ssh localhost -p 2222
```

**Hot code reload.** Edit your view function while the app is running:

```bash
iex -S mix run examples/dev/hot_reload_demo.exs
# Edit the view/1 function and save; UI updates automatically
```

**Crash isolation.** Components run in separate processes. One crash doesn't take down the app:

```bash
mix run examples/components/process_component_demo.exs
```

Working examples to study:

- `examples/getting_started/counter.exs`: the counter from this page
- `examples/demo.exs`: flagship demo with dashboard, sparklines, live stats
- `examples/getting_started/todo_app.exs`: a keyboard-driven todo list app


<!-- docs/WHY_OTP.md -->

# Why OTP for multi-surface apps

Most UI frameworks implement crash recovery with try/catch, state management with global stores, concurrency with goroutines or async/await, distribution with gRPC. Raxol gets all of that from OTP.

## The natural mapping

| OTP concept   | TUI equivalent            | What you get                                                     |
| ------------- | ------------------------- | ---------------------------------------------------------------- |
| GenServer     | Elm update loop           | `init/1 -> update/2 -> view/1`, managed by the runtime           |
| Process       | Component                 | Each Component can run in its own process                        |
| Supervisor    | Crash recovery            | A Component crashes, it restarts. The rest of the UI doesn't notice |
| Hot code swap | Live reload               | Change `view/1`, save, running app updates. No restart           |
| `:ssh`        | SSH serving               | Built into Erlang. No dep, no daemon, just `:ssh.daemon`         |
| `libcluster`  | Node discovery            | Gossip, DNS, Tailscale. Nodes find each other automatically      |
| `send/2`      | Inter-component messaging | No event bus library. Just processes sending messages            |
| ETS           | State management          | Fast shared state without serialization overhead                 |

These aren't analogies. They're the actual implementations.

## What this means in practice

### Crash isolation

In Ratatui or Bubble Tea, if a component panics, your whole app dies. In Raxol:

```elixir
process_component(UnstableWidget, %{path: "/dev/random"})
```

The supervisor restarts the component and renders the next frame. OTP was built for this.

There is one place this stops. On Unix and macOS the terminal backend is a
[termbox2](../packages/raxol_terminal/lib/termbox2_nif/) NIF, and a NIF runs
inside the VM's own address space. Segfault there and the whole node goes down,
supervisor or not.

The blast radius is small, though. That NIF only draws: cell writes, cursor
moves, present. Input never touches it. Keystrokes arrive over OTP's own
`prim_tty`, and a [canary in CI](../.github/workflows/ci-unified.yml) watches
that path in case OTP moves it. Windows skips the NIF entirely and falls back to
the pure Elixir `IOTerminal`, so nothing native is loaded there at all.

When the VM does die that way it dies without putting your terminal back. Run
`reset`; there is [a note in the
quickstart](getting-started/QUICKSTART.md#if-the-terminal-is-left-in-a-bad-state).

### Hot reload

Erlang's code server supports hot swapping at the module level. Save a file, and the running app picks up the new `view/1` on the next render cycle. No reconnection, no state loss. Same mechanism that lets telecom switches upgrade without dropping calls.

### SSH serving

Erlang ships with a full SSH server. Raxol wraps it:

```elixir
Raxol.SSH.serve(MyApp, port: 2222)
```

Each connection gets its own Lifecycle process with its own state. The whole thing is 4 modules, ~400 lines, because the hard part is in Erlang's `:ssh`.

Textual added SSH in 2024 via `textual-serve`, wrapping an external library. Bubble Tea and Ratatui have community wrappers.

### Distribution

BEAM was designed for distributed systems. Raxol's swarm module builds on that:

```elixir
Raxol.Swarm.Discovery.start_link(strategy: :tailscale, node_basename: "raxol")
Raxol.Swarm.TacticalOverlay.update_entity(:unit_1, %{position: {10.0, 20.0, 0.0}})
```

Nodes are BEAM nodes. Messages are Erlang messages. CRDTs merge with pure functions.

### Three rendering targets

A TEA module is `init/1`, `update/2`, `view/1`. The rendering target is a runtime decision:

- **Terminal**: Lifecycle renders to a screen buffer, diffs, writes ANSI
- **Browser**: `Raxol.LiveView.TEALive` hosts the same module in Phoenix, bridges events
- **SSH**: `Raxol.SSH.Session` wraps Lifecycle per-connection

One app, three outputs.

### AI agents

An agent is a TEA module where input comes from LLMs. Same `init/update/view`, same supervision. The framework is ~300 lines because most of it is OTP:

- `Agent.Session` is a GenServer wrapping Lifecycle
- `Agent.Team` is a Supervisor
- `Agent.Comm` is `GenServer.call`/`cast` with Registry lookups
- `Agent.Backend.HTTP` is `Stream.resource` over SSE

Agents are processes. Teams are supervision trees.

## The tradeoff

Raxol is slower per operation than Rust (Ratatui) or Go (Bubble Tea), but a
full frame remains within a 60fps budget. Current numbers and methodology live
in [Benchmarks](bench/README.md); keeping them there avoids stale copies.

You give up raw microbenchmark speed. You get process isolation, hot reload, distribution, SSH, and multi-target rendering. For anything that has to keep running while you change it, that's a good trade.

## Further reading

- [Architecture](core/ARCHITECTURE.md): how the render pipeline works
- [Agent Framework](features/AGENT_FRAMEWORK.md): AI agents as TEA apps
- [Distributed Swarm](features/DISTRIBUTED_SWARM.md): CRDTs and node discovery
- [SSH Deployment](cookbook/SSH_DEPLOYMENT.md): serving apps over SSH


<!-- docs/WHY_RAXOL.md -->

# Why Raxol

Most agent runtimes are a Python process wrapped around a model. Raxol is an OTP runtime.
That difference decides what an agent can survive, how many can run at once, where they can
render, and what they can safely be allowed to do.

For the TUI-framework comparison (Bubble Tea, Ratatui, Textual), see [Why OTP](WHY_OTP.md).
This page is about agents.

## The runtime

| | Hermes | Omnigent | Raxol |
|-|--------|----------|-------|
| Substrate | one Python process per agent | subprocess per conversation (FastAPI) | millions of supervised BEAM processes |
| Concurrency | ThreadPoolExecutor, app-level retry to dodge the convoy effect | per-conversation processes | preemptive scheduling, mailbox serialization |
| State on crash | SQLite file (single process) | external store | supervision trees restart with state intact |
| Surfaces | chat platforms (bridges) | editor harnesses | terminal, browser, SSH, MCP, Telegram, watch, speech from one module |
| Payments | none | none | wallets, spend limits, cross-chain settlement |

The BEAM was built for systems that cannot go down, cannot lose state, and hot-swap code
while running. An agent runtime wants exactly those properties. When a background reviewer
crashes, the turn is unaffected because it runs in an isolated process. When a node
restarts, an agent's memory and skills survive because they are backed by a supervision tree
and durable stores, not held in one process. When you want a thousand agents, you spawn a
thousand lightweight processes, not a thousand OS threads.

## Four things Raxol does that the others do not

### Governed execution (ALLOW / ASK / DENY)

Every tool call passes through an [authorization engine](features/AGENT_FRAMEWORK.md#authorization-allowaskdeny)
that returns allow, ask, or deny, with per-scope approval memory. This is stronger than a
binary approve/deny prompt: an agent can be allowed to read anywhere, asked before writing,
and denied the shell entirely, declaratively. The [Coding Agent](features/CODING_AGENT.md)
runs every mutating action through it, and the [Tool Catalog](reference/TOOL_CATALOG.md)
marks which tools are gated.

### One app, every surface

The same TEA module renders to a terminal, a LiveView, an SSH session, an agent over MCP, a
watch, and a chat, through one OTP fan-out. Competitors integrate each platform separately;
Raxol projects one running module. See [Surfaces](guides/SURFACES.md).

### A learning loop backed by supervision

Raxol has an after-turn [self-improvement](features/SELF_IMPROVEMENT.md) loop, agent-authored
skills, a provider-stack [memory](features/MEMORY.md) layer, and a dialectic user model, the
same capabilities Hermes markets as its differentiator. The difference is the substrate: the
reviewer is an isolated Task, skills are on-disk `SKILL.md` files with DETS telemetry
replayed on boot, and every Curator pass is reversible. A learning loop that cannot lose
state because it is backed by OTP supervision is a stronger claim than one backed by a
single-process database.

### Agentic commerce

Raxol agents can pay and be paid: wallets, ledger-enforced spending limits, transparent
HTTP 402 auto-pay, cross-chain settlement, stealth and shielded transfers, and ZKSAR trust
attestations. See [Agentic Commerce](features/AGENTIC_COMMERCE.md) and the
[Agent Commerce Protocol](features/ACP.md). Neither Hermes nor Omnigent has any of this, and
it is not a feature they can add without a runtime for it.

## What Raxol is not

Raxol is younger than its competitors in raw tool count and hosted polish. If your only need
is a single Python agent calling a large catalog of built-in tools on one machine, a Python
harness will get you there with less Elixir. Raxol earns its keep when you want agents that
survive crashes, run by the thousand, render to more than a chat window, are governed rather
than trusted, or move money. Those are BEAM problems, and Raxol is the runtime for them.

## See also

- [Why OTP](WHY_OTP.md): the TUI-framework comparison.
- [Philosophy](PHILOSOPHY.md): the design principles behind the runtime.
- [Build Your First Agent](getting-started/BUILD_AN_AGENT.md): put the runtime to work.


<!-- docs/PHILOSOPHY.md -->

# Philosophy

Raxol has one law, applied at every scale:

> **What is shown must be a provable projection of what is, and the wrong
> thing must be unrepresentable.**

Everything else in this repository is that law showing up at a different
altitude. It is the "why" behind the rules other documents state as "what"
(ADR-0029, `docs/WHY_OTP.md`, `docs/core/RENDERING.md`, the design-science
material).

## The law at each scale

**The cell.** A terminal is not a canvas; a cell holds one grapheme, two
colors, a few attributes, and nothing else. There is no alpha: a cell is
transparent precisely when we emit no background SGR for it, so transparency
is an absence, not a value. Almost every rendering bug this project has
shipped came from one name doing two jobs (`:black` meaning both "unpainted"
and "black"; `nil` meaning both "transparent" and "erase"), and the fix was
always the same move: split the meanings apart and make the wrong one
unrepresentable. See ADR-0029.

**The model.** `update/2` is pure; effects are Directives the runtime
executes. Because the model is a fold over messages, the journal is the
ground truth and every rendered frame is a disposable projection of it. Kill
the UI, respawn it: same truth. Time-travel debugging is not a feature we
built so much as a reward for purity we refused to give up.

**Color.** Hierarchy is solved, not eyeballed. The salience solver
(`Raxol.UI.Theming.Salience`) levels apparent lightness per tier against the
user's detected terminal ground, so a color's prominence states its
importance and cannot quietly misstate it. One `:anchor` per screen;
hierarchy is zero-sum, and two anchors are zero anchors.

**The interface.** A UI discloses state; it does not perform activity. No
spinner-forever, no success toast without evidence, no motion that does not
encode a state change. "Done" carries its evidence. Restraint is the trusted
register; ornament reads as slop. Interactive approval that degrades to
rubber-stamping is worse than no approval at all, which is why guarantees
live in enforcement layers, not in reassuring chrome.

**Money.** A process that holds a signing key is the crown jewel. The ledger
reserves atomically; in-flight intents are checkpointed; a crash
mid-settlement resumes to exactly one debit, because an ungoverned runtime
with no memory of the in-flight payment would re-sign and debit twice. The
spending policy is enforced by the same runtime that renders the screen
showing it.

**Agents.** An agent is not a client scraping our output; it is a second
species of observer reading the same Component tree through a different
projection. Terminal cells, LiveView DOM, and MCP tools are functors from
one source category (ADR-0012), so a human and an agent can never be shown
two different truths. The corollary cuts both ways: if an agent cannot tell
what is action and what is decoration in your component tree, your visual
hierarchy is probably lying to humans too.

**The prose.** Documentation that describes an API that does not exist is
the same defect class as a double debit. Docs get corrected toward what the
code does, not what a draft imagined.

## Why the origin story is one idea, not two

The README gives two seeds: a terminal for AGI, and the cockpit of a Gundam
Wing Suit. They are the same seed. Both describe a machine that a human (and
now an agent) must trust with real stakes while it is under fire: it cannot
go down, cannot lose state, must hot-swap components while running, and must
never show its operator something untrue, because the operator acts on what
the panel shows. The cockpit constraint set is the law under adversarial
conditions; the agent surface is the law extended to a second kind of
reader. OTP is not a technology preference here, it is the terrain the
problem keeps pointing back to: crash isolation, supervision, hot reload,
and distribution are what "cannot lie, cannot die" compiles to on the BEAM.

## Costume and body

Two registers coexist in this repository and must not be confused.

The **costume** is the flavor: synthwave palettes, border-beam comets, the
ZERO System's self-check and funnels. It is theater, and it is welcome.

The **body** is the discipline underneath: the cell model, the salience
solver, the journal, the ledger, one anchor per screen, adapting to the
user's terminal instead of painting over it. The character grid deletes
size, gloss, and shadow, which removes the tools that let design lie; what
remains is hierarchy, rhythm, and restraint, which were always the real
ones.

The costume only works because the body is real. The ZERO System demo lands
because the funnels are live CRDT entities, the boot lines probe real
modules, and the crash beat is pinned by `payment_recovery_test`. Scarcity
reads as authenticity, and authenticity reads as stakes. Beauty in Raxol is
evidence of engineering seriousness, never a substitute for it. A demo that
faked any of it would be ornament, and ornament reads as slop.

## What this implies for anything visual

The target feeling is sitting in the seat: dense, quiet, one thing glowing,
and complete certainty that if something goes wrong the panel will show it,
and if the panel shows nothing, nothing is wrong. That certainty is the
product. Concretely:

- An instrument panel grown around a log, never a replacement for the log.
  The terminal stays a terminal; the shell never takes the screen.
- Every visual element is a projection of durable truth, with prominence
  proportional to relevance. Every pixel passes "what does this tell me
  right now?"
- Trust comes from legibility, not reassurance. Calm is what a machine that
  cannot lie looks like.
- We are a guest in the user's terminal, not an occupier: their background,
  their scrollback, their light mode, their reduced-motion setting.
- Emptiness is paid for with information density elsewhere; that payment is
  what reads as confidence.
- When reviewing a visual change, the question is never "does this look
  right?" On the author's configuration it will. The question is: what is
  this value's one job, and what happens when the user's terminal is not
  mine?


<!-- docs/PACKAGES.md -->

# Packages

Raxol ships as a main package plus 17 focused subsystems. Use the main `raxol` package for the full framework, or take individual packages for narrower needs.

## Main

| Package                                            | Hex                       | What                                  |
| -------------------------------------------------- | ------------------------- | ------------------------------------- |
| [`raxol`](https://hex.pm/packages/raxol)           | `{:raxol, "~> 2.6"}`      | Full framework: runtime, UI, examples |

## Core

| Package                                                    | Hex                           | What                                       |
| ---------------------------------------------------------- | ----------------------------- | ------------------------------------------ |
| [`raxol_core`](https://hex.pm/packages/raxol_core)         | `{:raxol_core, "~> 2.6"}`     | Behaviours, events, config, plugins        |
| [`raxol_terminal`](https://hex.pm/packages/raxol_terminal) | `{:raxol_terminal, "~> 2.6"}` | Terminal emulation, termbox2 NIF           |
| [`raxol_mcp`](https://hex.pm/packages/raxol_mcp)           | `{:raxol_mcp, "~> 2.6"}`      | MCP server, client, registry, test harness |
| [`raxol_liveview`](https://hex.pm/packages/raxol_liveview) | `{:raxol_liveview, "~> 2.6"}` | Phoenix LiveView bridge, themes, CSS       |
| [`raxol_plugin`](https://hex.pm/packages/raxol_plugin)     | `{:raxol_plugin, "~> 2.6"}`   | Plugin SDK, testing, generator             |
| [`raxol_sensor`](https://hex.pm/packages/raxol_sensor)     | `{:raxol_sensor, "~> 2.6"}`   | Sensor fusion (zero deps)                  |

## Agents

| Package                                                    | Hex                           | What                                        |
| ---------------------------------------------------------- | ----------------------------- | ------------------------------------------- |
| [`raxol_agent`](https://hex.pm/packages/raxol_agent)       | `{:raxol_agent, "~> 2.6"}`    | AI agent framework                          |
| [`raxol_payments`](https://hex.pm/packages/raxol_payments) | `{:raxol_payments, "~> 0.2"}` | Agent payments, Xochi cross-chain, stealth  |
| `raxol_earn` (pre-alpha)                                    | `path: "packages/raxol_earn"`  | Virtuals Agent Commerce Protocol (seller)   |
| `raxol_agent_client_protocol` (pre-alpha)                  | `path: "packages/raxol_agent_client_protocol"` | Editor<->agent Agent Client Protocol (agentclientprotocol.com) |
| `raxol_symphony` (0.2.0, pre-alpha)                        | `path: "packages/raxol_symphony"` | Tracker-driven coding-agent orchestrator |
| `raxol_gateway` (pre-alpha)                                | `path: "packages/raxol_gateway"`  | Unified messaging gateway (multi-platform) |
| `raxol_cli` (pre-alpha)                                    | `path: "packages/raxol_cli"`      | The `raxol` command (`code`, `p`, `acp`, `agent`, `playground`, `new`), packaged as a self-contained Burrito binary |
| `raxol_console` (pre-alpha)                                | `path: "packages/raxol_console"`  | Boots a Virtuals ACP Console agent package onto the gateway stack |

The **coding agent** layers as: `Backend.Selector` (LLM backend adapter) -> the **Harness** engine (the event/command contract `Raxol.Agent.Contract` and the durable journal `Raxol.Agent.Journal` in `raxol_agent`, the projections and surface widgets `Raxol.Harness.*` in main `raxol`) -> the product surfaces `mix raxol.code` (interactive TUI, also over SSH), `mix raxol.p` (headless one-shot), and `mix raxol.acp` (ACP on stdio, for editors), all three in `raxol_agent` -> `raxol_symphony`, which orchestrates many agent runs above them. The Harness is the engine; the `mix raxol.harness.*.bless` tasks only regenerate its golden/fixture test snapshots.

## Surfaces

| Package                                                    | Hex                           | What                                        |
| ---------------------------------------------------------- | ----------------------------- | ------------------------------------------- |
| [`raxol_speech`](https://hex.pm/packages/raxol_speech)     | `{:raxol_speech, "~> 0.2"}`   | TTS (say/espeak), STT (Whisper), voice cmds |
| [`raxol_telegram`](https://hex.pm/packages/raxol_telegram) | `{:raxol_telegram, "~> 0.2"}` | Telegram bot, per-chat sessions, keyboards  |
| [`raxol_watch`](https://hex.pm/packages/raxol_watch)       | `{:raxol_watch, "~> 0.2"}`    | APNS/FCM push, glanceable summaries         |

## Dependency graph

```
raxol --> raxol_core, raxol_terminal, raxol_sensor, raxol_mcp,
          raxol_liveview, raxol_plugin

raxol_terminal --> raxol_core
raxol_mcp      --> raxol_core
raxol_liveview --> raxol_core (+ phoenix_live_view optional)
raxol_plugin   --> raxol_core

raxol_agent    --> raxol + raxol_mcp
raxol_payments --> raxol_agent (compile-time only)
raxol_earn      --> raxol_payments (runtime), raxol_mcp + raxol_agent (compile-time only)
raxol_agent_client_protocol --> (none; jason only, zero raxol deps)
raxol_symphony --> raxol_core, raxol_agent, raxol_mcp (all optional)
raxol_cli      --> raxol, raxol_agent (+ raxol_agent_client_protocol for `raxol acp`)
raxol_console  --> raxol_agent, raxol_gateway, raxol_earn

raxol_speech   --> raxol_core (+ bumblebee/nx/exla optional for STT)
raxol_telegram --> raxol_core (+ raxol/telegex/raxol_gateway optional)
raxol_watch    --> raxol_core (+ pigeon optional for APNS/FCM)
raxol_gateway  --> raxol_core (+ raxol_agent optional)

raxol_core     --> telemetry (only external dep)
raxol_sensor   --> (none)
```

The main `raxol` package does not depend on `raxol_agent`, `raxol_earn`, `raxol_agent_client_protocol`, `raxol_gateway`, or any of the surface packages. You opt into those.

## Publishing

See [Hex Publishing](https://github.com/DROOdotFOO/raxol/blob/master/CLAUDE.md#hex-publishing) for the publish order. `HEX_BUILD=1` strips local path deps so `mix hex.build` sees only Hex packages.


<!-- docs/features/README.md -->

# Feature catalog

Use this page to find a feature; each linked page owns its details.

| Area | Feature | Purpose |
| --- | --- | --- |
| Agents | [Agent framework](AGENT_FRAMEWORK.md) | TEA agents, backends, tools, and teams |
| Agents | [Coding agent](CODING_AGENT.md) | Interactive, headless, SSH, ACP, and MCP sessions |
| Agents | [Memory](MEMORY.md) | Recall providers and the user model |
| Agents | [Self-improvement](SELF_IMPROVEMENT.md) | After-turn review and skill curation |
| Agents | [Symphony](SYMPHONY.md) | Tracker-driven coding-agent orchestration (pre-alpha) |
| Protocols | [MCP](MCP.md) | Component trees projected as agent tools |
| Protocols | [Editor ACP](EDITOR_ACP.md) | Editor-to-agent JSON-RPC (pre-alpha) |
| Commerce | [Agentic commerce](AGENTIC_COMMERCE.md) | Wallets, spending controls, and HTTP 402 payment |
| Commerce | [Agent Commerce Protocol](ACP.md) | Seller-side services and settlement (pre-alpha) |
| Extensibility | [Plugin SDK](PLUGIN_SDK.md) | Plugin manifests, helpers, and generator |
| Runtime | [Sensor fusion](SENSOR_FUSION.md) | Polling, fusion, gauges, and sparklines |
| Runtime | [Distributed swarm](DISTRIBUTED_SWARM.md) | CRDT state, discovery, and topology |
| Runtime | [Adaptive UI](ADAPTIVE_UI.md) | Behavior-driven layout recommendations |
| Developer tools | [Recording and replay](RECORDING_REPLAY.md) | Asciinema capture and playback |
| Developer tools | [Time-travel debugging](TIME_TRAVEL_DEBUGGING.md) | Snapshot, step, and restore |
| Developer tools | [REPL](REPL.md) | Sandboxed Elixir evaluation |
| Developer tools | [Virtual filesystem](FILESYSTEM.md) | Pure in-memory filesystem and agent actions |
| Surfaces | [Gateway](GATEWAY.md) | Shared adapter for messaging platforms (pre-alpha) |
| Surfaces | [Telegram](TELEGRAM.md) | TEA apps as Telegram bots |
| Surfaces | [Speech](SPEECH.md) | TTS, Whisper STT, and voice commands |
| Surfaces | [Watch](WATCH.md) | APNS/FCM notifications and actions |
| Terminal | [Cursor effects](CURSOR_EFFECTS.md) | Trails, glow, and interpolation |

For measured performance, see [Benchmarks](../bench/README.md).


<!-- docs/features/ACP.md -->

# Agent Commerce Protocol (ACP)

`raxol_earn` is an Elixir/OTP implementation of the [Virtuals ACP](https://www.virtuals.io/) for selling agent services on Base. Where `raxol_payments` is about *paying* (an agent that buys things), `raxol_earn` is about *being paid* (an agent that offers a service and accepts on-chain settlement).

Status: pre-alpha. Not yet on Hex; use the path dep at `packages/raxol_earn/`.

> **Two protocols share the letters "ACP".** This page is the **Agent Commerce Protocol**
> (`Raxol.Earn`, on-chain payments for selling agent services). It is unrelated to the
> [Editor Agent Client Protocol](EDITOR_ACP.md) (`Raxol.AgentClientProtocol`, the JSON-RPC
> protocol between a code editor and an AI coding agent). Different acronym expansion,
> different domain.

## Job lifecycle

Every job is a state machine. One supervised `Raxol.Earn.JobSession` runs per active job, registered by `{chain_id, job_id}`:

```
:open -> :budget_set -> :funded -> :submitted -> :completed
                                            \-> :rejected
(any non-terminal) -> :expired
```

`Raxol.Earn.JobSession.Status` is a pure module holding the status enum and the legal transition graph. `JobSession` is the GenServer: it tracks role-aware status, keeps a chronological entry log, notifies subscribers, and emits `[:raxol, :earn, :job_session, :transition]` telemetry on every change. `apply_event/3` applies an OBSERVED status (an on-chain or SSE event) directly, bypassing role and adjacency gating.

```elixir
{:ok, _pid} =
  Raxol.Earn.JobSession.Supervisor.start_session(
    chain_id: 8453,
    job_id: "job-42",
    role: :provider
  )

# Provider actions transition the session; the Provider driver (below)
# pairs each with the matching hook write on-chain.
{:ok, :budget_set} = Raxol.Earn.JobSession.set_budget({8453, "job-42"}, budget)
{:ok, :submitted} = Raxol.Earn.JobSession.submit({8453, "job-42"}, deliverable)
```

The seller-side glue is `Raxol.Earn.JobSession.Provider`: for each lifecycle step it invokes the offering `Handler` (via `JobSession.HandlerSeam`), writes the hook call on-chain through `HookClient` + `ProviderAdapter` (the commit point: a failed write leaves the session untouched), then mirrors the resulting status with `apply_event/3`.

## Offerings

An offering is a service the agent sells. Declared via the `Offering` DSL:

```elixir
defmodule Raxol.Earn.Offerings.SentimentAnalysis do
  use Raxol.Earn.Offering,
    name: "Sentiment Analysis",
    price_usdc: 10,
    sla_minutes: 5,
    cluster: "analytics"

  # Decide whether to take the job. Return {:accept, response} to accept
  # and set the budget, or {:reject, reason} to bow out.
  @impl true
  def handle_request(request, _ctx) do
    {:accept, %{quoted_usdc: 10, text: request.text}}
  end

  # Payment is escrowed; produce the deliverable.
  @impl true
  def handle_deliver(request, _ctx) do
    {:deliver, %{sentiment: analyze(request.text)}}
  end
end
```

The DSL injects the `Handler` behaviour and registers metadata in the ETS-backed `Registry`. `JobSession.Provider` invokes the handler at each lifecycle step and writes the matching hook call on-chain with the configured wallet.

## Xochi cross-chain transfer (the first offering)

`Raxol.Earn.Xochi.TransferOffering` is the first offering: a **pure storefront** for cross-chain stablecoin transfers. raxol never signs or holds the buyer's funds.

- The buyer quotes and signs a Xochi intent themselves (`Raxol.Payments.Protocols.Xochi.quote_and_sign/3`) and puts the signed bundle in the job requirement's `signed_intent`.
- On delivery, `Raxol.Earn.Xochi.Settler` relays that bundle to Xochi via `execute_signed/2` (no re-signing) and polls it to settlement; the deliverable is the on-chain settlement tx hashes.
- The transfer settles through Xochi off-escrow, so the ACP core's take never bites it. The job is a **plain job** (`hook = address(0)`); the ACP budget is only raxol's storefront fee: **8 bps of the transfer**, set via `:fee_bps`. On completion the provider nets `budget * 0.90`.

`packages/raxol_earn/examples/buyer_signed_intent.exs` shows the buyer flow and the requirement/deliverable schemas to register on the marketplace.

## Launch liquidity gate

`TransferOffering.handle_request/2` accepts a job only for a corridor it can settle now, so a customer gets a clean rejection **before escrow** rather than an accept that fails at settlement. Four layers, all config-driven and inert by default:

- **Corridor support**: the live `Raxol.Payments.Xochi.Capabilities` matrix (which chains/tokens the solver fills), degrading to the static `Raxol.Payments.Assets` set when the endpoint is unreachable.
- **Per-order caps** (`config :raxol_earn, :destination_caps`): a max order size per destination `{chain, token_address}`; `0` closes the corridor. Rejects `{:over_capacity, chain, token}`.
- **Closed origins** (`config :raxol_earn, :closed_origins`): src chains to refuse outright (e.g. Robinhood while the USDG exit is down). Rejects `{:origin_closed, chain}`.
- **Rolling aggregate**: `Raxol.Earn.Xochi.CapacityLedger` bounds the *running total* committed per destination across concurrent jobs: reserve at accept, confirm at settle, release on failure, TTL-sweep if a job never settles. Opt-in via `capacity_gate_enabled: true`, which adds `Raxol.Earn.Xochi.CapacityGate` (the ledger + a periodic `CapacityRefresher`) to the seller tree.

`mix raxol_earn.derive_caps` reads the solver's live `balanceOf` per corridor and emits both maps (`--order-frac`/`--aggregate-frac`/`--min-usd`, per-chain RPC via `DERIVE_RPC_<chain>`). The `CapacityRefresher` runs the same reads on an interval, so aggregate capacity tracks the chain as fills drain inventory. Starting point in `packages/raxol_earn/config/destination_caps.example.exs`.

## On-chain writes

The v2 model writes **hook calls** to the active `AgenticCommerceV3` core; there is no separate memo model (the v1 `createMemo` / `Raxol.Earn.ContractClient` write surface and the `:acp_version` switch were retired: see `MIGRATION_V2.md`). `Raxol.Earn.HookClient` exposes `set_budget` / `submit` / `complete` / `reject`, each dispatched through an injected `Raxol.Earn.ProviderAdapter`:

- `SCA`: sponsored ERC-4337 v0.7 UserOps via `Raxol.Earn.Wallet.SCA` (Alchemy Modular Account v2 + paymaster). Self-deploys the account on the first write.
- `JSONRPC`: a plain EOA signing EIP-1559 typed transactions (`Raxol.Earn.Onchain.{RPC, Transaction, RLP}`), with nonce assignment serialized through `Raxol.Earn.Wallet.NonceServer`.
- `Mock`: in-process, for tests and `mix raxol_earn.bench`.

`Raxol.Earn.ABI` hand-rolls the Solidity encoder for the ACP methods. Real ABIs are vendored under `priv/abi/`; verified Base addresses live in `Raxol.Earn.Chain` (the active `acp_core_address` plus the hook/router/subscription addresses; the legacy `acp_contract_address`/`acp_router_address` remain only for indexer back-compat).

## Expiry

`JobSession` reaches the terminal `:expired` status via `expire/2` from any non-terminal status, so a job whose counterparty abandoned it can be closed rather than left wedged. `:expired`, `:completed`, and `:rejected` are terminal: the session process stops with `:normal` once it reaches one. On-chain escrow handling on expiry is the `AgenticCommerceV3` core's responsibility.

## Nonce serialization

The `Raxol.Earn.Wallet.NonceServer` GenServer serializes EVM nonce assignment through its mailbox, so two concurrent EOA writes from one wallet can never sign the same nonce. `ProviderAdapter.JSONRPC` routes every send through it. A send that fails before broadcast resyncs the counter, so the next send re-fetches the pending nonce from chain and re-fills the gap rather than leaving a hole that would strand every later transaction. The SCA/UserOp path uses ERC-4337 EntryPoint nonces and is unaffected.

## Seller stack

Opt-in via `:seller_enabled` in config:

- `Backend.InMemory`: in-process request queue (default)
- `Queue`: bounded mailbox, backpressure
- `Runtime`: worker pool dispatching to handlers
- `Supervisor`: ties it all together

`Backend.WebSocket` (Socket.IO v4 / Engine.IO over `Mint.WebSocket`, talking to Virtuals' relayer) is implemented alongside `Backend.InMemory`; the `Queue` drives `JobSession.Provider`. The protocol spec is available via the `virtuals-protocol-acp` skill.

## Status

Pre-alpha, not yet on Hex. The `Wallet.SCA` ERC-4337 stack, the real vendored ABIs (`priv/abi/`), and the `Backend.WebSocket` Socket.IO client are implemented; the on-chain paths are fork-validated against the real deployed core on Base. `mix raxol_earn.bench` runs end-to-end against the InMemory backend.

## See also

- [Agentic Commerce](AGENTIC_COMMERCE.md): the buyer side (raxol_payments)
- [Agent Framework](AGENT_FRAMEWORK.md): the runtime hosting the seller


<!-- docs/features/ADAPTIVE_UI.md -->

# Adaptive UI

The adaptive subsystem watches how you interact with the interface (which panes you focus on, what commands you run, how long you dwell) and suggests layout changes based on patterns. Accept or reject its suggestions, and it gets better over time.

## Architecture

```elixir
BehaviorTracker (records interactions)
    |
    v
LayoutRecommender (produces layout change suggestions)
    |
    v
FeedbackLoop (tracks accept/reject, optional Nx retraining)
```

Three GenServers under `Raxol.Adaptive.Supervisor` (`:one_for_one`). BehaviorTracker feeds windowed aggregates to LayoutRecommender, which emits suggestions. FeedbackLoop records whether you accepted or rejected them, and can retrain an Nx model if available.

## Quick start

```elixir
{:ok, _} = Raxol.Adaptive.Supervisor.start_link()

# Record what the user does
Raxol.Adaptive.BehaviorTracker.record(:pane_focus, %{pane: :logs})
Raxol.Adaptive.BehaviorTracker.record(:command_issued, %{command: :deploy})

# Get notified when the system has a suggestion
Raxol.Adaptive.LayoutRecommender.subscribe()
# Receive: {:layout_recommendation, %{action: :expand, target: :logs, ...}}

# Tell it whether the suggestion was good
Raxol.Adaptive.FeedbackLoop.accept(recommendation_id)
Raxol.Adaptive.FeedbackLoop.reject(recommendation_id)
```

## Behavior tracking

`Raxol.Adaptive.BehaviorTracker` logs timestamped events and computes windowed aggregates: pane dwell times, command frequency, that sort of thing.

```elixir
BehaviorTracker.record(:pane_focus, %{pane: :metrics})
BehaviorTracker.record(:pane_dwell, %{pane: :logs, duration_ms: 5000})
BehaviorTracker.record(:command_issued, %{command: :restart})
BehaviorTracker.record(:scroll_pattern, %{pane: :logs, direction: :down})

aggregates = BehaviorTracker.get_aggregates(5)  # last 5 windows
events = BehaviorTracker.get_recent_events(20)

BehaviorTracker.enable()
BehaviorTracker.disable()

# Real-time aggregate stream
BehaviorTracker.subscribe()
# Receive: {:behavior_aggregate, aggregate}
```

Event types: `:pane_focus`, `:pane_dwell`, `:command_issued`, `:alert_response`, `:scroll_pattern`, `:takeover_start`, `:takeover_end`, `:layout_override`.

## Layout recommendations

`Raxol.Adaptive.LayoutRecommender` looks at behavior aggregates and suggests layout changes. Uses rule-based logic by default, with optional Nx model support for learned recommendations.

```elixir
rec = LayoutRecommender.get_last_recommendation()
# => %{action: :expand, target: :logs, confidence: 0.85, ...}

LayoutRecommender.subscribe()
# Receive: {:layout_recommendation, recommendation}

# Nx model support (optional)
LayoutRecommender.set_pane_ids([:metrics, :logs, :alerts])
LayoutRecommender.set_model_params(trained_params)
```

Actions it can recommend: `:hide`, `:show`, `:expand`, `:shrink`.

## Feedback loop

`Raxol.Adaptive.FeedbackLoop` keeps a sliding window of accept/reject decisions so you can track how well the recommendations are doing.

```elixir
FeedbackLoop.submit_recommendation(recommendation)

FeedbackLoop.accept(recommendation_id)
FeedbackLoop.reject(recommendation_id)

accuracy = FeedbackLoop.get_accuracy()  # 0.0 - 1.0
history = FeedbackLoop.get_history(20)

# Retrain if Nx is available
{:ok, :trained, params} = FeedbackLoop.force_retrain()
# Without Nx:
{:ok, :rule_based_mode} = FeedbackLoop.force_retrain()
```

## Layout transitions

`Raxol.Adaptive.LayoutTransition` animates between layouts with easing. Pure functions, no GenServer. Call `tick/2` each frame.

```elixir
alias Raxol.Adaptive.LayoutTransition

transition = LayoutTransition.start(
  %{logs: %{height: 10}, metrics: %{height: 20}},   # from
  %{logs: %{height: 20}, metrics: %{height: 10}},   # to
  duration_ms: 300,
  easing: :ease_in_out
)

# Each frame:
case LayoutTransition.tick(transition, elapsed_ms) do
  {:in_progress, layout, transition} -> render(layout)
  {:done, final_layout} -> render(final_layout)
end

# Bail out mid-transition
current_layout = LayoutTransition.cancel(transition)
```

Easing: `:linear`, `:ease_in_out`, `:ease_out`.

## Example

```bash
mix run examples/subsystems/adaptive_ui_demo.exs
```


<!-- docs/features/AGENTIC_COMMERCE.md -->

# Agentic Commerce

Agents that can pay for things. `raxol_payments` gives any Raxol agent autonomous payment capabilities (wallet identity, quotes, transfers, spending limits) across chains and protocols.

## How it works

An agent returns payment commands from `update/2` the same way it returns shell or async commands. The `SpendingHook` intercepts commands before execution, checks them against the `SpendingPolicy`, and the `Ledger` tracks what's been spent.

```
Agent update/2 returns command
    |
    v
SpendingHook checks SpendingPolicy (per-request / session / lifetime limits)
    |
    v
Router.select/1 picks protocol based on context:
    same-chain HTTP 402 --> x402 or MPP (auto-pay)
    cross-chain         --> Xochi (intent-based settlement)
    privacy             --> Xochi (stealth/shielded)
    |
    v
Wallet signs the transaction (EIP-712)
    |
    v
Ledger records the spend
```

## Wallet backends

Two wallet implementations behind the `Raxol.Payments.Wallet` behaviour:

- `Wallets.Env`: private key from an environment variable. Simple, good for dev and CI. In a deployed release it refuses to load (a plaintext key in an env var lands in process listings and crash dumps) unless the operator opts in with `RAXOL_ALLOW_ENV_WALLET=true` or `config :raxol_payments, :allow_env_wallet, true`. Use `Wallets.Op` in production.
- `Wallets.Op`: private key fetched from 1Password via a GenServer. No plaintext key on disk. The key is fetched once at first use, wrapped in a `Secret`, and signed with locally, so signing does not round-trip 1Password per request.

Both implement the `Raxol.Payments.Wallet` callbacks: `address/0`, `chain_id/0`, and three signing functions: `sign_message/1` (raw message), `sign_typed_data/3` (EIP-712 typed data), and `sign_hash/1` (precomputed 32-byte digest). The behaviour has no balance callback.

## Protocols

### x402 (Coinbase)

Handles HTTP 402 Payment Required responses. The server says "pay me X to address Y," the wallet signs an EIP-712 authorization (ERC-3009 `transferWithAuthorization`), and the signed payload goes back in the retry header. All transparent to the agent.

### MPP (Machine Payments Protocol)

Stripe/Tempo's protocol for machine-to-machine payments. Same HTTP 402 flow, different wire format. The `AutoPay` Req plugin handles both x402 and MPP automatically; just add it as a response step.

### Permit2

`PermitWitnessTransferFrom` signing for Riddler's `/order` origin pull. Where x402 signs an ERC-3009 `transferWithAuthorization` (USDC only), Permit2 covers most ERC-20 tokens: the wallet signs a Permit2 witness that binds the transfer to the solver's order, so Riddler can pull the origin funds gaslessly (the agent must already hold a one-time Permit2 approval on-chain). `Protocols.Permit2.sign_quote/3` returns the digest, signed object, and signature; the digest is pinned byte-for-byte against viem in CI.

### Xochi

Intent-based cross-chain settlement. The agent says "I want to pay 10 USDC on Base to address X on Arbitrum," Xochi quotes a fee, the wallet signs the intent, and Riddler's solver network handles the actual cross-chain execution.

The fee has three additive layers rather than a single tier percentage: a solver spread plus gas floor (Riddler, never discounted), a venue fee (Xochi), and a routing fee (Raxol, which funds the token buyback, so agent volume routed through raxol feeds the flywheel). Trust discounts carve down the venue and routing layers only, leaving the solver floor identical at every tier. Headline totals run 0.10-0.40% by tier and asset (stablecoins lower, volatile assets higher), from Standard at 0.22% stable / 0.40% volatile down to Institutional at 0.10% / 0.22%.

The quote will carry an optional `fee_breakdown`: the per-layer split (solver, venue, routing), price impact, gas floor, total, and `surplus_share_pct` (the solver keeps 15% of positive-slippage surplus, the user keeps 85%). The routing line is raxol's own cut, worth surfacing to agents. It stays absent until Riddler emits it and the worker forwards it, and the `QuoteResponse` schema does not parse it yet, so treat it as a forthcoming field rather than something to read today.

Flow: `get_quote/2` -> `execute/3` (wallet signs EIP-712 intent) -> `poll_status/3`.

For a storefront that settles on a buyer's behalf, the signing and the submission split into a buyer-side half and a relay-side half:

- `sign_intent/2,3` and `quote_and_sign/3` (buyer side) quote and sign the intent and return the opaque bundle `{intent_id, quote_id, signature, nonce, pull_signature}` **without submitting**: the buyer hands this to the storefront.
- `execute_signed/2` (relay side) posts a pre-signed bundle to Xochi **without re-signing**, so the relay never holds the buyer's key.
- `execute/4` is the two composed: `sign_intent` then `execute_signed`.

The Xochi Cross-Chain Transfer ACP offering (in `raxol_earn`, see [ACP](ACP.md)) is built on this split: the buyer signs, raxol relays. `packages/raxol_earn/examples/buyer_signed_intent.exs` shows the buyer flow.

Xochi is the default for cross-chain and privacy (stealth addresses, shielded transfers). It's cash-positive by design: the protocol takes a fee, the agent pays it, done.

### Riddler (direct solver, B2B only)

Direct access to Riddler's Commerce API for bulk/institutional flows. Cash-negative for the protocol (solver subsidizes execution), so don't use it for agent payments. It exists for B2B integrations where the business relationship justifies the economics. The `Protocols.Riddler` module itself is deprecated and now delegates to Xochi internally; prefer `Protocols.Xochi` directly.

## Trust and compliance (ZKSAR)

ZKSAR (Zero-Knowledge Sanctions/AML Reporting) lets an agent prove compliance facts without
revealing the underlying data, and turns those proofs into a trust score that unlocks lower
fees and stronger privacy. The zero-knowledge proofs themselves are verified on-chain or in
the Xochi oracle; `Raxol.Payments.Zksar` verifies the oracle's signed attestation result
(type, expiry, issuer, structural integrity, and that the signature recovers to a trusted
issuer).

Six proof types (`@proof_types`):

| Type | Proves |
|------|--------|
| `:compliance` | Score below a jurisdiction threshold |
| `:risk_score` | A score comparison without revealing the score |
| `:pattern` | No structuring or velocity anomalies |
| `:attestation` | A valid credential exists |
| `:membership` | Address is in a whitelist |
| `:non_membership` | Address is NOT on a sanctions list |

`Zksar.TrustScore.aggregate/2` folds verified proofs into a 0-100 score with diminishing
returns: proofs are weighted by type, sorted descending, and each successive proof
contributes less (the first at full weight, the second about 91%, the third about 73%).

`Raxol.Payments.PrivacyTier` maps the score to a tier (the Xochi whitepaper's Glass Cube
model), which sets the fee and the settlement mode:

| Score | Tier | Fee (bps) | Settlement |
|-------|------|:---:|-----------|
| 0-24 | `:standard` | 30 | public |
| 25-49 | `:stealth` | 25 | stealth |
| 50-74 | `:private` | 20 | shielded |
| 75+ | `:sovereign` | 15 | shielded |

`:private` requires a `:compliance` attestation and `:sovereign` requires `:compliance` plus
`:non_membership`; without them, the tier is walked down toward `:standard`. Two lower-privacy
tiers (`:open`, `:public`) exist only as an opt-in override, so a user can choose to reveal
more, never less by default.

`Raxol.Payments.Router` is the verification choke point. Callers pass raw, signed
attestations; the Router runs `Zksar.verify_batch/2` against an operator-pinned issuer
allowlist (`config :raxol_payments, :zksar_allowed_issuers`) on every path, replacing the
attestation set with only the verified subset before it scores or checks tier requirements. It
fails closed: with the allowlist unset (the default `[]`), no attestation can be verified, so
none buys trust. A self-asserted `%{valid: true}` map is not a proof.

Signature verification is implemented and on by default, but the digest scheme it checks
(EIP-191 over canonical attestation fields) is provisional: it has not yet been reconciled
byte-for-byte with the live Xochi oracle signer, so it fails closed on mismatch (rejecting
real attestations rather than accepting forged ones) until a production vector is pinned.

## Spending controls

Three layers of limits in `SpendingPolicy`:

- **Per-request**: max amount for a single transaction
- **Per-session**: rolling total within one agent session
- **Lifetime**: hard cap across all sessions

The `Ledger` is an ETS-backed GenServer that tracks cumulative spend. `SpendingHook` implements the `CommandHook` behaviour from raxol_agent. It runs before every command and can deny execution if limits would be exceeded.

Both spend paths reserve atomically before signing: the `SpendGate` choke point (payment Actions) and `SpendingHook` (Pay directives) call `Ledger.try_spend`, which checks the caps and records in one step, and refund via `Ledger.release` when execution then fails, so two commands cannot both pass a check before either records. A non-positive or non-finite amount is rejected up front (it can never lower the running total). A missing policy leaves the gate permissive in development, but a deployed release fails closed: `require_policy` defaults to true in production (a deployed OTP release, detected by `Raxol.Payments.Deployment` via `RELEASE_NAME`) and false in development and tests, so a production `SpendGate` with no `SpendingPolicy` rejects the spend rather than allowing unlimited spend. Set the context flag or `config :raxol_payments, :require_policy` to override.

## Crash recovery

Cross-chain settlement is asynchronous: there is a window between dispatching an intent (sign + submit) and confirming it landed. A crash in that window, on a runtime with no fault isolation, makes the restarted agent re-quote and sign a second time, paying twice.

`Raxol.Payments.Checkpoint` closes the window. The cross-chain Actions (`ExecuteXochiIntent`, `ExecuteRelayTransfer`) checkpoint the dispatched intent before submitting it, keyed by a stable idempotency key derived from the payment. On a re-run after a crash, the Action finds the checkpoint and returns the in-flight intent for the caller to poll, instead of reserving budget and signing again. The spend is reserved and the signature released exactly once across the crash.

The store is injected via `context[:checkpoint]` as a `{module, handle}` pair, so the recovery layer does not bind raxol_payments to a particular durability mechanism:

- `Checkpoint.ETS`: an ETS table; survives a process crash when owned by a process that outlives it (a supervisor, the cockpit). Used for standalone runs and the demo.
- `Checkpoint.ContextStore`: backed by `Raxol.Agent.ContextStore`, so a deployed agent's in-flight intent persists in the same durable store that backs its own crash recovery.
- nil (the default): recovery disabled; every call quotes and signs.

Without a checkpoint, `ExecuteXochiIntent` in development still proceeds but emits `[:raxol, :payments, :xochi, :unchecked_settlement]`, so the double-settle exposure is observable rather than silent. A deployed release fails closed by default: `require_checkpoint` defaults to true in production (detected via `RELEASE_NAME`) and false in development and tests, so a production Action with no durable store returns `{:error, %Failure{reason: :checkpoint_required}}` before any signature. Inject a store to close the window; set the context flag or `config :raxol_payments, :require_checkpoint` to override. A poll that never reaches a terminal status returns `%Failure{reason: :stranded}` and emits `[:raxol, :payments, :xochi, :intent_stranded]` with the intent id, so a stranded settlement is reconciled rather than blindly re-executed.

The relay rail keys on the logical payment rather than its client-minted `transfer_id`, so a resume reuses the same `transfer_id` and an idempotent broadcaster dedupes a retried deposit. A definite execution failure (nothing dispatched) drops the checkpoint so a later retry starts clean. Set `context[:idempotency_key]` to force two otherwise-identical payments apart.

## Production safety

A process that holds a signing key is the crown jewel, so the fund-moving defaults are paranoid in a deployed release and permissive in development and tests. `Raxol.Payments.Deployment.production?/0` decides which: it is true for a deployed OTP release (detected by the `RELEASE_NAME` the release boot script sets, which survives into the running node where `Mix.env/0` does not), and can be forced either way with `config :raxol_payments, :deployment` or `RAXOL_PAYMENTS_DEPLOYMENT`.

- **Signing boundary.** Every fund-moving Action reaches `wallet.sign_*` only after `SpendGate.authorize/3` returns `:ok`. `sign_hash/1` (the opaque-digest signer) is reachable only through `Mandate.sign/2`, which builds the digest from a validated struct, never an arbitrary hash. A property test asserts a gate rejection yields zero signatures across the amount space, and a guard test keeps any new Action from reaching a signer without the gate.
- **Fail-closed defaults.** In production, `require_policy` and `require_checkpoint` default to true (see Spending Controls and Crash Recovery), and `Wallets.Env` refuses to load (see Wallet Backends).
- **EIP-712 golden vectors.** The digest for every signed struct (x402 ERC-3009, the Xochi intent, the Permit2 witness, the Mandate) is pinned to a committed value that default CI checks on every run, so a change to a type definition or the encoder turns CI red rather than silently signing against the wrong struct.

Two boot gates fail closed for a fund-moving deployment; call them at startup, alongside `Protocols.Xochi.assert_origin_pull_pinned!/2`:

```elixir
# In your application start/2, before serving traffic:
Raxol.Payments.Deployment.assert_distribution_secure!()
# Halts a production node that participates in plain (non-TLS) Erlang
# distribution: a shared magic cookie lets any peer invoke exported functions
# and read ETS. Require -proto_dist inet_tls with per-node certs, or set
# RAXOL_ALLOW_INSECURE_DISTRIBUTION=true to override.

Raxol.Payments.Deployment.assert_signing_isolated!()
# Halts a production node that both signs and exposes the interactive REPL
# (RAXOL_REPL_EXPOSED=true). Evaluating code on a node that holds keys is a
# capability-escape surface: keep the REPL on a node that cannot sign.
```

## Agent actions

Thirteen actions registered via the `Raxol.Agent.Action` behaviour, callable by LLMs:

| Action | What it does |
|--------|-------------|
| `payment_get_wallet_info` | Return the agent's wallet address and chain ID |
| `payment_get_quote` | Get a price quote for a transfer (route, fees; layered `fee_breakdown` forthcoming) |
| `payment_transfer` | Execute a payment |
| `payment_spending_status` | Current spend vs limits |
| `payment_list_history` | Transaction history for this session |
| `payment_create_mandate` | Issue a Xochi delegation envelope from this wallet |
| `payment_list_mandates` | List locally-stored Mandates (as member or as agent) |
| `payment_revoke_mandate` | Locally delete a stored Mandate (server budget honored until expiry; a 410 auto-prunes) |
| `payment_execute_xochi_intent` | Dispatch a cross-chain Xochi intent (checkpointed) |
| `payment_poll_xochi_status` | Poll the status of a dispatched Xochi intent |
| `payment_execute_deposit_route` | Fetch + verify a Tron-origin deposit-route quote (bare `deposit_address`, attestation-checked); Tron origins have no gasless pull |
| `payment_execute_relay_transfer` | Initiate a Tron transfer via Riddler Relay (checkpointed); Tron is public-only |
| `payment_poll_relay_status` | Poll a dispatched relay (Tron) transfer to a terminal status |

## Mandate (Xochi delegation envelope)

`Raxol.Payments.Mandate` is a per-request EIP-712 envelope that lets an AI agent transact on Xochi under a human Member's identity, inheriting that Member's Trust Tier and Privacy Level. The Member signs an envelope binding a specific agent wallet to a scoped budget; the agent presents `X-Xochi-Delegation: base64url(envelope)` on every protected Xochi call. Xochi verifies the signature server-side and decrements the budget in KV per call. No persistent session.

The schema mirrors `xochi/packages/shared/src/eip712.ts:182-263` exactly (snake_case fields, `string[]` scopes, chainId pinned to 1, zero verifyingContract for off-chain). The Elixir digest is verified byte-for-byte against viem's `hashTypedData`.

```elixir
alias Raxol.Payments.Mandate

{:ok, m} = Mandate.build(%{
  human_wallet: member_wallet.address(),
  agent_wallet: "0x...",
  scopes: ["quote", "execute"],
  max_amount_usd: 1000,
  max_calls: 50,
  expires_at: System.system_time(:second) + 3600
})
{:ok, signed} = Mandate.sign(m, member_wallet)
:ok = Mandate.Store.put(signed)
```

On the agent's outbound HTTP path:

```elixir
Req.new(url: "https://api.xochi.fi/api/intent/quote")
|> Raxol.Payments.Req.Mandate.attach(agent_wallet: agent_addr)
|> Req.post(json: quote_body)
```

`Mandate.Store` is a singleton (named ETS tables); start exactly one per node. Optional DETS persistence via `:mandate_store_path` in `config :raxol_payments`. Mandate is **orthogonal** to `SpendingPolicy`/`Ledger`: those govern operator-set session budgets locally; Mandate is a credential carried to Xochi for tier inheritance.

v1 follows the locked design at `xochi/docs/planning/agent-auth.md`. On the agent side, a `410 Gone` from Xochi (a revoked or exhausted envelope) prunes the local mandate through the outbound plugin and flags the response as terminal, so a server-side revocation converges locally without a manual `payment_revoke_mandate` call. The authenticated server-side revoke endpoint itself, along with the on-chain registry, Verified-Agent metadata, issuer browser UI, and auto-rotation on exhaustion, remain deferred.

### Two delegation models (Mandate vs SCA session key)

A deployed agent may carry two capability-delegation credentials, and they govern two different surfaces. Keep them distinct in an ops runbook so a de-authorization is complete:

- **Xochi Mandate** (`Raxol.Payments.Mandate`, this package) governs Xochi API calls. A Member signs an EIP-712 envelope binding an agent wallet to a scoped budget; the agent presents it per call via `Req.Mandate`. Revoke it by letting it expire, or (once Xochi ships the revoke endpoint) by `H(envelope)`; a `410 Gone` prunes the local copy.
- **ERC-4337 SCA session key** (`Raxol.Earn.Wallet.SCA`, in `raxol_earn`) governs Base/ACP UserOps. A session key installed on the modular account via `installValidation` signs sponsored UserOps for on-chain ACP job actions.

The two are independent: revoking one does not revoke the other. An agent that must be fully de-authorized needs both its Mandate revoked or expired **and** its SCA session key uninstalled. The Mandate is a Xochi-side credential scoped to Xochi endpoints; the session key is a Base-side signer scoped to the modular account. A leaked key revoked on Xochi but still holding a valid session key can still act through the ACP path, and vice versa.

## AutoPay (Req plugin)

`Raxol.Payments.Req.AutoPay` is a Req response step. Add it to any Req client and HTTP 402 responses get handled transparently:

```elixir
client = Req.new(base_url: "https://api.example.com")
  |> Raxol.Payments.Req.AutoPay.attach(wallet: my_wallet)

# If the server returns 402, AutoPay signs and retries automatically
{:ok, response} = Req.get(client, url: "/expensive-endpoint")
```

## Package details

Standalone package at `packages/raxol_payments/`. Depends on `raxol_agent` at compile time only (`runtime: false`) for the Action macro and CommandHook behaviour. Runtime deps: `req`, `ex_secp256k1`, `ex_keccak`, `jason`, `decimal`.

## See also

- [Agent Framework](AGENT_FRAMEWORK.md): how agents work (TEA, sessions, teams, commands)
- [Distributed Swarm](DISTRIBUTED_SWARM.md): multi-node agent coordination


<!-- docs/features/AGENT_FRAMEWORK.md -->

# Agent Framework

An agent is a TEA module where input comes from LLMs and tools instead of a keyboard. Same `init/update/view` loop, same OTP supervision, same crash isolation. The "user" is an AI model issuing commands and processing results.

For agent payment capabilities (wallets, spending controls, cross-chain transfers), see [Agentic Commerce](AGENTIC_COMMERCE.md). For the interactive coding agent built on this framework, see [Coding Agent](CODING_AGENT.md). For the learning and recall layers an agent can opt into, see [Self-Improvement](SELF_IMPROVEMENT.md) and [Memory](MEMORY.md).

## Quick start

```elixir
defmodule MyAgent do
  use Raxol.Agent

  def init(_ctx), do: %{findings: []}

  def update({:agent_message, _from, {:analyze, file}}, model) do
    {model, [shell("wc -l #{file}")]}
  end

  def update({:command_result, {:shell_result, %{output: out}}}, model) do
    {%{model | findings: [out | model.findings]}, []}
  end
end

{:ok, _} = Raxol.Agent.Session.start_link(app_module: MyAgent, id: :my_agent)
Raxol.Agent.Session.send_message(:my_agent, {:analyze, "lib/raxol.ex"})
```

## How it works

```elixir
use Raxol.Agent
    |
    v
Agent.Session (GenServer)
    |-- wraps Lifecycle with environment: :agent
    |-- skips terminal driver and plugin manager
    |-- registers in Agent.Registry for discovery
    |
    v
TEA cycle: init/1 -> update/2 -> view/1 (optional)
    |
    v
Commands: async/1, shell/1, send_agent/2
```

`use Raxol.Agent` sets up the standard TEA callbacks (`init/1`, `update/2`, `view/1`, `subscribe/1`) with defaults, and injects three command helpers:

- `async(fun)`: async command with a sender callback
- `shell(command, opts \\ [])`: shell command via Port
- `send_agent(target_id, message)`: message another agent

All callbacks are overridable. `view/1` defaults to `nil`, which means no rendering. Useful for headless agents that only process messages.

## Agent session

`Raxol.Agent.Session` is the GenServer hosting a single agent. It wraps `Lifecycle` with `environment: :agent`, which skips the terminal driver and plugin manager.

```elixir
# Start an agent
{:ok, _pid} = Raxol.Agent.Session.start_link(
  id: :code_reviewer,
  app_module: CodeReviewAgent
)

# Send a message (async, arrives as {:agent_message, from, payload} in
# update/2; from is the sender's id when attributed with the :from option,
# nil otherwise -- the framework never guesses a sender)
:ok = Raxol.Agent.Session.send_message(:code_reviewer, {:review, "lib/app.ex"})

# Read the agent's current model
{:ok, model} = Raxol.Agent.Session.get_model(:code_reviewer)

# Read the agent's rendered view tree
{:ok, tree} = Raxol.Agent.Session.get_view_tree(:code_reviewer)
```

Agents auto-register in `Raxol.Agent.Registry` by their `:id`. If the agent is dead, lookups return `{:error, :not_found}`.

## Communication

`Raxol.Agent.Comm` has three messaging primitives:

```elixir
alias Raxol.Agent.Comm

# Fire and forget. Pass from: to identify yourself; without it the
# target sees from = nil (attribution is caller-asserted, not verified).
:ok = Comm.send(:target_agent, {:task, data}, from: :my_agent)
# Arrives in target's update/2 as {:agent_message, :my_agent, {:task, data}}

# Synchronous call with timeout. The target must answer with
# Comm.reply(caller, ref, response) from its update/2:
#   def update({:agent_message, _from, {:call, caller, ref, q}}, model) do
#     Comm.reply(caller, ref, answer(q, model))
#     {model, []}
#   end
{:ok, reply} = Comm.call(:target_agent, {:query, params}, 5_000)

# Broadcast to every agent in a team (delivery filtered by the receiving
# session's team_id)
:ok = Comm.broadcast_team(:my_team, {:status_update, status})
# Arrives in each teammate's update/2 as
# {:agent_message, nil, {:team_broadcast, :my_team, {:status_update, status}}}
```

## Teams

`Raxol.Agent.Team` is an OTP Supervisor for agent groups:

```elixir
{:ok, _} = Raxol.Agent.Team.start_link(
  team_id: :review_team,
  coordinator: {ReviewCoordinator, [id: :coordinator]},
  workers: [
    {FileAnalyzer, [id: :analyzer_1]},
    {FileAnalyzer, [id: :analyzer_2]}
  ],
  strategy: :rest_for_one
)
```

Coordinator starts first. With `:rest_for_one`, a coordinator crash restarts all workers. Workers crash independently.

## Command types

Commands returned from `update/2` are processed by Lifecycle:

| Command    | Helper                        | Result in update/2                                                   |
| ---------- | ----------------------------- | -------------------------------------------------------------------- |
| Async      | `async(fn sender -> ... end)` | `{:command_result, {:async_result, value}}`                          |
| Shell      | `shell("ls -la")`             | `{:command_result, {:shell_result, %{output: ..., exit_status: ...}}}` |
| Send Agent | `send_agent(:target, msg)`    | Delivered to target as `{:agent_message, from, msg}`                 |

## Headless agents

When `view/1` returns `nil` (the default), no rendering happens. The agent is a pure message-processing loop, good for background workers, data pipelines, or agents that only talk to other agents.

## AI backend streaming

`Raxol.Agent.Backend.HTTP` does real SSE streaming to LLM providers:

```elixir
{:ok, stream} = Raxol.Agent.Backend.HTTP.stream(
  [%{role: "user", content: "Explain OTP"}],
  api_key: System.get_env("ANTHROPIC_API_KEY"),
  provider: :anthropic,
  model: "claude-sonnet-4-20250514"
)

# Stream elements:
# {:chunk, "text delta"}
# {:done, %{content: full_text, usage: %{...}}}
# {:error, "message"}
```

Supports Anthropic, OpenAI, Ollama, Proton's Lumo, Kimi 2.5/moonshot, OpenRouter, and Meituan's LongCat.
Provider is auto-detected from `:base_url` or set via `:provider`.

Without an explicit `:provider`, detection matches the `:base_url`: `anthropic` picks Anthropic, `ollama` (or the default Ollama port) picks Ollama, `moonshot` picks Kimi, and anything else is treated as OpenAI-compatible. The `FREE_AI=true` / `AI_API_KEY` backend switch is a convention of the example agents under `examples/agents/`, not the `Backend.HTTP` layer.

The `:openrouter` harness (via `Backend.Selector`) targets OpenRouter, an OpenAI-compatible aggregator. It attaches app-attribution headers (HTTP-Referer, X-OpenRouter-Title, X-OpenRouter-Categories) so Raxol's usage appears on openrouter.ai/rankings. Pass the key via `ExecutorConfig` `auth: %{api_key: ...}`.

The `:longcat` harness targets Meituan's LongCat (`https://api.longcat.chat/openai`, model `LongCat-2.0`), also OpenAI-compatible. It rides the `:openai` request/SSE path, which already handles LongCat's non-standard frames (a full `message` chunk instead of `delta`, the `reasoning_content` channel, and the underscore-less `finishreason` key). Pass the key via `ExecutorConfig` `auth: %{api_key: ...}`.

## Turn driver

`Raxol.Agent.Backend.HTTP` streams one model call. `Raxol.Agent.Turn` drives a whole
self-improving turn: it assembles tool context from the agent module's callbacks, runs the
reasoning loop, records the turn to a conversation log, then fires the background side
effects.

```elixir
{:ok, items} =
  Raxol.Agent.Turn.run(MyAgent, "refactor lib/foo.ex",
    backend: MyBackend,
    log: log_server,
    conversation_id: cid,
    agent_id: "my-agent",
    user_id: "user-123",        # optional, with :user_model
    user_model: MyApp.UserModel,
    session_search: MyApp.SessionSearch
  )
```

- `build_context/2` builds the tool context, each key present only when configured: memory
  (from `memory_providers`/`memory_provider`), skills (`skills_provider`), user context, and
  session search.
- `run/3` runs `Stream.react/2` with that context and records the stream into a
  [Conversation Log](#conversation-item-log).
- `after_turn/4` fires [self-improvement](SELF_IMPROVEMENT.md), the user-model refresh, and
  session indexing.

The agent *module* declares which providers it wants through zero-arity callbacks; the
*caller* supplies the running server instances through opts. Turn is the canonical driver
other runtimes can adopt.

## Native multi-vendor harness

An agent can run its own reasoning loop, or hand the loop to a vendor CLI (Claude Code,
Cursor) and expose Raxol's tools to it over MCP. `Raxol.Agent.ExecutorConfig`
(`%{harness, model, auth, opts}`) plus `Raxol.Agent.Backend.Selector.select/1` map a harness
atom to a backend:

| Harness | Backend |
|---------|---------|
| `:anthropic`, `:openai`, `:kimi`, `:ollama`, `:lm_studio`, `:llm7`, `:longcat`, `:openrouter` | `Backend.HTTP` |
| `:lumo` | `Backend.Lumo` |
| `:claude_native` | `Backend.ClaudeCode` |
| `:cursor` | `Backend.Cursor` |
| `:mock` | `Backend.Mock` |

A native backend reports `handles_tools_internally?/0` as `true`, which tells the framework
not to drive the reasoning loop: the CLI runs its own loop and calls Raxol's tools through an
injected MCP server (`Raxol.Agent.Harness.McpToolConfig` writes the `--mcp-config`). The
`:codex` harness is reserved (it speaks a stateful app-server protocol served by
`Raxol.Symphony.Runners.Codex`, not an agent backend), so `select/1` returns
`{:error, {:harness_not_implemented, :codex}}` for it.

## Authorization (ALLOW/ASK/DENY)

`Raxol.Agent.Authorization` is a three-way policy engine, richer than the deny-only
`PermissionHook`. It is what [`mix raxol.code`](CODING_AGENT.md) gates every mutating tool on.

- `Engine` is a pure reducer over a list of policies. It folds with `reduce_while`: a DENY
  short-circuits, an ALLOW merges whitelisted label writes, an ASK escrows writes and
  accumulates a prompt. Final precedence is deny > ask > allow.
- `Policy` is a data struct (`phases`, `conditions`, `writable_labels`, `scope`). Scope is
  `:once`, `:session`, or `:root`; a remembered ASK auto-allows within its scope, so
  "approve once covers the tree" works.
- `Server` is a per-workflow GenServer holding the policies and pending ASKs; `Hook`
  composes the engine into the `CommandHook` chain at the `:tool_call` phase, resolving an
  ASK through a synchronous prompter.

## Conversation item-log

`Raxol.Agent.Conversation` is a durable, append-only record of what an agent did, separate
from its compacted working memory.

- `Item` is an immutable typed entry (message, tool_call, tool_result, reasoning, error, and
  more) with a stable id `"<conversation_id>:<seq>"` and a monotonic, store-assigned seq.
- `Store` is a behaviour with cursor pagination (`:after`/`:before`/`:limit`/`:order`/`:type`);
  `Store.ETS` is the shipped adapter (an `ordered_set` keyed `{conversation_id, seq}`). Append
  is the only writer.
- `Log` is a GenServer that wraps a store (durability) with in-process subscriber fan-out
  (liveness) and no replay buffer. `subscribe/3` returns the snapshot and registers the
  subscriber in one serialized call, so the snapshot and the live tail partition exactly:
  every item once, no gap, no duplicate. Reconnect with an `:after` cursor.
- `Recorder` bridges `Stream` events into items (tool_use to tool_call, done to message, and
  so on). [Session search](MEMORY.md#session-search) indexes this log.

## Tunnel (reverse co-drive)

`Raxol.Agent.Tunnel` lets a teammate attach to an agent running on your machine over a single
outbound link, without your files or credentials leaving it. The host dials out to a server;
many logical channels multiplex over the one link; when a peer opens a channel, its frames
tunnel to the host, which spawns the channel's handler locally.

- `Frame` has four kinds: `:hello` (host identity, once), `:open`, `:data` (base64 when
  binary), `:close`. Kinds are decoded through a whitelist, never `String.to_atom` on link
  input.
- `Tunnel` is the endpoint GenServer (`role: :host` or `:server`), transport-agnostic:
  outbound frames go through a `send_fun`, inbound bytes arrive as `{:tunnel_recv, binary}`.
- `Tunnel.Link.connect/2` wires two endpoints in-process for tests and same-node co-driving.
  The cross-machine transport (a WebSocket host and server) is a drop-in doing the same two
  things.

## Examples

```bash
FREE_AI=true mix run examples/agents/zero_system.exs  # ZERO System cockpit w/ live LLM reasoning
# framework primitives run from the package (they need Raxol.Agent):
cd packages/raxol_agent && mix run examples/agents/react_agent.exs  # Actions + ReAct + tools + shell
cd packages/raxol_agent && mix run examples/agents/agent_team.exs   # coordinator + workers
```


<!-- docs/features/CODING_AGENT.md -->

# Coding Agent (`mix raxol.code`)

An interactive, multi-turn coding assistant that runs in the terminal, wearing the axol
face `≡··≡`. It is Raxol's answer to a terminal coding CLI: type a prompt, watch the agent
stream its reasoning, read files, and (with your per-call approval) write files and run
shell commands scoped to the current working directory. Every mutating tool call is gated
by the [Authorization engine](AGENT_FRAMEWORK.md#authorization-allowaskdeny), so a write or
a shell command never runs unattended.

It boots `Raxol.Agent.Code.App`, a thin Lifecycle TEA app that owns the coding loop over
the harness contract and reuses the same transcript and streaming machinery as the rest of
the agent stack.

## Running it

`mix raxol.code` lives in the `raxol_agent` package. Main `raxol` does not depend on
`raxol_agent` (the dependency runs the other way), so the task is package-scoped. Run it
from inside the package, or use the launcher shim from anywhere:

```bash
# from inside the package
cd packages/raxol_agent
mix deps.get            # once
mix raxol.code

# or from any directory, via the shim (keeps YOUR cwd as the agent's workspace)
bin/raxol-code
```

The `bin/raxol-code` shim runs the task from the package while keeping the caller's working
directory as the agent's workspace (the file and shell tools are scoped to it via
`RAXOL_CLI_CWD`). It `exec`s `mix` so the full-screen UI inherits the real terminal.

The Burrito-packaged `raxol` binary (`packages/raxol_cli`, unpublished until this
subcommand ships in a release) carries the same TUI as `raxol code`: every entry
point shares one launch path, `Raxol.Agent.Code.Launcher`, so flags, provider
resolution, and the session store cannot drift between them.

Not sure what to try first? These all work on this repo from a cold start:

```
summarize mix.exs
what are the three largest files under lib?
find every module that starts a GenServer
```

## Connecting a provider

With no `--backend`, the agent auto-detects a provider through the shared
`Raxol.Agent.Backend.Resolver`, in this precedence order:

1. an explicit `--api-key` (or an `:api_key` opt),
2. a 1Password reference stored by `/login` (resolved through the `op` CLI),
3. a provider env var (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `KIMI_API_KEY`, `OPENROUTER_API_KEY`, `LONGCAT_API_KEY`, ...),
4. the generic `AI_API_KEY` (plus optional `AI_BASE_URL` / `AI_MODEL`) for any OpenAI-compatible endpoint.

If nothing resolves, the TUI opens on an interactive setup wizard instead of
failing against a placeholder endpoint:

- a selectable provider list (`↑`/`↓` to move, `Enter` to connect, `Esc` to
  dismiss), each row marked connected (`●`) or not (`○`) with an actionable
  note when detection has a problem (a stored reference that needs
  `op signin`, or an env var that is set but empty);
- picking a keyed provider opens a masked credential entry: paste an `op://`
  reference (shown in the clear, stored) or an API key (masked). After a raw
  key connects, the wizard offers to save it to 1Password so no plaintext
  persists;
- picking a local provider (`lm_studio`, `ollama`) connects with no key.

The `/login` text command remains for scripted/power use (it works alongside
the wizard):

```
/login                                          # open the wizard
/login anthropic op://Vault/Anthropic/key       # store a 1Password reference (persisted)
/login openai sk-...                             # a session-only key (never written)
/login lm_studio                                 # a local server, no key
```

On connect (and once at launch for an auto-detected provider), the agent fires
a cheap, async validation ping and reports the outcome in the status line:
`validated`, `key rejected (HTTP 401)`, or `endpoint unreachable`. It prefers
the provider's token-free model-list endpoint (`GET /v1/models`), falling back
to a single-token completion only when that is unavailable. The check runs off
the UI process, so a slow or offline endpoint never blocks the TUI, and the
connection is usable immediately regardless.

Stored references live in `~/.raxol/providers.json` (override with
`$RAXOL_PROVIDERS`), owner-readable only. That file holds `op://` references,
models, and base URLs, never raw keys. Zero-config env usage still works: set
`ANTHROPIC_API_KEY` (or another provider var) and launch.

Supported providers: `anthropic`, `openai`, `kimi`, `openrouter`, `longcat`,
`lumo`, `ollama`, `lm_studio`, `llm7`, `mock`.

A repo can also pin its default provider and model in `.raxol/config.json`
(read from the working directory, references only, raw keys deliberately
ignored):

```json
{ "provider": "anthropic", "model": "claude-sonnet-5" }
```

Precedence is explicit flag, then the repo pin, then environment
auto-detection. `mix raxol.inspect` (or `/inspect` in the TUI) shows exactly
what would resolve in the current directory and why.

## Flags

| Flag | Effect |
|------|--------|
| `--backend NAME` | Pin an LLM backend (auto-detected if omitted). Validated against `Backend.Selector.supported_backends/0`. `--harness` is a deprecated alias. |
| `--model NAME` | Model override. |
| `--api-key KEY` | API key for the selected backend (else resolved from op/env). |
| `--base-url URL` | Override the backend base URL. |
| `--system TEXT` | System-prompt override. |
| `--continue` | Resume the most recently updated session. |
| `--resume ID` | Resume a specific session by id. |
| `--sessions` | Print saved sessions and exit (no TUI). |
| `--replay ID` | Print a session's transcript from its durable journal and exit (no TUI). |
| `--to-offset N` | With `--replay`: stop at journal offset N. Alone it is a usage error. |
| `--ascii` | ASCII-only face for terminals without a UTF-8 font. |
| `-h`, `--help` | Print usage and exit. |

The SSH flags (`--ssh`, `--ssh-port`, `--authorized-keys`, `--ssh-tenants`) are
described under [Serving over SSH](#serving-over-ssh).

```bash
mix raxol.code --backend anthropic --model claude-sonnet-5
mix raxol.code --continue
mix raxol.code --resume sess-1234-5
mix raxol.code --sessions
mix raxol.code --replay sess-1234-5
```

`--resume` wins over `--continue`; with neither, a fresh session is minted.
`--replay` refuses to combine with `--ssh`, `--sessions`, `--continue`, or
`--resume`.

## Keys

| Key | Action |
|-----|--------|
| type + Enter | Send a prompt (or a `/command`) |
| `a` / `y` | Approve the pending tool call once |
| `s` | Approve always (remembers the tool for this session) |
| `d` / `n` | Deny the pending approval |
| Shift+Tab / Ctrl+P | Toggle plan mode |
| Esc | Deny a pending approval, else interrupt the running turn |
| Ctrl+C | Quit |

Typing, Enter, plan-mode toggles, and backspace are accepted only when the agent is idle
(not mid-turn and not waiting on an approval).

## Slash commands

| Command | What it does |
|---------|--------------|
| `/help` | Show help |
| `/login [provider]` | Connect an LLM provider (1Password reference, session key, or local server) |
| `/clear` | Start a new session (the old file stays on disk) |
| `/model <name>` | Switch model for the next turns (bare `/model` on a connected provider opens a live model picker) |
| `/plan` | Toggle plan mode |
| `/compact` | Shrink history: keep the last 6 messages, replace older ones with a compaction marker, then persist |
| `/rewind` | Drop the last turn from the transcript and the conversation (and write a rewind marker to the journal, so a replay drops it too) |
| `/context` | Session stats (message, event, and token counts, plan on/off, model, session key) |
| `/usage` | Session token totals per direction, an estimated cost (env rates, else the `Raxol.Agent.LlmPrices` table), and the shared-ledger totals when a ledger is wired |
| `/sessions` | List up to 10 saved sessions |
| `/resume [id]` | Switch session in place; bare `/resume` opens a picker over the 20 most recent |
| `/fork [title]` | Branch a copy of this session under a new id and continue there |
| `/rename <title>` | Title this session (shown by `/sessions`) |
| `/export [path]` | Write the transcript to a file (default `<session>.txt` in the cwd) |
| `/transcript` | Write the transcript to a fresh 0600 file (a temp file, or the workspace in a jailed session) and print a pager hint |
| `/copy` | Copy the last assistant reply to the clipboard |
| `/find <text>` | Case-insensitive search over the transcript blocks (first 8 matches) |
| `/logout [provider]` | Disconnect the session's provider; with a name, also forget its stored credential reference |
| `/share` | Mint a read-only share link for this session |
| `/mcp` | List MCP servers configured in `.mcp.json` |
| `/hooks` | Show pre/post/stop hook counts |
| `/inspect` | Show every config source in use: provider resolution and why, the repo pin, hook rules, MCP servers, skills roots, session store (same output as `mix raxol.inspect`) |

A jailed session (multi-tenant SSH, see below) refuses `/login`, `/logout`, and
`/copy`: the keyboard principal there is a tenant, and all three reach host-global
state (the credential store, the host clipboard). `/rewind`, `/resume`, and `/fork`
refuse while a turn is running.

## Tools

The default toolset is read-only file inspection plus gated mutation and a read-only
sub-agent. Read-only tools run without a prompt; sensitive tools gate through approval.

| Tool | Sensitive? | Notes |
|------|:---:|-------|
| `list_dir`, `read_file`, `file_stat` | No | `read_file` supports `offset`/`limit` line ranges, caps at 256KB |
| `grep`, `glob` | No | `grep` uses ripgrep when available, else a bounded pure-Elixir walk; `glob` is cwd-relative |
| `write_file` | Yes | Refuses to clobber unless `overwrite: true`; returns a diff-shaped result |
| `edit_file` | Yes | `old_string` must match once unless `replace_all` |
| `bash` | Yes | `/bin/sh -c`, combined stdout+stderr, output truncated past 64KB |
| `task` | (delegating) | Delegates to a fresh read-only sub-agent that cannot write, run bash, or recurse |
| `lsp` | No | Language-server queries: diagnostics, symbols, definition, references, hover |
| `lsp_rename` | Yes | Renames a symbol through the language server and writes the edits |

Skills tools (`skills_list`, `skill_view`, `skill_manage`) join the toolset when a
skills provider is configured.

## Language server

The agent asks a language server about code rather than inferring it from text. `lsp`
answers what the IDE would: `diagnostics` (whether an edit actually compiles), `symbols`
(a file's outline), `definition`, `references` (before changing a signature), and `hover`.
`lsp_rename` renames a symbol everywhere it appears using the server's own understanding
of scope, and writes the result.

That last one is the difference between a rename and a find-and-replace: it will not touch
a same-named symbol in another scope, and it follows re-exports.

Positions crossing the tool boundary are **1-based** `line` and `column`, matching the line
numbers `read_file` prints. LSP itself is 0-based and counts columns in UTF-16 code units;
both conversions happen inside the tool, so a rename on a line containing an emoji or CJK
text lands on the right characters instead of one column early.

### Which server

Built-in defaults cover elixir (`elixir-ls`), rust (`rust-analyzer`), typescript
(`typescript-language-server`), python (`pyright-langserver`), and go (`gopls`), matched on
file extension. A repo overrides or extends them in `.raxol/lsp.json`:

```json
{
  "servers": {
    "elixir": { "command": "lexical" },
    "zig": { "command": "zls", "extensions": [".zig"] }
  }
}
```

Overriding a built-in by name keeps its extensions, so pointing `elixir` at a different
binary does not mean restating the file list. A server whose command is not on `PATH` is
reported as such rather than silently doing nothing, and a malformed file falls back to the
defaults rather than blocking boot. `mix raxol.inspect` (or `/inspect`) lists every server
that would serve the directory and whether it is installed.

### Lifecycle

Servers start on first use, not at boot, and one per language is kept for the session:
starting `rust-analyzer` per turn would mean indexing the crate per turn. Each is owned by
a `Raxol.Agent.Lsp.Pool` that monitors the session process, so when the session ends by any
path (a clean quit, an SSH disconnect, a crash) the pool stops every server it owns before
going down. No teardown path has to remember them. A server that crashes is dropped; the
next request for that language starts a fresh one.

The pool stops them explicitly rather than relying on the process link. A pool that exits
`:normal` does not take a linked, non-trapping process with it, so the clients, and the OS
subprocesses behind them, would otherwise outlive the session. Waiting for a cold server
to finish `initialize` also happens off the pool's own process, so a session that ends
during a start is noticed immediately instead of after the start timeout.

### Containment

Paths in go through the same cwd resolution as every other file tool. Results coming back
are the server's, and a language server indexes whatever it likes: a definition can land in
a dependency or the standard library. Those are reported as absolute paths in a normal
session and dropped in a jailed one. A rename's edits are each re-checked against the
workspace root before anything is written, so a server cannot direct a write outside it.

A rename is also bounded in width. Approval is asked before the server has answered, so
the approver sees a position and a new name and cannot see how many files the rename
reaches; a rename touching more than `max_files` (default 50) is refused with the count
instead of performed. Retrying with an explicit `max_files` is a fresh call, and therefore
a fresh approval that does carry the number. All the edits are composed before any of them
is written, so an edit that cannot apply fails with nothing changed rather than partway
through; if a write itself fails, the error names the files that already landed.

**A jailed (multi-tenant) session gets no language server at all.** A server is arbitrary
code execution on the workspace twice over: `.raxol/lsp.json` names the binary, and the
binary runs project code to answer anything. `rust-analyzer` executes `build.rs`, and
`elixir-ls` compiles the project. In a jail the workspace is tenant-written, which makes
this the same refusal hooks and MCP servers already get.

### Not yet

Diagnostics are surfaced when the model asks for them, not automatically after every write.
Post-write diagnostics are tracked in the parity epic.

Every path expands relative to the working directory: the tool context's `:cwd`
when the surface sets one (an ACP session root, a tenant jail), else
`RAXOL_CLI_CWD`, else the BEAM cwd. The result must stay under that root, and
containment is decided on the REAL path: `Raxol.Agent.Actions.Fs.resolve/2`
canonicalizes both sides component by component (`realpath`), so a symlink cannot
lexically hide an escape. A `../` escape, an outside-cwd absolute path, or a
symlink cycle is rejected with `:outside_cwd`.

`grep` and `glob` run that check on every path they WALK, entry by entry through
the recursive scan. `File.regular?/1` and `File.dir?/1` follow symlinks, so the
native grep walk tests each symlinked entry for containment, skips the ones that
escape, and re-tests at the read itself; `glob` rejects every wildcard match whose
realpath lands outside the root, so an out-of-workspace name stays undisclosed.

In a jailed session the `bash` tool is refused entirely
(`Raxol.Agent.Actions.Code.shell_jail_allow/1` returns
`{:error, :shell_disabled_in_jail}`) unless the context carries a
`Raxol.Agent.Sandbox.Shell`. A `/bin/sh -c` command line is not a path, so
`{:cd, cwd}` is a starting directory rather than a boundary and the fs
containment above does not apply to it.

## Approval UX

When the agent proposes a sensitive tool, the authorizer runs inside the reasoning loop and
blocks until you answer, with a 300-second timeout that defaults to deny. Nothing writes or
shells out on its own. The decision routes through `Raxol.Agent.Authorization.Engine`:

- **ALLOW**: the tool is already in this session's remembered set, so it runs with no prompt.
- **DENY**: plan mode plus a mutating tool is refused (`plan_mode_read_only`).
- **ASK**: otherwise a footer prompt opens: `[a]llow once` / `[s]always` / `[d]eny`, Esc denies.

"Allow always" remembers the tool for the rest of the session (per-tool memory held in the
app, not the engine), so you approve a class of action once.

## Plan mode

Toggle plan mode (Shift+Tab, Ctrl+P, or `/plan`) to investigate without touching anything.
Plan mode appends a read-only directive to the system prompt and has the Authorization
engine deny every mutating tool, with a `PLAN` chip in the status strip. Toggle it off to
execute.

## Spending limits

LLM spend is metered into the same `Raxol.Payments.Ledger` agent payments draw on,
through `Raxol.Agent.Code.CostLedger`. Wire it with the app options `:ledger`
(a Ledger server ref), `:spending_policy` (a `Raxol.Payments.SpendingPolicy`), and
optionally `:agent_id` (the ledger scope key, default `"raxol-code"` so a `/clear`
cannot mint its way out of a cap). Without raxol_payments in the host, or without
both a ledger and a policy, every call here degrades to a no-op and nothing changes.

Each provider call's cost is recorded as its `turn_completed` event folds, priced
from `RAXOL_COST_PER_MTOK_IN`/`RAXOL_COST_PER_MTOK_OUT` when both are set, else from
the `Raxol.Agent.LlmPrices` table. Sub-agent rounds from the `task` tool run in a
nested stream whose usage never reaches the parent fold, so they report through a
`:usage_sink` and are metered the same way. The gate then runs twice:

- at submit, so an exhausted budget refuses the NEXT prompt with a notice naming
  what clears it (`frozen`, `ledger_unreachable`, or the limit that tripped);
- inside the running turn, so a turn that blows the cap mid-loop is interrupted
  rather than allowed to keep looping through more provider calls.

A wired-but-dead ledger fails closed: an unanswerable `check_budget` reads as
`{:over, :ledger_unreachable}`.

With a ledger AND a policy wired, a model with no price fails closed too. An
unpriced model bills real tokens while the ledger records $0.00, so the first
response that reports billed tokens at $0.00 halts the running turn and blocks
the next prompt. The notice names the two fixes: set
`RAXOL_COST_PER_MTOK_IN`/`RAXOL_COST_PER_MTOK_OUT`, or `/model` a priced one.
Naming a model with `/model` clears the halt. The first round of a session
cannot be prevented (the billed model is only knowable from a response), so
this stops the second.

## Sessions

Conversation memory persists across turns and across runs, one JSON file per session.
`--continue` resumes the most recent; `--resume ID` a specific one. The default directory is
`$RAXOL_CODE_SESSIONS` if set, otherwise `~/.raxol/code_sessions`. A saved session stores the
messages and the durable transcript events, so a resume rebuilds both the model context and
the visual scrollback. Session ids are validated where they enter (`--resume`, `/resume`,
`--replay`) against the charset `[A-Za-z0-9._-]+`, excluding `.` and `..`, and the store
additionally passes the id through `Path.basename`, so a crafted id cannot escape the
sessions directory.

Alongside the JSON store, each session appends its durable-tier events to an
offset-addressed journal (`Raxol.Agent.Journal.FileStore`, one directory per session
under `$RAXOL_SESSIONS_DIR` or `~/.raxol/sessions`), through a single owning Writer, as
they fold. The JSON store only persists on turn boundaries, so the journal is what
survives a process death mid-turn; it opens lazily on the first durable event, and an
append failure lands on the status line without blocking the fold.

That journal is what `--replay ID` reads: it folds the records through
`Raxol.Harness.Projection` and prints the transcript without starting a TUI, with
`--to-offset N` replaying only the prefix at or below offset N. A session recorded
before the journal existed (or whose journal is gone) falls back to the JSON store.
Replay is read-only: a crash-torn tail is tolerated on read and healed only by the
owning Writer (`Reader.resume_scan/1`), so replaying a live session cannot disturb it.

`/rewind` drops the last turn from the transcript and the conversation and writes a
rewind marker into the journal, so `--replay` and the shared viewer drop it too. Turn
ids are only unique within one VM run, so a rewind removes the contiguous trailing run
of the last turn's events rather than every event with that id.

## The axol face

The status face `≡··≡` is a single source of truth in
`Raxol.UI.Components.Harness.AxolFace` (main `raxol` package). The gills `≡` stay constant
and the eyes carry state. `--ascii` swaps the gills for `=`.

| State | Unicode | ASCII | Color |
|-------|---------|-------|-------|
| `:boot` | `≡··≡` cycling | `=..=` cycling | cyan |
| `:idle` | `≡··≡` | `=..=` | none |
| `:thinking` | `≡''≡` | `=''=` | cyan |
| `:working` | `≡oo≡` `≡OO≡` | `=oo=` `=OO=` | cyan |
| `:done` | `≡^^≡` | `=^^=` | green |
| `:error` | `≡xx≡` | `=xx=` | red |

Contract events drive the face: a started turn is `:thinking`, a running tool is
`:working`, a finished turn is `:done`, and a failure is `:error`.

## Hooks and MCP config

Two optional per-project files, both read from `<cwd>/`:

- `.raxol/hooks.json` declares `pre_tool_use` / `post_tool_use` matchers (each
  `{"match": ..., "command": ...}`, `match` is an exact tool name or `"*"` and defaults to
  `"*"`) plus `stop` commands. A pre-hook that exits non-zero vetoes the tool (30-second
  timeout, `RAXOL_TOOL_NAME` in the environment); post-hooks are advisory; stop commands
  run at turn end.
- `.mcp.json` uses the standard `{"mcpServers": {name: {command, args, env}}}` format.
  Configured servers are started (supervised, off the boot path) and their tools join the
  live toolset as `mcp__<server>__<tool>`, sensitive by default: each call is
  approval-gated like any mutating tool, and plan mode denies them outright since an
  external tool's effects are unknown. `/mcp` shows per-server connection state
  (`●` connected, `✗` failed, `…` loading); a server that fails to start is skipped with
  a note, never fatal. At most 16 servers load per config, and server names are held to
  `[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}` (each one interns an atom and spawns a subprocess);
  refusals show up in `/mcp` alongside connection failures.

Both files name a command to execute, so both are read only when the session owns its
workspace. A jailed session (multi-tenant SSH, see below) loads NEITHER: its workspace is
writable by a tenant whose own `write_file` can author these files, and running them would
be arbitrary execution as the server uid: around the cwd jail, the `:jail` shell gate,
and the approval chain alike. `/mcp` and the status line say so rather than reporting an
empty config.

## Editors over ACP

`mix raxol.acp` serves the agent loop over the
[Agent Client Protocol](https://agentclientprotocol.com) on stdio, so an
ACP-speaking editor (Zed and its ecosystem) can spawn and drive it: each
`session/new` gets a real `Raxol.AgentClientProtocol.Session` running
`Raxol.Agent.ClientProtocol.TurnRunner` over the same provider resolution
as every other entrypoint. Turns are read-only on this surface until ACP's
permission flow is bridged to the authorization engine. Each session's file
tools scope to the `cwd` the editor names in `session/new`, so one server
handles projects in different directories and every tool call is contained
under its own session root. Point
the editor at the `bin/raxol-acp` shim rather than `mix` directly, so Mix
compile output never reaches the NDJSON wire; `mix help raxol.acp` has the
Zed `agent_servers` snippet. This is a repo-checkout feature: the protocol
package is a dev/test path dependency, so a Hex install of raxol_agent is
built without it and the task exits 1 with an explanation.

`Raxol.Agent.ClientProtocol.Serve` owns every exit code, so `mix raxol.acp`,
the packaged `raxol acp`, and the shim agree. A clean peer disconnect exits 0:
the transport reports the close, the connection stops, and the process answers
with a code instead of dying of the linked exit. The non-zero paths are a usage
error (64), a build with no ACP support (1), an unresolved provider (1), and a
connection that ended abnormally (1).

## Serving over SSH

`mix raxol.code --ssh --authorized-keys ~/.ssh/agent_keys` serves the same
TUI over SSH (default port 2222, `--ssh-port` to change): each connection
gets its own app instance and a fresh session, with the provider resolved
once, server-side, at launch. Auth is publickey only; this surface reaches
write and shell tools, so anonymous serving is not offered at all, and
`--continue`/`--resume` are rejected in this mode. Single-tenant by design:
every connection shares the server's filesystem, credentials, and session
store, so serve it only to keys you would hand a shell.

### Multi-tenant hosting

`mix raxol.code --ssh --ssh-tenants /srv/tenants` hosts many users from one
daemon. Each tenant is a directory under the root:

    /srv/tenants/<user>/
    ├── ssh/authorized_keys   # that user's keys: a key only authenticates
    │                         # the username it is filed under
    ├── work/                 # the cwd jail: every fs tool, /export, and
    │                         # /transcript confine here (bash is refused)
    ├── code_sessions/        # that user's session store (/sessions, /resume)
    └── sessions/             # that user's durable journal (/share, --replay)

The AUTHENTICATED username decides everything: usernames are restricted to
a conservative charset (anything else fails auth outright), the same
normalization maps the key lookup and the workspace so they can never
disagree, and a connection whose tenant options cannot be derived is
refused rather than started unjailed. A jailed session also loads no
`.raxol/hooks.json` and no `.mcp.json`: both name a command to run and both
live in the tenant's own writable workspace.

Spending identity is `ssh:<user>` (the tenant's `:agent_id`), so a shared
`Raxol.Payments.Ledger` + policy on the server-level app options gives each
tenant their own budget, enforced as described in
[Spending limits](#spending-limits): the running turn halts and the next
prompt is refused.

What the jail is NOT: separate OS uids. This is one BEAM under one uid, so
the confinement is the fs tools' path resolution, the refusal of the `bash`
tool, and the refusal to load workspace-configured commands. Untrusted
tenants want separate uids or containers on top.

Hosted deployment: set `RAXOL_SSH_CODE=true` with
`RAXOL_SSH_CODE_TENANTS=/data/tenants` and
`RAXOL_SSH_CODE_BUDGET_USD=<cap>` (and optionally `RAXOL_SSH_CODE_PORT`,
default 2223) and the main application serves the coding agent beside the
SSH playground. It refuses to start without BOTH: there is no anonymous or
single-tenant hosted mode, and no unmetered one either. A hosted tenant
spends the host's provider credential, so an unset or unparseable cap
refuses to serve rather than serving unbounded. The cap is per tenant
(lifetime and session), enforced through a `Raxol.SSH.CodeLedger` the
supervisor starts alongside the server, so the build needs raxol_payments.

Three dependencies have to be on the release code path, and boot refuses
without any of them: raxol_agent for the agent itself, raxol_payments for
the ledger, and `req` for the HTTP client every remote provider resolves
to. `req` is easy to miss because raxol_agent declares it optional and
optional dependencies do not propagate: a release could pass every other
check and still answer `{:error, :req_not_available}` on every turn. The
deploy app (`web/mix.exs`) declares all three.

Onboarding a user is `mkdir -p /data/tenants/<user>/ssh` plus writing
their `authorized_keys`; then `ssh <user>@host -p 2223` is the whole
client.

## Sharing a session read-only

`/share` mints a signed, expiring token (24h) for the current session and
ensures its journal exists for a viewer to replay. Configuration is one
secret: set `RAXOL_SHARE_SECRET` (or the `:share_secret` app option), at
least 32 bytes, on both the TUI host and the web host, and mount the viewer
in any Phoenix app:

    live "/share/:token", Raxol.Agent.Code.ShareLive

The view verifies the token offline (`Raxol.Agent.Code.ShareToken`, HMAC,
no server state), replays the session's durable journal with the same
rewind-marker-aware fold `--replay` uses, and follows new records live
from the journal high-watermark. Transcript only: the surface has no
input path.

A token grants read access to exactly one session until it expires, and
it FOLLOWS that session: the viewer keeps receiving new records for the
full 24h, so sharing is "watch me work", not "here is a snapshot". There
is no revocation short of rotating the secret.

The token also carries the scope its session id is meaningful in, because
ids are unique per journal base rather than per host. An unjailed session
signs the empty scope (the host's own base); a tenant session signs its
tenant name, and the viewer resolves
`<tenants_root>/<scope>/sessions` from `:share_tenants_root` or
`RAXOL_SSH_CODE_TENANTS`. A scoped token on a host with no tenants root
configured is refused rather than resolved against the host's own tree.

A blank or under-length secret reads as unconfigured, so `/share` says so
rather than minting a forgeable token, and a session whose id is not of the
shareable shape is refused with a message pointing at `/fork` or `/resume`
under a plain id.

`RAXOL_SHARE_BASE_URL` turns the `/share` notice into a pasteable link.
`phoenix_live_view` is an optional dependency; without it the viewer
module simply is not compiled and `/share` still mints tokens.
Multiplayer (shared input) is not scheduled.

## Driving the harness over MCP

`Raxol.Agent.Harness.McpTools` exposes the harness itself as MCP tools:
`harness_start_session`, `harness_send_prompt`, `harness_read_transcript`,
and `harness_list_sessions`. They share the TUI's session store, so a
session driven by an MCP client resumes in the TUI with `--resume` and vice
versa. Turns are read-only on the workspace (read/grep/glob; no
`write_file`, `edit_file`, or `bash` on this surface): there is no human
here to answer an approval prompt, so write capability waits on the MCP
authorizer wiring rather than shipping behind an allow-all flag. Run
`mix mcp.server` from `packages/raxol_agent` to serve them.

## See also

- [Agent Framework](AGENT_FRAMEWORK.md): the Turn driver, native harnesses, and the
  Authorization engine that `mix raxol.code` builds on.
- [Self-Improvement](SELF_IMPROVEMENT.md) and [Memory](MEMORY.md): the learning and recall
  layers an agent can opt into.


<!-- docs/features/CURSOR_EFFECTS.md -->

# Cursor Effects

Visual cursor trails and glow effects.

## Usage

```elixir
alias Raxol.Effects.CursorTrail

trail = CursorTrail.new()
trail = CursorTrail.update(trail, {10, 5})
trail = CursorTrail.update(trail, {11, 5})
buffer = CursorTrail.apply(trail, buffer)
```

## Configuration

```elixir
trail = CursorTrail.new(%{
  max_length: 20,
  decay_rate: 0.15,
  colors: [:cyan, :blue],
  chars: ["*", "+", "."],
  min_opacity: 0.1
})
```

## Presets

```elixir
trail = CursorTrail.rainbow()  # Rainbow colors, 24 points
trail = CursorTrail.minimal()  # Simple white dots, 5 points
trail = CursorTrail.comet()    # Long fading tail, 30 points
```

## Operations

```elixir
# Update with position
trail = CursorTrail.update(trail, {x, y})

# Clear trail
trail = CursorTrail.clear(trail)

# Enable/disable
trail = CursorTrail.set_enabled(trail, false)

# Update config
trail = CursorTrail.update_config(trail, %{colors: [:red]})

# Statistics
stats = CursorTrail.stats(trail)
```

## Advanced

```elixir
# Smooth interpolation
trail = CursorTrail.interpolate(trail, {5, 10}, {15, 10})

# Multi-cursor
positions = [{10, 5}, {20, 10}, {30, 15}]
trail = CursorTrail.multi_cursor(positions)

# Glow effect
buffer = CursorTrail.apply_glow(buffer, {x, y}, :cyan)
```

## Integration

```elixir
def render(state, cursor) do
  trail = CursorTrail.update(state.trail, cursor)
  buffer = CursorTrail.apply(trail, state.buffer)
  %{state | trail: trail, buffer: buffer}
end
```

See [benchmarks](../bench/README.md) for current numbers.


<!-- docs/features/DISTRIBUTED_SWARM.md -->

# Distributed Swarm

Cluster BEAM nodes with automatic discovery, track their health, elect a commander, and sync shared state with CRDTs. Works with libcluster's gossip, epmd, and DNS strategies, plus a custom Tailscale strategy for zero-config encrypted mesh.

## Quick start

```elixir
# Gossip: LAN multicast, no config needed
{:ok, _} = Raxol.Swarm.Discovery.start_link(strategy: :gossip)

# Tailscale: encrypted mesh, tag-filtered
{:ok, _} = Raxol.Swarm.Discovery.start_link(
  strategy: :tailscale,
  node_basename: "raxol",
  tag_filter: "tag:raxol"
)
```

## Discovery strategies

`Raxol.Swarm.Discovery` wraps libcluster with preset strategies:

| Strategy     | Use Case                   | Key Options                                     |
| ------------ | -------------------------- | ----------------------------------------------- |
| `:gossip`    | LAN multicast, zero config | --                                              |
| `:epmd`      | Static node list           | `hosts: [:"app@host1", :"app@host2"]`           |
| `:dns`       | Fly.io, Kubernetes         | `query: "app.internal", node_basename: "app"`   |
| `:tailscale` | Mesh VPN, encrypted        | `node_basename: "app", tag_filter: "tag:raxol"` |

```elixir
Raxol.Swarm.Discovery.available?()  # => true if libcluster is loaded

# Or bypass presets entirely with raw topologies
Raxol.Swarm.Discovery.start_link(
  topologies: [
    my_cluster: [
      strategy: Cluster.Strategy.Epmd,
      config: [hosts: [:"app@host1"]]
    ]
  ]
)
```

### Tailscale strategy

`Raxol.Swarm.Strategy.Tailscale` shells out to `tailscale status --json`, grabs online peers, filters by tag if configured, and builds BEAM node names from the results.

```elixir
Raxol.Swarm.Discovery.start_link(
  strategy: :tailscale,
  node_basename: "raxol",
  tag_filter: "tag:raxol",    # optional, only nodes with this tag
  use_dns_names: true,         # MagicDNS names instead of IPs
  poll_interval: 5_000         # ms between polls
)
```

## Node monitoring

`Raxol.Swarm.NodeMonitor` watches cluster nodes via `:net_kernel.monitor_nodes/1`, pings them on a timer, and tracks RTT history.

```elixir
{:ok, _} = Raxol.Swarm.NodeMonitor.start_link(ping_interval_ms: 1_000)

{:ok, health} = Raxol.Swarm.NodeMonitor.get_health(:"app@host1")
# => %{node: :"app@host1", status: :healthy, avg_rtt_ms: 2.4, ...}

healthy = Raxol.Swarm.NodeMonitor.list_healthy()
suspect = Raxol.Swarm.NodeMonitor.list_suspect()
all = Raxol.Swarm.NodeMonitor.list_all()

# Subscribe to status changes
Raxol.Swarm.NodeMonitor.subscribe()
# Receive: {:swarm_event, {:node_up, node}}
# Receive: {:swarm_event, {:node_down, node}}
# Receive: {:swarm_event, {:status_change, node, :healthy, :suspect}}
```

A node goes `:suspect` after 5s without a ping response, `:down` after 30s. RTT history keeps the last 60 measurements.

## Topology & Election

`Raxol.Swarm.Topology` assigns roles to nodes and runs seniority-based commander election. The longest-running node wins.

```elixir
{:ok, _} = Raxol.Swarm.Topology.start_link(quorum_size: 2)

role = Raxol.Swarm.Topology.get_role()        # :commander | :wingmate | :observer | :relay
{:ok, commander} = Raxol.Swarm.Topology.get_commander()
nodes = Raxol.Swarm.Topology.list_nodes()      # [{node, role}, ...]
count = Raxol.Swarm.Topology.node_count()

# Manual override
:ok = Raxol.Swarm.Topology.promote(:"app@host2", :wingmate)
```

Topology subscribes to NodeMonitor automatically. If the commander goes down, re-election kicks in.

## CRDTs

Two pure functional CRDT types, no coordination required:

### LWW Register (Last-Writer-Wins)

```elixir
alias Raxol.Swarm.CRDT.LWWRegister

reg = LWWRegister.new("initial_value")
reg = LWWRegister.update(reg, "new_value")

# Merge from another node: highest timestamp wins
merged = LWWRegister.merge(local_reg, remote_reg)
value = LWWRegister.value(merged)
```

### OR-Set (Observed-Remove Set)

Add-wins: if one node adds and another removes concurrently, the element stays.

```elixir
alias Raxol.Swarm.CRDT.ORSet

set = ORSet.new()
set = ORSet.add(set, "item_a")
set = ORSet.add(set, "item_b")
set = ORSet.remove(set, "item_a")

ORSet.member?(set, "item_a")  # => false
ORSet.to_list(set)             # => ["item_b"]
ORSet.size(set)                # => 1

merged = ORSet.merge(local_set, remote_set)
```

## Tactical overlay

`Raxol.Swarm.TacticalOverlay` is the shared state layer. Entities are LWW registers, waypoints are OR-sets. It syncs deltas between nodes periodically and does full anti-entropy exchanges to catch anything missed.

```elixir
{:ok, _} = Raxol.Swarm.TacticalOverlay.start_link(
  sync_interval_ms: 500,
  anti_entropy_interval_ms: 60_000
)

# Entities (LWW)
Raxol.Swarm.TacticalOverlay.update_entity(:unit_1, %{
  position: {10.0, 20.0, 0.0},
  heading: 45.0,
  status: :active
})

# Waypoints (OR-Set)
Raxol.Swarm.TacticalOverlay.add_waypoint(%{
  id: "wp_alpha",
  position: {50.0, 50.0, 0.0},
  label: "Rally Point"
})
Raxol.Swarm.TacticalOverlay.remove_waypoint("wp_alpha")

# Query
entities = Raxol.Swarm.TacticalOverlay.get_all_entities()
waypoints = Raxol.Swarm.TacticalOverlay.get_all_waypoints()

# Subscribe
Raxol.Swarm.TacticalOverlay.subscribe()
# Receive: {:overlay_event, {:entity_updated, :unit_1, data}}
# Receive: {:overlay_event, {:waypoint_added, waypoint}}
```

## CommsManager

`Raxol.Swarm.CommsManager` routes messages based on link quality. Critical messages always go through; low-priority ones get dropped when the link is bad.

```elixir
{:ok, _} = Raxol.Swarm.CommsManager.start_link()

:ok = Raxol.Swarm.CommsManager.send_msg(:"app@host2", data, :critical)
:ok = Raxol.Swarm.CommsManager.send_msg(:"app@host2", data, :normal)

quality = Raxol.Swarm.CommsManager.get_link_quality(:"app@host2")
# => :excellent | :good | :degraded | :poor | :disconnected
```

`:critical` and `:high` always send. `:normal` gets dropped on `:poor` links. `:low` gets dropped on `:degraded` or worse.

## Without libcluster

libcluster is optional. If it's not there:

- `Discovery.available?/0` returns `false`
- `Discovery.start_link/1` starts but doesn't do anything
- Everything else works in single-node mode
- You can still connect nodes manually with `:net_kernel.connect_node/1`


<!-- docs/features/EDITOR_ACP.md -->

# Editor Agent Client Protocol

> **Two protocols share the letters "ACP".** This page is the **Agent Client Protocol**
> ([agentclientprotocol.com](https://agentclientprotocol.com)): the JSON-RPC protocol
> between a code editor and an AI coding agent, implemented in `raxol_agent_client_protocol`
> (module root `Raxol.AgentClientProtocol`). It is unrelated to the
> [Agent Commerce Protocol](ACP.md) (`Raxol.Earn`, the Virtuals on-chain payments protocol).
> Different acronym expansion, different domain.

The Agent Client Protocol is to agentic coding what LSP is to language tooling: a JSON-RPC
2.0 protocol between a **client** (an editor or CLI host such as Zed) and an **agent** (the
AI coding process), spoken over a byte stream, almost always the agent's stdio. Raxol's
implementation (`raxol_agent_client_protocol`, pre-alpha `0.1.0-rc.0`) has zero raxol
dependencies (only `jason`) and implements both roles.

## The protocol shape

A turn is bidirectional. The client drives the handshake (`initialize`, then `session/new`
or `session/load` to resume, then `session/prompt`). During a prompt the agent streams
`session/update` notifications back, and may itself become the caller mid-turn:
`session/request_permission`, `fs/read_text_file`, `fs/write_text_file`, `terminal/*`. That
agent-to-client request direction is why the protocol is bidirectional and why the package
implements both the `:agent` and `:client` roles behind one connection core.

## Three layers

- **`Schema.*`**: the ACP v1 data model (content blocks, session/fs/terminal types,
  capabilities). Decoding is total: malformed or unknown wire input never crashes and never
  mints an atom from wire data.
- **`Rpc.*` / `Transport.*`**: the JSON-RPC 2.0 envelope plus pluggable carriers.
  `Transport.Stdio` is newline-delimited JSON over a real or spawned stdio pipe;
  `Transport.Paired` is an in-process linked-mailbox pair for tests and BEAM-local wiring.
- **Runtime**: `Connection` (one GenServer per peer, either role, never blocks on a peer,
  dispatches each inbound request to a supervised task), `Session` (per-session turn state
  machine under a `DynamicSupervisor`), and the `Agent` / `Client` behaviours you `use`.

`MethodTable` is the single source of truth for the wire vocabulary. The `Agent` / `Client`
callback surfaces and the dispatcher are generated from it at compile time, so the callbacks
and the router cannot drift from the protocol. Adding or changing a method is one table-row
edit.

## Minimal agent

```elixir
defmodule MyAgent do
  use Raxol.AgentClientProtocol.Agent
  alias Raxol.AgentClientProtocol.Connection
  alias Raxol.AgentClientProtocol.Schema.{ContentChunk, TextContent}
  alias Raxol.AgentClientProtocol.Schema.LifecycleExtras.SessionNotification
  alias Raxol.AgentClientProtocol.Schema.AgentTypes.{InitializeResponse, NewSessionResponse, PromptResponse}

  def initialize(req, _ctx), do: {:ok, InitializeResponse.new(req.protocol_version)}
  def new_session(_p, _ctx), do: {:ok, NewSessionResponse.new("sess-1")}

  def prompt(%{session_id: sid, prompt: blocks}, ctx) do
    text = Enum.map_join(blocks, "", fn {:text, tc} -> tc.text; _ -> "" end)
    chunk = ContentChunk.new({:text, TextContent.new("echo: #{text}")})
    Connection.notify(ctx.conn, "session/update", SessionNotification.new(sid, {:agent_message_chunk, chunk}))
    {:ok, PromptResponse.new(:end_turn)}
  end
end

{:ok, handle} = Raxol.AgentClientProtocol.Transport.Stdio.start_self()
{:ok, _sup} = Raxol.AgentClientProtocol.Agent.start_link(MyAgent,
  transport: {Raxol.AgentClientProtocol.Transport.Stdio, handle})
```

A client spawns the agent with `Transport.Stdio.start_spawn("elixir", ["--no-halt", "my_agent.exs"])`,
resolves the connection pid, and calls `initialize` before any other request. For BEAM-local
wiring with no subprocess, swap in `Transport.Paired.create_pair/0`.

## Running raxol as your agent

The sections above are about writing an agent with the package. This one is for
a host that wants to drive Raxol's own coding agent over ACP: an editor, or any
tool that spawns agent CLIs.

Install the CLI, then point the host at `raxol acp`:

```bash
curl -fsSL https://raxol.io/install | bash   # or: brew install droodotfoo/tap/raxol
raxol doctor                                 # confirms "acp surface: available"
```

Zed, and anything sharing its `agent_servers` shape:

```json
{
  "agent_servers": {
    "Raxol": {
      "command": "/usr/local/bin/raxol",
      "args": ["acp"],
      "cwd": "/path/to/your/project"
    }
  }
}
```

What the handshake tells you: `agentInfo` names `raxol` and its version,
`agentCapabilities` advertises `loadSession`, and `authMethods` offers both of
the registry's accepted kinds (browser sign-in per provider, plus Terminal Auth
via `raxol login`). Nothing on the ACP wire carries a model, so the provider is
resolved from the host's own configuration.

Three behaviours worth knowing before wiring up:

- **`session/new`'s `cwd` scopes the session.** The fs, grep, and glob tools
  resolve every path under it, so one server can drive several projects at once
  and each session is contained under its own root. A blank `cwd` falls back to
  the process working directory.
- **Turns run the full toolset, and writes are gated.** Every sensitive call
  costs one `session/request_permission` round trip offering allow-once and
  reject-once. Reads are not gated, so a read-heavy turn adds no protocol
  traffic. The gate is fail-closed on the DECISION: a client that refuses,
  times out, disconnects, or does not implement the method at all denies the
  write and keeps reading. A host that implements nothing still gets a working
  read-only agent.
- **Sessions are durable.** Ids are stable across restarts and name a journal on
  disk, so a host can store one and hand it back to `session/load` later. The
  replay re-sends the same `session/update` frames the original turn delivered.

### Verifying an integration

`scripts/acp_probe.py` is a dependency-free ACP client that runs the full
handshake, answers `session/request_permission`, and records every frame. Point
it at the same command your host will spawn:

```bash
scripts/acp_probe.py raxol acp --backend mock
```

A `__NON_JSON_STDOUT__` entry in the transcript means something wrote non-JSON
to the wire before a frame, which a strict NDJSON client would reject.

### Two caveats

**The ACP surface is a source-build feature.** `raxol_agent_client_protocol` is
a path dependency of `raxol_agent`, so a Hex install of `raxol_agent` is
compiled without `Raxol.Agent.ClientProtocol.StdioAgent` and has no ACP surface
at all; adding the dependency downstream does not retroactively enable it. The
packaged CLI (npm, Homebrew, the install script) is built from source and does
have it. `raxol doctor` reports which you have.

**Native-CLI backends bypass all of the above.** With `--backend claude_native`
or another passthrough, the turn runs the other CLI's tool loop: raxol's
Actions, the `cwd` scoping, and `session/request_permission` never execute. The
tools carry the other agent's names, and its refusals look identical on the wire
to our gate denying something. `raxol acp` warns about this on stderr at boot.
Use an API-key backend when testing these paths.

## Durable resumable sessions

`Ext.*` is a vendor extension (carried on the standard `_meta["raxol.io"]` rider plus new
`_raxol/*` methods, ACP's own extension mechanism) that makes a session reattachable across
connections:

- An append-only, single-writer journal per session (write-then-publish: a subscriber sees a
  record only after it is durably written).
- Offset-based reattach and replay with no gap and no duplicate: a reattaching client
  registers as a live subscriber before reading the high watermark, then replays history up
  to it.
- `RXC1` capability tokens: detached Ed25519, offline-verifiable. There is no `alg` field;
  the literal `RXC1` prefix is the algorithm binding, so downgrade confusion is structurally
  unexpressible.
- Taint is annotated, never filtered, so `history ++ live` stays the durable stream.

The extension is opt-in rather than turnkey: you wire the journal, reattach, and attach
policy yourself. The current journal is in-memory (durable across connections within a node's
lifetime, not yet across restarts).

## Provenance and license discipline

The package is deliberately layered to stay pure MIT. The `Schema.*` layer is ported MIT to
MIT from `f1729/agent_client_protocol` with defects fixed; the conformance corpus is ported
MIT to MIT from `openclaw/acpx`; the OTP runtime is a clean-room implementation (the official
Apache-2.0 SDKs and other implementations were studied as design references only, no code
copied). The official ACP JSON Schema is vendored SHA256-pinned as a dev/test oracle and is
excluded from the published package, so Apache-2.0 terms never propagate downstream. See the
package `NOTICE.md`.

## See also

- [Agent Commerce Protocol](ACP.md): the unrelated on-chain payments ACP.
- [Coding Agent](CODING_AGENT.md): Raxol's own terminal coding agent.
- `scripts/acp_probe.py`: a minimal ACP client for verifying an integration.


<!-- docs/features/FILESYSTEM.md -->

# Virtual File System

An in-memory filesystem that's purely functional: immutable struct, O(1) path lookups, zero side effects. Good for sandboxed environments, agent workspaces, and poking around in the REPL.

## Core API

```elixir
alias Raxol.Commands.FileSystem

fs = FileSystem.new()

{:ok, fs} = FileSystem.mkdir(fs, "/docs")
{:ok, fs} = FileSystem.create_file(fs, "/docs/readme.txt", "Hello!")
{:ok, entries} = FileSystem.ls(fs, "/docs")        # => ["readme.txt"]
{:ok, content} = FileSystem.cat(fs, "/docs/readme.txt")  # => "Hello!"

{:ok, fs} = FileSystem.cd(fs, "/docs")
FileSystem.pwd(fs)                                  # => "/docs"
FileSystem.exists?(fs, "readme.txt")                # => true

{:ok, info} = FileSystem.stat(fs, "readme.txt")
# => %{type: :file, size: 6, path: "/docs/readme.txt", ...}

{:ok, fs} = FileSystem.rm(fs, "readme.txt")
{:ok, tree} = FileSystem.tree(fs, "/", 3)
# => {"/", :directory, [{"docs", :directory, []}]}
```

Anything that changes the filesystem returns `{:ok, new_fs}` or `{:error, reason}`. Error reasons are atoms: `:not_found`, `:already_exists`, `:parent_not_found`, `:not_a_directory`, `:is_a_directory`, `:directory_not_empty`, `:cannot_remove_root`.

## REPL Integration

Call `Evaluator.with_vfs/1` to get a `vfs` binding and shell-like helpers auto-imported from `Raxol.REPL.VfsHelpers`:

```elixir
alias Raxol.REPL.Evaluator

eval = Evaluator.new() |> Evaluator.with_vfs()

{:ok, _, eval} = Evaluator.eval(eval, "vfs = mkdir(vfs, \"/src\")")
{:ok, _, eval} = Evaluator.eval(eval, "vfs = touch(vfs, \"/src/app.ex\", \"defmodule App do\\nend\")")
{:ok, _, eval} = Evaluator.eval(eval, "vfs = ls(vfs)")
{:ok, _, eval} = Evaluator.eval(eval, "vfs = cat(vfs, \"/src/app.ex\")")
{:ok, _, eval} = Evaluator.eval(eval, "tree(vfs)")
```

Helpers print their output via IO (captured by the evaluator) and return the VFS struct so you can chain them.

| Helper | What it does | Mutates VFS? |
|--------|--------|:---:|
| `ls(vfs)` / `ls(vfs, path)` | Print directory listing | No |
| `cd(vfs, path)` | Change working directory | Yes |
| `pwd(vfs)` | Print current directory | No |
| `cat(vfs, path)` | Print file contents | No |
| `mkdir(vfs, path)` | Create directory | Yes |
| `touch(vfs, path)` / `touch(vfs, path, content)` | Create file | Yes |
| `rm(vfs, path)` | Remove file or empty dir | Yes |
| `tree(vfs)` / `tree(vfs, path, depth)` | Print directory tree | No |
| `stat(vfs, path)` | Print node metadata | No |

The `Evaluator.prelude` field imports VfsHelpers before every eval, which is why bare names like `ls` and `mkdir` work without any aliasing.

## Agent actions

The VFS is also wired up as `Raxol.Agent.Action` modules, so LLMs can call them as tools:

```elixir
alias Raxol.Agent.Actions.Vfs
alias Raxol.Agent.Action.ToolConverter

# Generate LLM tool definitions
tools = ToolConverter.to_tool_definitions(Vfs.actions())

# Dispatch an LLM tool call
context = %{vfs: model.vfs}
tool_call = %{"name" => "vfs_write_file", "arguments" => %{"path" => "/app.ex", "content" => "..."}}
{:ok, result} = ToolConverter.dispatch_tool_call(tool_call, Vfs.actions(), context)
new_vfs = result.vfs  # mutating actions return the updated VFS
```

| Action | Tool Name | Returns VFS? |
|--------|-----------|:---:|
| `Vfs.ListDir` | `vfs_list_dir` | No |
| `Vfs.ReadFile` | `vfs_read_file` | No |
| `Vfs.WriteFile` | `vfs_write_file` | Yes |
| `Vfs.MakeDir` | `vfs_make_dir` | Yes |
| `Vfs.Remove` | `vfs_remove` | Yes |
| `Vfs.ChangeDir` | `vfs_change_dir` | Yes |
| `Vfs.GetTree` | `vfs_get_tree` | No |

VFS resolution checks `params[:vfs]` first (so Pipeline composition works), then `context[:vfs]`, and falls back to a fresh filesystem if neither exists.

### Pipeline composition

```elixir
alias Raxol.Agent.Action.Pipeline

{:ok, state, commands} = Pipeline.run(
  [
    {Vfs.MakeDir, %{path: "/src"}},
    {Vfs.WriteFile, %{path: "/src/app.ex", content: "defmodule App do\nend"}}
  ],
  %{},
  %{vfs: FileSystem.new()}
)
```

The updated VFS flows through the pipeline on its own: each action's result gets merged into the next action's params.

## Internals

Internally it's a flat map keyed by absolute path (`%{"/" => node, "/docs" => node, ...}`). Parent-child relationships are tracked both ways (parents keep a `children` list). Path resolution handles `.`, `..`, absolute paths, relative paths, and `-` for the previous directory. Timestamps come from `System.monotonic_time(:millisecond)`.

There are also formatting helpers: `format_ls/3` for styled directory listings and `format_cat/3` for line-numbered file output.

## Playground demo

`mix raxol.playground` has a VFS demo with a shell-like interface: `ls`, `cd`, `cat`, `pwd`, `mkdir`, `rm`, `tree`, and `help` all work.


<!-- docs/features/GATEWAY.md -->

# Unified Messaging Gateway

One daemon that connects many chat platforms through a shared contract. Each platform is an
adapter that owns only its own I/O and translation; routing, per-chat sessions, pairing,
authorization, and history all live in the gateway. A chat becomes an OTP process, so
platform fan-out is supervision rather than a single-process message queue.

The package (`raxol_gateway`, pre-alpha `0.1.0`) depends on `raxol_core`, with `raxol_agent`
optional (used only to record turns to a durable conversation log). It has no auto-started
tree: you wire the supervisor yourself.

## The adapter contract

`Raxol.Gateway.Adapter` is the five-callback behaviour every platform implements:

| Callback | Purpose |
|----------|---------|
| `connect/1` | Open a platform connection, return a `conn` handle |
| `disconnect/1` | Close it |
| `platform/0` | The platform atom |
| `normalize_event/1` | Translate a raw platform event to `{:ok, Route.t(), event}` or `:ignore` |
| `send_message/3` | Send a rendered reply to a route |

The contract is frozen (ADR-0023): additions must be optional callbacks, and existing
callbacks do not change shape.

Shipping adapters:

| Adapter | Platform | Package |
|---------|----------|---------|
| `Raxol.Gateway.Adapter.InMemory` | `:in_memory` (reference; sink pid) | raxol_gateway |
| `Raxol.Telegram.GatewayAdapter` | `:telegram` (text messages, chunked plain-text sends) | raxol_telegram |
| `Raxol.Gateway.Adapter.Discord` | `:discord` (MESSAGE_CREATE text, chunked plain-text sends) | raxol_gateway |
| `Raxol.Gateway.Adapter.Email` | `:email` (bidirectional: SMTP send + RFC822 inbound; transport injected) | raxol_gateway |

A full Telegram wiring pairs the adapter with `Raxol.Telegram.UpdatePoller` (getUpdates
long polling) feeding `normalize_event/1` into the router:

```elixir
{:ok, conn} = Raxol.Telegram.GatewayAdapter.connect(bot_token: token)

Raxol.Gateway.Supervisor.start_link(
  handler: {Raxol.Gateway.Handler.Agent, [system_prompt: "..."]},
  adapter: {Raxol.Telegram.GatewayAdapter, conn}
)

Raxol.Telegram.UpdatePoller.start_link(
  conn: conn,
  on_update: fn raw ->
    # Authorize BEFORE routing: the adapter is a pure translator and checks
    # nothing, and Handler.Agent runs a paid backend call per text event.
    with {:ok, route, event} <- Raxol.Telegram.GatewayAdapter.normalize_event(raw),
         :allow <- Raxol.Gateway.Pairing.authorize(Raxol.Gateway.Pairing, route) do
      case Raxol.Gateway.SessionRouter.route(Raxol.Gateway.SessionRouter, route, event) do
        :ok -> :ok
        # Log rejects (rate limit, max sessions): the poller advances its
        # offset regardless, so a silent drop is permanent loss.
        {:error, reason} -> Logger.warning("update rejected: #{inspect(reason)}")
      end
    else
      :ignore -> :ok
      :deny -> :ok
    end
  end
)
```

The Discord wiring is the same shape with the roles renamed: the feed is
`Raxol.Gateway.Adapter.Discord.GatewaySocket` (one Gateway v10 WebSocket with
client heartbeats, identify/resume, and exponential reconnect; requires the
optional `mint_web_socket` dependency), whose `:on_event` passes raw dispatch
frames through `Raxol.Gateway.Adapter.Discord.normalize_event/1`. Replies go
out over REST (`POST /channels/:id/messages`, optional `req` dependency),
chunked at Discord's 2000 code points. Guild messages route as
`chat_type: :guild`, DMs as `:dm`; bot-authored messages never normalize, so
two agents cannot loop each other. Note the MESSAGE_CONTENT intent is
privileged: enable it in the Discord developer portal or guild message
content arrives empty.

```elixir
{:ok, conn} = Raxol.Gateway.Adapter.Discord.connect(bot_token: token)

Raxol.Gateway.Supervisor.start_link(
  handler: {Raxol.Gateway.Handler.Agent, [system_prompt: "..."]},
  adapter: {Raxol.Gateway.Adapter.Discord, conn}
)

Raxol.Gateway.Adapter.Discord.GatewaySocket.start_link(
  token: token,
  on_event: fn frame ->
    with {:ok, route, event} <- Raxol.Gateway.Adapter.Discord.normalize_event(frame),
         :allow <- Raxol.Gateway.Pairing.authorize(Raxol.Gateway.Pairing, route) do
      case Raxol.Gateway.SessionRouter.route(Raxol.Gateway.SessionRouter, route, event) do
        :ok -> :ok
        {:error, reason} -> Logger.warning("dispatch rejected: #{inspect(reason)}")
      end
    else
      :ignore -> :ok
      :deny -> :ok
    end
  end
)
```

## Agent-backed handler

`Raxol.Gateway.Handler.Agent` turns any chat into an agent conversation: each inbound
`%{text: text}` event runs one synchronous turn through `Raxol.Agent.Stream` and replies
with the collected answer. It requires the optional `raxol_agent` dependency.

```elixir
Raxol.Gateway.Supervisor.start_link(
  handler:
    {Raxol.Gateway.Handler.Agent,
     [
       system_prompt: "You are a helpful assistant.",
       # No backend pinned: auto_provider resolves credentials from the
       # environment (1Password ref -> provider env vars -> AI_API_KEY).
       agent_opts: []
     ]},
  adapter: {MyAdapter, conn}
)
```

Per-chat history is kept in the handler state (capped by `:max_history`, default 40
messages) and, when the session has a `:log`, also recorded to the conversation log. A
failed turn logs the full reason and replies with a short error message. Turns run
synchronously inside the per-chat session process, so set `:idle_timeout` comfortably
above the longest expected turn.

## TEA app handler

`Raxol.Gateway.Handler.Lifecycle` runs a full TEA app per chat under
`environment: :gateway` (a registered Lifecycle environment: no terminal driver, no
plugin manager, unnamed processes, so any number of chats can run the same app module
concurrently). Each inbound `%{text: t}` event becomes a Raxol event (char key or paste,
mirroring the Telegram input adapter), and the reply is the app's next rendered frame as
plain text. Requires the optional `raxol` dependency.

```elixir
Raxol.Gateway.Supervisor.start_link(
  handler: {Raxol.Gateway.Handler.Lifecycle, [app_module: MyTeaApp, width: 60, height: 16]},
  adapter: {MyAdapter, conn}
)
```

Turns are collected deterministically (event fold barrier, then a synchronous engine
render), and `:event_fn` / `:format_fn` are injectable for custom event mapping or frame
formatting. Frames the app renders between turns are discarded: a chat surface replies
to messages; spontaneous pushes are `Raxol.Gateway.Delivery`'s job. The handler's
`terminate/2` (a new optional `Handler` callback the session invokes on clean stops)
stops the per-chat Lifecycle so it cannot outlive its chat.

## Voice notes

`Raxol.Gateway.Pipeline.Transcribe` is a feed-loop stage that turns a voice media event
(`%{media: %{kind: :voice, ref: ..., ...}}`, what `Raxol.Telegram.GatewayAdapter` emits
for `message.voice` updates) into the ordinary `%{text: transcript}` event before it is
routed. It runs in the feed loop rather than the session so the conversation log records
the transcript (a session logs each inbound event before its handler runs) and so STT
never blocks a per-chat mailbox. Non-voice events pass through untouched.

```elixir
{:ok, conn} = Raxol.Telegram.GatewayAdapter.connect(bot_token: token)

Raxol.Telegram.UpdatePoller.start_link(
  conn: conn,
  on_update: fn raw ->
    with {:ok, route, event} <- Raxol.Telegram.GatewayAdapter.normalize_event(raw),
         :allow <- Raxol.Gateway.Pairing.authorize(Raxol.Gateway.Pairing, route),
         {:ok, event} <-
           Raxol.Gateway.Pipeline.Transcribe.run(event,
             fetch_fn: fn media -> Raxol.Telegram.GatewayAdapter.fetch_media(conn, media) end
           ) do
      case Raxol.Gateway.SessionRouter.route(Raxol.Gateway.SessionRouter, route, event) do
        :ok -> :ok
        # Log rejects: the poller advances its offset regardless, so a
        # silent drop is permanent loss.
        {:error, reason} -> Logger.warning("update rejected: #{inspect(reason)}")
      end
    else
      :ignore -> :ok
      :deny -> :ok
    end
  end
)
```

The three stages are injectable functions: `:fetch_fn` (platform download, here
`fetch_media/2` = Bot API `getFile` + file GET), `:convert_fn` (default: ffmpeg via a
temporary file, `-f f32le -ac 1 -ar 16000`, executable allowlisted), and `:recognize_fn`
(default: `Raxol.Speech.Recognizer.recognize/1` from the optional `raxol_speech`
dependency). The stage fails open per event: any failure (STT missing, download or
conversion error, empty transcript) drops that one voice note with a warning and
`[:raxol_gateway, :transcribe, :error]` telemetry; text traffic is never affected. Audio
bytes and transcripts stay out of the logs, as does the download URL (it embeds the bot
token).

Mind the cold start: the first recognition after boot pays the XLA graph compile, which
can take minutes on CPU. Give the Recognizer a generous `:recognize_timeout_ms` (via
`Raxol.Speech.Supervisor`'s `:recognizer_opts`) or warm it up front, otherwise every
cold call times out, aborts the compile, and the next call starts over.

## Routing and sessions

`Raxol.Gateway.Route` (`platform`, `chat_type`, `chat_id`, optional `user_id`) identifies a
chat. `Route.key/1` forms the stable session key:

```
agent:main:{platform}:{chat_type}:{chat_id}
```

`Raxol.Gateway.SessionRouter` (a `BaseManager` GenServer) starts one `Raxol.Gateway.Session`
process per chat under a `DynamicSupervisor`, keyed by that string, with an idle timeout
(default 10 minutes), a per-key start cooldown (default 5 seconds), and a max-session bound
(default 1000). Sessions run a `Raxol.Gateway.Handler` (`init/2` and `handle_event/2`,
plus an optional `terminate/2` invoked on clean session stops for handlers that own
linked processes). Each inbound event and each reply can be recorded to an optional log
keyed by a stable `conversation_id`.

## Pairing and authorization

`Raxol.Gateway.Pairing` issues 8-character DM pairing codes (from an unambiguous alphabet,
1-hour TTL, per-user request cooldown, global lockout after repeated failed confirms) and
decides `authorize/2` in this order:

1. the platform is configured to allow everyone, else
2. the user is paired, else
3. the user is in the platform allowlist, else
4. the user is in the global allowlist, else
5. deny.

## Delivery

`Raxol.Gateway.Delivery.deliver/3` resolves four outbound destinations:

- `{:direct, route}`: reply to the originating chat.
- `{:home, route}`: a configured home channel (cron or background results).
- `{:cross_platform, route}`: a different platform's chat.
- `{:target, "platform:chat_id"}`: an explicit target string. The platform is matched against
  connected adapters by string comparison, never turned into an atom from input.

### Email as a delivery target

`Raxol.Gateway.Adapter.Email` handles both directions. Outbound is what the
`{:home, route}` mode wants: cron and background results land in a mailbox with no
platform approval process. It needs the optional `gen_smtp` dependency, and
a route addresses a mailbox directly:

```elixir
{:ok, conn} =
  Raxol.Gateway.Adapter.Email.connect(
    relay: "smtp.example.com",
    port: 587,
    tls: :always,
    username: "bot@example.com",
    password: System.fetch_env!("SMTP_PASSWORD"),
    from: "bot@example.com",
    subject: "Nightly digest"
  )

route = Raxol.Gateway.Route.new(%{platform: :email, chat_type: :dm, chat_id: "ops@example.com"})
:ok = Raxol.Gateway.Adapter.Email.send_message(conn, route, rendered_report)
```

The rendered reply becomes a text/plain MIME message (utf-8, quoted-printable); nothing
is chunked, since email has no chat-style length limit.

### Email as a conversational surface

Inbound email makes an email thread a per-chat session like any other platform.
`normalize_event/1` parses a raw RFC822 message (`:mimemail.decode`, never raising) into
`{:ok, route, %{text: body}}`: it routes on the normalized sender address
(`chat_type: :dm`, `chat_id` lower-cased with the display name stripped), takes the first
`text/plain` part with quoted history trimmed, and surfaces `Message-ID`/`In-Reply-To`/
`References`/`Subject` under the event's `:email` key. Non-mail, unparseable, or
sender-less input returns `:ignore`.

The transport that pulls mail off a mailbox is injected, not bundled, because `gen_smtp`
only speaks SMTP and the mailbox a deployment reads (IMAP, POP3, the Gmail API, or an SMTP
listener) is its choice. `Raxol.Gateway.Adapter.Email.Inbox` is the sink-agnostic poll
feed (mirroring `Raxol.Telegram.UpdatePoller`): an injectable `:fetch_fn` cursor loop with
exponential backoff and credential-redacted status. `Raxol.Gateway.Adapter.Email.ThreadStore`
is a capped per-conversation store the wiring records inbound `Message-ID`s into and the
adapter reads back through `conn`'s `:thread_lookup`, so outbound replies set
`In-Reply-To`/`References`/`Re:` headers and mail clients keep the conversation together
(the frozen `send_message/3` has no per-send channel for the reply id).

```elixir
{:ok, store} = Raxol.Gateway.Adapter.Email.ThreadStore.start_link(name: MyThreads)

{:ok, conn} =
  Raxol.Gateway.Adapter.Email.connect(
    relay: "smtp.example.com",
    from: "bot@example.com",
    thread_lookup: Raxol.Gateway.Adapter.Email.ThreadStore.thread_lookup_fn(store)
  )

Raxol.Gateway.Adapter.Email.Inbox.start_link(
  fetch_fn: fn cursor -> MyMailbox.fetch_since(cursor) end,
  on_message: fn raw ->
    case Raxol.Gateway.Adapter.Email.normalize_event(raw) do
      {:ok, route, event} ->
        with :allow <- Raxol.Gateway.Pairing.authorize(MyPairing, route) do
          Raxol.Gateway.Adapter.Email.ThreadStore.record_event(store, route, event)
          Raxol.Gateway.SessionRouter.route(MyRouter, route, event)
        end

      :ignore ->
        :ok
    end
  end
)
```

## Handoff

`SessionRouter.handoff(server, from_key, to_route)` rebinds a conversation to another
platform's route, carrying the source session's `conversation_id` and the router's log. Since
the log is keyed by `conversation_id`, history follows the conversation across platforms.

## Supervision

`Raxol.Gateway.Supervisor` ties it together with `:rest_for_one`, so the router (which
references the sessions supervisor) restarts if that supervisor dies:

```elixir
defmodule EchoHandler do
  @behaviour Raxol.Gateway.Handler
  def init(_route, _opts), do: {:ok, %{}}
  def handle_event(%{text: t}, state), do: {:reply, "echo: #{t}", state}
end

{:ok, conn} = Raxol.Gateway.Adapter.InMemory.connect(%{sink: self()})

{:ok, _sup} =
  Raxol.Gateway.Supervisor.start_link(
    handler: {EchoHandler, []},
    deliver: fn route, rendered ->
      Raxol.Gateway.Adapter.InMemory.send_message(conn, route, rendered)
    end
  )

route = Raxol.Gateway.Route.new(%{platform: :in_memory, chat_type: :dm, chat_id: "42"})
:ok = Raxol.Gateway.SessionRouter.route(Raxol.Gateway.SessionRouter, route, %{text: "hi"})
# => receives {:gateway_sent, route, "echo: hi"}
```

## Status

The gateway core (adapter contract, routing, sessions, pairing, delivery, handoff) is
complete, the adapter contract is frozen, `Handler.Agent` (agent-backed conversations)
and `Handler.Lifecycle` (a full TEA app per chat under `environment: :gateway`) ship,
and three platforms sit behind the frozen contract: Telegram
(`Raxol.Telegram.GatewayAdapter` + `Raxol.Telegram.UpdatePoller`; text and voice notes -
keyboards, callbacks, and other media are still the TEA surface's domain), Discord
(`Raxol.Gateway.Adapter.Discord` + its `GatewaySocket`; text-only this slice), and
Email (`Raxol.Gateway.Adapter.Email`; bidirectional SMTP send + RFC822 inbound via
`Email.Inbox` + `Email.ThreadStore`, with the mailbox transport injected). Voice notes
transcribe through `Raxol.Gateway.Pipeline.Transcribe`. Still deployment-supplied: the
concrete inbound-email transport (IMAP/POP/Gmail).
Any module satisfying the `Handler` callbacks works alongside the shipped handlers. See
`docs/adr/0023-unified-messaging-gateway.md`.

## See also

- [Telegram](TELEGRAM.md), [Watch](WATCH.md), [Speech](SPEECH.md): the existing per-surface
  bridges the gateway generalizes.
- [Agent Framework](AGENT_FRAMEWORK.md): the Conversation log that carries per-chat history.


<!-- docs/features/MCP.md -->

# MCP as a Rendering Target

MCP is a rendering target alongside terminal, browser, and SSH. The Component tree is the source of truth, and MCP tools and resources are projections of it, so the same running module serves a human and an agent without either seeing a different truth. See [ADR-0012](https://github.com/DROOdotFOO/raxol/blob/master/docs/adr/0012-mcp-as-rendering-target.md) for the design rationale.

## Quick start

```bash
mix mcp.server
```

This starts an MCP server on stdio, with tools auto-derived from your app's Component tree. Wire it into Claude Code or any MCP client.

```elixir
# In your app
defmodule MyApp do
  use Raxol.Core.Runtime.Application
  # ... your normal init/update/view ...
end

# In an MCP client
session = Raxol.MCP.Test.start_session(MyApp)
session
|> type_into("search", "elixir")
|> click("submit")
|> assert_component("results", fn c -> c[:content] != nil end)
```

The agent sees a structured Component tree, not a flat screenshot. It picks the action it wants from a typed schema.

## Tool derivation

Each interactive Component implements `Raxol.MCP.ToolProvider`. The protocol exposes semantic actions per Component:

| Component    | Actions                                       |
| ------------ | --------------------------------------------- |
| `Button`     | `click`                                       |
| `TextInput`  | `type_into`, `clear`, `get_value`             |
| `SelectList` | `select`, `get_selected`, `get_options`       |
| `Checkbox`   | `toggle`, `get_checked`                       |
| `Modal`      | `confirm`, `dismiss`                           |
| `Table`      | `select_row`, `sort`, `get_rows`              |
| `Tree`       | `expand`, `collapse`, `select_node`           |

Add `@mcp_exclude true` to a Component's attrs to suppress tool derivation, useful for internal scaffolding Components that shouldn't show up in the agent's action menu.

## Focus lens

A Component tree with 50 Components generates 100+ tools. That's too many for an LLM to reason about. The focus lens filters to ~15 tools per interaction based on:

- Current focused Component
- Mouse hover (in `:hover` focus mode)
- Modal stack (modals shadow background Components)
- Recently interacted-with Components

```elixir
tools =
  Raxol.MCP.FocusLens.filter(all_tools,
    mode: :focused,
    focused_id: "search_input",
    max_tools: 15
  )

length(tools) # ~15, not ~100
```

The lens is attention-aware: agents see what a human would see, not a flat dump of every possible action.

## Resources

Model state is exposed as MCP resources via projections declared on the app:

```elixir
defmodule MyApp do
  use Raxol.Core.Runtime.Application

  @mcp_resource "myapp://state/cart"
  def project_cart(model), do: %{items: model.cart, total: cart_total(model)}
end
```

The MCP client can read `myapp://state/cart` to inspect what the agent is working with. Updates stream as diffs through `Raxol.MCP.Diff`, so the agent doesn't need to re-fetch the full state every turn.

## Test harness

`Raxol.MCP.Test` is a pipe-friendly test harness:

```elixir
import Raxol.MCP.Test
import Raxol.MCP.Test.Assertions

test "submit flow" do
  session = start_session(MyApp)

  session
  |> type_into("email", "user@example.com")
  |> type_into("password", "secret")
  |> click("submit")
  |> assert_component("status", fn c -> c.content == "Logged in" end)
  |> stop_session()
end
```

The harness goes through the same MCP transport as a real client, so what your tests exercise is what an agent will hit.

## Context tree

`Raxol.MCP.ContextTree` assembles a unified view of state from:

- TEA model
- Component tree (with focus lens applied)
- Active agents (`Raxol.Agent.Registry`)
- Swarm state (when distributed)
- Pending notifications

The tree is streamed as diffs over the MCP connection, so agents track changes incrementally rather than polling.

## Property tests

`Raxol.MCP.ToolProvider` is functor-law-tested: tool derivation commutes with Component composition. If you compose two Components, the derived tools are the same as the tools you'd get by deriving them separately and merging. This catches bugs where a wrapping Component would accidentally hide tools from a child.

## See also

- [ADR-0012](https://github.com/DROOdotFOO/raxol/blob/master/docs/adr/0012-mcp-as-rendering-target.md): design rationale
- [Agent Framework](AGENT_FRAMEWORK.md): agents that consume MCP
- [Symphony](SYMPHONY.md): orchestrator that exposes its own MCP surface


<!-- docs/features/MEMORY.md -->

# Memory

Three opt-in layers on one provider contract: a stack that composes several memory providers
at once, full-text recall over raw conversation history, and a dialectic user model derived
in the background. All of it composes through the single `Raxol.Agent.Memory` behaviour, so
the rest of the framework sees one provider that happens to be a composite.

The default store is pure Elixir with no SQLite and no NIF: a BM25-lite inverted index in
concurrent-read ETS, with the durable records mirrored to DETS and every secondary index
rebuilt on boot.

## The provider contract

`Raxol.Agent.Memory` is the behaviour every provider implements:

| Callback | Purpose | Required? |
|----------|---------|:---:|
| `search/2` | Recall curated records for a query | Yes |
| `store/2` | Persist a record | Yes |
| `forget/2` | Delete a record by id | Yes |
| `prefetch/2` | Records to prime the turn (defaults to `search`) | No |
| `build_system_prompt/1` | System-prompt memory block | No |
| `build_user_context/1` | Per-user block, injected into the last user message | No |

`build_user_context/1` is injected into the **last user message** rather than the system
prompt, on purpose: refreshing it per turn does not invalidate the cacheable system prefix.

## Enabling it

Two callbacks, singular or stacked (default off):

```elixir
defmodule MyAgent do
  use Raxol.Agent

  # Stack the built-in ETS store alongside an external semantic provider.
  # A non-empty memory_providers/0 takes precedence over memory_provider/0.
  def memory_providers do
    [Raxol.Agent.Memory.Store.Ets, {MyApp.SemanticMemory, index: "prod"}]
  end
end
```

Setting either callback auto-exposes the `memory_remember` / `memory_recall` / `memory_forget`
tools. The user model and session search are caller-supplied instances, wired through
[`Raxol.Agent.Turn`](AGENT_FRAMEWORK.md#turn-driver):

```elixir
Raxol.Agent.Turn.run(MyAgent, prompt,
  backend: MyBackend,
  log: log_server,
  conversation_id: cid,
  agent_id: "my-agent",
  user_id: "user-123",
  user_model: MyApp.UserModel,        # enables the user-context block + async refresh
  session_search: MyApp.SessionSearch # enables the session_search tool + post-turn indexing
)
```

## Provider stack

`Raxol.Agent.Memory.Stack` composes N providers behind the one contract. `store` and `forget`
fan out to every provider; `search` queries all of them, normalizes each provider's results
into a `[0, 1]` rank (its top hit is 1.0, scaling down by position), merges, dedups by
content, and takes the limit. A provider that raises or exits is caught and degrades to no
results rather than breaking the stack.

Where Hermes keeps its built-in layer plus exactly one external provider, the stack composes
the built-in store with as many external providers as you configure, and re-ranks across all
of them.

## Session search

`Raxol.Agent.Memory.SessionSearch` answers "what did we say about X three sessions ago." It
is a full-text inverted index over raw conversation-item text (BM25-lite, k1 1.2 / b 0.75, no
recency or tag weighting), distinct from semantic memory:

- `attach(server, log, conversation_id)` subscribes to a [Conversation
  Log](AGENT_FRAMEWORK.md#conversation-item-log), indexes the snapshot, then indexes every
  appended item live.
- The `session_search` tool returns **raw** matching items (id, conversation, seq, type,
  content), not summaries. Summarization is a provider concern, not a search concern.
- The default backend is the ETS index. A `SessionSearch.Sqlite` FTS5 adapter is available
  for deployments that outgrow it.

This contrasts with a single-process embedded database file: the index lives in
concurrent-read ETS and is fed live by a pub/sub subscription to the conversation log.

## Dialectic user model

`Raxol.Agent.UserModel` is a native, OTP-shaped version of dialectic user modeling: a derived
representation of the user (preferences, goals, habits) keyed by `user_id`, reasoned out on a
cheap auxiliary model.

- `refresh_async/4` derives in a spawned `Task` off the GenServer, so the foreground turn
  never blocks on the model call. `refresh/4` is the synchronous variant for explicit use.
  The Turn driver refreshes asynchronously after each turn.
- `build_user_context/1` returns the derived block, which `Memory.Manager` appends to the
  last user message. Keeping it out of the system prompt preserves the cacheable prefix while
  still refreshing per turn.

This is a deliberate improvement over injecting the dialectic into the system prompt (which
forces a frozen snapshot to keep the cache warm): here the user block refreshes every turn
and the cached prefix stays intact.

## Recall ranking

The default `Raxol.Agent.Memory.Store.Ets` scores `relevance + recency + tag_bonus`:

- **relevance**: length-normalized BM25-lite (k1 1.2, b 0.75).
- **recency**: `0.3 * exp(-age_days / 30)`.
- **tag_bonus**: `0.5 * |record.tags intersect query_tags|`.

An empty query degrades to most-recent-N. Every secondary index (tokens, tags, agent, doc
frequencies) is rebuilt from the durable records on open, so no stale index can survive a
restart.

## Tools

| Tool | Input | Returns |
|------|-------|---------|
| `memory_remember` | `content`, `type`, `tags` | `id`, `stored` |
| `memory_recall` | `query`, `limit` | curated record summaries |
| `memory_forget` | `id` | `forgotten: true` |
| `session_search` | `query`, `limit`, `conversation_id` | raw conversation items |

`memory_recall` returns curated facts the agent chose to keep; `session_search` returns raw
past messages. They are different questions with different answers.

## See also

- [Self-Improvement](SELF_IMPROVEMENT.md): the after-turn reviewer that writes facts into
  memory.
- [Agent Framework](AGENT_FRAMEWORK.md): the Turn driver and the Conversation item-log that
  session search indexes.


<!-- docs/features/PLUGIN_SDK.md -->

# Plugin SDK

`raxol_plugin` is the developer-facing SDK over the 40-module plugin system in `raxol_core`. If you're writing a plugin, this is the package you depend on. If you're consuming plugins from your app, you don't need it; the runtime is in `raxol_core`.

## Quick start

```elixir
defmodule MyPlugin do
  use Raxol.Plugin

  @impl true
  def init(_config) do
    {:ok, %{counter: 0}}
  end

  @impl true
  def filter_event({:key, %{key: :tab}} = event, _state) do
    {:ok, event}
  end

  @impl true
  def handle_command(:bump, _args, state) do
    {:ok, %{state | counter: state.counter + 1}, :ok}
  end
end
```

`use Raxol.Plugin` sets the behaviour and provides six overridable defaults (`terminate/2`, `enable/1`, `disable/1`, `filter_event/2`, `handle_command/3`, `get_commands/0`) so you only implement what you need; only `init/1` is required. The `use` line takes no options. To declare plugin metadata (name, version, dependencies), build a `Raxol.Plugin.Manifest` (see [Manifests](#manifests)).

## Generator

```bash
mix raxol.gen.plugin my_plugin
```

Generates a plugin module at `lib/my_plugin.ex` (the module name is underscored into the path) and a matching `test/my_plugin_test.exs` with a lifecycle smoke test. Pass a dotted name like `MyApp.Plugins.Logger` to nest the generated files.

## API Facade

`Raxol.Plugin.API` wraps `Raxol.Core.Runtime.Plugins.PluginManager` with try/catch guards. Use it instead of calling the manager directly:

```elixir
:ok = Raxol.Plugin.API.load(MyPlugin, %{some: "config"})
:ok = Raxol.Plugin.API.enable(:my_plugin)
state = Raxol.Plugin.API.get_state(:my_plugin)
:ok = Raxol.Plugin.API.disable(:my_plugin)
Raxol.Plugin.API.unload(:my_plugin)
```

`load/2` takes a module atom and a config map; the other functions take the plugin id (an atom). If the plugin crashes or doesn't exist, the API returns `{:error, reason}` rather than letting the exit propagate.

## Manifests

`Raxol.Plugin.Manifest` builds a plain manifest map (not a struct, for cross-package safety) from keyword options and validates it. Use it to declare a plugin's identity, version, and dependencies:

```elixir
manifest =
  Raxol.Plugin.Manifest.new(
    id: :my_plugin,
    name: "My Plugin",
    version: "0.1.0",
    module: MyPlugin,
    depends_on: [{:other_plugin, "~> 1.0"}]
  )

case Raxol.Plugin.Manifest.validate(manifest) do
  :ok -> :ok
  {:error, errors} -> IO.inspect(errors)
end
```

## Testing

`Raxol.Plugin.Testing` provides ExUnit helpers:

```elixir
defmodule MyPluginTest do
  use ExUnit.Case
  import Raxol.Plugin.Testing

  setup do
    {:ok, state} = setup_plugin(MyPlugin, %{})
    {:ok, state: state}
  end

  test "passes the tab key through", %{state: state} do
    assert_handles_event(MyPlugin, {:key, %{key: :tab}}, state)
  end

  test "bump increments the counter", %{state: state} do
    {new_state, :ok} = assert_handles_command(MyPlugin, :bump, [], state)
    assert new_state.counter == 1
  end
end
```

The helpers exercise plugin callbacks in isolation without starting the full plugin manager, so there is no process to tear down: `setup_plugin/2` calls `init/1`, and `assert_handles_event/3` / `assert_handles_command/4` / `simulate_lifecycle/2` / `assert_halts_event/3` drive the other callbacks directly.

## What's in `raxol_core`

The 40-module runtime (plugin manager, dependency resolver, lifecycle, capability detector, security audit, permission mode, ETS cache, etc.) lives in `raxol_core`. You generally don't touch it directly; the SDK is the contract.

The split exists so apps that *use* plugins don't need to depend on the SDK that *creates* them.

## See also

- [GUIDE](https://github.com/DROOdotFOO/raxol/blob/master/docs/plugins/GUIDE.md): step-by-step plugin authoring
- [PLUGIN_TEMPLATES](https://github.com/DROOdotFOO/raxol/blob/master/docs/plugins/PLUGIN_TEMPLATES.md): ready-made starters
- [TESTING](https://github.com/DROOdotFOO/raxol/blob/master/docs/plugins/TESTING.md): in-depth testing patterns


<!-- docs/features/RECORDING_REPLAY.md -->

# Recording & Replay

Record terminal sessions to asciinema v2 `.cast` files. Play them back with pause, seek, and speed controls. If your app crashes mid-recording, the session auto-saves so you can see what happened.

## Quick start

```bash
mix raxol.record my_session.cast
mix raxol.replay my_session.cast
```

## Recorder

`Raxol.Recording.Recorder` captures output and input events with timestamps. The rendering engine and dispatcher call into it automatically while it's running.

```elixir
{:ok, _} = Raxol.Recording.Recorder.start_link()

# These are typically called by the framework, not by you:
Raxol.Recording.Recorder.record_output(data)
Raxol.Recording.Recorder.record_input(data)

Raxol.Recording.Recorder.active?()  # => true

session = Raxol.Recording.Recorder.get_session()  # peek without stopping
session = Raxol.Recording.Recorder.stop()          # stop and get the session
```

## Player

`Raxol.Recording.Player` replays a `.cast` file or session struct:

```elixir
Raxol.Recording.Player.play("my_session.cast", speed: 2.0, max_delay: 5.0)
Raxol.Recording.Player.play(session, interactive: true)
```

Keyboard controls during playback:

| Key         | Action                          |
| ----------- | ------------------------------- |
| `space`     | Pause / resume                  |
| `+` / `=`   | Speed up (1x -> 2x -> 4x -> 8x) |
| `-`         | Slow down                       |
| `>` / `.`   | Skip forward 5s                 |
| `<` / `,`   | Skip backward 5s                |
| `0`-`9`     | Jump to 0%-90%                  |
| `q` / `ESC` | Quit                            |

Options: `:speed` (default 1.0), `:max_delay` (default 5.0s cap between events), `:interactive` (default true).

## Asciicast v2 Format

`Raxol.Recording.Asciicast` reads and writes the standard asciinema v2 format:

```elixir
alias Raxol.Recording.Asciicast

Asciicast.write!(session, "output.cast")

{:ok, session} = Asciicast.read("output.cast")
session = Asciicast.read!("output.cast")

# String encode/decode
cast_string = Asciicast.encode(session)
session = Asciicast.decode(cast_string)
```

The format is a JSON header followed by newline-delimited event arrays:

```
{"version": 2, "width": 80, "height": 24, "timestamp": 1234567890}
[0.5, "o", "Hello"]
[1.2, "o", " World\r\n"]
```

Upload `.cast` files to [asciinema.org](https://asciinema.org) to share them.

## Programmatic usage

```elixir
{:ok, _} = Raxol.Recording.Recorder.start_link()

# ... run your app ...

session = Raxol.Recording.Recorder.stop()
Raxol.Recording.Asciicast.write!(session, "debug_session.cast")

Raxol.Recording.Player.play("debug_session.cast")
```

On crash, the current session is saved automatically. No explicit stop needed for post-mortem.


<!-- docs/features/REPL.md -->

# REPL

Interactive Elixir REPL with AST-based sandboxing. Three safety levels: wide open for local use, locked down for SSH. Bindings persist between evaluations, IO gets captured, and runaway code hits a timeout.

## Quick start

```bash
mix raxol.repl
mix raxol.repl --sandbox standard
mix raxol.repl --sandbox strict
mix raxol.repl --timeout 10000
```

## Evaluator

`Raxol.REPL.Evaluator` is a functional wrapper around `Code.eval_string`. It spawns evaluation in a monitored process with a timeout, swaps the group leader to capture IO, and carries bindings forward between calls.

```elixir
alias Raxol.REPL.Evaluator

evaluator = Evaluator.new()

{:ok, result, evaluator} = Evaluator.eval(evaluator, "x = 1 + 2")
result.value      # => 3
result.output     # => "" (captured IO, empty here)
result.formatted  # => "3"

# Bindings carry over
{:ok, result, evaluator} = Evaluator.eval(evaluator, "x * 10")
result.value      # => 30

# IO gets captured
{:ok, result, _} = Evaluator.eval(evaluator, ~s[IO.puts("hello")])
result.output     # => "hello\n"

# Runaway code times out (default 5000ms)
{:error, "Evaluation timed out", evaluator} =
  Evaluator.eval(evaluator, "Process.sleep(:infinity)", timeout: 1000)

Evaluator.bindings(evaluator)  # => [x: 3]
Evaluator.history(evaluator)   # => [{"x * 10", result}, ...]

evaluator = Evaluator.reset_bindings(evaluator)  # clears bindings, keeps history
evaluator = Evaluator.clear_history(evaluator)    # clears history, keeps bindings
```

## Sandbox levels

`Raxol.REPL.Sandbox` walks the AST with `Macro.prewalk` and rejects code that calls blocked modules or functions, before it ever runs.

```elixir
alias Raxol.REPL.Sandbox

Sandbox.check("Enum.map([1,2,3], & &1 * 2)", :standard)  # => :ok
Sandbox.check("System.cmd(\"rm\", [\"-rf\", \"/\"])", :standard)  # => {:error, ["..."]}
```

| Level | What it does | When to use it |
|-------|-------------|----------------|
| `:none` | Allows everything | Local terminal, you trust the user |
| `:standard` | Blocks known-dangerous calls | Default for interactive use |
| `:strict` | Whitelist-only | SSH, web, untrusted input |

**Standard** blocks: `System.cmd`, `System.shell`, `File.rm`, `File.rm_rf`, `File.write`, `Port.open`, `Code.eval_string`, `Code.eval_quoted`, `:os.cmd`, and friends.

**Strict** only allows: `Enum`, `Stream`, `Map`, `Keyword`, `List`, `Tuple`, `MapSet`, `String`, `Integer`, `Float`, `Atom`, `IO`, `Kernel`, `Range`, `Regex`, `Date`, `Time`, `DateTime`, `NaiveDateTime`, `Calendar`, `Access`, `Base`, `URI`, `Jason`, `Inspect`. Everything else gets rejected.

## Over SSH

The playground serves a REPL demo over SSH:

```bash
mix raxol.playground --ssh
```

Use `:strict` sandbox for anything exposed to the network.

## Playground demo

The REPL is one of the playground demos (`mix raxol.playground` -> REPL). It has input history (up/down), formatted output, a bindings panel, and shows the active sandbox level.


<!-- docs/features/SELF_IMPROVEMENT.md -->

# Self-Improvement

An agent that gets more capable the longer it runs. After a successful turn, a background
reviewer studies what the agent did on a cheap auxiliary model and writes durable takeaways:
facts appended to memory and reusable skills authored as `SKILL.md` files. A Curator then
ages those agent-authored skills over time, keeping the useful ones and archiving the stale
ones, with tar.gz backup and rollback.

The whole loop is OTP-shaped: the reviewer runs in an unlinked `Task` (a crash is logged,
never propagated to the turn), the skills index is a supervised GenServer backed by on-disk
`SKILL.md` files plus DETS telemetry, and every Curator pass is reversible. There is no
single-process store of record to lose.

Skills interoperate with the [agentskills.io](https://agentskills.io) `SKILL.md` format, so
an agent's authored skills sit alongside the ones under `~/.agents/skills`.

## Enabling it

Two `use Raxol.Agent` callbacks turn it on, both opt-in (default off):

```elixir
defmodule MyAgent do
  use Raxol.Agent

  # Procedural memory: exposes skills_list / skill_view / skill_manage as tools.
  def skills_provider, do: Raxol.Agent.Skills.Store

  # After-turn self-improvement on a cheap auxiliary model.
  def self_improve do
    %{enabled: true, model: "claude-haiku-4-5", min_tool_calls: 5}
  end
end
```

Bring up the store and Curator under supervision through app config:

```elixir
config :raxol_agent,
  skills_provider: Raxol.Agent.Skills.Store,
  skills_root: "~/.raxol/skills",
  curator: [skills: {Raxol.Agent.Skills.Store, []}]   # keyword list including :skills
```

The self-improvement side effect fires automatically when a turn is driven through
[`Raxol.Agent.Turn`](AGENT_FRAMEWORK.md#turn-driver). A runtime that drives `Stream.react/2`
directly must call `SelfImprove.after_turn/3` itself; it is a seam, not magic.

## The after-turn loop

`Raxol.Agent.SelfImprove` reviews a completed turn and appends what it learned.

- `after_turn(items, writers, config)` gates on success and `min_tool_calls` (default 5),
  then spawns an unlinked review `Task`. Returns `:spawned` or `:skipped`.
- The reviewer formats the turn, calls the auxiliary model, parses a
  `{memories, skills}` result (tolerating code-fenced JSON), then writes each memory to the
  configured memory provider and each skill to the store tagged `created_by: :agent`.
- The reviewer can only append to memory and the skill store. It never calls `update/2`,
  never touches the live conversation, and its crash is caught and logged.

With no `:backend`/`:model` set and no auxiliary slot configured, review routes through
`Raxol.Agent.Auxiliary` and degrades to the Mock backend (a no-op). See
[auxiliary-model routing](AGENT_FRAMEWORK.md).

## Skills

`Raxol.Agent.Skill` parses and renders the `SKILL.md` format: YAML frontmatter plus a
markdown body. Modeled frontmatter keys are `name` (required), `description`, `version`,
`category`, and `created_by`; any other keys are preserved under `metadata`, so a round trip
never drops a third-party field. Nothing on the wire is turned into an atom.

`Raxol.Agent.Skills.Store` is the warm index (a `BaseManager` GenServer):

- **Managed root** (writable, default `~/.raxol/skills`): skills the agent or user author.
- **External dirs** (read-only, default `~/.agents/skills`): shared skills, scanned for
  `**/SKILL.md`. A managed skill wins over an external one of the same name.
- **Telemetry** (persisted to DETS, replayed into ETS on boot): `use_count`, `view_count`,
  `last_used_at`, `created_at`, `state`, `pinned`. Skill content is re-read from disk every
  boot, so disk is the source of truth and stale content cannot outlive its file.
- A supporting-file read through `skill_view` is path-guarded: absolute paths and `..` are
  rejected, so a read cannot escape the skill directory.

Three tools let the LLM work with skills:

| Tool | Input | Returns |
|------|-------|---------|
| `skills_list` | none | skill metadata only (the cheap disclosure level) |
| `skill_view` | `name`, optional `path` | the `SKILL.md` body, or one supporting file |
| `skill_manage` | `action` (`create`/`patch`/`edit`/`delete`), `name`, fields | `ok`, `name` |

Foreground `skill_manage` creates are tagged `created_by: :user`; the background reviewer's
are tagged `created_by: :agent`. Only that provenance difference makes a skill eligible for
curation.

## Curator

`Raxol.Agent.Curator` ages agent-authored skills so the library does not accumulate cruft.

- **Lifecycle**: `active -> stale -> archived`, measured from a skill's last use. Defaults:
  stale after 30 idle days, archived after 90.
- **Curatable** means `created_by: :agent` and managed and not pinned. User-authored,
  external, and pinned skills are never aged or rewritten.
- **Gating**: a scheduled pass runs at most every 168 hours (7 days) and only after at least
  2 idle hours; the first pass is deferred a full interval. The runtime resets the idle
  clock with `note_activity/1`.
- **Backup and rollback**: before any non-dry-run pass, the Curator writes a compressed
  tarball of the skills root and keeps the newest 5. `rollback/0` restores the latest and
  reloads the store, so a bad aging pass is reversible.
- `run(dry_run: true)` and `plan/0` compute what a pass would do without touching anything.

Consolidation (model-driven merge of near-duplicate skills) is opt-in and not yet
implemented.

## What supervision buys

Three properties fall out of running the loop on OTP rather than in-process:

- The reviewer is an isolated, unlinked `Task`. A model error or parse failure is logged and
  the turn is unaffected.
- The durable substrate is on-disk `SKILL.md` files plus DETS telemetry, separated from the
  warm ETS index. A crash of the store or Curator loses no skill content and no telemetry,
  and the supervisor restarts the manager.
- Every Curator mutation is preceded by a backup and is reversible.

## See also

- [Memory](MEMORY.md): the recall layer the reviewer writes facts into.
- [Agent Framework](AGENT_FRAMEWORK.md): the Turn driver that wires self-improvement into a
  turn, and auxiliary-model routing.
- [Why Raxol](../WHY_RAXOL.md): how this loop compares to the Python agent stacks.


<!-- docs/features/SENSOR_FUSION.md -->

# Sensor Fusion

Poll hardware sensors, batch their readings, fuse them with weighted averaging, and render the results as gauges and sparklines. The whole pipeline is supervised, so feeds crash and reconnect independently.

## Quick start

```elixir
# 1. Implement a sensor
defmodule TempSensor do
  @behaviour Raxol.Sensor.Behaviour

  def connect(_opts), do: {:ok, %{}}
  def read(state) do
    reading = %Raxol.Sensor.Reading{
      sensor_id: :temp,
      timestamp: System.monotonic_time(:millisecond),
      values: %{temp: 42.0 + :rand.uniform() * 10},
      quality: 1.0
    }
    {:ok, reading, state}
  end
  def disconnect(_state), do: :ok
end

# 2. Start the supervisor and feed
{:ok, _} = Raxol.Sensor.Supervisor.start_link(
  fusion: [batch_window_ms: 100, thresholds: %{temp: %{temp: {:gt, 80}}}]
)
Raxol.Sensor.Supervisor.start_feed(sensor_id: :temp, module: TempSensor)

# 3. Subscribe to fused data
Raxol.Sensor.Fusion.subscribe()
# Receive: {:fused_update, %{sensors: %{temp: %{values: ..., alerts: ...}}, ...}}
```

## Implementing a sensor

Sensors implement `Raxol.Sensor.Behaviour`:

```elixir
@callback connect(opts :: keyword()) :: {:ok, state} | {:error, term()}
@callback read(state) :: {:ok, Reading.t(), state} | {:error, term()}
@callback disconnect(state) :: :ok

# Optional, defaults to 100ms
@callback sample_rate() :: pos_integer()
```

`read/1` returns a `Reading` struct each time it's polled:

```elixir
%Raxol.Sensor.Reading{
  sensor_id: :my_sensor,      # atom identifier
  timestamp: integer(),        # monotonic milliseconds
  values: %{key: number()},   # named measurements
  quality: 0.0..1.0,          # signal quality
  metadata: %{}                # optional extra data
}
```

## Feed API

`Raxol.Sensor.Feed` manages a single sensor: connecting, polling on a timer, buffering in a circular buffer, and forwarding readings to fusion.

```elixir
# Start a feed (usually via Raxol.Sensor.Supervisor.start_feed/2)
{:ok, pid} = Raxol.Sensor.Feed.start_link(
  sensor_id: :temp,
  module: TempSensor,
  sample_rate_ms: 100,       # poll interval
  buffer_size: 1000,          # circular buffer capacity
  max_errors: 10,             # errors before giving up
  connect_opts: []            # passed to sensor's connect/1
)

# Query feed state
{:ok, latest} = Raxol.Sensor.Feed.get_latest(pid)
history = Raxol.Sensor.Feed.get_history(pid, 20)
status = Raxol.Sensor.Feed.get_status(pid)  # :running | :connecting | :error | :stopped

# Force reconnection
Raxol.Sensor.Feed.reconnect(pid)
```

When started via `Raxol.Sensor.Supervisor.start_feed/2`, the feed automatically forwards readings to the Fusion process.

## Fusion API

`Raxol.Sensor.Fusion` collects readings from all feeds, batches them on a timer, and produces a fused state map:

```elixir
state = Raxol.Sensor.Fusion.get_fused_state()
# => %{
#   sensors: %{
#     temp: %{values: %{temp: 45.2}, quality: 0.95, reading_count: 42, alerts: []},
#     pressure: %{values: %{psi: 14.7}, quality: 1.0, reading_count: 38, alerts: []}
#   },
#   fused_at: 1234567890
# }

# Or subscribe for push updates
Raxol.Sensor.Fusion.subscribe()
# Receive: {:fused_update, fused_state}

# Manual feed registration
Raxol.Sensor.Fusion.register_feed(:temp, feed_pid)
Raxol.Sensor.Fusion.unregister_feed(:temp)
```

### Thresholds

Set thresholds when starting the supervisor. Crossed thresholds show up in the sensor's `alerts` list:

```elixir
Raxol.Sensor.Supervisor.start_link(
  fusion: [
    batch_window_ms: 100,
    thresholds: %{
      temp: %{temp: {:gt, 80}},      # alert when temp > 80
      pressure: %{psi: {:lt, 10}}     # alert when psi < 10
    }
  ]
)
```

## HUD Components

`Raxol.Sensor.HUD` has pure functions that turn sensor data into terminal cells. Each returns `[{x, y, char, fg, bg, attrs}]` tuples for the rendering pipeline.

Every function takes a `{x, y, width, height}` region as its first argument.

### Gauge

```elixir
cells = Raxol.Sensor.HUD.render_gauge(
  {0, 0, 30, 3},     # region
  75.0,               # current value
  label: "TEMP",
  min: 0.0,
  max: 100.0,
  thresholds: {0.6, 0.85}  # yellow at 60%, red at 85%
)
```

### Sparkline

```elixir
cells = Raxol.Sensor.HUD.render_sparkline(
  {0, 0, 40, 5},
  [42.0, 45.1, 43.2, 47.8, 44.0, 46.3],
  label: "CPU"
)
```

### Threat indicator

```elixir
cells = Raxol.Sensor.HUD.render_threat(
  {0, 0, 20, 5},
  :high,              # :none | :low | :medium | :high | :critical
  135.0,              # bearing in degrees
  label: "THREAT"
)
```

### Minimap

Braille-dot 2D map with normalized coordinates:

```elixir
cells = Raxol.Sensor.HUD.render_minimap(
  {0, 0, 20, 10},
  [
    %{x: 0.5, y: 0.5, char: "@"},   # 0.0-1.0 normalized
    %{x: 0.8, y: 0.2, char: "x"}
  ],
  border: true
)
```

## Supervision tree

`:rest_for_one` strategy:

```
Raxol.Sensor.Supervisor
  |-- Registry (name lookup for feeds)
  |-- DynamicSupervisor (hosts Feed processes)
  |-- Fusion (batches readings from all feeds)
```

Fusion restarts fresh on crash. Feeds are independent of each other.

## Example

`examples/subsystems/sensor_hud_demo.exs` has 3 mock sensors wired to gauge, sparkline, and threat Components:

```bash
mix run examples/subsystems/sensor_hud_demo.exs
```


<!-- docs/features/SPEECH.md -->

# Speech Surface

`raxol_speech` gives a Raxol app a voice and an ear. TTS announces accessibility events; STT turns spoken words into key/paste events. Both go through the same surface, so a single app can be driven by keyboard, mouse, or voice without app-level changes.

## TTS

```elixir
Raxol.Speech.Speaker.speak("Document saved")
Raxol.Speech.Speaker.stop_speaking()
```

The `Speaker` GenServer subscribes to Accessibility announcements at startup. Anything the framework announces (focus changes, validation errors, status updates) gets spoken automatically if `Speaker` is in the supervision tree. High-priority announcements interrupt current speech.

Backends behind `Raxol.Speech.TTS.Backend`:

- `OsSay`: macOS `say`, Linux `espeak`. Sanitizes input (strips control chars, caps at 10KB).
- `Noop`: swallows speech. Default in test and CI.

Pick a backend when you start the supervisor:

```elixir
# in your supervision tree
{Raxol.Speech.Supervisor, tts_backend: Raxol.Speech.TTS.OsSay}
```

## STT

Push-to-talk: `start_recording/0` opens the mic, `stop_recording/0` closes it and runs transcription.

```elixir
:ok = Raxol.Speech.Listener.start_recording()
# ... user speaks ...
{:ok, "open file readme"} = Raxol.Speech.Listener.stop_recording()
```

`Listener` captures from the mic via a `sox` Port, bounded by `max_duration_ms` and `max_bytes` (configured at `start_link/1` time, defaults 5 min / 10 MB). `Recognizer` runs Whisper through Bumblebee in a background Task. The two are wired `:rest_for_one`: if Recognizer crashes, Listener restarts with it.

Optional deps: `bumblebee`, `nx`, `exla`. Without them, `Recognizer.recognize/1` returns `{:error, :bumblebee_not_available}`.

## Voice commands

`InputAdapter` maps transcribed phrases to Raxol events. 20 phrases ship by default (`InputAdapter.default_commands/0` has the full list):

| Phrase                         | Event                                |
| ------------------------------ | ------------------------------------ |
| "tab" / "next"                 | Tab                                  |
| "previous"                     | Shift+Tab                            |
| "enter"                        | Enter                                |
| "escape"                       | Escape                               |
| "backspace"                    | Backspace                            |
| "up" / "down" / "left" / "right" | Arrow keys                         |
| "page up" / "page down"        | Page Up / Page Down                  |
| "scroll up" / "scroll down"    | `k` / `j`                            |
| "space"                        | Space char                           |
| "yes" / "no" / "help"          | `y` / `n` / `h`                      |
| "quit" / "exit"                | `q`                                  |

Any phrase that is not a recognized command falls through to a `:paste` event with the original text as payload, so dictating prose injects it verbatim.

Custom commands merge with the defaults via the `:commands` option:

```elixir
Raxol.Speech.InputAdapter.translate(text,
  commands: %{
    "save" => {:key, %{key: :char, char: "s", modifiers: [:ctrl]}},
    "new tab" => {:key, %{key: :char, char: "t", modifiers: [:ctrl]}}
  }
)
```

## Security

`Listener` validates `record_command` against an allowlist before spawning the Port. Don't expose this surface to untrusted networks; the threat model assumes a trusted local user holding a microphone.

## See also

- [Watch](WATCH.md): the other accessibility-aware surface (push notifications)
- `Raxol.Core.Accessibility`: the announcement source Speaker subscribes to


<!-- docs/features/SYMPHONY.md -->

# Symphony

`raxol_symphony` orchestrates coding agents against a ticket tracker. It's an Elixir/OTP port of OpenAI's [Symphony](https://github.com/openai/symphony). The orchestrator polls a tracker, claims eligible issues, isolates each one in a per-issue workspace, and runs a coding agent until the workflow hits a terminal state.

Status: pre-alpha. Not yet on Hex; use the path dep at `packages/raxol_symphony/`.

## Quick start

```bash
mix raxol.symphony --workflow ./WORKFLOW.md
```

`WORKFLOW.md` defines tracker source, eligibility rules, retry policy, and per-issue runner config. It's hot-reloaded via `file_system`, so editing it doesn't restart the orchestrator. Last-known-good is served if a save leaves the file in an invalid state.

## Architecture

```
Tracker (Memory | Linear | GitHub Issues)
    |
    v
Orchestrator (BaseManager GenServer)
    |-- polls tracker, claims eligible issues
    |-- per-issue workspace under config.workspace.root
    |
    v
Runner (RaxolAgent | Codex)          PubSub
    |                                   |
    v                                   v
Coding agent run                    Six surfaces
                                    (terminal, LiveView,
                                     MCP, Telegram, Watch, JSON API)
```

## Runners

| Runner       | What it wraps                | Notes                                     |
| ------------ | ---------------------------- | ----------------------------------------- |
| `RaxolAgent` | `Raxol.Agent.Stream`         | Default. Same stack as `raxol_agent`.     |
| `Codex`      | `codex app-server` via Port  | JSON-RPC 2.0 over stdio. Three-step handshake (`initialize` -> `initialized` -> `thread/start`), per-turn `turn/start` cycles. |

Pick a runner per workflow. Mix-and-match isn't supported in a single run.

### Codex authentication

The Codex runner spawns the externally-authenticated `codex` CLI and does not
drive its interactive/OAuth sign-in (that stays out-of-band via `codex login`).
It only *selects and verifies* the credential the CLI already holds, via an
optional `codex.auth` block:

```
codex:
  command: "codex app-server"
  auth:
    mode: inherit        # inherit (default) | api_key | codex_home
    api_key_env: OPENAI_API_KEY   # env var *name* to read the key from (api_key mode)
    codex_home: ~/.codex          # CODEX_HOME to inject (codex_home mode)
    require_login: false          # hard-fail preflight when unauthenticated
```

Config stores only references (an env var name, a path), never the secret; the
key value is read from the environment at spawn and injected into the child
process. `mode: inherit` (the default) injects nothing and preserves the
ambient-env behavior. `require_login: true` makes an unauthenticated spawn fail
preflight with `{:error, :codex_unauthenticated}` instead of stalling mid-turn.
Each spawn emits `[:raxol, :symphony, :codex, :auth]` telemetry
(`%{mode, authenticated?, source}`, never the secret).

## Surfaces

Every surface subscribes to the same orchestrator snapshot via Phoenix.PubSub, so they stay consistent without per-surface state:

- **Terminal**: TEA dashboard listing active runs and their state.
- **LiveView**: `/symphony` mounts the same dashboard in the browser.
- **MCP**: 7 tools (`list_runs`, `get_run`, `resume_run`, etc) plus `symphony://runs` as an MCP resource.
- **Telegram**: per-issue session, inline keyboards, approval prompts.
- **Watch**: debounced push to APNS/FCM, tap-to-approve actions.
- **JSON API**: `GET /api/v1/state`, `GET /api/v1/runs/:issue_id`, `POST /api/v1/refresh`, `POST /api/v1/runs/:issue_id/stop`.

## Evidence collection

`Raxol.Symphony.Evidence.collect/3` runs per dispatch. It pulls:

- GitHub CI status and PR comments via the GitHub API
- Code complexity via `cloc` (falls back to SLOC if `cloc` isn't installed)
- Asciinema `.cast` recording of the agent's terminal session

Set `recording.enabled: true` in the workflow to capture casts. The `Evidence.Capture` GenServer writes one `.cast` per run under `evidence.dir`.

## Retry behaviour

Three retry classes, configured per workflow:

| Class        | Trigger                         | Backoff                              |
| ------------ | ------------------------------- | ------------------------------------ |
| Continuation | Agent yields, expects re-prompt | Fixed 1s                             |
| Failure      | Run exits with error            | Exponential, `10s * 2^n`, capped     |
| Stall        | No output for `read_timeout_ms` | Restart from snapshot, no backoff    |

`turn_timeout_ms` bounds each individual turn; exceeding it bumps the stall counter.

## Configuration

`WORKFLOW.md` is parsed into `Raxol.Symphony.Workflow` at load time. Sample shape:

```yaml
---
tracker:
  type: github
  owner: example
  repo: thing
  labels: [agent-eligible]

workspace:
  root: ./symphony-workspaces

runner:
  type: raxol_agent
  read_timeout_ms: 120000
  turn_timeout_ms: 300000

recording:
  enabled: true
  dir: ./evidence

retry:
  failure_cap_ms: 600000
---
```

## See also

- [Agent Framework](AGENT_FRAMEWORK.md): the runtime each agent runs in
- [MCP](MCP.md): how the orchestrator's MCP surface is derived


<!-- docs/features/TELEGRAM.md -->

# Telegram Surface

`raxol_telegram` runs a TEA app as a Telegram bot. Each chat gets a session with its own TEA model; inline keyboards become Button Components; HTML `<pre>` blocks render the buffer.

## Quick start

```elixir
# Add the supervisor with your TEA app module
children = [
  {Raxol.Telegram.Supervisor, app_module: MyApp.CounterApp}
]
```

Wire your Telegex polling/webhook handler into `Bot.handle_update/2`:

```elixir
defmodule MyApp.TelegramHandler do
  use Telegex.Polling.GenHandler

  @impl true
  def on_update(update) do
    Raxol.Telegram.Bot.handle_update(update, allowed_chat_ids: [123456789])
  end
end
```

The Telegex polling handler uses Telegex's own config (`config :telegex, token: ...`). The Bot API 10.x senders (`Raxol.Telegram.HTTP`) also take the token from a `bot_token:` option or `config :raxol_telegram, bot_token: ...`. `allowed_chat_ids` is passed at the `handle_update/2` call site.

Send a message to the bot from an allowed chat and the router spawns a `Session` for that chat. The session hosts a Lifecycle with `environment: :telegram`.

## Access control

`allowed_chat_ids` is optional. If set, the `Bot` update handler drops messages from other chats before they reach the router. Leave it out to accept all chats, not recommended unless the bot is public-facing.

## Session lifecycle

`SessionRouter` keeps a per-chat session map, capped at 1000 entries. New chats get a new session; existing chats route to the running one. Sessions idle out after 10 minutes of no traffic. A 5s cooldown between session creations rate-limits accidental floods.

| Event                  | What happens                                                              |
| ---------------------- | ------------------------------------------------------------------------- |
| Single-char text       | `:key` event with `char: <c>`                                             |
| Multi-char text        | `:paste` event with the trimmed text                                      |
| Inline `key:<name>`    | `:key` event (special key like `:up`/`:enter`, or char for length-1 keys) |
| Inline `btn:<id>`      | `:click` event with `component_id: <id>`                                  |
| `/start`               | Session created (or routed to existing one)                               |
| `/stop`                | Session terminated                                                        |
| 10min silence          | Session terminates, model dropped                                         |

## Output

`OutputAdapter` takes the screen buffer and produces a Telegram message:

- Buffer -> HTML `<pre>` block (with monospace styling preserved)
- Interactive Components -> inline keyboard buttons in document order

Message edit dedup prevents redundant API calls when the rendered output doesn't change between updates.

## Security

`telegex` is an optional dep. Without it the surface compiles but does nothing, useful for environments where Telegram isn't wanted.

The bot token is the only secret. Don't commit it; load via `System.fetch_env!/1` at runtime.

## Bot API 10.x surface (2026-06)

Telegram's June 2026 release added rich-text messages, admin bots for `chat_join_request` updates, and poll hyperlinks. `raxol_telegram` 0.2 covers all three plus an MCP export path for the Guardian decisions.

These features ride on top of the per-chat Session model above. They use `Raxol.Telegram.HTTP` (shared raw `Req` transport with `post_fn` injection for tests) because Telegex 1.8 predates Bot API 10.1 and does not expose the new endpoints. The package adds `req ~> 0.5` as an optional dep; without it, the HTTP-bound modules return `{:error, :req_not_available}` and consumers can call `to_payload/3` themselves to get the JSON body.

### Rich messages (`Raxol.Telegram.RichMessage`)

Builders for Bot API 10.1's `sendRichMessage` family. Paragraph, heading (1-6), table + cell, list + list_item (ordered / unordered), details (collapsible "Show More"), math (block + inline), thinking. Inline formatting: bold, italic, underline, strikethrough, code, spoiler, subscript, superscript.

```elixir
import Raxol.Telegram.RichMessage

msg = rich_message([
  heading(1, "Build status"),
  paragraph([bold("master"), text(" is red")]),
  details([text("Show stacktrace")], [paragraph([code("UndefinedFunctionError")])]),
  table([
    [cell([bold("Module")]), cell([bold("Coverage")])],
    [cell([text("Bot")]),    cell([text("94%")])]
  ]),
  math(~S"\\int_0^1 x^2 dx = \\frac{1}{3}")
])

{:ok, _} = Raxol.Telegram.RichMessage.Sender.send(chat_id, msg)
```

`chunk/2` enforces the 32K hard cap (returns `{:error, :too_long}` rather than truncating) and the 8K Show More boundary (wraps the tail of long content in a `details` block). Sender telemetry: `[:raxol_telegram, :rich_message, :sent | :error]` with `chat_id`, `byte_size`, `chunked?`, `reason`.

### AI Guardian ([ADR-0014](https://github.com/DROOdotFOO/raxol/blob/master/docs/adr/0014-telegram-ai-guardian.md))

Behaviour for screening `chat_join_request` updates. Guardian runs outside the per-chat Session model: applicants are keyed by user, not chat, and the decision (approve / decline / hand off to a mini-app) happens at the Bot dispatch layer.

```elixir
defmodule MyApp.SpamFilter do
  @behaviour Raxol.Telegram.Guardian

  @impl true
  def screen(applicant) do
    cond do
      blocked?(applicant.user_id) -> {:decline, "user previously banned"}
      missing_bio?(applicant)     -> {:ask_mini_app, "https://verify.myapp.com", "Verify"}
      true                        -> {:approve, nil}
    end
  end
end

# In app env:
config :raxol_telegram, guardian: MyApp.SpamFilter
```

`Bot.handle_update/2` gains a new clause for `%{chat_join_request: _}`. The applicant payload is normalised by `InputAdapter.translate_join_request/1` (handles both atom-keyed and string-keyed maps). `Guardian.decide/2` invokes the configured module's `screen/1`; `Guardian.apply_decision/3` calls the Bot API.

Bot API path selection is automatic: when the applicant carries a `query_id` (Bot API 10.1+), `apply_decision/3` uses `answerChatJoinRequestQuery`; without `query_id`, it falls back to the pre-10.0 `approveChatJoinRequest` / `declineChatJoinRequest` pair. The 10.1 path auto-falls back on `bot_api_error` too (e.g. against an older API server).

For the `:ask_mini_app` path, `Raxol.Telegram.MiniApp.build_url/2` appends `chat_id`, `user_id`, and `query_id` as query parameters so the consumer-hosted mini-app backend has everything it needs to call `answerChatJoinRequestQuery` itself with the right context. The mini-app is not hosted by `raxol_telegram`.

Guardian telemetry: `[:raxol_telegram, :guardian, :received | :approved | :declined | :asked | :denied | :error]`. All carry `chat_id` and `user_id`; terminal events also carry `reason` (or `url` for `:asked`), `source` (`:bot` or `:mcp`), and `error_reason` for failures.

### MCP Exports

`Raxol.Telegram.Guardian.MCPTools.register/0` exposes four tools through `Raxol.MCP.Registry`. Symmetric with [ADR-0012](https://github.com/DROOdotFOO/raxol/blob/master/docs/adr/0012-mcp-as-rendering-target.md): an external agent can observe and override Guardian decisions over MCP without protocol glue.

| Tool | Purpose |
|------|---------|
| `telegram_guardian_approve` | Admit an applicant (10.1 or pre-10.0 path, same selection logic) |
| `telegram_guardian_decline` | Reject an applicant |
| `telegram_guardian_screen`  | Run the configured screener on a synthetic applicant without applying the decision |
| `telegram_guardian_list_pending` | Returns `[]` in v1; persistence lands in v2 |

Registration is opt-in and requires `raxol_mcp` at runtime; without it, `register/0` returns `{:error, :raxol_mcp_not_available}` and the rest of the package keeps working. No compile-time dep on `raxol_mcp`.

### Polls with hyperlinks (`Raxol.Telegram.Poll`)

`send_poll/4` accepts options as plain `String.t()`, `{:link, label, url}` tuples (entire text is one hyperlink), or `%{text: ..., entities: [...]}` maps for arbitrary entity layouts. `link_entity/3` builds a `text_link` entity at a specific UTF-16 offset.

```elixir
import Raxol.Telegram.Poll

send_poll(chat_id, "Which doc?",
  [
    "Plain text option",
    link_option("Read ADR-0014", "https://github.com/example/adr/0014"),
    %{text: "See source", entities: [link_entity(4, 6, "https://github.com/example")]}
  ],
  is_anonymous: false,
  allows_multiple_answers: true,
  bot_token: token
)
```

Option count is validated client-side (2-10); other constraints (text length, entity bounds) are left to the API.

### Self-hosted bot API server

For groups expecting >30 req/s during join floods (Telegram's public API rate cap), point `:api_base` at a [gramiojs/telegram-bot-api](https://github.com/gramiojs/telegram-bot-api) Docker image:

```elixir
Raxol.Telegram.RichMessage.Sender.send(chat_id, msg,
  bot_token: token,
  api_base: "https://bot-api.internal"
)
```

The same `:api_base` option works on `Raxol.Telegram.Poll.send_poll/4` and `Raxol.Telegram.Guardian.apply_decision/3` (shared `HTTP` transport).

## As a gateway adapter

`Raxol.Telegram.GatewayAdapter` puts Telegram behind the frozen
`Raxol.Gateway.Adapter` contract (requires the optional `raxol_gateway`
dependency): text messages normalize to the gateway's `%{text: binary}` event
shape, replies go out as plain-text `sendMessage` calls chunked at Telegram's
4096 UTF-16-code-unit limit. `Raxol.Telegram.UpdatePoller` is the matching
update feed: a supervised `getUpdates` long-poll loop with exponential backoff
and a sink-agnostic `:on_update` function, so it drives either the gateway
router or `Bot.handle_update/2`. Callbacks, keyboards, and media stay on the
TEA surface (this package's `Session`); the gateway path is text-first. See
[Gateway](GATEWAY.md) for the full wiring example.

## See also

- [Gateway](GATEWAY.md): multi-platform gateway; Telegram is the first adapter behind the frozen contract
- [Watch](WATCH.md): another push surface for mobile
- [Agent Framework](AGENT_FRAMEWORK.md): if your bot is an agent, use this stack
- [ADR-0014](https://github.com/DROOdotFOO/raxol/blob/master/docs/adr/0014-telegram-ai-guardian.md): full Guardian design rationale


<!-- docs/features/TIME_TRAVEL_DEBUGGING.md -->

# Time-Travel Debugging

Every `update/2` call gets snapshotted: the message, the model before, the model after. Step backwards and forwards through your app's history, diff any two points, restore old state into the live app. Disabled by default, zero overhead when off.

## Enabling

```elixir
Raxol.start_link(MyApp, time_travel: true)
```

That's it. The Dispatcher now records a snapshot after every `update/2`.

## Navigation

`Raxol.Debug.TimeTravel` keeps a cursor into the snapshot history:

```elixir
alias Raxol.Debug.TimeTravel

{:ok, snapshot} = TimeTravel.current()

# Walk through history
{:ok, snapshot} = TimeTravel.step_back()
{:ok, snapshot} = TimeTravel.step_forward()
{:ok, snapshot} = TimeTravel.jump_to(42)

# Push a historical model back into the live app
# (sends {:restore_model, model} to the Dispatcher)
:ok = TimeTravel.restore()

# Resume recording after restoring
:ok = TimeTravel.resume()

# Pause recording while you poke around
:ok = TimeTravel.pause()

entries = TimeTravel.list_entries()
# => [%{index: 0, message: :inc, changed: true}, ...]

count = TimeTravel.count()
:ok = TimeTravel.clear()
```

## Diffing

Pick any two snapshots and see exactly what changed between them:

```elixir
{:ok, changes} = TimeTravel.diff(10, 15)

# Each change is one of:
# {:changed, [:path, :to, :key], old_value, new_value}
# {:added, [:path, :to, :key], value}
# {:removed, [:path, :to, :key], value}
```

You can also diff arbitrary maps directly with `Snapshot.diff/2`, which does recursive comparison and tracks the key path:

```elixir
alias Raxol.Debug.Snapshot

Snapshot.diff(
  %{count: 1, items: [1, 2]},
  %{count: 2, items: [1, 2, 3]}
)
# => [
#   {:changed, [:count], 1, 2},
#   {:changed, [:items], [1, 2], [1, 2, 3]}
# ]

Snapshot.changed?(snapshot)  # did the model actually change?
Snapshot.summary(snapshot)   # "Snapshot #42: :inc (2 changes)"
```

## Export / Import

Save a debugging session to disk and load it later:

```elixir
:ok = TimeTravel.export("debug_session.bin")

{:ok, count} = TimeTravel.import_file("debug_session.bin")
# => {:ok, 150}
```

Uses Erlang's binary term format.

## Manual recording

`TimeTravel.record/4` is called by the Dispatcher automatically, but you can also record snapshots yourself:

```elixir
TimeTravel.record(message, model_before, model_after)
```

Snapshots live in a CircularBuffer. Old ones get evicted when it's full, so memory stays bounded.


<!-- docs/features/WATCH.md -->

# Watch Surface

`raxol_watch` pushes glanceable summaries from a Raxol app to iOS or Android devices. Accessibility announcements become notifications; taps come back as Raxol events. It's a low-bandwidth surface for status updates rather than full UI.

## Quick start

```elixir
children = [
  {Raxol.Watch.Supervisor, push_backend: Raxol.Watch.Push.APNS}
]

# APNS/FCM credentials are read by the backend module from its own config.
```

Register a device:

```elixir
# register(device_token, :apns | :fcm, opts)
Raxol.Watch.DeviceRegistry.register("device-token-here", :apns)
Raxol.Watch.DeviceRegistry.register("wear-os-token", :fcm, high_priority_only: true)
```

Supported opts: `muted: false`, `high_priority_only: false`.

When the app announces something via Accessibility, registered devices get a push.

## Push backends

| Backend  | Notes                                |
| -------- | ------------------------------------ |
| `APNS`   | Apple Push Notification service      |
| `FCM`    | Firebase Cloud Messaging (Android)   |
| `Noop`   | Drops sends; logs a warning in prod  |

`pigeon` is the optional dep that powers APNS/FCM. Without it the surface compiles but defaults to `Noop`.

## Debouncing

`Notifier` subscribes to Accessibility events with a 1s debounce. Multiple rapid announcements coalesce into one push, useful when a form change emits five field-validation announcements in 200ms.

High-priority announcements (errors, alerts) bypass the debounce and push immediately.

Parallel send across devices via `Task.async_stream`. Failures log per-device but don't block the others.

## Tap actions

When a user taps a notification, `ActionHandler.handle_action/2` translates the action ID into a `Raxol.Core.Events.Event`. Default mapping:

| Action ID     | Event                                          |
| ------------- | ---------------------------------------------- |
| `details`     | `:key` with `key: :enter`                      |
| `acknowledge` | `:key` with `key: :enter`                      |
| `pause`       | `:key` with `char: " "` (space)                |
| `quit`        | `:key` with `char: "q"`                        |
| `next`        | `:key` with `key: :tab`                        |
| `previous`    | `:key` with `key: :tab, modifiers: [:shift]`   |
| `mute`        | `:custom` with `%{action: :mute}` (W4)         |
| `pin`         | `:custom` with `%{action: :pin}` (W4)          |
| `delete`      | `:custom` with `%{action: :delete}` (W4)       |
| `dismiss`     | `nil` (no event emitted)                       |

Pass `action_map:` to merge in custom bindings:

```elixir
Raxol.Watch.ActionHandler.handle_action("snooze",
  action_map: %{"snooze" => {:key, %{key: :char, char: "s"}}}
)
```

Custom event types beyond `:key` work too: any `{type, data}` where `type` is an atom produces `Event.new(type, data)`.

`Formatter` attaches `details` + `dismiss` to normal notifications,
`acknowledge` + `details` + `dismiss` to high-priority ones, and the full
chat tap-back set (`reply` + `mute` + `pin` + `delete` + `dismiss`) to
chat-style notifications.

## Quick reply (text input)

iOS `UNTextInputNotificationAction` and Android `RemoteInput` prompt the user for text before the action arrives back at the app. `handle_reply_action/3` translates the action ID + typed text into a `:reply` event:

```elixir
event = Raxol.Watch.ActionHandler.handle_reply_action("reply", "Sounds good!")
# %Event{type: :reply, data: %{action: "reply", text: "Sounds good!"}}

# Or dispatch through the configured channel:
Raxol.Watch.ActionHandler.dispatch_reply("reply", "Sounds good!", to: MyApp.TEA)
# Sends {:watch_action, %Event{type: :reply, ...}} to MyApp.TEA
```

Replies use the same `{:watch_action, event}` channel as other tap-backs, so consumers pattern-match on `event.type == :reply` rather than a separate message tag.

## Notification categories

`Raxol.Watch.Categories` returns pure data for the host iOS / Android apps to register at launch:

```elixir
# Host iOS app passes this to UNUserNotificationCenter.setNotificationCategories
ios_payload = Raxol.Watch.Categories.ios_categories()

# Host Android app reads per-category action arrays
chat_actions = Raxol.Watch.Categories.android_actions("raxol_chat")
```

Three category buckets matching the `:category` field on notifications:

| Category        | Actions                                         |
| --------------- | ----------------------------------------------- |
| `raxol_alert`   | Details, Dismiss                                |
| `raxol_status`  | Details, Dismiss                                |
| `raxol_chat`    | Reply (text input), Mute, Pin, Delete, Dismiss  |

Pure data, no platform calls. The host app translates to `UNNotificationCategory` (iOS) or `NotificationCompat.Action` + `RemoteInput` (Android).

## Formatting

`Formatter` truncates the watch-glance `body` to 160 chars (using `String.length`, so emoji count correctly) and maps Raxol priority levels to APNS/FCM priority fields. Buffer content is stripped to plain text; styling doesn't survive the trip.

The full untruncated text is preserved under `:body_long` on every constructor, for the watch detail view that fetches when the user taps "expand".

### Rich notification constructors (W1)

| Constructor             | Carries                                    |
| ----------------------- | ------------------------------------------ |
| `format_announcement/2` | Text-only, priority-based actions          |
| `format_model_summary/2`| Multi-line projection text, status actions |
| `format_audio/4`        | `:audio_url`, chat actions                 |
| `format_image/4`        | `:image_url` + `:media_type` (`:photo` default), chat actions |
| `format_sticker/4`      | Convenience over `format_image/4` with `:media_type => :sticker` |
| `format_location/4`     | `:location => %{lat, lng, label?}`, chat actions |
| `format_long_message/3` | Body truncated to glance, `:body_long` carries full |
| `format_chat_message/3` | Same as `format_long_message/3` but documented for chat use |

Notification fields exposed: `body_long`, `audio_url`, `image_url`, `media_type` (`:sticker | :photo | :video_thumb`), `location`. Existing constructors stay backward-compatible (new fields nil-defaulted).

## APNS payload encoding (W2)

`Raxol.Watch.Push.APNS.build_payload/1` emits the JSON payload with:

- `mutable-content: 1` when `audio_url` or `image_url` is present, so the host iOS app's `UNNotificationServiceExtension` triggers an attachment fetch.
- `interruption-level: "time-sensitive"` and `aps.sound: "default"` for high-priority notifications (iOS 15+, surfaces past Focus modes).
- Custom data at the top level: `raxol.audio_url`, `raxol.image_url`, `raxol.media_type` (atom serialized as string), `raxol.location` (the `{lat, lng, label?}` map), `raxol.body_long` (only when distinct from `body`).

`build_payload/1` is `@doc`-public so consumers can introspect or test the payload shape without Pigeon mocking.

## FCM payload encoding (W3)

`Raxol.Watch.Push.FCM.build_notification_object/1` and `build_data_payload/1` emit the FCM body:

- `notification.image` carries `image_url` (Wear OS auto-downloads).
- `data.category` and JSON-encoded `data.actions` always present.
- `data.raxol_audio_url` (string)
- `data.raxol_media_type` (string)
- `data.raxol_location` (JSON-encoded map; FCM data values must be strings)
- `data.raxol_body_long` (only when distinct from `body`)

The host iOS / Wear OS app downloads the media and renders the notification: `UNNotificationServiceExtension` on iOS, `NotificationCompat` + `BigPictureStyle` / `MessagingStyle` on Android.

## Device registry

`DeviceRegistry` is ETS-backed with `read_concurrency: true`. Crash-safe init means the registry recovers cleanly if the GenServer restarts.

Devices don't expire automatically. Hook `unregister/1` into your auth layer when sessions end.

## See also

- [Telegram](TELEGRAM.md): interactive messaging surface
- [Speech](SPEECH.md): the other accessibility-driven surface


<!-- docs/guides/SKILL_AUTHORING.md -->

# Skill Authoring

A skill is procedural memory: a reusable `SKILL.md` file that teaches an agent how to do
something. Raxol uses the [agentskills.io](https://agentskills.io) `SKILL.md` format, so a
skill you write here sits alongside the ones under `~/.agents/skills` and is portable to
other tools that speak the same format.

Agents author skills on their own (the [self-improvement](../features/SELF_IMPROVEMENT.md)
reviewer writes them after a successful turn), and you can author them by hand. This guide
is the hand-authoring contract.

## The format

A `SKILL.md` is YAML frontmatter followed by a markdown body:

```markdown
---
name: git-bisect-a-regression
description: Find the commit that introduced a bug using git bisect.
version: "1"
category: git
---

# Git bisect a regression

Use this when a test passed at some older commit and fails now.

1. `git bisect start`
2. `git bisect bad` at the current (broken) commit.
3. `git bisect good <known-good-sha>`.
4. For each commit git checks out, run the failing test and mark
   `git bisect good` or `git bisect bad`.
5. When git prints the first bad commit, run `git bisect reset`.

Keep the working tree clean before you start; stash or commit first.
```

### Frontmatter fields

| Field | Required | Notes |
|-------|:---:|-------|
| `name` | yes | A kebab-case identifier, unique within a store. |
| `description` | no | One line. This is what an agent sees in `skills_list` before opening the skill. |
| `version` | no | A string. |
| `category` | no | Groups the skill on disk (`<root>/<category>/<name>/`). |
| `created_by` | no | `agent` or `user`. Set automatically; only agent-authored skills are curated. |

Any other frontmatter key is preserved under the skill's metadata, so fields another tool
depends on survive a round trip. Nothing from a `SKILL.md` is ever turned into an atom.

### Body

Write the body for the reader that will act on it: an LLM with tools. Be concrete and
procedural. State the trigger ("use this when..."), then the steps, then the caveats. Keep
it focused on one task; a skill that tries to cover everything gets opened for nothing.

## Where skills live

`Raxol.Agent.Skills.Store` reads from two places:

- **Managed root** (writable, default `~/.raxol/skills`): skills the agent or you author
  here. New skills are written here.
- **External dirs** (read-only, default `~/.agents/skills`): shared skills. A managed skill
  wins over an external one of the same name.

Skill content is re-read from disk on every boot, so disk is the source of truth. Usage
telemetry (how often a skill is used and viewed, its lifecycle state) is persisted
separately and survives restarts.

## Authoring from an agent

The three skills tools let an agent manage its own procedures:

| Tool | Purpose |
|------|---------|
| `skills_list` | List skills as metadata only (the cheap disclosure level). |
| `skill_view` | Read a skill's body, or one supporting file inside its directory. |
| `skill_manage` | Create, patch, or delete a skill. |

A supporting-file read through `skill_view` is path-guarded: absolute paths and `..` are
rejected, so a skill cannot read outside its own directory.

## Curation

Agent-authored skills are aged by the [Curator](../features/SELF_IMPROVEMENT.md#curator):
`active` to `stale` (default 30 idle days) to `archived` (default 90). Pin a skill to
protect it from aging, and note that user-authored and external skills are never curated.
Every Curator pass writes a backup first and is reversible.

## The safety dimension

A skill in Raxol carries more than instructions. When a skill's steps call tools, those
calls run under the same [ALLOW/ASK/DENY authorization](../features/AGENT_FRAMEWORK.md#authorization-allowaskdeny)
as any other tool call, and across whichever [surface](SURFACES.md) the agent is running
on. A skill cannot smuggle in a privileged action: writing a file or running a shell
command from inside a skill is still a sensitive tool call, still gated, still auditable in
the [conversation item-log](../features/AGENT_FRAMEWORK.md#conversation-item-log).

## See also

- [Self-Improvement](../features/SELF_IMPROVEMENT.md): how agents author and curate skills.
- [Build Your First Agent](../getting-started/BUILD_AN_AGENT.md): enabling the skills store.
- [Tool Catalog](../reference/TOOL_CATALOG.md): the built-in tools skills can drive.


<!-- docs/guides/SURFACES.md -->

# Surfaces: Write Once, Render Everywhere

One TEA module renders to many surfaces without modification. The same `init/update/view`
that draws a terminal UI also serves a browser, an SSH session, an agent over MCP, a
Telegram chat, a watch, and a speech interface. You write the application once; Raxol
projects it.

```
                          +---> Terminal   (termbox2 NIF)
                          |
                          +---> Browser    (Phoenix LiveView)
                          |
  TEA module (GenServer) -+---> SSH         (Erlang :ssh)
                          |
                          +---> Agent (MCP) (auto-derived tools)
                          |
                          +---> Telegram / Watch / Speech
```

## The surfaces

| Surface | Package | What it is |
|---------|---------|-----------|
| Terminal | `raxol_terminal` | The native TUI, over the termbox2 NIF (Windows falls back to a pure-Elixir driver). |
| Browser | `raxol_liveview` | The same component tree as a Phoenix LiveView, cells mapped to DOM. |
| SSH | `raxol` (built-in) | The TUI served over `:ssh`; each connection is its own session. |
| Agent (MCP) | `raxol_mcp` | Every interactive component auto-derives MCP tools; an LLM drives the same UI a human does. |
| Telegram | `raxol_telegram` | A TEA app as a bot: per-chat sessions, inline keyboards. |
| Watch | `raxol_watch` | Glanceable summaries and tap-to-action from accessibility events, over APNS/FCM. |
| Speech | `raxol_speech` | TTS announcements and Whisper STT with voice commands. |

Each surface has its own feature doc: [MCP](../features/MCP.md), [Telegram](../features/TELEGRAM.md),
[Watch](../features/WATCH.md), [Speech](../features/SPEECH.md). The
[Unified Messaging Gateway](../features/GATEWAY.md) connects many chat platforms through one
adapter contract.

## One fan-out, not N adapters

A Raxol app is one OTP process publishing its state, and each surface is a subscriber that
projects that state its own way. Adding a surface adds a subscriber, not a rewrite. The
terminal, the LiveView, and the SSH session can render the same running module at the same
time, each staying in sync through Phoenix.PubSub.

That is a different model from a chat bot framework, where each platform is a separate
integration that reimplements the conversation. Here the conversation, the state, and the
view logic live once in the TEA module; the surfaces are projections of it. A watch shows a
summary of the same model the terminal draws in full; an agent reads the same component tree
a human clicks.

## Animation across surfaces

A `view/1` can declare animation intent (`animate(element, property: :opacity, to: 1.0,
duration: 300)`). Surfaces that can accelerate it do (LiveView emits CSS transitions);
surfaces that cannot compute frames server-side (the terminal). The same declaration, honored
differently per surface, with `prefers-reduced-motion` respected. Hints are declarative
metadata, never imperative commands.

## The agent surface

MCP is built the same way as the others. Component types implement a `ToolProvider`
behaviour, so the framework derives an agent's toolset from the same component tree it
renders for a human, and a focus lens narrows it to the roughly 15 relevant tools per
interaction. An LLM `type_into` a field and `click` a button through the exact structure a
person sees. See [MCP as a Rendering Target](../features/MCP.md).

## See also

- [Why Raxol](../WHY_RAXOL.md): why one OTP runtime beats per-platform integrations.
- [Core Concepts](../getting-started/CORE_CONCEPTS.md): the TEA model the surfaces project.
- [Build Your First Agent](../getting-started/BUILD_AN_AGENT.md): the agent surface in practice.


<!-- docs/reference/TOOL_CATALOG.md -->

# Tool / Action Catalog

Every LLM-callable Agent Action, with its parameters and authorization tier.
Generated by `mix raxol.docs.tools` (run from `packages/raxol_payments`),
introspected from each action's `__action_meta__/0`. Do not edit by hand.
`mix raxol.docs.tools --check` fails when this file is stale.

Total: 36 tools, 8 sensitive.

## Authorization

Read-only tools always run. A tool marked **sensitive** is denied by default
(`Raxol.Agent.ToolPolicy.deny_sensitive/0`) unless the surface installs a
`:tool_authorizer`, which resolves it through the ALLOW/ASK/DENY
[Authorization engine](../features/AGENT_FRAMEWORK.md#authorization-allowaskdeny).
The [Coding Agent](../features/CODING_AGENT.md) shows the interactive approval UX
(allow once / always / deny).

## Agent tools (`raxol_agent`)

| Tool | Sensitive | Description | Parameters |
|------|:---:|-------------|------------|
| `bash` | yes | Run a shell command via `/bin/sh -c` in the current working directory. Returns combined stdout+stderr and the exit status. Output is captured (not interactive) and truncated past 64KB. | `command` (string, required), `timeout_ms` (integer), `cd` (string) |
| `edit_file` | yes | Replace `old_string` with `new_string` in a file (relative to the current working directory). `old_string` must match exactly once unless `replace_all` is true. Returns the before/after diff. | `path` (string, required), `old_string` (string, required), `new_string` (string, required), `replace_all` (boolean) |
| `file_stat` | no | Stat a path (relative to the current working directory): type, size in bytes, and mtime. | `path` (string, required) |
| `glob` | no | List files matching a wildcard pattern (e.g. "**/*.ex"), relative to the current working directory. Returns cwd-relative paths, sorted. | `pattern` (string, required), `path` (string) |
| `grep` | no | Search file contents for a regular expression under a directory (relative to cwd). Uses ripgrep when available, else a pure search. Returns matches as {path, line, text}. | `pattern` (string, required), `path` (string), `ignore_case` (boolean), `max_results` (integer) |
| `list_dir` | no | List entries of a directory (relative to the current working directory). Returns names with a trailing / for directories. | `path` (string) |
| `memory_forget` | no | Delete a memory by its id. | `id` (string, required) |
| `memory_recall` | no | Search cross-session memory for facts relevant to a query. | `query` (string, required), `limit` (integer) |
| `memory_remember` | no | Persist a fact to cross-session memory so it can be recalled in future sessions. | `content` (string, required), `type` (string), `tags` (list of string) |
| `read_file` | no | Read a text file (relative to the current working directory). Optionally read a line range with `offset` (1-based start line) and `limit` (line count). Returns at most 256KB; `truncated` flags when the content was longer. | `path` (string, required), `offset` (integer), `limit` (integer) |
| `session_search` | no | Search prior conversation history (raw messages and tool results) for items relevant to a query. Returns the actual messages, not summaries. | `query` (string, required), `limit` (integer), `conversation_id` (string) |
| `skill_manage` | no | Create, patch, or delete a skill (procedural memory). action create: write a new skill; patch/edit: update an existing one; delete: remove it. | `action` (string, required), `name` (string, required), `description` (string), `category` (string), `version` (string), `body` (string), `metadata` (map) |
| `skill_view` | no | Read a skill's contents by name. Omit `path` to read the SKILL.md body; pass a relative `path` to read one supporting file inside the skill directory. | `name` (string, required), `path` (string) |
| `skills_list` | no | List available skills as metadata only (name, category, description, state). Call skill_view with a name to read a skill's contents. | (none) |
| `task` | no | Delegate a self-contained subtask to a fresh read-only sub-agent and return its final answer. Use for focused investigation (searching, reading, summarizing across many files) that would otherwise clutter the main conversation. The sub-agent has no prior context and cannot write files or run commands: give it everything it needs in `prompt`. | `prompt` (string, required), `max_iterations` (integer) |
| `vfs_change_dir` | no | Change the current working directory in the virtual filesystem | `path` (string, required) |
| `vfs_get_tree` | no | Get a directory tree representation from the virtual filesystem | `path` (string), `depth` (integer) |
| `vfs_list_dir` | no | List files and directories at a path in the virtual filesystem | `path` (string) |
| `vfs_make_dir` | no | Create a directory in the virtual filesystem | `path` (string, required) |
| `vfs_read_file` | no | Read the contents of a file in the virtual filesystem | `path` (string, required) |
| `vfs_remove` | no | Remove a file or empty directory from the virtual filesystem | `path` (string, required) |
| `vfs_write_file` | no | Create a file with content in the virtual filesystem | `path` (string, required), `content` (string, required) |
| `write_file` | yes | Create a file (relative to the current working directory) with the given content. Refuses to clobber an existing file unless `overwrite` is true: use `edit_file` for targeted changes. Parent directories are created as needed. | `path` (string, required), `content` (string, required), `overwrite` (boolean) |

## Payment tools (`raxol_payments`)

| Tool | Sensitive | Description | Parameters |
|------|:---:|-------------|------------|
| `payment_create_mandate` | yes | Issue a Xochi delegation envelope: sign an EIP-712 Mandate authorizing a specific agent wallet to call scoped Xochi endpoints within a budget. Returns the base64url envelope to present in X-Xochi-Delegation. | `agent_wallet` (string, required), `scopes` (list of string, required), `max_amount_usd` (integer, required), `max_calls` (integer, required), `expires_at` (integer, required), `nonce` (string) |
| `payment_execute_deposit_route` | yes | Fetch and verify a Tron-origin cross-chain deposit-route quote. Returns the verified deposit_address (+ deadline) for your own Tron wallet to fund; raxol does not send the funds. Poll settlement with payment_poll_xochi_status. | `wallet` (string, required), `from_chain_id` (integer, required), `to_chain_id` (integer, required), `from_token` (string, required), `to_token` (string, required), `amount_atomic` (string, required), `recipient_address` (string, required), `slippage_bps` (integer), `trust_score` (integer) |
| `payment_execute_relay_transfer` | yes | Initiate a Tron cross-chain transfer through Riddler Relay: quote, authorize the spend, and start execution. Returns the deposit address to fund and the transfer id to poll. Tron is public-only; a stealth request fails closed (no silent downgrade) and must be re-requested as public. | `amount` (string, required), `from_chain_id` (integer, required), `to_chain_id` (integer, required), `from_token` (string, required), `to_token` (string, required), `to_address` (string, required), `from_address` (string), `settlement` (string), `slippage_bps` (integer) |
| `payment_execute_xochi_intent` | yes | Execute a cross-chain or stealth payment through Xochi: quote, authorize the spend, sign the EIP-712 intent, and submit. Returns the intent id and status to poll. | `amount` (string, required), `from_chain_id` (integer, required), `to_chain_id` (integer, required), `from_token` (string, required), `to_token` (string, required), `settlement` (string), `recipient_meta_address` (string), `recipient_address` (string), `slippage_bps` (integer), `trust_score` (integer), `min_to_amount` (string) |
| `payment_get_quote` | no | Probe a URL to check if it requires payment and get pricing | `url` (string, required), `method` (string) |
| `payment_get_wallet_info` | no | Get the agent's wallet address and chain ID | (none) |
| `payment_list_history` | no | List recent payment history | `limit` (integer) |
| `payment_list_mandates` | no | List Xochi Mandate envelopes stored locally. role="member" lists envelopes the local wallet issued; role="agent" lists envelopes addressed to the local wallet. | `role` (string, required) |
| `payment_poll_relay_status` | no | Poll a Relay (Tron) transfer by id until it reaches a terminal status (completed, failed, or refunded). Returns the final status and tx hash. | `transfer_id` (string, required), `timeout_ms` (integer), `interval_ms` (integer) |
| `payment_poll_xochi_status` | no | Poll a Xochi intent by id until it reaches a terminal status (completed, failed, expired, refunded). Returns the final status and settlement details. | `intent_id` (string, required), `timeout_ms` (integer), `interval_ms` (integer) |
| `payment_revoke_mandate` | no | Locally delete a stored Xochi Mandate envelope so it can no longer be selected for outbound requests. Note: Xochi's server-side budget counter for this envelope remains until expires_at, per agent-auth.md (2026-04-27), no server revoke endpoint exists in v1. | `envelope_hash` (string, required) |
| `payment_spending_status` | no | Check current spending against budget limits | (none) |
| `payment_transfer` | yes | Authorize an explicit same-chain transfer to an address: runs the spend gate and reserves budget, but does NOT broadcast. Use payment_execute_xochi_intent to actually move funds cross-chain or with privacy. | `to` (string, required), `amount` (string, required), `currency` (string) |


<!-- docs/core/ARCHITECTURE.md -->

# Architecture

How Raxol works, from application model to terminal output.

## The big picture

```elixir
Your App (TEA)          Raxol (Framework)           Rendering Targets
┌─────────────┐    ┌───────────────────────┐    ┌─────────────┐
│ init/1      │    │ Lifecycle (GenServer) │    │ termbox2 NIF│
│ update/2    │───>│ Rendering Engine      │───>│ IOTerminal  │
│ view/1      │    │ Layout Engine         │    │ LiveView    │
│ subscribe/1 │    │ Event Dispatcher      │    │ SSH         │
│             │    │ MCP Tool Deriver      │───>│ MCP (tools) │
└─────────────┘    └───────────────────────┘    └─────────────┘
```

Your app provides pure functions. Raxol manages the runtime loop, layout, rendering, and I/O. You never write ANSI escape codes.

## Application model: TEA

Every Raxol app implements The Elm Architecture:

```elixir
use Raxol.Core.Runtime.Application

def init(context) -> model                    # Initial state
def update(message, model) -> {model, cmds}   # State transitions
def view(model) -> view_tree                  # Declarative UI
def subscribe(model) -> [subscription]        # External events
```

The runtime calls `view(model)` after every `update`, diffs the resulting Element tree against the previous one, and renders only what changed. Same diffing idea as React's virtual DOM, but the Element tree describes terminal cells, not HTML nodes.

## Layer stack

### 1. View DSL -> element tree

The `view/1` callback uses macros to build a tree of plain maps:

```elixir
column style: %{padding: 1} do
  [
    text("Hello", fg: :cyan),
    row do
      [button("+", on_click: :inc), button("-", on_click: :dec)]
    end
  ]
end
```

Produces: `%{type: :column, children: [%{type: :text, ...}, %{type: :row, ...}], ...}`

### 2. Preparer -> measured element tree

`Raxol.UI.Layout.Preparer` walks the element tree and pre-measures all text nodes via `Raxol.UI.TextMeasure`, producing a `PreparedElement` tree with cached display widths. This is the "prepare" phase of a two-phase prepare/layout architecture (inspired by [Pretext](https://github.com/nicklockwood/Pretext)):

- Text measurement handles CJK double-width characters, fullwidth symbols, and combining characters correctly via `Raxol.Terminal.CharacterHandling`
- On terminal resize, only the layout phase re-runs; text measurements are cached and reused when content hasn't changed
- `prepare_incremental/2` compares content hashes to skip re-measurement of unchanged nodes
- `PreparedElement` also carries `animation_hints`, declarative metadata attached via `Raxol.Animation.Helpers.animate/2` in `view/1`. These hints flow through to backends untouched; the Preparer just preserves them alongside measurements

### 3. Layout engine -> positioned elements

`Raxol.UI.Layout.Engine` takes the element tree and computes `{x, y, width, height}` for every node. Uses cached measurements from the Preparer when available. Supports:

- **Flexbox**: `row`/`column` with `flex`, `gap`, `align_items`, `justify_content`
- **CSS Grid**: `grid` with `template_columns`, `template_rows`
- **Box model**: `padding`, `border`, `margin`, `width`, `height`

The `:flex` container path (`Raxol.UI.Layout.Flexbox` + `FlexItem` +
`Flexbox.Solver`/`Positioner`) implements CSS Flexbox section 9.7 on a
monospace cell grid, with a handful of intentional divergences (e.g.
terminal-pragmatic `flex: 1` equalization, no baseline alignment) and an
automatic minimum-size floor derived from min-content measurement. See
[LAYOUT.md](./LAYOUT.md) for the full supported-property reference,
divergence table, and the text-wrapping API (`Raxol.UI.TextLayout`).

### 4. Composer -> cell grid

`Raxol.UI.Rendering.Composer` walks the positioned tree and produces cell tuples:

```elixir
{x, y, char, fg_color, bg_color, attrs}
```

Each cell is one character at one position with its styling. Cell x-positions account for character display width: CJK characters advance x by 2, not 1.

### 5. Screen buffer -> diff

`Raxol.Terminal.ScreenBuffer` holds the current and previous frame. Only changed cells produce output.

### 6. Terminal backend -> output

Platform-detected backend writes ANSI escape sequences:

- **Unix/macOS**: Native C NIF via termbox2 (`packages/raxol_terminal/lib/termbox2_nif/c_src/`)
- **Windows**: Pure Elixir `IOTerminal` using `IO.write/1`
- **Browser**: LiveView bridge via PubSub (`Raxol.LiveView.TEALive` in `raxol_liveview` package). When positioned elements carry animation hints, `TerminalBridge.animation_css/1` emits CSS `transition` rules targeting `data-raxol-id` selectors, plus a `prefers-reduced-motion` media query. The browser handles interpolation client-side instead of re-rendering every frame from the server.
- **SSH**: Erlang `:ssh` module (`Raxol.SSH.Server`)
- **Telegram**: Buffer-to-plaintext via an `io_writer` callback (`Raxol.Core.Runtime.Rendering.Backends.render_to_telegram/2`)
- **MCP**: Tool/resource derivation from Component tree (`Raxol.MCP.Server`, see ADR-0012). `StructuredScreenshot` includes animation hints in JSON Component summaries so agents can reason about animated state.

### MCP as rendering target (ADR-0012)

MCP is a rendering target alongside terminal, LiveView, and SSH. Instead of rendering pixels, it renders capabilities: tools and resources derived from the Component tree.

```
view(model) -> Component tree -> ToolProvider per Component -> MCP tool set
                              -> app projections            -> MCP resources
```

Each Component type implements `Raxol.MCP.ToolProvider`, mapping its state to MCP tools (e.g., TextInput -> type_into/clear/get_value, Table -> sort/filter/select_row). A focus lens filters to ~15 relevant tools per interaction. The context tree assembles model, Components, agents, swarm topology, and notifications into browsable MCP resources.

This means every Raxol app is AI-controllable with zero glue code. Package: `raxol_mcp` (depends on `raxol_core`). See `docs/adr/0012-mcp-as-rendering-target.md` for full details.

## Event flow

```
Terminal Input
  -> Driver (raw bytes -> Event struct)
  -> Dispatcher (GenServer)
  -> Capture phase (root -> target, W3C-style)
  -> Target handlers (on_click, on_change)
  -> Bubble phase (target -> root)
  -> Component handle_event/3
  -> App update/2
```

Events bubble through the view tree. Any handler can return `:stop` to halt propagation or `:passthrough` to continue. Unhandled events reach `update/2`.

## OTP architecture

Every Raxol app runs as a supervision tree:

```
Application Supervisor
├── Lifecycle (GenServer): owns the TEA loop
├── Dispatcher (GenServer): event routing
├── FocusManager (GenServer): tab order, focus state
├── Rendering.Engine: view -> layout -> render -> output
├── ThemeManager: ETS-backed theme registry
├── I18nServer: ETS-backed translations
└── [ProcessComponent supervisors]: optional per-Component processes
```

### Process-Per-Component (Optional)

Any Component can run in its own process via `process_component/2`:

```elixir
process_component(ExpensiveChart, data: sensor_feed)
```

The component gets its own GenServer under a DynamicSupervisor. If it crashes, it restarts without affecting the rest of the UI. State is preserved in ETS across restarts.

### Hot code reload (dev only)

`Raxol.Dev.CodeReloader` watches `.ex` files via FileSystem, debounces changes, recompiles, and sends `:render_needed` to the Lifecycle. Your app updates in-place without restart.

## Performance design

- **Two-phase rendering**: Text measurement (expensive, Unicode-aware) is cached separately from layout (cheap arithmetic). On resize, only layout re-runs.
- **Buffer diff**: Only changed cells are written. ~2ms for 80x24.
- **ETS for reads**: Theme, i18n, config, and metrics use ETS tables. Reads bypass GenServer serialization entirely.
- **Synchronized output**: Uses DEC mode 2026 (`\e[?2026h`) to batch terminal writes, preventing flicker.
- **Damage tracking**: `DamageTracker` computes rectangular dirty regions. `RenderBatcher` coalesces rapid updates into single frames at 60fps.
- **Color downsampling**: `Raxol.Style.Colors.Adaptive` detects terminal capabilities and maps 24-bit colors to 256 or 16 colors automatically.
- **Lazy scroll content**: `ScrollContent` behaviour enables cursor-based streaming for large datasets in `Viewport`; only the visible slice is materialized.

## Terminal compatibility

- **Unicode width**: `TextMeasure` delegates to `CharacterHandling` for correct CJK double-width, combining characters, fullwidth symbols, and emoji width calculation across layout, rendering, and text wrapping
- **Border fallback**: Box drawing uses ASCII (`+-|`) when Unicode isn't supported
- **Color detection**: `COLORTERM`, `TERM`, capability queries for truecolor/256/16/mono

## Key modules

| Module                                 | Role                                |
| -------------------------------------- | ----------------------------------- |
| `Raxol.Core.Runtime.Lifecycle`         | TEA loop GenServer                  |
| `Raxol.Core.Runtime.Events.Dispatcher` | Event routing + bubbling            |
| `Raxol.Core.Runtime.Rendering.Engine`  | view -> prepare -> layout -> render |
| `Raxol.UI.TextMeasure`                 | Unicode display width (facade)      |
| `Raxol.UI.Layout.Preparer`            | Pre-measure text, cache widths      |
| `Raxol.UI.Layout.Engine`               | Flexbox/Grid layout computation     |
| `Raxol.UI.Layout.ScrollContent`       | Cursor-based lazy scroll behaviour  |
| `Raxol.UI.Rendering.Composer`          | Element tree -> cell grid           |
| `Raxol.Terminal.ScreenBuffer`          | Double-buffered cell storage        |
| `Raxol.Terminal.CharacterHandling`     | CJK/Unicode width (wcwidth)         |
| `Raxol.Terminal.Renderer`              | Cell grid -> ANSI string            |
| `Raxol.Terminal.Driver`                | Platform backend selection          |
| `Raxol.Core.Renderer.View`             | View DSL macros                     |
| `Raxol.Animation.Helpers`              | `animate/2`, `stagger/2`, `sequence/2` for view hints |
| `Raxol.Animation.Hint`                 | Hint struct, CSS property/timing mapping |

## References

- [Buffer API](./BUFFER_API.md)
- [Quickstart Guide](../getting-started/QUICKSTART.md)
- [Component Gallery](../getting-started/COMPONENT_GALLERY.md)
- [Theming Cookbook](../cookbook/THEMING.md)


<!-- docs/core/BUFFER_API.md -->

# Buffer API Reference

API reference for Raxol.Core buffer primitives. Note: raxol_core depends on telemetry at runtime.

## Raxol.Core.Buffer

Pure functional buffer operations for terminal rendering.

### Types

```elixir
@type cell :: %{char: String.t(), style: map()}
@type line :: %{cells: list(cell())}
@type t :: %{lines: list(line()), width: non_neg_integer(), height: non_neg_integer()}
```

### create_blank_buffer/2

```elixir
@spec create_blank_buffer(non_neg_integer(), non_neg_integer()) :: t()
```

Creates a blank buffer with the specified dimensions. All cells initialized to blank spaces.

```elixir
buffer = Raxol.Core.Buffer.create_blank_buffer(80, 24)
```

Performance: < 1ms for standard 80x24 buffer.

---

### write_at/5

```elixir
@spec write_at(t(), non_neg_integer(), non_neg_integer(), String.t(), map()) :: t()
```

Writes text at the specified coordinates with optional styling. Text wraps character-by-character, no automatic line breaks. Out-of-bounds writes are silently ignored. Unicode graphemes supported.

```elixir
buffer = Buffer.write_at(buffer, 5, 3, "Hello, World!")
buffer = Buffer.write_at(buffer, 5, 4, "Styled text", %{bold: true, fg_color: :blue})
```

Performance: < 1ms for typical strings.

---

### get_cell/3

```elixir
@spec get_cell(t(), non_neg_integer(), non_neg_integer()) :: cell() | nil
```

Retrieves the cell at the specified coordinates. Returns `nil` if out of bounds.

```elixir
cell = Buffer.get_cell(buffer, 5, 3)
# => %{char: "A", style: %{}}
```

Performance: O(1).

---

### set_cell/5

```elixir
@spec set_cell(t(), non_neg_integer(), non_neg_integer(), String.t(), map()) :: t()
```

Updates a single cell. More efficient than `write_at` for single characters. Out-of-bounds updates are silently ignored.

```elixir
buffer = Buffer.set_cell(buffer, 10, 5, "~", %{bg_color: :red})
```

Performance: < 100us.

---

### clear/1

```elixir
@spec clear(t()) :: t()
```

Resets all cells to blank. Returns new buffer with same dimensions.

---

### resize/3

```elixir
@spec resize(t(), non_neg_integer(), non_neg_integer()) :: t()
```

Resizes to new dimensions. Expanding fills with blank spaces. Shrinking crops from bottom and right. Existing content is preserved where it fits.

```elixir
buffer = Buffer.resize(buffer, 120, 40)  # expand
buffer = Buffer.resize(buffer, 40, 20)   # shrink (content cropped)
```

Performance: < 2ms for standard sizes.

---

### to_string/1

```elixir
@spec to_string(t()) :: String.t()
```

Converts buffer to a multi-line string. Styles are not rendered (use `Raxol.Core.Renderer` for styled output). Useful for testing and debugging.

---

## Raxol.Core.Renderer

Pure functional rendering and diffing.

### render_to_string/1

Renders buffer to plain ASCII string (no ANSI codes). < 1ms for 80x24 buffer.

### render_diff/2

```elixir
@spec render_diff(Buffer.t(), Buffer.t()) :: list()
```

Calculates minimal updates between two buffers. Returns a list of operation tuples:

- `{:move, x, y}` - Move cursor to position
- `{:write, text, style}` - Write text with style
- `{:clear_line, y}` - Clear line at y, emitted when a changed row becomes entirely blank (spaces with no visible style and no hyperlink). Rendered as `ESC[2K`, which erases the entire physical terminal row - buffers narrower than the terminal should not rely on the diff staying inside the buffer's width when a row goes blank.

```elixir
diff = Renderer.render_diff(old_buffer, new_buffer)
IO.write(Renderer.apply_diff(diff))
```

Only generates updates for changed cells. Batches consecutive changes into single writes; a row that goes fully blank collapses to a single `{:clear_line, y}`. < 2ms for 80x24 buffer.

### apply_diff/1

```elixir
@spec apply_diff(list()) :: String.t()
```

Converts diff operations to an ANSI output string with cursor movement and styling codes.

---

## Raxol.Core.Style

Style management and ANSI escape code generation.

### new/1

```elixir
style = Style.new(bold: true, fg_color: :blue)
```

Options: `:bold`, `:italic`, `:underline`, `:fg_color`, `:bg_color`.

### merge/2

```elixir
result = Style.merge(base, override)
# Second map wins on conflicts
```

### Color types

- Named: `:black`, `:red`, `:green`, `:yellow`, `:blue`, `:magenta`, `:cyan`, `:white`
- RGB: `Style.rgb(255, 100, 50)` returns `{255, 100, 50}`
- 256-color: `Style.color_256(196)` returns the palette index

### to_ansi/1

Converts style to ANSI escape codes: `Style.to_ansi(%{bold: true, fg_color: :blue})` => `"\e[1;34m"`.

---

## Raxol.Core.Box

Box drawing and area fill utilities.

### draw_box/6

```elixir
@spec draw_box(Buffer.t(), integer(), integer(), integer(), integer(), box_style()) :: Buffer.t()
```

Box styles:

- `:single` - Single line
- `:double` - Double line
- `:rounded` - Rounded corners
- `:heavy` - Bold lines
- `:dashed` - Dashed lines

Performance: 38-588us depending on size and style.

### draw_horizontal_line/5 and draw_vertical_line/5

Draw lines at specified coordinates with a given character.

### fill_area/7

```elixir
buffer = Box.fill_area(buffer, 10, 5, 20, 10, " ", %{bg_color: :blue})
buffer = Box.fill_area(buffer, 10, 5, 20, 10, ".", %{})
```

Performance: ~44us for 10x10, ~1.3ms for full 80x24.

---

## Performance targets

All operations designed for < 1ms on standard 80x24 buffers:

| Operation           | Target | Actual (avg) |
| ------------------- | ------ | ------------ |
| create_blank_buffer | < 1ms  | ~0.5ms       |
| write_at            | < 1ms  | ~0.1ms       |
| get_cell            | < 1ms  | ~0.001ms     |
| set_cell            | < 1ms  | ~0.1ms       |
| render_diff         | < 2ms  | ~2ms         |
| draw_box            | < 1ms  | 0.04-0.6ms   |
| fill_area (small)   | < 1ms  | 0.04ms       |

See `bench/core/` for detailed benchmarks.

## Error handling

All functions use defensive programming. Out-of-bounds coordinates are silently ignored. No exceptions for normal usage. Pattern matching validates input types at compile time.

## Thread Safety

All modules are pure functional with no shared state. Safe for concurrent use. No GenServers or processes. Immutable data structures throughout. Works in any context (LiveView, Phoenix, CLI, scripts).

## See Also

- [Architecture](./ARCHITECTURE.md)


<!-- docs/core/LAYOUT.md -->

# Flex & Text Layout

<!--
Documents the `:flex` layout path (`Raxol.UI.Layout.FlexItem` +
`Flexbox.Solver` + `Flexbox.Positioner`) and the `Raxol.UI.TextLayout`
wrapping API. Literal `:row`/`:column` elements run on the same flex
engine through a compatibility translation (see section 6).
-->

This page is the supported-property reference for `:flex` containers and
`Raxol.UI.Components.Display.Text`. It documents what Raxol actually does
today (verified against source), not the CSS spec Raxol approximates.

## 1. Flex properties

A `:flex` container's own properties are read from its `style:` map when
present, falling back to legacy top-level/`attrs` forms. Item properties
(`flex`, `width`, `margin`, ...) are always read from the *child's*
`style:` map (`Raxol.UI.Layout.FlexItem.resolve/5`).

### Container properties

| Property           | Values                                                                 | Where                                                                                     | Notes |
|---------------------|-------------------------------------------------------------------------|---------------------------------------------------------------------------------------------|-------|
| `direction`         | `:row`, `:column` (`:row_reverse`/`:column_reverse` accepted, see divergences) | `attrs.flex_direction`; the `row`/`column` do-block macros set this for you | Default `:row`. NOT read from `style:`: `Engine.enrich_flex_attrs/1` only translates `style.justify_content`/`align_items`/`gap`/`padding`, not direction. |
| `justify_content`   | `:flex_start`, `:flex_end`, `:center`, `:space_between`, `:space_around`, `:space_evenly` | `style: %{justify_content: ...}` or `attrs.justify_content` | Default `:flex_start`. All variants distribute leftover space via exact integer splits (largest-remainder / Bresenham), spacing always sums exactly to the free space, no cells lost to `div/2` truncation. |
| `align_items`       | `:flex_start`, `:flex_end`, `:center`, `:stretch`                       | `style: %{align_items: ...}` or `attrs.align_items`                                        | Default `:stretch`. No `:baseline` (see divergences). |
| `align_self`        | same values as `align_items`, or `nil` (inherit)                        | child's `style: %{align_self: ...}`, or legacy `child.attrs.align_self`                     | Overrides the container's `align_items` for one child. |
| `align_content`     | `:flex_start`, `:flex_end`, `:center`, `:space_between`, `:space_around` | `attrs.align_content` only                                                                  | No `style:` translation exists for this key; set it directly in `attrs` if you're not going through the `row`/`column`/`flex` builders. Only matters when wrapping (`flex_wrap: :wrap`). |
| `flex_wrap`         | `:nowrap`, `:wrap` (anything non-`:nowrap` multi-lines)                 | `attrs.flex_wrap` only                                                                      | Default `:nowrap`. `:wrap_reverse` is accepted as a value but is not distinguished from `:wrap`: lines are not reversed. |
| `gap`                | integer, or `%{row: r, column: c}`                                      | `style: %{gap: ...}` or `attrs.gap`                                                         | Default `0`. Subtracted from the container's main-axis size before flexible-length resolution runs, and included in `MinContent` row aggregation (`sum(child min) + gap * (n - 1)`). |
| `padding`            | integer, `{v, h}`, `{t, r, b, l}`, or `%{top:, right:, bottom:, left:}` | `style: %{padding: ...}` or `attrs.padding`                                                 | Parsed by `Raxol.UI.Layout.LayoutUtils.parse_padding/1`. Applied to the container's space before children are measured or laid out; `:prepared_cache` and other extra space keys are preserved through the padding step, so flex children always measure with cache intact. |

### Item (child) properties

| Property                      | Values                                                                 | Where (child's `style:` map)          | Notes |
|--------------------------------|---------------------------------------------------------------------------|------------------------------------------|-------|
| `flex`                        | integer `n`; `{grow, shrink, basis}`; `%{grow:, shrink:, basis:}`         | `style: %{flex: ...}`                    | Integer shorthand `flex: n` expands to `grow: n, shrink: 1, basis: 0` **and** `min_main: 0` (terminal-pragmatic sugar: see divergences). Tuple/map forms set grow/shrink/basis only, no min override. Legacy `child.attrs.flex` (map form) is honored at lowest precedence for back-compat. |
| `flex_grow`                   | non-negative integer                                                     | `style: %{flex_grow: ...}`               | Default `0`. Ignored if `flex:` shorthand is also set. |
| `flex_shrink`                 | non-negative integer                                                     | `style: %{flex_shrink: ...}`             | Default `1`. |
| `flex_basis`                  | non-negative integer, `{:pct, n}`, or `:auto`                            | `style: %{flex_basis: ...}`              | Default `:auto`: resolves to the content main size (measured), or the explicit `width`/`height` when set. |
| `width` / `height`            | non-negative integer, `{:pct, n}`, or `:auto`/unset                      | `style: %{width: ...}` (or top-level `child.width`) | Explicit main-axis size wins over an `:auto` basis. On the cross axis it sets `cross_size` and disables the stretch guard (see below). |
| `min_width` / `min_height`    | non-negative integer, `{:pct, n}`, or `:auto`/unset                      | `style: %{min_width: ...}`               | Explicit value always wins over the `flex: n` sugar's `min_main: 0` override. Unset (`:auto`) falls back to the automatic minimum size (Section 3). |
| `max_width` / `max_height`    | non-negative integer, `{:pct, n}`, or `:auto`/unset                      | `style: %{max_width: ...}`               | `:auto`/unset means unbounded (`:infinity`). |
| `{:pct, n}`                   | any dimension/margin field above                                         | (tuple value, not a separate key)        | Resolves against the container's *definite* dimension only. Against an indefinite (`nil`) dimension it behaves as `:auto` per spec, EXCEPT margin percentages, which always resolve against the container **width** regardless of which side or axis. |
| `margin`                      | integer; `{h, v}`; `{t, r, b, l}`; `:auto` (whole value or per-side)      | `style: %{margin: ...}`                  | Sides may independently be `:auto`. `:auto` counts as `0` during sizing; main-axis auto margins absorb positive free space before `justify_content` runs; cross-axis auto margins center (both sides) or push (one side) and disable stretch. |
| `align_self`                  | see container `align_items`                                              | `style: %{align_self: ...}`              | See container table row above. |

## 2. Intentional CSS divergences

These are deliberate, not bugs: Raxol targets a monospace cell grid, not
a pixel box model, and some CSS corners aren't worth the complexity on a
terminal. Source: `Raxol.UI.Layout.FlexItem` moduledoc.

| CSS behavior | Raxol behavior | Why |
|---|---|---|
| `flex: 1` keeps each item's own `min-width`/`min-height: auto` (min-content) floor, so equal-`flex:1` columns don't always equalize if content differs. | `flex: n` sugar also sets `min_main: 0`, so equal-`flex` items *always* equalize: an explicit `min_width`/`min_height` in `style:` still overrides the sugar. | Terminal-pragmatic: equal columns that actually equalize is the common case terminal UIs want; the CSS min-content floor is opt-in via an explicit min. |
| `row-reverse` / `column-reverse` reverse visual order and flip the start/end edges for `justify-content`. | `:row_reverse`/`:column_reverse` are accepted as `flex_direction` values but map to the *same* axis pair as `:row`/`:column` (`get_axes/1`), no actual reversal of child order or edges happens. | Not implemented. Documented divergence: do not rely on reverse directions; they silently behave like the non-reversed direction. |
| `align-items: baseline` aligns items along their text baseline. | Not implemented. `Positioner.align_cross/4` has no `:baseline` clause; passing it falls through to a no-op catch-all (the child keeps whatever cross position it already had, effectively broken, not "close enough"). | No font metrics/baseline concept on a monospace cell grid worth the complexity. Use `:center` or `:flex_start` instead. |
| The fractional-flex-factor rule: if `sum(flex-grow) < 1`, only that fraction of free space is distributed, the rest stays unfilled. | Not implemented: grow/shrink factors are non-negative integers, so a fractional sum can't occur; all free space is always distributed among items with non-zero factors. | Documented as N/A rather than a gap: the precondition (fractional factors) can't arise given the integer-only factor type. |
| Percentages parse from CSS-like strings (`"50%"`) or numbers with a unit. | Only the `{:pct, n}` tuple is accepted; no string parsing. | One unambiguous representation, no parser/locale edge cases. |
| Margin percentages resolve against the *containing block's inline-axis size* per side semantics some engines special-case. | Margin percentages always resolve against the container's **width**, for all four sides, matching the CSS spec's actual (if surprising) rule. | Explicitly called out in `FlexItem` moduledoc because it trips people up even in browsers: Raxol matches spec here rather than "fixing" it. |
| Pixel box model: fractional/subpixel sizes, box-sizing modes. | Everything is whole cells; sizes are non-negative integers (or `{:pct, n}` rounded to a whole cell). No box-sizing switch, padding/border/margin math is always "content-box"-shaped in cells. | No subpixel concept on a terminal grid. |
| Invalid CSS values are ignored (the property falls back to its previous/initial value, per CSS's error-handling rule). | Invalid values (negative sizes, negative flex factors, malformed percentages/margins) are clamped to the nearest valid value (usually `0`) and reported via the `[:raxol, :layout, :invalid_style]` telemetry event. Layout never raises on style input. | Fail-soft with observability instead of silent CSS-style ignoring; a clamp is easier to spot in a telemetry dashboard than a silently-dropped property. |
| Overflow (`sum(min-size) > container`) is handled per `overflow` property (default `visible`, content spills out / overlaps). | Content clips at the container's main-end edge; siblings never overlap. Pairs with the text-overflow affordances in Section 4 so clipping degrades gracefully instead of silently truncating mid-glyph. | |

## 3. Automatic minimum size

CSS's `min-width: auto` / `min-height: auto` default (an item never
shrinks below its own minimum content size) is implemented via
`Raxol.UI.Layout.MinContent`, wired into `FlexItem.resolve/5` as the
`auto_min_fun` callback (`Flexbox.calculate_single_line_layout/5`):

- **Inline axis** (main axis is `:horizontal`, i.e. `flex_direction: :row`):
  the automatic minimum is `MinContent.width/1`: the width of the
  *longest unbreakable segment*, not the full content width. Text and
  labels break at spaces, after hyphens, and between CJK ideographs (each
  CJK grapheme is its own break opportunity), so a long sentence's
  min-content is just its longest word, not the whole sentence. A
  `:divider` has min-content `1` (not the available width: a full-width
  divider would otherwise inflate the measured container width and rob
  fixed-width siblings during shrink). A `:spacer` has min-content `0`.
- **Block axis** (main axis is `:vertical`, i.e. `flex_direction: :column`):
  the automatic minimum is the item's already-measured content size on
  that axis (no separate min-content pass: vertical wrapping isn't
  modeled, so "min content height" and "content height" coincide).
- Items **never shrink below** their automatic minimum; if the sum of
  minimums exceeds the container, the excess overflows per the clip
  rule (Section 2) rather than content vanishing or items overlapping.
- An **explicit `min_width`/`min_height`** in `style:` always overrides the
  automatic minimum (and overrides the `flex: n` sugar's `min_main: 0`,
  per `FlexItem.resolve/5`'s `{explicit_min, flex.min_main_override}`
  precedence).
- **`flex: n`** (the integer shorthand) opts out of the automatic minimum
  entirely by setting `min_main: 0` directly: use `flex: {n, s, b}`
  or explicit `flex_grow`/`flex_shrink`/`flex_basis` keys if you want the
  automatic-minimum floor to still apply.

## 4. Text layout

`Raxol.UI.TextLayout` is the canonical wrapping entry point, unifying
code that used to be scattered across `Input.TextWrapping` and ad-hoc
Component logic. `Raxol.UI.Components.Display.Text` exposes it via props.

### `white_space` (CSS Text Module Level 3)

| Value       | Newlines  | Space/tab collapsing | Wraps at width? |
|-------------|-----------|-----------------------|------------------|
| `:normal`   | collapse  | collapse              | yes |
| `:nowrap`   | collapse  | collapse              | no |
| `:pre`      | preserve  | preserve              | no |
| `:pre_wrap` | preserve  | preserve              | yes |
| `:pre_line` | preserve  | collapse              | yes |

`:normal` is the default and is deliberately bit-identical to the
pre-existing greedy word-wrap (including its non-CJK-safe character-count
line-fit check), so existing callers see unchanged output. The other four
modes are CJK-width-safe code paths
via `Raxol.UI.TextMeasure`. A single grapheme wider than `width` is never
split mid-grapheme; it's emitted alone on its own line even if it exceeds
`width`.

```elixir
alias Raxol.UI.Components.Display.Text

# Preserve blank lines and leading indentation (like <pre>), but still wrap
# long lines to the container width.
Text.init(content: "  def foo do\n\n  end", white_space: :pre_wrap, width: 20)
```

### `text_overflow: :ellipsis` (single-line truncation)

Only takes effect when `white_space` is `:nowrap` or `:pre` (the two
non-wrapping cases); at any other combination it's a no-op. Never splits
a double-width grapheme: the cut lands one column early instead.

```elixir
Text.init(
  content: "a very long single line that must not wrap",
  white_space: :nowrap,
  text_overflow: :ellipsis,
  width: 12
)
# => "a very lo…"
```

### `line_clamp` (CSS Overflow Module Level 4)

Caps wrapped output at `max_lines`, appending a block-ellipsis (`…`) to
the last kept line only if something was actually cut. Overrides the
legacy `wrap`/`truncate` props entirely when set; `white_space` still
selects the wrapping mode underneath it.

```elixir
Text.init(
  content: "Raxol is a multi-surface application runtime for Elixir built on OTP.",
  width: 20,
  line_clamp: 2
)
# => ["Raxol is a", "multi-surface Raxol…"]  (illustrative; exact break
#     points depend on TextMeasure/greedy wrap)
```

### `text_wrap: :pretty` (Knuth-Plass, `Raxol.UI.TextLayout.Pretty`)

`:auto` (default) is the existing greedy wrapper. `:pretty` runs a
Knuth-Plass-style dynamic program that minimizes total raggedness
(`sum(abs(width - line_width) ** 2)`) plus an orphan penalty for a
paragraph's last line containing a single word, avoiding the ugly
"one long word alone on the last line" greedy artifact. Break
opportunities: whitespace runs, after a hyphen, and between CJK
ideographs. Only applies when `white_space: :normal` (the only mode with
freely chosen break points); other modes ignore it. `:pretty` never
produces more lines than `:auto` for the same input.

```elixir
Text.init(
  content: "the quick brown fox jumps over the lazy dog",
  width: 15,
  text_wrap: :pretty
)
```

## 5. Distribution and caching guarantees

These guarantees hold across the current `:flex` layout path:

- **Free space distribution is exact.** `Flexbox.Solver` uses
  largest-remainder apportionment, so grow/shrink distributes every
  round's cells exactly, with no loss to integer truncation.
- **Explicit box `width`/`height` on a flex child is honored** as the
  main-axis size input (via `FlexItem.resolve/5`'s `explicit_main`),
  instead of being overridden by content measurement.
- **Padding tuples are honored end-to-end**, including through the
  `apply_padding` step, which preserves `:prepared_cache` (and any other
  extra space keys): flex children measure with cache intact instead of
  falling back to an uncached path.
- **`gap` is included in main-axis measurement**: the container's usable
  main size is `container_main - total_gaps` before flexible-length
  resolution runs, and `MinContent`'s row aggregation adds
  `gap * (n - 1)` to the summed child minimums: gap doesn't cause
  under- or over-fitting against the container.

## 6. The `:row`/`:column` compatibility dialect

Literal `%{type: :row}` / `%{type: :column}` element maps (as opposed to
the View DSL `row`/`column` macros, which build `:flex` maps) predate the
flex engine and carried their own conventions. They now translate onto
the flex engine (`Engine.containers_compat_to_flex/2`) preserving those
conventions:

- `gap` defaults to **1** in layout and **0** in measurement (the old
  dialect disagreed with itself; both behaviors are preserved so
  auto-sized boxes keep their dimensions)
- `justify`/`align` default to `:start` (mapped to `:flex_start`; the
  dialect never stretched children)
- children default to `flex_shrink: 0`: natural size, overflow instead of
  reflow. A child that declares any flex property opts out.
- `gap`/`justify`/`align`/`padding` are read from `attrs` first, then
  top-level keys, then the defaults above.

New code should use `:flex` (the View DSL macros) directly.

## 7. Overflow and scroll anchoring

- `style: %{overflow: :visible | :hidden | :clip | :auto | :scroll}` on
  boxes and flex containers. Anything but `:visible` (the default) stamps
  every descendant with the container's content rectangle as clip bounds;
  the renderer drops cells outside it. Nested clips intersect.
  `:auto`/`:scroll` clip identically, scrolling itself is the Viewport
  component's job; the property only guarantees containment.
- `Raxol.UI.Components.Display.Viewport` takes
  `overflow_anchor: :auto | :none`. `:auto` (default) is the terminal-log
  idiom: a viewport scrolled to the bottom stays pinned to the bottom as
  content grows; scrolling up releases the pin. `:none` never moves
  `scroll_top` on content changes.
- Chart wrapper boxes (`Raxol.UI.Charts.ViewBridge`) size themselves to
  the chart's render region and default to `overflow: :hidden`: chart
  cells can never paint outside the declared region.

## Future work

- **Span-aware wrapping**: a wrapped paragraph cannot yet carry per-span
  styles (`MarkdownRenderer` drops inline styling on lines that wrap;
  single-line content keeps it). Requires the wrapper to thread style
  runs through break points.
- **`:fill` as a dimension value**: accepted by the box path but not by
  `FlexItem` (where it clamps to `:auto` with telemetry). Candidate
  alias: `{:pct, 100}`.
- **`align-items: baseline`, reverse directions, `wrap-reverse`**:
  intentionally unsupported (see section 2); revisit only with a concrete
  consumer.

## Only `:box` is addressable

`Flexbox.process_flex/3` emits positioned elements for a container's *children*,
never for the container itself. A `:flex`/`:row`/`:column` **dissolves** into its
children's positions: it is pure layout, and it does not exist in the output.

Only `:box` produces a positioned element of its own. So a box is the only thing
that can:

- carry an `:id` (and therefore an accessibility role, a `data-raxol-id` CSS
  selector, an MCP projection, a change-stream identity)
- bound its own width/height
- stamp `:clip_bounds` on its descendants (i.e. clip its content)
- paint a background or a border

**A component that must be addressable has to render as a `:box`.** This is not
a gap to be filled in later; it is the split between "paints and is addressable"
and "positions its children and vanishes".

This has bitten us twice. `Viewport` rendered as a `:row`, so it could neither
bound nor clip its own content: its windowed rows painted straight through the
enclosing border and over whatever followed. The same `:row` had no id, so the
LiveView surface had nothing to key `overflow-anchor` CSS to, and the feature
shipped inert.

If you need flex layout *and* addressability, nest them: a `:box` that carries
the identity, bounds, clipping and background, wrapping a `:flex` that does the
layout.

## See also

- `docs/core/ARCHITECTURE.md`: where layout sits in the render pipeline.
- Module docs: `Raxol.UI.Layout.FlexItem` (resolved-item semantics),
  `Raxol.UI.Layout.Flexbox.Solver` (flexible-length algorithm),
  `Raxol.UI.Layout.MinContent` (automatic minimum measurement).


<!-- docs/core/RENDERING.md -->

# Rendering: how paint works, and the rules that keep it honest

The *why* lives in [ADR-0029: The Terminal Cell Model](../adr/0029-the-terminal-cell-model.md).
What follows is the working reference, including the traps that have actually
bitten us.

---

## The model in one paragraph

A terminal is a grid of cells. A cell is `{x, y, grapheme, fg, bg, attrs}`: one
grapheme, one foreground, one background. No alpha. No sub-cell positioning. No
layers. The renderer turns positioned elements into cells, composes them into a
`ScreenBuffer`, and emits one SGR-styled run per contiguous same-style span,
each terminated with `\e[0m`.

---

## The five rules

### 1. A background is optional. A foreground is not.

Text needs a colour, so a foreground falls back to the theme. A background does
not fall back to anything.

```elixir
text(content: "hi", fg: :red)     # fg :red, bg unpainted
box(border: :single)              # fully transparent, border included
box(border: :single, bg: :blue)   # a blue panel
```

### 2. Unpainted (`nil`) means *show what is beneath*: never "black", never "erase"

```elixir
bg: nil      # transparent: the parent's fill, or the terminal
bg: :black   # an actual, opaque black
```

Over a filled parent, an unpainted cell inherits the parent's fill. At the top of
the tree there is nothing beneath, so the terminal shows through. Both come from
the same rule; you do not need to special-case "transparent" widgets.

> **Never** default an unpainted background to `:black`. It renders `\e[40m`: an
> opaque black cell. It looks correct on a black terminal and punches a hole
> through a transparent one.

### 3. Never paint a background equal to the terminal's own

A cell is transparent *precisely because* we emitted no background for it. So
painting the terminal's own colour is not a no-op. It is the destruction of
transparency, disguised as a no-op.

This includes the theme's `background`, which is only an *assumption* about the
terminal's. **Themes colour content** (fg, accents, borders); the terminal owns
the canvas. If an app genuinely wants a different canvas, it sets a background
explicitly.

To adapt to the real background rather than assume it, use
`Raxol.UI.CellDim`: it detects the ground via OSC 11 and solves colours against
it.

**If you want an opaque panel, ask for `:surface`, not `:background`.** They are
different colours doing different jobs:

```elixir
Theme.get_color(theme, :background)  # what we ASSUME the terminal is, never paint this
Theme.get_color(theme, :surface)     # a raised opaque thing, painted on purpose
```

A modal is opaque by design: it sits over dimmed content that must not read
through it. It gets that opacity from `:surface`. Reaching for `:background`
looks identical on a default terminal and is wrong everywhere else.

### 4. A box's background fills the whole box, border included

```elixir
box(border: :single, bg: :blue)                            # blue panel, border on the fill
box(border: :single, bg: :blue, border_bg: :red)           # red frame around a blue panel
box(border: :single, bg: :blue,
    background_clip: :padding_box)                         # fill inset; outline sits on what's behind
```

A cell cannot be half-painted, so the border glyph's cell is either inside the
background or outside it. `:border_box` (the default) puts it inside: **a frame
is the panel's edge, not a thing floating beside it.** Leaving it unpainted cuts
a one-cell channel around the box through which the backdrop shows: a seam no
choice of colours can fix.

### 5. The frame owns its geometry: don't hand-roll escape codes

Row joins are `\r\n` (raw output does not cook a bare `\n`), autowrap is disabled
(`\e[?7l`), and every run is `\e[0m`-terminated. If you are writing escape codes
by hand in a component, you are almost certainly in the wrong layer.

> **Never** embed raw ANSI in a string passed to `text/1` or the View DSL. Use
> `text("hi", fg: :cyan, style: [:bold])`, never `text("\e[36mhi\e[0m")`.

---

## Where to put things

| You want | Set it on |
|---|---|
| a colour for text | `fg:` |
| a filled panel | `bg:` on a `box` |
| an *opaque* panel, colour from the theme | `bg:` ← `Theme.get_color(theme, :surface)` |
| a differently-coloured frame | `border_bg:` |
| the frame to *not* be part of the fill | `background_clip: :padding_box` |
| a colour that adapts to the user's terminal | `Raxol.UI.CellDim` / the H-K palette |
| the terminal's canvas to change | nothing: it isn't yours (`:background` is an assumption, not a paint) |

---

## How a frame reaches the terminal

The renderer diffs the grid and emits every frame (keyframe or diff) in one
absolute-CUP vocabulary. `Raxol.Core.Runtime.Rendering.Backends.build_terminal_frame/4`
holds the whole decision:

- The previous frame is already in hand as `state.buffer`, so the grid is its
  own diff basis. `keyframe?/3` is true on the first frame, on a `force_repaint`
  (resume, resize), or when the dimensions change; otherwise the frame is a
  diff.
- A keyframe is a leading `\e[2J` followed by every row. A diff is only the rows
  whose cells changed: `changed_rows/2` compares `prev.cells` against
  `next.cells` row by row.
- Either kind emits each row at its absolute position: `\e[y;1H\e[0m\e[2K` then
  the row's bytes. There are no `\r\n` row-joins and no full-screen clear on the
  common path.

This is only safe because a row is a pure function of its own cells.
`Raxol.Terminal.Renderer.render_row/2` carries no pen state from the row above (
every run is `\e[0m`-terminated) so a row re-emitted in isolation is
byte-identical to its slice of the full frame, and a diff never has to reason
about what the row above left on the pen.

Two consequences worth knowing:

- A control byte or a standalone zero-width character in a cell is blanked at
  the write boundary (`Backends.sanitize_char/1`). Under incremental rendering
  nothing repaints a corrupted row, so an in-cell `\e`/`\n`/`\t` (which would
  bleed onto the next row) must be made unrepresentable downstream. (A ZWJ
  *inside* an emoji cluster is load-bearing and never reaches here alone, since
  cells hold whole grapheme clusters.)
- Style batching is on for this path (`Raxol.Terminal.Renderer.new/4` with
  batching `true`): adjacent same-style cells merge into one SGR run,
  round-trip-identical (each run still `\e[0m`-terminated) and far fewer bytes
  on a styled UI.

> **Proposed (not yet implemented).** A view could declare a cursor park at
> the root of its element tree (a `Backends.declared_cursor/1` seam) so that
> every frame kind ends with a park tail (DECTCEM show/hide plus an absolute
> CUP) because the emitted rows moved the physical cursor and nothing else
> puts it back. `build_terminal_frame/4` does not emit a park tail today; this
> paragraph describes the intended design, not shipped behavior.

---

## Region prominence

> **Proposed (not yet implemented).** This section describes an intended
> design. None of the modules or functions named below
> (`Raxol.UI.ColorResolver`, `Raxol.UI.ColorIntent`,
> `Raxol.UI.RegionPolicy.region_prominence/4`,
> `Raxol.UI.Layout.Engine.stamp_region_prominence/2`, `@region_gamma`) exist in
> the codebase yet. Do not treat it as a reference to shipped behavior.

Intent colors resolve to literals exactly once, at the render choke point:
`Raxol.UI.ColorResolver` is the single whole-list pass that turns
`Raxol.UI.ColorIntent` structs into concrete colors "as close to the terminal
writer as this codebase gets". Focus-driven region dimming rides that same pass.

- **The policy is pure.** `Raxol.UI.RegionPolicy.region_prominence/4` takes the
  region paths present this frame, the focused path, and any mounted dimming
  overlays, and returns `%{region_path => float}`. The focused region's whole
  lineage (itself, its ancestors, and its descendants) stays at `1.0`, so a
  focused input never dims its own panel; a peer region drops one ladder step
  (`0.8`); each overlay multiplies everything outside its own subtree by `0.45`;
  the product is floored at `0.4`, below which a region reads as a broken
  terminal. With `focus: nil` and no overlays every region resolves to `1.0`, so
  an app that never focuses a region and never opens an overlay renders
  byte-identically to pre-region code.
- **The engine wires it in.** `Raxol.UI.Layout.Engine.stamp_region_prominence/2`
  stamps the resolved float on every positioned element (threading
  `:focused_region` from the render context) as a transient marker the
  `ColorResolver` reads and then strips. It is never a real cell attribute.
- **The fade is closed-form.** Both foreground and background fade apparent
  lightness toward the terminal ground and scale chroma by `p ** @region_gamma`,
  where `@region_gamma = ln(0.65) / ln(0.45)`. That exponent is the exact solve
  that reproduces the existing modal-dim look through the unified formula: the
  modal dialog dim is just the `focus: nil`, single-overlay case of the general
  policy.

This is the same discipline as rule 3 above: prominence is granted by a solver
against the user's real ground, never hand-set in a component.

---

## Traps that have actually bitten us

Each of these shipped. Each was invisible on an opaque black terminal with the
default theme.

**`gap` is not read from `style`.** A literal `:row`/`:column` runs through the
Containers compat map, which reads `gap` from `:attrs` or the **top level** (
never from `:style`) and otherwise defaults it to **1** in layout mode.

```elixir
%{type: :column, style: %{gap: 0}, children: ...}   # ignored -> gap 1, double-spaced
%{type: :column, gap: 0, children: ...}             # correct
```

**An unknown border variant renders as a space.** `BorderRenderer`'s catch-all
maps anything unrecognised to `:none`, whose horizontal run is `" "`. So
`variant: :heavy` (a style that does not exist) renders an *invisible* divider
rather than failing. Resolve unknown variants to a visible default.

**Only `:box` is addressable.** `:flex`/`:row`/`:column` dissolve into their
children's positions and never become positioned elements: they cannot carry an
id, bound their own height, or clip their content. A component that must be
addressable (identity, bounds, clipping, CSS keying, an a11y role) has to render
as a `:box`. See [LAYOUT.md](LAYOUT.md).

**Cells do not composite.** Writing a cell replaces what was there. This is why
rule 2 exists; if you add a new composition point, it must inherit an unpainted
background rather than write `nil` over a fill.

**Stacking two boxes to fake opacity is a workaround, not a design.** The modal
used to render an unbordered fill box behind an identical bordered box, because a
border only paints its own ring and the interior would otherwise show the dimmed
content beneath. Once a box fills its interior (rule 4) the outer box is dead
weight, and a doubled footprint in the layout. If you find yourself relying on
paint order between two elements of the same size, the layer below you is missing
something.

---

## The question to ask

Every bug above passed review, passed tests, and looked right, because it was
tested on the configuration that hides it.

So the review question is not *"does this look right?"* It is:

> **What is this value's one job, and what happens when the user's terminal is
> not mine?**

Every one of these bugs was **one name doing two jobs**: `:black` meaning both
*unpainted* and *black*; `nil` meaning both *transparent* and *erase*; a box's
`bg` meaning both *fill* and *border paint*. The fix was always to split the
meanings apart and make the wrong one unrepresentable.


<!-- docs/core/SINGLETONS.md -->

# VM-Singleton GenServer Audit

Every named-singleton (`name: __MODULE__`) process in the Raxol codebase,
classified **intentional** or **questionable**, with the reasoning. Issues
#228 and #229 surfaced because the runtime had *latent* singletons: modules
registered as VM-wide names that were actually meant to support multiple
concurrent instances. Making the contract explicit means the same class of
bug cannot repeat without a deliberate decision.

## Why this matters

`GenServer.start_link(name: __MODULE__)` collides on the second call from
the same VM with `{:error, {:already_started, _}}`. For a process designed
to be VM-wide (one config store, one rate limiter), that's correct. For a
process designed to be per-instance (one Dispatcher per Raxol Lifecycle,
one PluginManager per app), it's a latent multi-tenancy bug that surfaces
the moment two SSH sessions, two LiveView mounts, or two agents run
concurrently in the same node.

Raxol explicitly supports concurrent SSH (`Raxol.SSH.Session` per channel),
agent fan-out (`Raxol.Agent.Team`), and multi-mount LiveView. Every named
singleton is a constraint on that surface.

## Policy

1. **Supervisors** may use `name: __MODULE__`; canonical and harmless.
2. **VM-wide services** (config, registries, accessibility queues, rate
   limiters, dev tools) may use `name: __MODULE__`. Document the "why this
   is one-per-VM" reasoning here.
3. **Per-instance processes** (Dispatcher, Lifecycle, anything spawned per
   app/session/channel) MUST accept `[name: nil]` and be addressable by pid
   from the parent's state. Hardcoding `name: __MODULE__` for these is a
   bug.
4. New `name: __MODULE__` registrations must update this file.

A CI grep guard at `scripts/check_singletons.sh` enforces (4) by failing
when the set of singleton-registering call sites diverges from the
allowlist below.

## Allowlist

### Supervisors (canonical)

| Module | Path |
|--------|------|
| `Raxol.Core.CoreSupervisor` | `lib/raxol/core/core_supervisor.ex` |
| `Raxol.Core.Runtime.RuntimeSupervisor` | `lib/raxol/core/runtime/runtime_supervisor.ex` |
| `Raxol.Core.ServerRegistry` | `lib/raxol/core/server_registry.ex` |
| `Raxol.DynamicSupervisor` | `lib/raxol/dynamic_supervisor.ex` |
| `Raxol.Terminal.Supervisor` | `packages/raxol_terminal/lib/raxol/terminal/terminal_supervisor.ex` |
| `Raxol.Core.Runtime.Plugins.PluginSupervisor` | `packages/raxol_core/lib/raxol/core/runtime/plugins/plugin_supervisor.ex` |
| `Raxol.Agent.Supervisor` | `packages/raxol_agent/lib/raxol/agent/supervisor.ex` |
| `Raxol.MCP.Supervisor` | `packages/raxol_mcp/lib/raxol/mcp/supervisor.ex` |
| `Raxol.Speech.Supervisor` | `packages/raxol_speech/lib/raxol/speech/supervisor.ex` |
| `Raxol.Telegram.Supervisor` | `packages/raxol_telegram/lib/raxol/telegram/supervisor.ex` |
| `Raxol.Watch.Supervisor` | `packages/raxol_watch/lib/raxol/watch/supervisor.ex` |

### VM-wide services (intentional)

| Module | Why one-per-VM |
|--------|----------------|
| `Raxol.RBAC` | One role/permission set per node |
| `Raxol.Dev.CodeReloader` | One reloader watches the VM in dev |
| `Raxol.Core.Runtime.ProcessStore` | Explicit Process-dictionary replacement; VM-wide by design |
| `Raxol.Core.Config.ConfigStore` | VM-wide config |
| `Raxol.Core.Accessibility.Announcements` | One global accessibility queue |
| `Raxol.Core.Runtime.Plugins.PluginLifecycle` | Plugins shared across Lifecycles; ETS-backed Registry, plugin_id-namespaced state. Confirmed by #229. |
| `Raxol.Core.Runtime.Plugins.ResourceBudget` | One budget shared across plugins |
| `Raxol.Speech.Recognizer` | One Whisper model per VM |
| `Raxol.Speech.Listener` | One microphone per host |
| `Raxol.Speech.Speaker` | One TTS pipeline per VM |
| `Raxol.Speech.TTS.OsSay` | OS process owns audio device |
| `Raxol.Speech.TTS.Noop` | Test stub |
| `Raxol.Telegram.SessionRouter` | One bot router per VM (re-evaluate if multi-bot needed) |
| `Raxol.Watch.Notifier` | Push fan-out via DeviceRegistry; only one notifier needed |
| `Raxol.Watch.DeviceRegistry` | ETS-backed device list |
| `Raxol.Watch.Push.Noop` | Test stub |
| `Raxol.Symphony.Runners.Noop` | Test stub |

### Questionable (re-evaluate when next touched)

| Module | Concern |
|--------|---------|
| `Raxol.Demo.SessionManager` | Manages demo sessions, but registered VM-wide. Two demo apps would collide. Likely OK in practice, but confirm. |
| `Raxol.Core.Metrics.MetricsCollector` | Metrics collector is VM-wide (intentional), but per-Lifecycle metric isolation may be wanted. |
| `Raxol.Recording.Recorder` | Singleton recorder; multi-session recording would interleave. Investigate before exposing recording over SSH. |
| `Raxol.Terminal.Buffer.SafeManager` | Registered as `__MODULE__` but the underlying module is `ScreenBuffer.Manager`. Buffer-per-emulator scenarios may want per-instance. |

## CI guard

`scripts/check_singletons.sh` greps the first-party tree for
`GenServer\|Agent\|Supervisor\|DynamicSupervisor\.start_link.*name: __MODULE__`
and diffs the result against `scripts/.singletons-allowlist`. To add a new
singleton:

1. Implement the registration.
2. Add the file path to `scripts/.singletons-allowlist`.
3. Document the reasoning in this file under "Allowlist" above.

If you're tempted to add a registration but the process is per-instance,
follow the `:agent`/`:liveview`/`:ssh` pattern in
`Raxol.Core.Runtime.Lifecycle.Initializer.start_dispatcher/5`: accept
`[name: nil]` from the caller and skip name registration in those envs.

## Related

- #228: Dispatcher singleton blocked concurrent SSH (fixed in #232)
- #229: PluginManager `already_started` adoption (fixed in #233)


<!-- docs/cookbook/README.md -->

# Cookbook

Practical recipes for building terminal applications with Raxol.

## Guides

- [Building Apps](BUILDING_APPS.md): TEA patterns, state machines, scrollable lists, key chords, testing
- [Custom Components](CUSTOM_COMPONENTS.md): author your own Components
- [Configuration](CONFIG.md): TOML config, environment overrides
- [SSH Deployment](SSH_DEPLOYMENT.md): Serve apps over SSH, production setup, Fly.io
- [Theming](THEMING.md): Terminal colors, theme system, LiveView CSS, accessibility
- [LiveView Integration](LIVEVIEW_INTEGRATION.md): Embed terminals in Phoenix LiveView
- [Performance Optimization](PERFORMANCE_OPTIMIZATION.md): 60fps rendering, diffing, caching

## Examples

Complete applications are in the [`examples/`](../../examples/README.md) directory:

- `examples/getting_started/counter.exs`: Minimal TEA app
- `examples/demo.exs`: Live BEAM dashboard with sparklines
- `examples/apps/file_browser.exs`: File browser with tree navigation
- `examples/getting_started/todo_app.exs`: Todo list (state machine modes)
- `examples/ssh/ssh_counter.exs`: SSH-served counter


<!-- docs/cookbook/BUILDING_APPS.md -->

# Building Apps

Patterns you reach for once the counter from the
[Quickstart](../getting-started/QUICKSTART.md) grows into a real app.

> All code below assumes `use Raxol.Core.Runtime.Application`, which aliases
> `Raxol.Core.Events.Event` as `Event`. You can write `%Event{...}` instead of
> the full module path.

## State design

### Flat state with derived values

Keep your model flat. Derive display values in `view/1`, not `update/2`:

```elixir
# Model: just raw data
%{
  items: ["milk", "eggs", "bread"],
  cursor: 0,
  filter: "",
  editing: false
}

# Derive in view
def view(model) do
  filtered = Enum.filter(model.items, &String.contains?(&1, model.filter))
  visible_count = length(filtered)
  # ...render filtered, visible_count...
end
```

### State machines via pattern matching

Use atoms for mode, pattern match in both `update/2` and `view/1`:

```elixir
def init(_ctx), do: %{mode: :browsing, items: [], selected: nil}

def update(msg, %{mode: :browsing} = model) do
  case msg do
    %Raxol.Core.Events.Event{type: :key, data: %{key: :enter}} ->
      {%{model | mode: :editing}, []}
    %Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: "/"}} ->
      {%{model | mode: :searching, filter: ""}, []}
    _ -> {model, []}
  end
end

def update(msg, %{mode: :searching} = model) do
  case msg do
    %Raxol.Core.Events.Event{type: :key, data: %{key: :escape}} ->
      {%{model | mode: :browsing, filter: ""}, []}
    %Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: c}} ->
      {%{model | filter: model.filter <> c}, []}
    _ -> {model, []}
  end
end

def view(%{mode: :browsing} = model), do: render_browser(model)
def view(%{mode: :searching} = model), do: render_search(model)
def view(%{mode: :editing} = model), do: render_editor(model)
```

## Common recipes

Use **state machines** when your app has distinct modes (browsing vs editing vs searching). Use **scrollable lists** when you have more items than fit on screen. Use **keychord sequences** for Vim-style multi-key commands.

### Scrollable list

```elixir
def init(_ctx) do
  %{
    items: Enum.map(1..100, &"Item #{&1}"),
    cursor: 0,
    scroll_offset: 0,
    visible_rows: 20
  }
end

def update(msg, model) do
  case msg do
    %Raxol.Core.Events.Event{type: :key, data: %{key: :down}} ->
      new_cursor = min(model.cursor + 1, length(model.items) - 1)
      scroll = adjust_scroll(new_cursor, model.scroll_offset, model.visible_rows)
      {%{model | cursor: new_cursor, scroll_offset: scroll}, []}

    %Raxol.Core.Events.Event{type: :key, data: %{key: :up}} ->
      new_cursor = max(model.cursor - 1, 0)
      scroll = adjust_scroll(new_cursor, model.scroll_offset, model.visible_rows)
      {%{model | cursor: new_cursor, scroll_offset: scroll}, []}

    _ -> {model, []}
  end
end

defp adjust_scroll(cursor, offset, visible) do
  cond do
    cursor < offset -> cursor
    cursor >= offset + visible -> cursor - visible + 1
    true -> offset
  end
end

def view(model) do
  visible_items =
    model.items
    |> Enum.slice(model.scroll_offset, model.visible_rows)
    |> Enum.with_index(model.scroll_offset)

  column do
    Enum.map(visible_items, fn {item, idx} ->
      if idx == model.cursor do
        text("> #{item}", fg: :cyan, style: [:bold])
      else
        text("  #{item}")
      end
    end)
  end
end
```

See `examples/getting_started/todo_app.exs` for a working scrollable list.

### Periodic data refresh

```elixir
def subscribe(_model) do
  [subscribe_interval(1000, :refresh)]
end

def update(:refresh, model) do
  stats = %{
    memory: :erlang.memory(:total) |> div(1024 * 1024),
    processes: :erlang.system_info(:process_count),
    uptime: :erlang.statistics(:wall_clock) |> elem(0) |> div(1000)
  }
  {%{model | stats: stats}, []}
end
```

### Confirmation dialog

```elixir
def update(:delete_pressed, model) do
  {%{model | confirm: "Delete #{model.selected}?"}, []}
end

def update(:confirm_yes, model) do
  items = List.delete(model.items, model.selected)
  {%{model | items: items, confirm: nil, selected: nil}, []}
end

def update(:confirm_no, model) do
  {%{model | confirm: nil}, []}
end

def view(%{confirm: msg} = model) when is_binary(msg) do
  column do
    [
      render_main(model),
      box style: %{border: :double, padding: 1, width: 40} do
        column style: %{gap: 1} do
          [
            text(msg, fg: :yellow, style: [:bold]),
            row style: %{gap: 2} do
              [
                button("Yes", on_click: :confirm_yes),
                button("No", on_click: :confirm_no)
              ]
            end
          ]
        end
      end
    ]
  end
end
```

### Multi-panel layout with tab switching

```elixir
@panels [:files, :preview, :log]

def update(%Raxol.Core.Events.Event{type: :key, data: %{key: :tab}}, model) do
  current = Enum.find_index(@panels, &(&1 == model.panel))
  next = Enum.at(@panels, rem(current + 1, length(@panels)))
  {%{model | panel: next}, []}
end

def view(model) do
  column do
    [
      # Tab bar
      row style: %{height: 1} do
        Enum.map(@panels, fn panel ->
          if panel == model.panel do
            text(" #{panel} ", fg: :black, bg: :cyan, style: [:bold])
          else
            text(" #{panel} ", fg: :white)
          end
        end)
      end,
      # Active panel content
      box style: %{border: :single, flex: 1} do
        render_panel(model.panel, model)
      end
    ]
  end
end
```

### Sparkline helper

Render inline charts from a list of values:

```elixir
@spark_chars ~w(▁ ▂ ▃ ▄ ▅ ▆ ▇ █)

defp sparkline(values) when values == [], do: ""

defp sparkline(values) do
  max_val = Enum.max(values)
  if max_val == 0 do
    String.duplicate(hd(@spark_chars), length(values))
  else
    values
    |> Enum.map(fn v ->
      idx = trunc(v / max_val * 7)
      Enum.at(@spark_chars, min(idx, 7))
    end)
    |> Enum.join()
  end
end

# Usage in view:
text("Memory: #{sparkline(model.memory_history)}", fg: :green)
```

See `examples/demo.exs` for sparklines in action.

### Progress bar helper

```elixir
defp progress_bar(value, max, width) do
  pct = if max > 0, do: value / max, else: 0
  filled = trunc(pct * width)
  empty = width - filled
  String.duplicate("█", filled) <> String.duplicate("░", empty)
end

# Usage:
text("[#{progress_bar(75, 100, 30)}] 75%", fg: :cyan)
```

## Keyboard patterns

### Vim-style keybindings

```elixir
def update(%Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: "j"}}, model) do
  # Down
  {%{model | cursor: min(model.cursor + 1, length(model.items) - 1)}, []}
end

def update(%Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: "k"}}, model) do
  # Up
  {%{model | cursor: max(model.cursor - 1, 0)}, []}
end

def update(%Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: "g", ctrl: false}}, model) do
  # Go to top
  {%{model | cursor: 0}, []}
end

def update(%Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: "G"}}, model) do
  # Go to bottom
  {%{model | cursor: length(model.items) - 1}, []}
end
```

### Key chord sequences

Track a key buffer for multi-key commands:

```elixir
def update(%Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: c}}, model) do
  chord = model.key_buffer <> c

  case chord do
    "dd" -> {%{model | items: delete_current(model), key_buffer: ""}, []}
    "gg" -> {%{model | cursor: 0, key_buffer: ""}, []}
    _ when byte_size(chord) >= 2 -> {%{model | key_buffer: ""}, []}
    _ -> {%{model | key_buffer: chord}, []}
  end
end
```

## Styling patterns

### Color by value

```elixir
defp status_color(:ok), do: :green
defp status_color(:warning), do: :yellow
defp status_color(:error), do: :red
defp status_color(_), do: :white

defp cpu_color(pct) when pct > 90, do: :red
defp cpu_color(pct) when pct > 70, do: :yellow
defp cpu_color(_), do: :green
```

### Bordered panels with titles

```elixir
defp panel(title, content, opts \\ []) do
  fg = Keyword.get(opts, :fg, :cyan)

  box style: %{border: :single, flex: 1, padding: 0} do
    column do
      [
        text(" #{title} ", fg: fg, style: [:bold]),
        text(String.duplicate("─", 40), fg: :magenta),
        content
      ]
    end
  end
end
```

### Conditional content

```elixir
def view(model) do
  column do
    [
      text("Status: #{model.status}"),
      if model.loading do
        text("Loading...", fg: :yellow)
      else
        text("Ready", fg: :green)
      end,
      if model.error do
        text("Error: #{model.error}", fg: :red)
      end
    ]
    |> List.flatten()
    |> Enum.reject(&is_nil/1)
  end
end
```

## Testing

### Test your update function

`update/2` is a pure function, so test it directly:

```elixir
test "increment increases count" do
  model = %{count: 0}
  {new_model, cmds} = MyApp.update(:increment, model)
  assert new_model.count == 1
  assert cmds == []
end

test "quit sends command" do
  model = %{count: 0}
  quit_event = %Raxol.Core.Events.Event{
    type: :key, data: %{key: :char, char: "q"}
  }
  {_model, cmds} = MyApp.update(quit_event, model)
  assert [%Raxol.Core.Runtime.Directive.Stop{}] = cmds
end
```

### Test state transitions

```elixir
test "search mode filters items" do
  key = fn
    k when is_atom(k) -> %Raxol.Core.Events.Event{type: :key, data: %{key: k, char: nil}}
    c -> %Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: c}}
  end

  model = %{mode: :browsing, items: ["apple", "banana", "avocado"], filter: ""}

  # Enter search mode
  {model, _} = MyApp.update(key.("/"), model)
  assert model.mode == :searching

  # Type filter
  {model, _} = MyApp.update(key.("a"), model)
  assert model.filter == "a"

  # Escape returns to browsing
  {model, _} = MyApp.update(key.(:escape), model)
  assert model.mode == :browsing
  assert model.filter == ""
end
```

## Next steps

- [Component Gallery](../getting-started/COMPONENT_GALLERY.md): every Component with examples
- [Cookbook index](./README.md): the other recipes


<!-- docs/cookbook/CONFIG.md -->

# TOML Configuration

Raxol reads TOML configuration through `Raxol.Config` (backed by UnifiedConfigManager on the BaseManager pattern), with environment-specific overrides, runtime updates, validation, and hot reload.

## File structure

```bash
config/
├── raxol.toml                    # Main config (you create this; copy from the example)
├── raxol.example.toml            # All options documented
└── environments/
    ├── development.toml          # Dev overrides
    └── production.toml           # Production overrides
```

Test settings live in `config/test.exs`, not a TOML file.

### Loading order

1. `config/raxol.toml`: base
2. `config/environments/{env}.toml`: environment overrides
3. Runtime overrides via `Raxol.Config.set/2`

Later values win.

## Full schema

```toml
# Terminal
[terminal]
width = 80
height = 24
scrollback_size = 10000
encoding = "UTF-8"
bell = true

[terminal.cursor]
style = "block"          # block, underline, bar
blink = true
blink_rate = 500         # ms

[terminal.colors]
palette = "default"      # default, solarized, dracula, nord
true_color = true

[terminal.font]
family = "monospace"     # font family
size = 12                # point size
bold = false             # bold by default

# Buffer
[buffer]
max_size = 1048576       # 1MB
chunk_size = 4096
compression = false
compression_threshold = 10240

# Rendering
[rendering]
fps_target = 60
max_frame_skip = 3
enable_animations = true
animation_duration = 200  # ms
performance_mode = false
gpu_acceleration = true

# Plugins
[plugins]
enabled = true
directory = "plugins"
auto_reload = false
allowed = []             # empty = all allowed
disabled = []
load_timeout = 5000      # ms

# Security
[security]
session_timeout = 1800   # 30 minutes
max_sessions = 5
enable_audit = true
password_min_length = 8
password_require_special = true
password_require_numbers = true
enable_2fa = false

[security.rate_limiting]
enabled = true
window = 60000           # 1 minute
max_requests = 100

# Performance
[performance]
profiling_enabled = false
benchmark_on_start = false
cache_size = 100000
cache_ttl = 300000       # 5 minutes
worker_pool_size = 4

# Theme
[theme]
name = "default"
auto_switch = false
custom_themes_dir = "themes"

# Logging
[logging]
level = "info"           # debug, info, warning, error
file = "logs/raxol.log"
max_file_size = 10485760 # 10MB
rotation_count = 5
format = "text"          # text, json
include_metadata = true

# Accessibility
[accessibility]
screen_reader = false
high_contrast = false
focus_indicators = true
reduce_motion = false
font_scaling = 1.0

# Keybindings
[keybindings]
enabled = true
config_file = "keybindings.toml"
vim_mode = false
emacs_mode = false
```

## API

### Starting

The config server starts automatically via `Raxol.Application`:

```elixir
children = [
  {Raxol.Config, [config_file: "config/raxol.toml"]},
  # ...
]
```

### Reading values

```elixir
width = Raxol.Config.get([:terminal, :width])
# => 80

bg_color = Raxol.Config.get([:terminal, :background], default: "#000000")

terminal_config = Raxol.Config.get([:terminal])
# => %{"width" => 80, "height" => 24, ...}

all_config = Raxol.Config.all()
```

### Writing values at runtime

```elixir
Raxol.Config.set([:terminal, :width], 120)

Raxol.Config.set([:rendering], %{
  "fps_target" => 120,
  "gpu_acceleration" => true
})
```

### Loading and reloading

```elixir
{:ok, config} = Raxol.Config.load_file("config/custom.toml")
:ok = Raxol.Config.reload()
```

### Validation

```elixir
case Raxol.Config.validate() do
  {:ok, :valid} ->
    IO.puts("Configuration is valid")

  {:error, errors} ->
    Enum.each(errors, &IO.puts("  - #{&1}"))
end
```

### Exporting

```elixir
:ok = Raxol.Config.export("config/current.toml")
```

## Environment overrides

### Development

```toml
[logging]
level = "debug"
include_metadata = true

[performance]
profiling_enabled = true

[plugins]
auto_reload = true

[rendering]
performance_mode = false
```

### Test

```toml
[terminal]
width = 80
height = 24
scrollback_size = 100

[logging]
level = "warning"
file = "logs/test.log"

[performance]
profiling_enabled = false
worker_pool_size = 2
```

### Production

```toml
[logging]
level = "warning"
format = "json"

[performance]
cache_size = 1000000
worker_pool_size = 8

[rendering]
performance_mode = true
gpu_acceleration = true

[security]
enable_audit = true
enable_2fa = true
```

## Integration examples

```elixir
# Terminal emulator
defmodule MyTerminal do
  def init do
    width = Raxol.Config.get([:terminal, :width])
    height = Raxol.Config.get([:terminal, :height])
    Raxol.Terminal.Emulator.new(width, height)
  end
end

# Rendering pipeline
defmodule MyRenderer do
  def render(buffer) do
    fps_target = Raxol.Config.get([:rendering, :fps_target])
    frame_time = 1000 / fps_target
    do_render(buffer, frame_time)
  end
end

# Plugin loader
defmodule PluginLoader do
  def load_plugins do
    if Raxol.Config.get([:plugins, :enabled]) do
      dir = Raxol.Config.get([:plugins, :directory])
      auto_reload = Raxol.Config.get([:plugins, :auto_reload])
      load_from_directory(dir, auto_reload: auto_reload)
    end
  end
end
```

## Watching for changes

```elixir
defmodule ConfigWatcher do
  use GenServer

  def init(state) do
    Process.send_after(self(), :check_config, 1000)
    {:ok, state}
  end

  def handle_info(:check_config, state) do
    new_value = Raxol.Config.get([:my, :setting])

    state = if new_value != state.current_value do
      handle_config_change(new_value)
      %{state | current_value: new_value}
    else
      state
    end

    Process.send_after(self(), :check_config, 1000)
    {:noreply, state}
  end
end
```

## Validation rules

Built-in validation covers:

- Terminal dimensions: must be positive integers
- Performance settings: cache size, worker pool size must be positive
- Security settings: session timeout, max sessions must be positive

Extend validation by modifying `validate_config/1` in `Raxol.Config`.

## Best practices

**Keep env-specific values out of the main config.** Put them in `environments/*.toml`.

**Document options.** Maintain `raxol.example.toml` with comments:

```toml
# Frame rate target for rendering
# Higher values = smoother animation but more CPU
# Default: 60
fps_target = 60
```

**Always provide defaults in code:**

```elixir
timeout = Raxol.Config.get([:network, :timeout], default: 5000)
```

**Group related settings** with nested tables:

```toml
[network]
timeout = 5000
retries = 3

[network.pool]
size = 10
overflow = 5
```

## Migrating from Application.get_env

Before:

```elixir
width = Application.get_env(:raxol, :terminal_width, 80)
```

After:

```elixir
width = Raxol.Config.get([:terminal, :width], default: 80)
```

Before (`config/config.exs`):

```elixir
config :raxol,
  terminal_width: 80,
  terminal_height: 24
```

After (`config/raxol.toml`):

```toml
[terminal]
width = 80
height = 24
```

## Troubleshooting

**Config server not started:** Make sure `{Raxol.Config, []}` is in your supervision tree.

**Invalid TOML syntax:**

```bash
mix run -e "File.read!('config/raxol.toml') |> Toml.decode!()"
```

**Missing config values:** Always use defaults:

```elixir
value = Raxol.Config.get([:section, :key], default: "fallback")
```

**Performance:** Config values are cached in memory. Keep files under 1MB and avoid storing large data blobs. Use references to external files instead.


<!-- docs/cookbook/CUSTOM_COMPONENTS.md -->

# Custom Components

Raxol provides two levels for building reusable UI:

1. **View helpers**: Private functions in your TEA module that return element trees. Start here.
2. **Component behaviour**: `Raxol.UI.Components.Base.Component` for stateful, reusable Components with lifecycle hooks.

Most apps only need view helpers. Use the Component behaviour when you need internal state, event handling, or want to publish a reusable Component.

---

## View helpers (start here)

Extract parts of your `view/1` into private functions. These are plain Elixir, with no special framework support needed.

```elixir
defmodule MyApp do
  use Raxol.Core.Runtime.Application

  @impl true
  def init(_context), do: %{items: ["Milk", "Eggs", "Bread"], cursor: 0}

  @impl true
  def update(message, model) do
    case message do
      %Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: "j"}} ->
        {%{model | cursor: min(model.cursor + 1, length(model.items) - 1)}, []}
      %Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: "k"}} ->
        {%{model | cursor: max(model.cursor - 1, 0)}, []}
      %Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: "q"}} ->
        {model, [Directive.stop()]}
      _ -> {model, []}
    end
  end

  @impl true
  def view(model) do
    column style: %{padding: 1, gap: 1} do
      [
        header("Shopping List"),
        item_list(model.items, model.cursor),
        footer()
      ]
    end
  end

  @impl true
  def subscribe(_model), do: []

  # View helpers
  # These are just functions returning element trees.
  # No special behaviour, no lifecycle. Plain Elixir.

  defp header(title) do
    box style: %{border: :double, width: :fill, padding: 0} do
      text(title, style: [:bold], fg: :cyan)
    end
  end

  defp item_list(items, cursor) do
    rows =
      items
      |> Enum.with_index()
      |> Enum.map(fn {item, idx} ->
        prefix = if idx == cursor, do: "> ", else: "  "
        style = if idx == cursor, do: [:bold], else: []
        text("#{prefix}#{item}", style: style)
      end)

    box style: %{border: :single, padding: 1, width: 30} do
      column style: %{gap: 0} do
        rows
      end
    end
  end

  defp footer do
    text("[j/k] navigate  [q] quit", style: [:dim])
  end
end
```

View helpers are composable, testable (call them and inspect the return value), and require zero boilerplate. Use them for panels, status bars, formatted tables, help text, anything that's a pure function of data.

---

## Component behaviour

For Components that need their own state and event handling, use the Component behaviour. Built-in Components like `Button`, `TextInput`, `Checkbox`, `Table`, `SelectList`, and `Modal` all use this pattern.

### The behaviour

`Raxol.UI.Components.Base.Component` defines these callbacks:

| Callback | Required? | Purpose |
|----------|-----------|---------|
| `init/1` | Yes | Initialize state from props. Return `{:ok, state}` or just `state`. |
| `render/2` | Yes | Render state to an element tree. `render(state, context)` |
| `handle_event/3` | Yes | Handle UI events. `handle_event(event, state, context)` |
| `update/2` | Yes | Handle messages. `update(message, state)` |
| `mount/1` | No | Setup after init (subscriptions, etc). Default: `{state, []}` |
| `unmount/1` | No | Cleanup on removal. Default: `state` |

### Minimal example

```elixir
defmodule MyApp.Components.Counter do
  use Raxol.UI.Components.Base.Component

  @impl true
  def init(props) do
    {:ok, %{
      id: Map.get(props, :id, "counter"),
      count: Map.get(props, :initial, 0),
      on_change: Map.get(props, :on_change),
      style: Map.get(props, :style, %{}),
      theme: Map.get(props, :theme, %{})
    }}
  end

  @impl true
  def update(:increment, state) do
    new_state = %{state | count: state.count + 1}
    notify(new_state)
    new_state
  end

  def update(:decrement, state) do
    new_state = %{state | count: state.count - 1}
    notify(new_state)
    new_state
  end

  def update(_msg, state), do: state

  @impl true
  def render(state, _context) do
    row style: %{gap: 1} do
      [
        button("-", on_click: {:click, :decrement}),
        text("#{state.count}", style: [:bold]),
        button("+", on_click: {:click, :increment})
      ]
    end
  end

  @impl true
  def handle_event({:click, action}, state, _context) do
    {update(action, state), []}
  end

  def handle_event(_event, state, _context) do
    {state, []}
  end

  defp notify(%{on_change: nil}), do: :ok
  defp notify(%{on_change: callback, count: count}), do: callback.(count)
end
```

### Using a Component

Components are used via their module's `init/1`, `handle_event/3`, and `render/2`:

```elixir
# In your TEA module's init/1:
{:ok, counter_state} = MyApp.Components.Counter.init(%{initial: 10})
model = %{counter: counter_state}

# In update/2, forward events:
counter = MyApp.Components.Counter.update(:increment, model.counter)
{%{model | counter: counter}, []}

# In view/1:
MyApp.Components.Counter.render(model.counter, %{})
```

### Real-world pattern: Checkbox

Here's how the built-in Checkbox is structured (simplified):

```elixir
defmodule Raxol.UI.Components.Input.Checkbox do
  use Raxol.UI.Components.Base.Component

  @impl true
  def init(props) do
    {:ok, %{
      id: Keyword.get(props, :id, "checkbox-#{:erlang.unique_integer([:positive])}"),
      checked: Keyword.get(props, :checked, false),
      disabled: Keyword.get(props, :disabled, false),
      label: Keyword.get(props, :label, ""),
      on_toggle: Keyword.get(props, :on_toggle),
      style: Keyword.get(props, :style, %{}),
      theme: Keyword.get(props, :theme, %{}),
      focused: false
    }}
  end

  @impl true
  def handle_event(%Event{type: :key, data: %{key: :space}}, state, _ctx) do
    if state.disabled do
      {state, []}
    else
      new_state = %{state | checked: not state.checked}
      if state.on_toggle, do: state.on_toggle.(new_state.checked)
      {new_state, []}
    end
  end

  def handle_event(_event, state, _ctx), do: {state, []}

  @impl true
  def render(state, _context) do
    mark = if state.checked, do: "[x]", else: "[ ]"
    style = if state.focused, do: [:bold], else: []
    text("#{mark} #{state.label}", style: style)
  end

  # update/2 handles prop changes
  @impl true
  def update(props, state) when is_map(props) do
    Raxol.UI.Components.Base.Component.merge_props(props, state)
  end

  def update(_msg, state), do: state
end
```

Key patterns:
- `init/1` takes a keyword list or map, returns `{:ok, state}`
- State is a flat map with `:id`, `:style`, `:theme` (standard keys)
- `handle_event/3` pattern-matches on `%Event{}` structs
- Disabled state is checked before acting
- Callbacks (`:on_toggle`) are optional and nil-checked
- `merge_props/2` handles style/theme deep-merging when props update

---

## Guidelines

### State shape

All components should include these standard keys:

```elixir
%{
  id: "unique-id",       # Required for the rendering pipeline
  style: %{},            # Layout/visual overrides
  theme: %{},            # Theme tokens
  focused: false,        # Focus state (for keyboard navigation)
  disabled: false         # Disabled state (skip event handling)
}
```

Add component-specific keys alongside these.

### Event handling

Events arrive as `%Raxol.Core.Events.Event{}` structs:

```elixir
def handle_event(%Event{type: :key, data: %{key: :enter}}, state, _ctx) do
  # Handle enter key
  {state, []}
end

def handle_event(%Event{type: :key, data: %{key: :char, char: ch}}, state, _ctx) do
  # Handle printable character
  {%{state | buffer: state.buffer <> ch}, []}
end
```

Return `{new_state, commands}` or `:passthrough` to let the event bubble up.

### Testing

Components are plain modules, so test them directly:

```elixir
test "checkbox toggles on space" do
  {:ok, state} = Checkbox.init(checked: false, label: "Agree")

  space = %Raxol.Core.Events.Event{type: :key, data: %{key: :space}}
  {new_state, []} = Checkbox.handle_event(space, state, %{})

  assert new_state.checked == true
end

test "disabled checkbox ignores events" do
  {:ok, state} = Checkbox.init(checked: false, disabled: true)

  space = %Raxol.Core.Events.Event{type: :key, data: %{key: :space}}
  {new_state, []} = Checkbox.handle_event(space, state, %{})

  assert new_state.checked == false
end
```

### When to use what

| Need | Approach |
|------|----------|
| Panel, section, formatted output | View helper (private function) |
| Reusable Component with internal state | Component behaviour |
| One-off stateful Component in your app | Keep state in your TEA model |
| Crash-isolated Component | `process_component(MyComponent, props)` |

Start with view helpers. Graduate to the Component behaviour when you find yourself passing state and event handlers around manually.

---

## Further reading

- [Component Gallery](../getting-started/COMPONENT_GALLERY.md): All built-in Components with examples
- [Building Apps](../cookbook/BUILDING_APPS.md): TEA patterns and recipes
- [Examples](https://github.com/DROOdotFOO/raxol/blob/master/examples/README.md): Runnable examples from beginner to advanced
- Built-in components to study: `lib/raxol/ui/components/input/` and `lib/raxol/ui/components/display/`


<!-- docs/cookbook/LIVEVIEW_INTEGRATION.md -->

# LiveView Integration

Two approaches: the **TEA bridge** (`Raxol.LiveView.TEALive`) runs a full TEA app rendered to HTML via PubSub, and the **raw Buffer** approach where you build a `Buffer` and push it to the LiveView yourself. Most recipes below use the raw approach since it's simpler to show in isolation.

> **Note:** Direct buffer manipulation via `Raxol.Core.{Buffer, Box}` is an advanced, low-level approach. The canonical Raxol API is TEA-based: your `view/1` callback returns an element tree and the framework handles rendering. Prefer the TEA bridge for new integrations.

## Basic terminal embedding

### Static terminal

```elixir
defmodule MyAppWeb.SimpleTerminalLive do
  use MyAppWeb, :live_view
  alias Raxol.Core.{Buffer, Box}

  def mount(_params, _session, socket) do
    buffer =
      Buffer.create_blank_buffer(80, 24)
      |> Box.draw_box(0, 0, 80, 24, :double)
      |> Buffer.write_at(10, 10, "Welcome to My App!", %{bold: true, fg_color: :cyan})
      |> Buffer.write_at(10, 12, "Press any key to continue...")

    {:ok, assign(socket, buffer: buffer)}
  end

  def render(assigns) do
    ~H"""
    <div class="container">
      <.live_component
        module={Raxol.LiveView.TerminalComponent}
        id="terminal"
        buffer={@buffer}
        theme={:nord}
      />
    </div>
    """
  end
end
```

### Periodic updates

```elixir
defmodule MyAppWeb.ClockLive do
  use MyAppWeb, :live_view
  alias Raxol.Core.{Buffer, Box}

  def mount(_params, _session, socket) do
    if connected?(socket) do
      :timer.send_interval(1000, self(), :tick)
    end

    {:ok, assign(socket, buffer: create_clock())}
  end

  def handle_info(:tick, socket) do
    {:noreply, assign(socket, buffer: create_clock())}
  end

  defp create_clock do
    time = Time.utc_now() |> Time.to_string() |> String.slice(0..7)

    Buffer.create_blank_buffer(30, 10)
    |> Box.draw_box(0, 0, 30, 10, :single)
    |> Buffer.write_at(10, 4, time, %{fg_color: :green, bold: true})
  end
end
```

---

## Event handling

### Keyboard input

```elixir
def render(assigns) do
  ~H"""
  <.live_component
    module={Raxol.LiveView.TerminalComponent}
    id="keyboard"
    buffer={@buffer}
    theme={:nord}
    on_keypress="handle_keypress"
  />
  """
end

def handle_event("handle_keypress", %{"key" => key}, socket) do
  socket =
    socket
    |> update(:key_count, &(&1 + 1))
    |> assign(last_key: key)
    |> update_buffer()

  {:noreply, socket}
end
```

### Mouse clicks

```elixir
def render(assigns) do
  ~H"""
  <.live_component
    module={Raxol.LiveView.TerminalComponent}
    id="mouse"
    buffer={@buffer}
    theme={:dracula}
    on_click="handle_click"
  />
  """
end

def handle_event("handle_click", %{"x" => x, "y" => y}, socket) do
  buffer = Buffer.write_at(socket.assigns.buffer, x, y, "X", %{fg_color: :red})
  {:noreply, assign(socket, buffer: buffer)}
end
```

### Paste support

```elixir
<.live_component
  module={Raxol.LiveView.TerminalComponent}
  id="paste"
  buffer={@buffer}
  theme={:dracula}
  on_paste="handle_paste"
/>
```

---

## State synchronization

### Two-way data binding

Keep socket state in sync with terminal display:

```elixir
defmodule MyAppWeb.CounterLive do
  use MyAppWeb, :live_view
  alias Raxol.Core.{Buffer, Box}

  def mount(_params, _session, socket) do
    socket = assign(socket, buffer: Buffer.create_blank_buffer(40, 15), count: 0)
    {:ok, update_display(socket)}
  end

  def handle_event("increment", _, socket) do
    {:noreply, socket |> update(:count, &(&1 + 1)) |> update_display()}
  end

  def handle_info({:keypress, "+"}, socket) do
    handle_event("increment", nil, socket)
  end

  defp update_display(socket) do
    buffer =
      Buffer.create_blank_buffer(40, 15)
      |> Box.draw_box(0, 0, 40, 15, :double)
      |> Buffer.write_at(5, 6, "Count: #{socket.assigns.count}", %{fg_color: :green})

    assign(socket, buffer: buffer)
  end
end
```

### External state changes

Subscribe to PubSub for external updates:

```elixir
def mount(_params, _session, socket) do
  if connected?(socket) do
    Phoenix.PubSub.subscribe(MyApp.PubSub, "system:stats")
  end

  {:ok, assign(socket, buffer: create_buffer(), stats: %{cpu: 0, memory: 0})}
end

def handle_info({:stats_updated, stats}, socket) do
  buffer =
    create_buffer()
    |> Buffer.write_at(5, 5, "CPU: #{stats.cpu}%", cpu_color(stats.cpu))
    |> Buffer.write_at(5, 7, "Memory: #{stats.memory}%", memory_color(stats.memory))

  {:noreply, assign(socket, buffer: buffer, stats: stats)}
end

defp cpu_color(cpu) when cpu > 80, do: %{fg_color: :red, bold: true}
defp cpu_color(cpu) when cpu > 50, do: %{fg_color: :yellow}
defp cpu_color(_), do: %{fg_color: :green}
```

---

## Multiple terminals

### Split screen

```elixir
def render(assigns) do
  ~H"""
  <div class="split-screen">
    <div class="left-panel">
      <.live_component
        module={Raxol.LiveView.TerminalComponent}
        id="left-terminal"
        buffer={@left_buffer}
        theme={:nord}
        on_keypress="handle_left_key"
      />
    </div>

    <div class="right-panel">
      <.live_component
        module={Raxol.LiveView.TerminalComponent}
        id="right-terminal"
        buffer={@right_buffer}
        theme={:dracula}
        on_keypress="handle_right_key"
      />
    </div>
  </div>
  """
end
```

---

## Error boundaries

Catch rendering errors without crashing:

```elixir
def handle_info({:keypress, key}, socket) do
  case safe_update(socket, key) do
    {:ok, buffer} ->
      {:noreply, assign(socket, buffer: buffer, error: nil)}

    {:error, reason} ->
      Logger.error("Buffer update failed: #{inspect(reason)}")
      {:noreply, assign(socket, error: "Failed to process key: #{reason}")}
  end
end

defp safe_update(socket, key) do
  try do
    {:ok, Buffer.write_at(socket.assigns.buffer, 5, 10, "Last key: #{key}")}
  rescue
    e -> {:error, Exception.message(e)}
  end
end
```

---

## Performance: Diff rendering

`TerminalComponent` diffs buffers automatically on each render, so you do not need to track `previous_buffer` yourself. Simply assign the new buffer and the component handles the rest:

```elixir
def handle_info(:tick, socket) do
  frame = socket.assigns.frame + 1
  {:noreply, assign(socket, buffer: create_buffer(frame), frame: frame)}
end
```

### Debounced updates

Avoid excessive re-renders:

```elixir
@debounce_ms 300

def handle_info({:keypress, key}, socket) do
  if socket.assigns.timer_ref do
    Process.cancel_timer(socket.assigns.timer_ref)
  end

  new_input = socket.assigns.input <> key
  timer_ref = Process.send_after(self(), :update_buffer, @debounce_ms)

  {:noreply, assign(socket, input: new_input, timer_ref: timer_ref)}
end

def handle_info(:update_buffer, socket) do
  {:noreply, assign(socket, buffer: create_buffer(socket.assigns.input), timer_ref: nil)}
end
```

---

## Animation hints

When a TEA app uses `Raxol.Animation.Helpers.animate/2` in its `view/1`, the rendering engine passes those hints through to `TerminalBridge`, which emits CSS `transition` rules targeting `data-raxol-id` selectors. The browser handles interpolation, with no per-frame server re-renders needed.

```elixir
import Raxol.Animation.Helpers

def view(model) do
  box id: "panel", style: %{border: :single} do
    text("Hello")
  end
  |> animate(property: :opacity, from: 0.0, to: 1.0, duration: 300)
end
```

The generated HTML includes `data-raxol-id="panel"` on the relevant spans, and a `<style>` block with:

```css
[data-raxol-id="panel"] { transition: opacity 300ms cubic-bezier(0.33, 1, 0.68, 1) 0ms; }
@media (prefers-reduced-motion: reduce) {
  [data-raxol-id] { transition-duration: 0.01ms !important; }
}
```

`stagger/2` adds incrementing delays across a list of elements. `sequence/2` chains animations on a single element so they play one after another. Both are pure functions that attach metadata; they don't start server-side timers.

The terminal backend ignores hints entirely and relies on server-computed frames via `Animation.Framework`. MCP includes hints in `StructuredScreenshot` JSON so agents can see what's animating.

---

## CSS Customization

```css
.split-screen {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1rem;
  height: 600px;
}

.terminal-container {
  background: #1e1e1e;
  border-radius: 8px;
  padding: 1rem;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
```

---

## Examples

- `examples/reference/liveview/tea_counter_live.ex`: TEA app rendered in the browser
- `examples/reference/liveview/01_simple_terminal/`: Step-by-step simple terminal

(These need a Phoenix host: they are reference modules, not `mix run` scripts.)

## Next steps

- [Buffer API](../core/BUFFER_API.md): the primitives behind `buffer_to_html/2`
- [Cookbook index](./README.md): the other recipes


<!-- docs/cookbook/PERFORMANCE_OPTIMIZATION.md -->

# Performance Optimization

Techniques for achieving 60fps terminal rendering.

## Performance targets

| Operation         | Budget  | Typical | Excellent |
| ----------------- | ------- | ------- | --------- |
| Buffer create     | < 1ms   | 0.3ms   | 0.1ms     |
| write_at (single) | < 100us | 50us    | 20us      |
| draw_box          | < 500us | 240us   | 150us     |
| render_diff       | < 2ms   | 1.2ms   | 0.5ms     |
| Full render       | < 16ms  | 8ms     | 4ms       |
| LiveView update   | < 16ms  | 5ms     | 2ms       |

16ms per frame = 60fps.

---

## Buffer diffing

Only update what changed.

```elixir
defmodule PerformantRenderer do
  alias Raxol.Core.{Buffer, Renderer}

  def render_loop(state) do
    new_buffer = create_frame(state)
    diff = Renderer.render_diff(state.buffer, new_buffer)
    IO.write(Renderer.apply_diff(diff))

    Process.sleep(16)  # ~60fps
    render_loop(%{state | buffer: new_buffer})
  end
end
```

Without diffing: ~15ms for 80x24 buffer (clear + full redraw). Diff rendering brings typical updates to ~2ms.

### Smart diffing

Full render every N frames or when buffer dimensions change:

```elixir
defp major_change?(state, new_buffer) do
  rem(state.frame_count, 60) == 0 or
  state.buffer.width != new_buffer.width or
  state.buffer.height != new_buffer.height
end
```

---

## Caching strategies

### Style caching

Reuse style maps via module attributes (compile-time):

```elixir
@header_style Style.new(bold: true, fg_color: :cyan)
@error_style Style.new(bold: true, fg_color: :red)

def render_dashboard(buffer, data) do
  buffer
  |> Buffer.write_at(5, 1, "Dashboard", @header_style)
  |> Buffer.write_at(5, 3, data.message, message_style(data.status))
end

defp message_style(:ok), do: @success_style
defp message_style(:error), do: @error_style
defp message_style(_), do: %{}
```

10-20% faster by avoiding style allocation.

### Buffer caching

Cache static parts of the UI:

```elixir
# Cache the static frame in a module attribute or process state
@main_frame Buffer.create_blank_buffer(80, 24)
            |> Box.draw_box(0, 0, 80, 24, :double)
            |> Buffer.write_at(10, 1, "My Application", %{bold: true})

# Only update dynamic content per render
@main_frame |> Buffer.write_at(10, 10, "Time: #{Time.utc_now()}")
```

---

## Lazy rendering

Only render visible content.

### Viewport rendering

```elixir
defmodule ViewportRenderer do
  def render_viewport(data, viewport) do
    buffer = Buffer.create_blank_buffer(viewport.width, viewport.height)

    data
    |> filter_visible(viewport)
    |> Enum.reduce(buffer, fn item, buf ->
      x = item.x - viewport.offset_x
      y = item.y - viewport.offset_y
      Buffer.write_at(buf, x, y, item.text, item.style)
    end)
  end
end
```

100x faster for large datasets (render 24 rows instead of 1000+).

### Virtual scrolling

Only render visible rows in scrollable lists:

```elixir
def render_list(buffer, items, scroll_offset, visible_rows) do
  visible_items = Enum.slice(items, scroll_offset, visible_rows)

  visible_items
  |> Enum.with_index()
  |> Enum.reduce(buffer, fn {item, idx}, buf ->
    Buffer.write_at(buf, 2, idx + 2, format_item(item))
  end)
  |> add_scrollbar(scroll_offset, length(items), visible_rows)
end
```

---

## 60fps checklist

- [ ] Use diff rendering. Don't redraw everything
- [ ] Cache static content. Reuse unchanged buffers
- [ ] Minimize allocations. Reuse style maps
- [ ] Batch updates. Group operations
- [ ] Lazy render. Only render visible content
- [ ] Profile regularly. Measure before optimizing
- [ ] Set frame budget. Warn if > 16ms
- [ ] Test on slow hardware

### Frame budget monitor

```elixir
defmodule FrameBudget do
  @fps_60_budget_us 16_000

  def render_with_budget(render_fn) do
    {time_us, result} = :timer.tc(render_fn)

    if time_us > @fps_60_budget_us do
      Logger.warn("Slow render: #{time_us}us (> #{@fps_60_budget_us}us)")
    end

    result
  end
end
```

---

## Common pitfalls

### Creating styles repeatedly

```elixir
# Bad: new style map each iteration
Enum.each(lines, fn line ->
  Buffer.write_at(buffer, 0, line, "Text", %{fg_color: :cyan})
end)

# Good: reuse style
style = %{fg_color: :cyan}
Enum.reduce(lines, buffer, fn line, buf ->
  Buffer.write_at(buf, 0, line, "Text", style)
end)
```

30% faster for 100+ writes.

### Full redraws

```elixir
# Bad: clear and redraw everything
IO.write("\e[2J\e[H")
IO.puts(Buffer.to_string(buffer))

# Good: diff only changed cells
diff = Renderer.render_diff(old_buffer, new_buffer)
IO.write(Renderer.apply_diff(diff))
```

Diff rendering brings typical updates to ~2ms.

### Blocking in render loop

```elixir
# Bad: sync HTTP call in render loop
data = HTTPClient.get("/api/stats")  # blocks!

# Good: async fetch, render from cache
buffer = create_frame(state.cached_data)
```

---

## Profiling

### Manual

```elixir
{time, result} = :timer.tc(fn -> Buffer.create_blank_buffer(80, 24) end)
IO.puts("Create buffer: #{time}us (#{time / 1000}ms)")
```

### Benchee

```elixir
Benchee.run(%{
  "create_buffer" => fn -> Buffer.create_blank_buffer(80, 24) end,
  "draw_box" => fn -> Box.draw_box(buffer, 0, 0, 80, 24, :double) end,
  "diff_render" => fn ->
    new = Buffer.write_at(buffer, 40, 12, "X")
    Renderer.render_diff(buffer, new)
  end
}, time: 5, memory_time: 2)
```

### Performance tests

```elixir
test "full frame render meets 60fps budget" do
  buffer = create_complex_frame()
  {time, _} = :timer.tc(fn -> Buffer.to_string(buffer) end)
  assert time < 16_000, "Full render too slow: #{time}us"
end
```

---

## Benchmarks

See `docs/bench/README.md` for the full benchmark suite comparing Raxol against Ratatui, Bubble Tea, and Textual.

## Next steps

- [Buffer API](../core/BUFFER_API.md): the primitives these techniques operate on
- [Cookbook index](./README.md): the other recipes


<!-- docs/cookbook/SSH_DEPLOYMENT.md -->

# SSH Deployment

Serve Raxol apps over SSH. Each connection gets its own process: one app, many users.
Erlang ships `:ssh` in the standard library, so this is a wrapper rather than a server: no extra dependency, no separate daemon.

## Quick start

Any TEA app can be served over SSH. Authentication is required unless anonymous access is explicitly requested, so a fund-bearing surface is never silently anonymous:

```elixir
# Public, read-only app (a dashboard, a component catalog): anonymous is fine,
# but an anonymous surface must state every resource cap or it refuses to start,
# and it binds loopback only until the exposure is separately acknowledged.
Raxol.SSH.serve(MyApp,
  port: 2222,
  allow_anonymous: true,
  max_connections: 50,
  max_per_ip: 10,
  idle_timeout: :timer.minutes(5),
  max_session_duration: :timer.hours(1)
)

# A surface that can reach payment Actions: require a public key.
Raxol.SSH.serve(MyApp, port: 2222, authorized_keys_dir: "/etc/raxol/authorized")
```

With neither option the server refuses to start. See [Authentication](#authentication) and [Anonymous surface defaults](#anonymous-surface-defaults) below.

Connect from any machine:

```bash
ssh localhost -p 2222
```

No client-side dependencies. Any SSH client works: PuTTY, OpenSSH, even `ssh` from a phone.

## Full example

```elixir
# lib/my_ssh_app.exs
defmodule MySshApp do
  use Raxol.Core.Runtime.Application

  @impl true
  def init(_ctx), do: %{count: 0}

  @impl true
  def update(msg, model) do
    case msg do
      :increment -> {%{model | count: model.count + 1}, []}
      :decrement -> {%{model | count: model.count - 1}, []}
      %Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: "q"}} -> {model, [Directive.stop()]}
      %Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: "="}} -> update(:increment, model)
      %Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: "-"}} -> update(:decrement, model)
      _ -> {model, []}
    end
  end

  @impl true
  def view(model) do
    column style: %{padding: 1, align_items: :center} do
      [
        text("SSH Counter", fg: :cyan, style: [:bold]),
        text("Count: #{model.count}", style: [:bold]),
        row style: %{gap: 1} do
          [button("=", on_click: :increment), button("-", on_click: :decrement)]
        end,
        text("Press q to disconnect", fg: :magenta)
      ]
    end
  end

  @impl true
  def subscribe(_model), do: []
end

# Start SSH server (a public counter demo, so anonymous access is intended;
# anonymous surfaces state their caps and stay on loopback by default)
{:ok, _} =
  Raxol.SSH.serve(MySshApp,
    port: 2222,
    allow_anonymous: true,
    max_connections: 10,
    max_per_ip: 2,
    idle_timeout: :timer.minutes(5),
    max_session_duration: :timer.hours(1)
  )

# Keep alive
Process.sleep(:infinity)
```

Run it:

```bash
mix run lib/my_ssh_app.exs
```

This is a simplified version of `examples/ssh/ssh_counter.exs`.

## How it works

```
SSH Client  --->  :ssh.daemon (Erlang)
                    |
                    +--> CLIHandler (SSH protocol)
                           |
                           +--> Session (per-connection)
                                  |
                                  +--> Lifecycle (TEA loop)
                                         |
                                         +--> Your App
```

1. Erlang's built-in `:ssh` module handles the SSH protocol
2. The SSH CLI handler translates SSH channel events to Raxol events
3. The SSH session manager creates a per-connection Lifecycle process
4. Your app runs identically to local mode, with the same `init/update/view`

Each connection is isolated. One user's crash doesn't affect others.

## Configuration

### Authentication

A surface that can reach payment Actions must not be silently anonymous, so authentication is fail-closed: pass one of two options, or the server refuses to start.

- `allow_anonymous: true` accepts any connection. Use it for a public, read-only app (a dashboard, the component playground).
- `authorized_keys_dir: "/path"` requires public-key auth. The directory holds an `authorized_keys` file listing the permitted public keys, and a connection must present a listed key.

For any surface that can move funds, use `authorized_keys_dir` and bind the connection to that identity before it reaches a payment Action. Do not rely on an in-app login screen inside an otherwise-anonymous SSH session, which would put credential handling inside your `update/2` instead of the transport.

### Anonymous surface defaults

An anonymous surface once reached the public internet on the strength of one env var, so `allow_anonymous: true` now carries three defaults of its own:

- **Loopback bind.** An anonymous server binds `127.0.0.1` unless the exposure is separately acknowledged with `anonymous_public: true` (or `RAXOL_SSH_ANONYMOUS_PUBLIC=1`). One flag cannot carry a surface from laptop demo to public internet; the dangerous combination requires stating the danger.
- **Stated resource caps.** `max_connections`, `max_per_ip`, `idle_timeout`, and `max_session_duration` are required (positive integers, timeouts in milliseconds) or the server refuses to start. Fifty concurrent anonymous shells must be a decision, not an omission.
- **A boot posture line.** Every start logs one greppable line naming the resulting exposure:

  ```
  [SSH] listening 127.0.0.1:2222 auth=none max_conn=50 per_ip=10 idle=300s session_max=3600s host_keys=ed25519(/etc/raxol/ssh_keys)
  ```

  The posture is the thing that goes wrong silently; this puts it where people already look. Every accept and close is also logged with the peer address, authenticated user, duration, and outcome, so whether the surface is worth running is answerable from the logs.

When probing an exposure from outside, remember that "I could not connect" is not "it is closed": test every address family the host actually has, not just the one in DNS.

### Port and host keys

```elixir
Raxol.SSH.serve(MyApp,
  port: 3000,
  host_keys_dir: "/etc/raxol/ssh_keys",  # default: ~/.raxol/ssh_keys
  allow_anonymous: true
)
```

Host keys are auto-generated (ed25519, mode `0600`) on first run under a persistent per-user directory (`~/.raxol/ssh_keys`), so clients do not get host-key-changed warnings on restart. Point `host_keys_dir` at a directory your service account owns to keep the key stable across deploys. The server refuses to start if any host key in the directory is group- or world-readable: a readable private host key makes every client's host-key trust forgeable.

Never bake a host key into a container image: the same key ships on every deploy and every replica, readable by anyone who can pull the image. Generate at first boot into persistent storage (a volume) instead, and if no volume exists, leave the SSH surface off rather than shipping a static key.

### Running alongside a Phoenix app

Add the SSH server to your supervision tree:

```elixir
# lib/my_app/application.ex
def start(_type, _args) do
  children = [
    MyAppWeb.Endpoint,
    {Raxol.SSH.Server,
     app_module: MyTerminalApp,
     port: 2222,
     authorized_keys_dir: "/etc/raxol/authorized"}
  ]

  Supervisor.start_link(children, strategy: :one_for_one)
end
```

Now the same app runs in the browser (via LiveView) and over SSH simultaneously.

## Production Considerations

### Persistent host keys

The server generates an ed25519 key on first boot; all it needs is a persistent, owner-only directory. To pre-generate instead:

```bash
mkdir -p /etc/raxol/ssh_keys
ssh-keygen -t ed25519 -f /etc/raxol/ssh_keys/ssh_host_ed25519_key -N ""
chmod 600 /etc/raxol/ssh_keys/ssh_host_ed25519_key
```

RSA is optional (`ssh-keygen -t rsa`) for very old clients; note current OpenSSH refuses SHA-1 RSA host keys, so an RSA-only server can be unreachable from modern clients.

### Isolation from signing

Do not co-locate an SSH surface with a node that holds signing keys. The interactive REPL and any anonymous surface are capability-escape risks next to a wallet, so keep the payment node separate. On the signing node, call `Raxol.Payments.Deployment.assert_signing_isolated!/0` at boot: it refuses to start when `RAXOL_REPL_EXPOSED=true`. If the SSH box joins an Erlang cluster with the signing node, run distribution over TLS (`-proto_dist inet_tls` with per-node certs), never a shared magic cookie, and gate it with `Raxol.Payments.Deployment.assert_distribution_secure!/0`.

### Systemd service

```ini
[Unit]
Description=Raxol SSH App
After=network.target

[Service]
Type=simple
User=raxol
ExecStart=/usr/local/bin/mix run --no-halt
WorkingDirectory=/opt/my_app
Environment=MIX_ENV=prod
Restart=always

[Install]
WantedBy=multi-user.target
```

### Fly.io

Expose the SSH port in `fly.toml`:

```toml
[[services]]
  internal_port = 2222
  protocol = "tcp"

  [[services.ports]]
    port = 2222
```

Then connect:

```bash
ssh your-app.fly.dev -p 2222
```

Two things this block does that are easy to miss. First, for an anonymous server it needs `anonymous_public: true` (or `RAXOL_SSH_ANONYMOUS_PUBLIC=1`) or the daemon sits on loopback and the proxied port connects to nothing. That is the point: exposing an anonymous surface is a two-step, stated decision. Second, verify the exposure from outside the app on EVERY address the app holds. Fly's shared IPv4 proxies 80/443 only, so a TCP probe against it can look closed while a dedicated IPv6 (`fly ips list`) serves the port to anyone who scans for it. "I could not connect" is not "it is closed."

### Erlang distribution and epmd

This is a BEAM default, not a Raxol one, but it ships with every deployment: `epmd` binds `0.0.0.0:4369` (and `[::]`) by default, and the distribution listener binds every interface too. A cloud provider that only routes declared services will not expose these, but any extra interface on the machine (a Tailscale/WireGuard mesh, a second NIC) is bound as well, and anything that can reach the distribution port and knows the cookie has full remote code execution on the node.

For the common case of not clustering at all, pin both to loopback:

```bash
# epmd: loopback only (set before the VM starts)
export ERL_EPMD_ADDRESS=127.0.0.1
```

```elixir
# releases: rel/vm.args.eex -- distribution listener on loopback only
-kernel inet_dist_use_interface {127,0,0,1}
```

If you do cluster, treat the distribution port like a root shell: private network only, TLS distribution (`-proto_dist inet_tls`) across anything shared, and never a guessable cookie. See [Isolation from signing](#isolation-from-signing) above for the payment-node rules.

## Use Cases

SSH beats web dashboards when you want zero client setup: no HTTPS certs, no browser, works over slow networks, instant startup. Same `init/update/view` whether local, over SSH, or in a browser.

- **Shared dashboards**: Deploy a monitoring dashboard. Anyone with SSH access can view it.
- **Remote admin tools**: Database inspection, log viewers, config editors, all in the terminal.
- **Pair programming**: Multiple users connected to the same app. Each sees independent state (or share state via PubSub).
- **IoT/embedded**: Run on a Raspberry Pi. SSH in from anywhere to check sensor readings.
- **Bastion host UIs**: Replace clunky web admin panels with fast terminal interfaces.

## Next steps

- [Architecture](../core/ARCHITECTURE.md): how the render pipeline works
- [Cookbook index](./README.md): the other recipes


<!-- docs/cookbook/THEMING.md -->

# Theming

Color schemes and styling for terminal and LiveView apps.

## Terminal theming

### Inline colors

The View DSL accepts colors directly via `fg:` and `bg:`:

```elixir
text("Hello", fg: :cyan)                    # Named ANSI color
text("Warning", fg: :yellow, style: [:bold]) # Bold yellow
text("Custom", fg: {255, 107, 174})          # RGB tuple
text("256-color", fg: 198)                   # 256-color palette index
text("Hex color", fg: "#ff6bae")             # Hex string
```

Available named colors: `:black`, `:red`, `:green`, `:yellow`, `:blue`, `:magenta`, `:cyan`, `:white`.

### Color by status

Pattern match to return colors based on data:

```elixir
defp severity_color(:critical), do: :red
defp severity_color(:warning), do: :yellow
defp severity_color(:info), do: :cyan
defp severity_color(_), do: :white

# Usage:
text(message, fg: severity_color(level))
```

### Terminal capability detection

Raxol auto-detects terminal color support and downsamples:

- **Truecolor** (24-bit): RGB tuples and hex strings render exactly
- **256-color**: RGB is mapped to the nearest 256-color value
- **16-color**: Mapped to the closest ANSI color
- **Mono**: All colors stripped, styling preserved (bold, underline)

`Raxol.Style.Colors.Adaptive.adapt_color_safe/1` handles this transparently.

### Synthwave '84 palette example

The flagship demo uses Synthwave '84 Soft mapped to ANSI:

```elixir
# Consistent color language across your app
defp accent, do: :cyan       # Titles, active elements
defp highlight, do: :magenta # Key hints, borders
defp warn, do: :yellow       # Warnings, headers
defp ok, do: :green          # Success, healthy
defp err, do: :red           # Errors, critical

# Usage:
text(" DASHBOARD ", fg: accent(), style: [:bold])
text(" q:quit  Tab:switch ", fg: highlight())
text("CPU: #{pct}%", fg: if(pct > 90, do: err(), else: ok()))
```

### Themed panels

Create a reusable panel helper:

```elixir
defp panel(title, opts \\ []) do
  border = Keyword.get(opts, :border, :single)
  active = Keyword.get(opts, :active, false)
  children = Keyword.get(opts, :children, [])

  box style: %{border: (if active, do: :double, else: border), flex: 1} do
    column do
      [text(" #{title} ", fg: :cyan, style: [:bold]) | children]
    end
  end
end
```

---

## Theme system

### ThemeManager

Switch themes at runtime:

```elixir
# Switch to a built-in theme
Raxol.UI.Theming.ThemeManager.set_theme(:nord)
```

### Component-level theming

Components read theme styles via `Raxol.UI.Theming.Theme.component_style/2`. In a TEA module, apply theme styles in `view/1`:

```elixir
def view(model) do
  theme = Raxol.current_theme()
  btn_style = Raxol.UI.Theming.Theme.component_style(theme, :button)

  column do
    [
      text("Submit", fg: btn_style[:fg] || :cyan, style: [:bold])
    ]
  end
end
```

### Pseudo-state styles

Themes can define styles for `:focus`, `:active`, and `:disabled` states:

```elixir
component_styles: %{
  button: %{
    fg: :cyan,
    focus: %{fg: :white, bg: :blue, bold: true},
    active: %{fg: :black, bg: :cyan},
    disabled: %{fg: :white, dim: true}
  }
}
```

The `FocusHelper` module resolves the correct style based on Component state.

### Built-in themes

Themes are stored as JSON in `priv/themes/`:

```bash
ls priv/themes/
# Default.json
```

---

## LiveView theming

When using the LiveView bridge, theming is applied via CSS classes on the terminal container div. Set a `data-theme` attribute (or a theme CSS class) on the container element, then define per-theme CSS rules targeting that class:

```html
<div id="terminal" class="terminal" data-theme="nord">
  <!-- terminal content rendered here -->
</div>
```

Built-in LiveView themes: `:default`, `:light`, `:nord`, `:dracula`, `:synthwave84`.

### Custom CSS theme

The following is an example custom CSS theme based on the Synthwave84 palette:

```css
.terminal.theme-custom {
  background-color: #1a1a2e;
  color: #e0e0e0;
}

.terminal.theme-custom .fg-cyan {
  color: #40c4ff;
}
.terminal.theme-custom .fg-magenta {
  color: #ff6bae;
}
.terminal.theme-custom .fg-green {
  color: #00ff9d;
}
.terminal.theme-custom .fg-yellow {
  color: #ffd700;
}
.terminal.theme-custom .fg-red {
  color: #ff5555;
}

.terminal.theme-custom .cursor {
  background-color: #40c4ff;
}
```

### Dynamic theme switching

```elixir
def handle_event("change_theme", %{"theme" => theme}, socket) do
  {:noreply, assign(socket, theme: String.to_existing_atom(theme))}
end
```

---

## Color palettes

Popular palettes mapped to ANSI for quick reference:

### Nord

`red: #bf616a, green: #a3be8c, yellow: #ebcb8b, blue: #81a1c1, magenta: #b48ead, cyan: #88c0d0`

### Dracula

`red: #ff5555, green: #50fa7b, yellow: #f1fa8c, blue: #bd93f9, magenta: #ff79c6, cyan: #8be9fd`

### Tokyo Night

`red: #f7768e, green: #9ece6a, yellow: #e0af68, blue: #7aa2f7, magenta: #ad8ee6, cyan: #449dab`

### Catppuccin Mocha

`red: #f38ba8, green: #a6e3a1, yellow: #f9e2af, blue: #89b4fa, magenta: #f5c2e7, cyan: #94e2d5`

---

## Accessibility

### High contrast

Use maximum contrast ratios. Avoid relying on color alone:

```elixir
# Bad: color is the only indicator
text("OK", fg: :green)
text("FAIL", fg: :red)

# Good: text + color
text("[OK] Passed", fg: :green)
text("[!!] FAILED", fg: :red, style: [:bold])
```

### WCAG contrast checking

Ensure foreground/background pairs meet WCAG AA (4.5:1 ratio):

```elixir
# High contrast pairs that work everywhere:
text("...", fg: :white, bg: :black)    # 21:1
text("...", fg: :black, bg: :green)    # ~5.5:1
text("...", fg: :black, bg: :cyan)     # ~8.6:1
text("...", fg: :black, bg: :yellow)   # ~10.2:1
```

---

## Examples

- `examples/demo.exs`: Flagship demo using Synthwave '84 mapped to ANSI
- `examples/advanced/color_system_demo.ex`: Color system with adaptive downsampling

## Next steps

- [Building Apps](./BUILDING_APPS.md): TEA patterns and recipes
- [Cookbook index](./README.md): the other recipes


<!-- docs/adr/README.md -->

# Architecture Decision Records

ADRs for the Raxol project. Each one captures a single architectural decision: why it was made, not just what was decided.

## ADR Index

| ADR | Title | Status | Date |
|-----|-------|--------|------|
| [0001](0001-component-based-architecture.md) | Component-Based Architecture | Accepted (Revised) | 2025-01-27 |
| [0002](0002-parser-performance-optimization.md) | Parser Performance Optimization | Implemented | 2025-01-27 |
| [0003](0003-terminal-emulation-strategy.md) | Terminal Emulation Strategy | Accepted | 2025-01-27 |
| 0004 | -- | Withdrawn (number reserved, never authored) | -- |
| [0005](0005-runtime-plugin-system-architecture.md) | Runtime Plugin System Architecture | Implemented | 2025-06-20 |
| 0006 | -- | Withdrawn (number reserved, never authored) | -- |
| [0007](0007-state-management-strategy.md) | State Management Strategy | Implemented | 2025-05-15 |
| [0008](0008-phoenix-liveview-integration-architecture.md) | Phoenix LiveView Integration Architecture | Implemented | 2025-05-20 |
| [0009](0009-high-performance-buffer-management.md) | High-Performance Buffer Management | Implemented | 2025-04-20 |
| [0010](0010-functional-error-handling-architecture.md) | Functional Error Handling Architecture | Implemented | 2025-02-01 |
| [0011](0011-terminal-module-consolidation.md) | Terminal Module Consolidation | Implemented | 2025-02-27 |
| [0012](0012-mcp-as-rendering-target.md) | MCP as Rendering Target | Implemented | 2026-04-05 |
| [0013](0013-event-dispatch-backpressure.md) | Event-dispatch Backpressure | Implemented | 2026-06-03 |
| [0014](0014-telegram-ai-guardian.md) | Telegram AI Guardian admin behaviour | Accepted | 2026-06-13 |
| [0015](0015-workflow-graph.md) | Workflow Graph (`Raxol.Workflow.*`) | Accepted | 2026-06-15 |
| [0016](0016-acp-job-workflow.md) | raxol_earn Job migration to `Raxol.Workflow` | Superseded (v1->v2) | 2026-06-16 |
| [0017](0017-acp-workflow-paused-jobs.md) | Workflow paused-run query and pause-checkpoint contract | Superseded (v1->v2) | 2026-06-16 |
| [0018](0018-operator-flow-contract.md) | Operator-flow contract for paused runs | Proposed | 2026-06-16 |
| [0019](0019-workflow-concurrency.md) | Workflow concurrency (`add_join/4` + `add_channel/3`) | Accepted | 2026-06-16 |
| [0020](0020-agent-sandbox-thread-policies.md) | Phase 26: Agent Sandbox, Thread log, declarative Policies | Accepted | 2026-06-16 |
| [0021](0021-self-improving-agents-skills-curation.md) | Self-improving agents: runtime skills + background curation | Accepted | 2026-06-17 |
| [0022](0022-memory-providers-fulltext-dialectic.md) | Memory provider stack, full-text recall, dialectic user modeling | Accepted | 2026-06-17 |
| [0023](0023-unified-messaging-gateway.md) | Unified messaging gateway (`raxol_gateway`) | Accepted | 2026-06-17 |
| [0024](0024-execution-backends-hibernation.md) | Pluggable execution backends and serverless hibernation | Accepted | 2026-06-18 |
| [0025](0025-cronjob-scheduled-tasks.md) | Cronjob scheduled-task tool | Accepted | 2026-06-18 |
| [0026](0026-execute-code-pipeline-collapse.md) | `execute_code` programmatic tool-calling | Proposed | 2026-06-18 |
| [0027](0027-delegate-task-subagents.md) | `delegate_task` summary-only subagents | Accepted | 2026-06-18 |
| [0028](0028-auxiliary-model-routing.md) | Auxiliary-model routing | Accepted | 2026-06-21 |
| [0029](0029-the-terminal-cell-model.md) | The Terminal Cell Model | Accepted | 2026-07-14 |
| [0030](0030-acp-session-update-delivery-ordering.md) | ACP session/update delivery ordering contract | Accepted | 2026-07-18 |
| [0031](0031-console-runtime-integration.md) | Raxol as a Virtuals ACP Console runtime | Proposed | 2026-07-29 |
| [0032](0032-agent-filesystem-grants.md) | Multi-root filesystem grants for agent sessions | Proposed | 2026-08-26 |
| [0033](0033-web3-data-surface.md) | Indexer-agnostic web3 data surface (raxol_web3) | Proposed | 2026-08-31 |

## Template

New ADRs should follow this structure:

```markdown
# ADR-XXXX: Title

## Status
[Proposed | Accepted | Deprecated | Superseded by ADR-YYYY]

## Context
What is the issue that we're seeing that is motivating this decision?

## Decision
What is the change that we're proposing and/or doing?

## Consequences
What becomes easier or more difficult to do because of this change?

### Positive
- List of positive consequences

### Negative
- List of negative consequences

### Mitigation
How do we mitigate the negative consequences?

## Validation
How do we validate that this decision was correct?

## References
Links to related documentation, discussions, or resources.
```

## Why ADRs?

They preserve context for why decisions were made, help new contributors understand the architecture, and give us something concrete to revisit when circumstances change.

## Adding a New ADR

1. Create a new file using the template above
2. Number it sequentially (0012, 0013, etc.)
3. Start with status "Proposed"
4. Get review, then update to "Accepted"
5. Update the index table in this file

## By Category

### Core architecture
- [0001: Component-Based Architecture](0001-component-based-architecture.md)
- [0003: Terminal Emulation Strategy](0003-terminal-emulation-strategy.md)
- [0007: State Management Strategy](0007-state-management-strategy.md)
- [0011: Terminal Module Consolidation](0011-terminal-module-consolidation.md)
- [0029: The Terminal Cell Model](0029-the-terminal-cell-model.md)

### Performance
- [0002: Parser Performance Optimization](0002-parser-performance-optimization.md)
- [0009: High-Performance Buffer Management](0009-high-performance-buffer-management.md)
- [0013: Event-dispatch Backpressure](0013-event-dispatch-backpressure.md)

### Web Integration
- [0008: Phoenix LiveView Integration Architecture](0008-phoenix-liveview-integration-architecture.md)

### Extensibility
- [0005: Runtime Plugin System Architecture](0005-runtime-plugin-system-architecture.md)

### Code Quality
- [0010: Functional Error Handling Architecture](0010-functional-error-handling-architecture.md)

### AI & MCP
- [0012: MCP as Rendering Target](0012-mcp-as-rendering-target.md)
- [0014: Telegram AI Guardian admin behaviour](0014-telegram-ai-guardian.md)
- [0033: Indexer-agnostic web3 data surface (raxol_web3)](0033-web3-data-surface.md)

### Orchestration
- [0015: Workflow Graph](0015-workflow-graph.md)
- [0016: raxol_earn Job migration to Workflow](0016-acp-job-workflow.md)
- [0017: Workflow paused-run query and pause-checkpoint contract](0017-acp-workflow-paused-jobs.md)
- [0018: Operator-flow contract for paused runs](0018-operator-flow-contract.md)
- [0030: ACP session/update delivery ordering contract](0030-acp-session-update-delivery-ordering.md)
- [0019: Workflow concurrency (joins + channels)](0019-workflow-concurrency.md)

### Agent stack
- [0020: Phase 26 - Agent Sandbox, Thread log, declarative Policies](0020-agent-sandbox-thread-policies.md)
- [0021: Self-improving agents: runtime skills + background curation](0021-self-improving-agents-skills-curation.md)
- [0022: Memory provider stack, full-text recall, dialectic user modeling](0022-memory-providers-fulltext-dialectic.md)
- [0023: Unified messaging gateway (raxol_gateway)](0023-unified-messaging-gateway.md)
- [0024: Pluggable execution backends and serverless hibernation](0024-execution-backends-hibernation.md)
- [0025: Cronjob scheduled-task tool](0025-cronjob-scheduled-tasks.md)
- [0026: execute_code programmatic tool-calling](0026-execute-code-pipeline-collapse.md)
- [0027: delegate_task summary-only subagents](0027-delegate-task-subagents.md)
- [0028: Auxiliary-model routing](0028-auxiliary-model-routing.md)
- [0031: Raxol as a Virtuals ACP Console runtime](0031-console-runtime-integration.md)
- [0032: Multi-root filesystem grants for agent sessions](0032-agent-filesystem-grants.md)

## Coverage

31 authored ADRs (29 active; 0016 and 0017 are superseded by the raxol_earn v1->v2 seller-stack migration) covering core framework, performance, web integration, extensibility, state management, code quality, AI/MCP architecture, surface-specific admin patterns, orchestration, the cross-layer operator-flow contract, Workflow concurrency, the agent-stack sandbox + audit + policies primitive, self-improving agents (runtime skills + curation), the memory provider stack with full-text recall and dialectic user modeling, the unified messaging gateway, the Hermes-extraction Tier 2 agent capabilities (execution backends + hibernation, cronjob scheduling, execute_code pipeline collapse, delegate_task subagents, and auxiliary-model routing), the terminal cell model, the ACP session/update delivery-ordering contract, the Virtuals ACP Console runtime integration, multi-root filesystem grants for agent sessions, and the indexer-agnostic web3 data surface. (Numbers 0004 and 0006 are withdrawn placeholders.)

