Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chatty

A desktop and terminal AI agent, built in Rust. Run multi-tool agents against the LLM provider you choose, with everything stored locally and an optional fully local setup through Ollama.

Why Chatty

Your provider, your data. Connect OpenRouter, Azure OpenAI or a local Ollama instance. Conversations, memory and settings stay in local files and a local SQLite database; nothing is hosted by Chatty.

An agent, not a chat box. The model can plan, call tools, read and edit files in a workspace you choose, run code in a sandbox, query spreadsheets and data files, search the web, and drive a real browser to check its own work. Every side-effecting action goes through an approval mode you control.

Two front ends, one engine. The desktop app and the terminal app share the same core, so the agent behaves the same whether you are chatting in a window or scripting chatty-tui headless in a pipeline. Sub-agents are just headless terminal instances.

Fast and native. Built on GPUI, the GPU-accelerated UI framework from the Zed editor. Markdown, code, LaTeX math, Mermaid diagrams, charts, PDFs and tables render natively in the transcript.

Download

PlatformFormat
macOS (Intel & Apple Silicon).dmg installer
Linux (x86_64).tar.gz archive or AppImage
Windows (x86_64).exe installer

All builds are on GitHub Releases. The app checks for updates in the background and verifies them before installing.

Where to go next

I want to…Read
Install and start chattingGetting started
Understand what the agent can doAgents & tools · Security & approvals
Use the terminal app or sub-agentsTerminal interface · Sub-agents
Build a team of named workersTutorial: your first named worker · Tutorial: a small agentic team · Tutorial: an autonomous dev team
Write a plugin or an MCP integrationBuild a WASM plugin · Extensions & MCP
Contribute to the codeBuild and run · Contributing patterns
Look something upReference · Glossary

Getting started

When to read this: You are installing Chatty for the first time and want to send your first message.

Chatty is a desktop and terminal AI agent that runs on your own machine: your keys, conversations and files stay local, and the model only reaches what you switch on. Why it exists: the landing page.

1. Install

Download the latest release from GitHub Releases:

PlatformFile
macOS (Intel and Apple Silicon).dmg
Linux (x86_64).AppImage
Windows (x86_64).exe installer

On Linux, make the AppImage executable and run it:

chmod +x chatty-linux-x86_64.AppImage
./chatty-linux-x86_64.AppImage

AppImages need FUSE 2. If it fails with error loading libfuse.so.2, install it: sudo apt install libfuse2t64 on Ubuntu 24.04+, sudo apt install libfuse2 on Ubuntu 22.04 and Debian, sudo dnf install fuse-libs on Fedora. Without FUSE, ./chatty-linux-x86_64.AppImage --appimage-extract-and-run works too.

Chatty checks for new releases in the background and offers them in the status footer. Details: Advanced.

2. Connect a provider and add a model

  1. Click the gear icon in the title bar to open Settings, then Models & Providers.
  2. Manage keys — paste an OpenRouter key, point at a local Ollama, or connect Azure OpenAI. Press Test to check it.
  3. Add model — search the provider’s catalogue, tick the models you want, then Add.

Ollama models appear on their own once Ollama is running. Azure fields, the roster, favourites and troubleshooting: Providers & models.

3. Send a message

Close Settings and type. A new conversation opens on a start screen that shows what is switched on — modules, MCP servers, agents, file access, memory and whether a workspace is set — so you know what the agent can reach before you ask. Switch models with the selector at the bottom of the chat.

  • Type / for the command picker (↑/↓, Enter).
  • Type @ to mention a file from the working directory.

Rendering, attachments, artifacts, cost tracking and search: Chatting.

4. Turn on tools (optional)

Tools are off by default, so at this point Chatty is a chat window. To let the agent read and edit files, run commands and use extensions, open Settings → Code Execution, set a Workspace Directory, switch on Enable Code Execution and choose an approval mode. Walkthrough: Agents & tools. What each approval mode does and how the sandbox works: Security & sandboxing.

Tip

Web access is a separate switch (Settings → Internet) and is on by default, so the agent can fetch pages and search the web even before you enable code execution.

Desktop or terminal?

AppUse when
Chatty (desktop)Daily work, settings, attachments, artifacts
chatty-tuiTerminal sessions, scripts and pipelines, headless sub-agents

Both share the same providers and models. Install the terminal app from the desktop app: Terminal interface.

Next

Providers & models

When to read this: You want to connect an LLM provider, add models to the roster, pick a default, or work out why a model is missing.

Everything lives on one page: Settings → Models & Providers. The roster lists every model as a row — favourite star, provider, context window, input price per million tokens and temperature — with a status chip per provider across the top. Changes save automatically.

Connect a provider

Click Manage keys. One sheet holds all three providers; each row shows its status and has Save and Test buttons. Keys are stored with your app settings, never in your project files. Removing a key hides that provider’s models but keeps their settings.

OpenRouter

One key gives access to many upstream models (Claude, GPT, Gemini, Mistral and more). Paste the key (it starts with sk-or-), Save, then Test — a good key shows Key verified and the chip reads Connected · N models. Chatty also pulls the OpenRouter catalogue, including pricing, so the roster’s price column fills itself.

Ollama

No key needed — a local instance is detected automatically. The URL field defaults to http://localhost:11434; change it and Save if Ollama runs elsewhere (another machine, a different port). Test shows Running · N models.

Note

Plain http:// is only for your own network. Provider URLs (and MCP server, remote agent and Hive registry URLs) may use http:// only for localhost/loopback and private-LAN addresses (10.x.x.x, 172.16–31.x.x, 192.168.x.x — for example a model server on the Docker bridge, http://172.17.0.1:8000). Any other host must use https://; there is no override.

Ollama models are added for you: Chatty discovers what is installed shortly after launch and keeps the roster in step, removing models you have deleted from Ollama and detecting per model whether it accepts images.

Note

A pulled model does not show up? Discovery runs at launch, and again every time the Settings window opens or is brought forward — so an Ollama started after Chatty, or a model you ollama pulled while Chatty was open, appears next time you open Settings. No restart needed. If Test fails, make sure Ollama is running (ollama list in a terminal) and that the URL matches where it listens. Models you added by hand under Enter an identifier manually are left alone by discovery.

Azure OpenAI

Azure serves your own deployments rather than a public catalogue, so it needs three things:

  1. API key, or switch on Use Entra ID instead of a key to sign in with your Azure account.
  2. Endpoint URL — your resource address, e.g. https://my-resource.openai.azure.com. The v1 base URL (https://my-resource.openai.azure.com/openai/v1/) and the deployment or Responses URLs the Azure portal shows work too.
  3. Deployment name — the deployment you created in Azure, e.g. gpt-5-chat.

Press Connect & fetch models. Each further deployment is added by name in the Add model sheet.

When a request carries tools (an agent turn), chatty sends it to the resource’s Responses API, the one OpenAI’s reasoning models accept tools on. A plain chat without tools, such as a title or a summary, goes to the deployment’s chat completions, so an older deployment without the Responses API still chats. The API version under Edit… → Advanced applies to those chat completions. A model-inference endpoint (…services.ai.azure.com/models) serves chat completions only: chatty uses it for everything, and a reasoning model with tools there asks you to switch to the resource’s v1 base URL.

Add a model

Click Add model. The sheet opens on the provider’s catalogue: search it, tick as many models as you like, and add them in one go — no identifiers to type. Models already in your roster show as added. If the OpenRouter catalogue is empty, add a key in Manage keys first and reopen the sheet.

For anything the catalogue does not list, expand Enter an identifier manually and give it a display name and the provider’s model identifier (for example qwen2.5:0.5b).

Favourites, default and per-model settings

  • Click a row’s star to pin the model to the top; the Favourites and Local chips filter the roster.
  • A row’s ⋯ menu offers Set as default (the model new conversations start with), Edit… and Remove.
  • Edit… has a Basic tab (name, identifier, temperature, system prompt) and an Advanced tab: max tokens, Max Context Window (turns on the context fill bar in the chat footer), top-p, cost per million input and output tokens, and the Azure API version.

Favourites and the default survive catalogue syncs and restarts.

Capabilities

Chatty records three capabilities per model and uses them to show or hide the attachment buttons and the temperature control:

CapabilityWhat it controls
ImagesImage attachments in chat
PDFPDF attachments
TemperatureThe temperature slider (off for some reasoning models)

Starting values by provider: OpenRouter models accept images and PDFs; Azure OpenAI models accept images but not PDFs; Ollama models are detected one by one, so a vision model gets the image button and a text-only model does not.

Next

Chatting

When to read this: You have a model connected and want to know what the chat window can do — rendering, attachments, artifacts, cost tracking, search and themes.

The composer

  • Model selector at the bottom switches models mid-conversation; the roster and default come from Providers & models.
  • Type / for the command picker (↑/↓, Enter). Saved skills appear there with a skill badge. Full list: slash commands.
  • Type @ to mention a file from the working directory. Hidden files and common build folders (.git, node_modules, target, dist, build) are skipped.
  • Add attachments attaches images and PDFs. The buttons only appear for models that accept them.
  • The folder icon sets a per-chat working directory: Agents & tools.

Sending while a reply streams

Send (or Enter) doesn’t wait for the current reply to finish — a message sent mid-stream is queued and appears as its own bubble below the reply in progress, with × in front to take it back and ↑ to send it now instead, which cancels the current reply and runs that message next. Up to 5 messages can queue at once; a sixth is refused with a notice under the queue. Stop cancels the reply but leaves anything queued in place — nothing runs until you send again, and that send runs the queued messages first, in order.

The running indicator above the composer grows a “· 84s · turn 11 · 11254 tok” suffix — elapsed time once a second has passed, then the running turn count and this turn’s own token total once a round-trip has completed — a way to see a long reply is still making progress now that replies have no default turn cap.

Rich rendering

Responses render as Markdown with:

  • Syntax-highlighted code in dozens of languages, with Copy code on every block.

  • Math — inline $...$ and block $$...$$, drawn in the theme colour, with Copy LaTeX.

  • Mermaid diagrams from fenced mermaid blocks — flowcharts, sequence, class, ER, Gantt and many more — drawn inline in light or dark to match the theme. Copy Mermaid copies the source; Copy as PNG copies the image.

  • Image and PDF previews for attachments and generated files.

Models that think out loud (for example Claude extended thinking) get their reasoning folded into a collapsible section so it stays inspectable without filling the transcript.

Artifacts

When the agent produces a document, a card appears in the transcript and the file opens in a panel beside the chat:

  • PDFs the agent typesets, paged in the panel.

  • Charts — bar, line, pie, donut, area and candlestick — drawn natively and theme-aware, with Copy as PNG.

  • Query results from SQL the agent ran over CSV, Parquet or JSON files in the workspace, shown as a table with the SQL one tab away.

  • Markdown documents the agent wrote, rendered or as source — including any Mermaid diagrams in the file, drawn inline the same way as in chat.

  • PowerPoint decks the agent writes, rendered as real slide images and paged with Prev/Next, with the extracted text a tab away.

When browser tools are on, a live view of the page the agent is driving docks in the same panel: Agents & tools.

File explorer

The sidebar doubles as a light IDE over your working directory. A Chats | Files toggle sits in the status bar at the bottom of the window, next to the warning/error indicators; switching to Files replaces the conversation list with a tree rooted at your workspace directory (or the current folder if none is set), folders first, and picks up files the agent adds or removes within a couple of seconds without needing a refresh. Switching conversations re-roots the tree to that conversation’s own working directory. You can also reach it from the caret next to the artifact button in the titlebar (or the floating button on macOS) — pick Files, which expands the sidebar if it’s collapsed and switches it to Files mode.

Drag the edge between the sidebar and the chat to resize it — handy for reading long filenames or deeply nested paths in full.

Click a file to open it in the artifact panel, using the same viewers as agent-produced artifacts (Markdown, code, PDF, PowerPoint, images, tables). Right-click a row, the header, or an empty folder for New file…, New folder…, Rename…, Delete…, Reveal in file manager and Copy path. Deleting asks for confirmation first and cannot be undone.

Ctrl/⌘-click a row to add it to a selection, or Shift-click to select a range; the right-click menu then reads Delete N items… and Copy paths for the whole selection. Drag a selected row onto a folder (or onto the empty area to move it to the workspace root) to move it there — an open file’s tab follows it to the new path.

Press Ctrl/Cmd+P from anywhere to open Go to File, a fuzzy quick-open over every file in the workspace — handy when the sidebar is collapsed or you just want to jump straight to a file. Type to filter, then click or press Enter on a match to open it in the artifact panel.

The Source tab is editable: press Ctrl/Cmd+S or click Save to write your changes back to disk. An unsaved file shows a dot on its tab; switching tabs keeps your edits, and closing a tab with unsaved changes asks before discarding them.

Tool-call traces

Every tool call is a collapsible block showing the name, arguments, output, duration and status (success, error or cancelled). File edits get a proper diff — additions green, deletions red, unchanged runs collapsed with Show N more lines on large patches. Multi-step work also shows a To-dos card that updates in place; see Agents & tools.

Context window

Long agent runs fill the context quickly. The footer shows:

  • Fill bar — segments for the system preamble, tool definitions, history and the latest message, coloured green, amber or red as the window fills. It appears once Max Context Window is set on the model (Edit… → Advanced).
  • Token popover — hover the bar for per-segment estimates and the provider’s input and output counts.
  • /compact — summarises the older half of the history so the run can continue.

Pull request status

When the workspace is a git checkout whose origin is on GitHub, a bar above the composer shows the pull request for the current branch: number, repository, branch, +added −deleted and a CI pill listing each check. Click the number, repository or branch to open the PR in your browser; × hides the bar until the branch or PR changes.

The bar follows Enable Git Integration in Settings → Code Execution. Private repositories need the GitHub CLI (gh) installed and signed in; public ones work without it. No PR, no GitHub remote or no workspace means no bar. The terminal app shows the same thing (#591 open ✓) in its status line.

  • Conversations are stored in a local database on your machine by default.
  • Titles are generated automatically. The search icon in the title bar opens Search conversations…, which filters the sidebar as you type.
  • Each conversation’s ⋯ menu has Download, which saves the transcript as a Markdown file, Take online…, and Delete.
  • The sidebar shows the running cost per conversation; each reply shows its input and output tokens and cost. Pricing uses the per-million-token rates on the model, which the OpenRouter catalogue fills in for you.
  • Chatty only ever appends to the prompt it sends — the system instructions, tool list and prior turns already sent are never rewritten — so a provider’s prompt cache keeps recognising them turn after turn. Providers that price cached input lower pass that saving straight through to the cost shown here.
  • Regenerate under a reply asks for a fresh answer. Chatty keeps both versions, which is what makes preference-pair export possible (Advanced).

Taking a conversation online

Take online… in a conversation’s ⋯ menu uploads its history to a chatty-server you name and continues it there — useful for a long-running task you want to keep going after you close the laptop. A globe icon badges the conversation in the sidebar while it runs remotely, and while it does the composer’s model chip shows the model the server reported (or “Hosted model” if it reported none) instead of your local pick, with no model picker, since the server decides the model. The same menu offers Bring back here to return it. The confirmation dialog lists exactly what moves (message history, traces, the model id) and what never does (workspace files, attachments, MCP servers, memory, skills, and provider API keys — those stay on this machine). The conversation’s local copy is kept either way, so bringing it back does not lose anything, and a move mid-turn is refused until the turn finishes. The server only accepts requests from a signed-in Hive session, so sign in first (Settings → Extensions → Hive); without it, the move or the hosted turn fails with a message telling you to sign in.

Themes and text

Settings → General offers twenty-odd theme families (Ayu, Catppuccin, Everforest, Flexoki, Gruvbox, Matrix, Solarized, Tokyo Night and more), a Dark Mode switch for the light or dark variant of each, and a Font Size from 8 to 32.

Next

Terminal dock

When to read this: You use the desktop app and want a shell next to the conversation — to run a build, look at git status or try what the agent just wrote — without leaving Chatty.

The desktop app has a terminal panel under the chat, placed and behaving like the terminal panel in VS Code or Zed. (For the terminal app, chatty-tui, see Terminal interface.)

Open and close it

KeysWhat it does
Ctrl+J (Linux, Windows) / Cmd+J (macOS)Open the dock and put the keyboard in its terminal; press again to close it and return to the message box
Ctrl+`Same as Ctrl/Cmd+J
Ctrl+Shift+`Open a new terminal tab

The dock sits under the message box and spans the chat column only: the sidebar and the artifact panel keep their place beside it. The message box stays above the dock and keeps working while it is open.

  • Resize: drag the dock’s top edge. The height is remembered.
  • Maximise: the square button on the right of the tab strip grows the dock to fill the chat column; the transcript is hidden while it is maximised and the message box stays. Click it again to restore.
  • Hide: the chevron on the far right closes the dock, like Ctrl/Cmd+J.

Closing the dock doesn’t stop anything: its terminals keep running and are there again when you reopen it.

Tabs

Each terminal is a tab. + opens another, × closes one, and dragging a tab sideways changes the order. A tab is named after the program running in it and the folder it is in, for example bash — chatty2 at the prompt or cargo — chatty2 during a build; on macOS and Windows it always shows the shell and the folder the terminal started in. Hover a tab for the full title the program set, such as the shell’s user@host: path.

When the shell exits (you typed exit, or it crashed), the tab stays, its name in italics followed by the exit status, for example bash — chatty2 exited 0. One click on × closes it.

Closing a tab while a program is running in it, such as vim or a build, asks for confirmation first; closing a tab that is only showing its prompt doesn’t. On Windows Chatty can’t tell the two apart, so closing a running terminal always asks. Closing the last tab closes the dock.

The Agent tab

The first tab, with the robot icon, is always Agent: the shell the agent runs its commands in for the active conversation. It shows every command the agent ran with its full output, not just the part a tool row has room for, and you can type in it too: it is one shell, shared by you and the agent.

  • Switching conversations switches the Agent tab to that conversation’s shell. Your own tabs stay.
  • Before the agent used its shell the tab says so and offers Start shell, so you can prepare the environment (activate a virtualenv, cd, export a variable) before you ask. It starts the same shell the agent then uses. With code execution off in Settings → Code Execution the agent has no shell and the tab says that instead.
  • Its × hides the dock; the Agent tab can’t be closed, and the shell keeps running.
  • Which lines were the agent’s: a small blue bar in the left margin marks each command the agent ran. Commands you typed have none.
  • Status: next to its name the tab says what the shell is doing: idle, agent running cargo test, you typing, or you running ….
  • Sandbox: when the agent’s shell runs in the sandbox, the tab says sandboxed (or sandboxed · no network with network isolation on). What you type there runs in the same sandbox. When the sandbox can’t run on this machine, the tab says not sandboxed; hover it to see why.

Sharing the keyboard. You can type at any time. While the agent’s command runs, what you type goes to that program: answer its prompt, or press Ctrl+C to stop it (the agent then gets the interrupted output and exit code 130). While you have a half-typed line at the prompt, the agent’s next command waits for you: the tab turns amber and says Agent waiting for you with the command it wants to run. Press Enter or clear the line (Ctrl+U) and it goes ahead. If you don’t within two seconds, the agent is told the terminal is busy and can try again.

Show in terminal. Each shell command row in the conversation has a terminal button: it opens the dock on the Agent tab, scrolled to that command. If the same command ran more than once, it shows the latest run; a command that has scrolled out of the scrollback, or ran in a shell that has since restarted, can’t be shown.

The agent can read its own tab with terminal_read without you sharing it, so it can see what you ran there (“what did I just run?”). A hidden-input prompt, such as a password prompt, is never read.

Where a terminal starts

A new terminal starts in the active conversation’s working directory, the same folder the file explorer shows for it (set with the folder icon in the message box or /cd). Without one, it starts in the workspace directory from Settings → Code Execution, and without that, in your home folder.

Your terminals belong to the window, not to a conversation. Switching to another conversation leaves them running and visible; only new terminals pick up the new conversation’s folder. The Agent tab is the exception: it always shows the active conversation’s shell.

Quitting Chatty ends every shell it started. Terminals are not restored the next time Chatty starts.

Keyboard focus

Click in a terminal to type into it. Escape is passed to the program in the terminal (vim needs it) and does not move you out. To go back to the conversation, click the message box, or press Ctrl/Cmd+J to close the dock.

A few key combinations always go to Chatty, even while a terminal has the keyboard, so the dock can always be closed:

  • Ctrl+J / Cmd+J
  • Ctrl+`
  • Ctrl+Shift+`

In a shell, Ctrl+J only types a newline, which Enter already does.

Sharing a terminal with the agent

Your terminals are yours: the agent can’t see what is in them until you share one. Each tab has an eye icon; crossed out, the tab is not shared.

Click the eye on a tab that is not shared and choose how much the agent gets:

  • Read only: the agent can read the screen and the scrollback, for example to answer Why did that fail? after a failing build.
  • Read + run: the agent can also run commands in this terminal, each one only after you approve it.

Tick Remember this, don’t ask again to skip the question from then on: the eye shares every tab at that level with one click. Settings → Terminal → When sharing a terminal changes or undoes that choice (Ask brings the question back).

Click the eye on a shared tab to stop sharing it at once.

A tab you haven’t shared is still listed to the agent (its name and folder), so it can tell you it isn’t shared and ask you to share it, but its contents are never read. While a terminal waits at a plain text password prompt (sudo, ssh, su, passwd, a script’s read -s, or gpg with --pinentry-mode loopback), the agent gets “terminal is at a hidden-input prompt” instead of the screen, even from a shared tab. Full-screen passphrase boxes, such as the one gpg usually draws in the terminal (pinentry-curses), and pinentry windows outside the terminal are not caught: the passphrase itself is never on screen there, but the rest of the terminal can be read as at any other moment. Windows has no way to tell that a program is asking for a password, so on Windows don’t leave a shared tab at a password prompt.

Every read shows up in the conversation as a tool row such as Read terminal · bash — chatty2 · 42 lines, naming the tab and how many lines the agent was given; the row’s copy button gives the exact output. The tab it read lights up for a moment.

Letting the agent run a command in your terminal

The agent normally runs commands in its own shell. When you want one to run in a tab of yours instead (your ssh session, your activated virtualenv, the dev server you are watching), share that tab as Read + run and ask for it, for example Restart the dev server in my terminal.

A tab shared this way is your own shell: it is not sandboxed and it holds your credentials. So every command the agent wants to run there first shows an approval card with the exact command, the tab’s name and folder, and the line Runs in your shell, not the sandbox. Nothing is typed until you click Approve; Deny tells the agent no. This card always appears, one per command, whatever the approval mode under Settings → Code Execution: auto-approve never applies to your terminals.

Once approved, the command is typed at your prompt and appears in the tab as if you had typed it, marked with the same small blue bar in the left margin as the agent’s commands in the Agent tab (the margin appears in your tab with the agent’s first command there). The agent gets the command’s output and exit code when it finishes. A command that is still running after two minutes (a server, say) keeps running; the agent is told so and can read the tab later.

The agent is refused, and nothing is typed, when:

  • the tab is shared Read only: it is told to ask you to share the tab as Read + run;
  • you are typing at the prompt, or a program is running in the tab (vim, a Python prompt, a password prompt, a running build): it waits two seconds, then is told what is in the way;
  • the tab’s shell has no shell integration (anything other than bash or zsh, such as fish): without it the agent can’t tell when a command finishes, so it doesn’t type into the tab at all;
  • the command has more than one line: only single-line commands are typed at your prompt;
  • another command of the agent’s is already waiting or running in that tab.

tmux panes outside Chatty are only ever read, never run in.

The terminal goes with your message

While the dock is open on a terminal the agent may read (the Agent tab, or a tab you shared), each message you send carries a short snapshot of it, so why did that fail? works without pointing the agent at the terminal. A chip above the message box says what goes along, for example Terminal: bash — chatty2 · 32 lines:

  • Click the chip to see exactly what the agent gets: the tab’s name and folder, then the last command, its exit code and the last 40 lines of its output. A shell that doesn’t mark its commands gives the last 40 lines on screen instead. A snapshot is at most about 2,000 tokens; longer output is cut from the top, with a line telling the agent to read the terminal itself for more.
  • Click its × to send this message without it.
  • Nothing changed, nothing sent: a snapshot goes along only when the terminal shows something different from the last one sent in this conversation, so a long chat doesn’t pay for the same screen every message, and the chip isn’t shown.
  • Nothing is attached while the dock is closed, from a tab you haven’t shared, or at a password prompt.

Your sent message shows the same chip; click it to see what was sent. Settings → Terminal → Attach terminal context automatically turns this off.

The tmux panes outside Chatty are a separate switch, Enable Terminal Access in Settings → Code Execution (see Agents and tools).

Settings

Settings → Terminal:

SettingDefaultApplies
Font Familythe theme’s code fontat once
Font Sizethe theme’s code font sizeat once
Shellyour login shell ($SHELL on macOS and Linux, PowerShell on Windows)to new terminals
Scrollback Lines10,000to new terminals
Dock Height300 pixelswhen the dock is next drawn
When sharing a terminalAskthe next click on a tab’s eye icon
Attach terminal context automaticallyonthe next message

Dragging the dock’s top edge updates Dock Height too.

Agents & tools

When to read this: You want to understand how the agent works, switch its tools on, and see what it can do once they are.

How the loop works

Each message builds an agent from your enabled tools and extensions, then runs a streaming multi-step loop:

You send a message
       │
       ▼
  Agent reasons → decides to call a tool
       │
       ▼
  Tool runs (with your approval if required)
       │
       ▼
  Agent reads the result → reasons again
       │        ...repeats until done, or up to Max Agent Turns (default: no limit)...
       ▼
  Agent streams its final answer to you

Tool calls, inputs, outputs and reasoning render as collapsible trace blocks beside the reply (Chatting).

The loop also watches itself while it runs, so a bad turn recovers instead of quietly failing: a model that’s still reasoning is not mistaken for a hung one, a turn that comes back with nothing is retried rather than treated as done, and a model stuck re-issuing the same tool call is nudged onto a different one. A tool’s own error message reaches the transcript intact, so a failure is reported as a failure rather than read back as a success.

For multi-step work the agent writes a plan first: a goal and an ordered to-do list. A To-dos card appears in the transcript and updates in place (pending, in progress, done, blocked) with a progress counter. Scroll past it and a compact Plan N of M strip pins to the top of the transcript — click to unfold the plan, or press Esc to dismiss the overlay without cancelling the run. The agent ticks steps off as it goes and runs a verification step before its final reply. In the terminal interface the card renders inline in the transcript instead, and its position also appears in the status bar — see Terminal interface.

The agent can also pause mid-turn to ask up to 4 clarifying questions, each with pre-made options plus a free-text answer (ask_user). It shows up as a card above the chat input; pick an option or type your own answer, then submit. Questions you leave blank are sent as unanswered so the agent knows what it still doesn’t know. In the terminal interface it replaces the input row instead — see Terminal interface.

Enable tools

Everything below is off until you switch it on in Settings → Code Execution:

  1. Set a Workspace Directory (an absolute path). File, shell and git tools can only touch files inside it.
  2. Turn on Enable Code Execution.
  3. Choose an Approval Mode — what they mean is on Security & sandboxing.

Optional switches on the same page: Enable Terminal Access (read-only view of your tmux panes, below), Enable Git Integration (git tools and the pull request bar), Enable Browser Tools with Allow Browser Access to Private Network, Enable Code Execution Tool with Enable Docker Fallback and a Docker Host field, plus Max Agent Turns, a command Timeout, a Max Output size and Network Isolation. Web access has its own page, Settings → Internet, and is on by default.

Per-chat working directory. The folder icon in the composer (Select Working Directory) overrides the workspace for that conversation; × resets it to the global one. The override is saved with the conversation. /cd <dir> does the same from the keyboard, and /add-dir <dir> widens the workspace by one more directory.

Project instructions (AGENTS.md / CLAUDE.md)

If your project has an AGENTS.md or CLAUDE.md — the instruction files other coding agents read — Chatty’s agent reads them too and follows them. It looks in the working folder and in every folder above it up to the root of the git repository, taking AGENTS.md when a folder has both. A personal file for every project goes in ~/.agents/AGENTS.md (or ~/.claude/CLAUDE.md). The most specific file comes last and wins when two disagree; what you ask in the chat still overrides them. Files are read when a conversation starts or its working folder changes, and very long files are cut off after the first 32 KB.

What the agent can do

Tool names and their exact scopes are in the tools catalog; this is the map.

Files and code

The agent can read files, list directories, search by glob or content, jump to a definition, and create, edit, rename, move or delete files — always inside the workspace. Edits show as diffs in the transcript. With git integration on it can also inspect status and diffs, stage, commit and merge branches.

Rename the User struct to Account across the project and update every import.

Reads never ask. Writes, deletes and git changes follow your approval mode; inside the workspace they apply immediately under either auto-approve mode.

Shell and running code

Shell commands run in a persistent session inside the sandbox, with streaming output, the workspace as its working directory and your secrets available as environment variables. Enable Code Execution Tool adds a code runner: simple Python runs on the host in milliseconds; with Enable Docker Fallback (Docker must be running) the agent can also run JavaScript, TypeScript, Rust, Bash and Python that needs third-party packages, each in a fresh container. Chatty finds the Docker socket on its own, including rootless Docker and Docker Desktop; Docker Host covers unusual setups (for example /run/user/1000/docker.sock).

Run the test suite, fix whatever fails, and run it again.

Shell commands and code runs follow your approval mode; sandboxed commands run without prompting under Auto-approve Sandboxed.

Enable Terminal Access (off by default) lets the agent read your own terminal: the tmux panes you have open, as plain text, through the read-only terminal_read tool. It cannot type into them or run anything there. Without a pane named it reads the one you were in last, so after a failing build in another pane you can simply ask:

What failed in my terminal?

The agent sees whatever the pane shows, secrets included, so leave it off unless you want this. The tool is only offered while tmux is running; in the terminal interface the group is called terminal (--enable terminal, or /tools).

In the desktop app you can also share the terminals in its own terminal dock, one tab at a time, with the eye icon on the tab; that needs no setting, and a tab you never share is never read. A tab shared as Read + run also lets the agent run a command there (terminal_run), and every such command asks for your approval first, even under Auto-approve All: it runs in your own shell, not the sandbox. See Letting the agent run a command in your terminal.

Data and documents

The agent can run SQL over CSV, Parquet and JSON files in the workspace and describe their schema — Excel and ODS files too — read and write Excel, Word and PowerPoint files, extract text and page images from PDFs, typeset PDFs and draw charts. Results open as artifacts beside the chat.

Load sales.parquet, chart revenue by region for the last four quarters, and write a two-page PDF summary.

Reading and querying never ask. Writing spreadsheets and other files follows your approval mode.

Web and the built-in browser

With Settings → Internet on, the agent can search the web (Tavily or Brave if you add a key, a basic fallback otherwise) and fetch any public page as readable text. A rate-limited fetch is retried automatically, and a page that’s blocked or gone is served from the Wayback Machine’s closest archived copy instead, labelled with its capture date. The same page also holds optional cloud services — a hosted browser agent and a cloud code sandbox — which need their own API keys and stay off until you add one.

Better keyless results with a local reranker. Without a search API key, you can have the results reordered by a small cross-encoder model on your own machine. Fill in Rerank Endpoint and Rerank Model under Settings → Internet → Keyless search, then press Test. In our evaluation this took the right page in the top five from 48 % to 57 % of questions, and the right page at the very top from 27 % to 49 %. The cost is about one second per search (p95 went from ~2.3 s to ~3.2 s). If the endpoint is down, search keeps working and returns results in their normal order. The reranker is not used when a Tavily or Brave key is set.

Any server with a Cohere/Jina-style /rerank endpoint works. For BAAI/bge-reranker-v2-m3 (about 1.1 GB of GPU memory at fp16, or ~600 MB as a GGUF on CPU), use one of:

# vLLM (GPU); vLLM recognises the model as a reranker by itself
vllm serve BAAI/bge-reranker-v2-m3 --port 8001 --max-model-len 1024
# endpoint: http://127.0.0.1:8001/rerank   model: BAAI/bge-reranker-v2-m3

# llama.cpp (CPU or GPU), with a GGUF build of the same model
llama-server -m bge-reranker-v2-m3-Q8_0.gguf --reranking --port 8001
# endpoint: http://127.0.0.1:8001/v1/rerank   model: bge-reranker-v2-m3

The browser tools drive a real Chrome on your machine, so the agent can look at what it just built instead of guessing: it renders the page, screenshots it, spots the problem, fixes it, and re-checks. It can also click — a button, a link, a menu item — and fill in text fields, by picking an element out of its own snapshot of the page rather than guessing coordinates or positions; the click or typing is refused if the element moved, is covered by something else, or is off screen. Typing replaces a field’s contents and never submits it — that’s a separate click the agent makes on its own. The agent never types into a password or card-number field; those are always refused, so sign in yourself after taking control. While it works, a live view of the page docks beside the chat; you can click and type in it, and take control at any moment. Hand back & continue gives the browser back to the agent and tells it to take a fresh look at the page and carry on from there.

One limit worth knowing: the screenshot reaches the model on its next turn, not inside the tool result. In practice the agent captures the screenshot, finishes its turn, and reviews the image on the turn after. Say keep going if it stops after capturing.

A link that opens a new tab or window — target="_blank", window.open, a “Sign in with…” popup — is followed automatically: the live view, your clicks and typing, and the agent’s own tools all switch to the new tab. Open tabs sit in the artifact panel’s tab bar next to the open files, each with a globe icon and a × to close it, so you can click back to the page underneath (or to a file) while the popup is still open. Switching to a file leaves the browser tabs in the bar; clicking one brings the page back. Closing the tab you are on shows the one next to it, and closing the last one leaves a blank page. Only the tab you are looking at is streamed live. The same address filtering applies to whatever any tab navigates to, on its own or otherwise: a tab that lands somewhere the browser is not allowed to reach is marked blocked in the bar and shows nothing until it comes back somewhere allowed.

By default the browser is limited to localhost and files inside your workspace — it is for reviewing your own work. With Internet access on it can also open public websites, with the same address filtering as fetch and search so private and internal network targets stay out of reach. If you need it to reach something on your own network — another machine on your LAN, say — turn on Allow Browser Access to Private Network; cloud-metadata addresses (169.254.x.x) stay blocked either way. The browser profile is never signed in to anything, so looking at a page never asks for approval. Clicking and typing are the exception: on localhost and workspace files they’re automatic, but a click or a keystroke on any other site asks you to approve it first, every time — the agent may be acting inside a session you’re logged into under take-control, and that always gets a confirmation. Turn the tools on with Enable Browser Tools; they need a workspace, where screenshots and console logs are kept.

Chrome is not bundled. If you already have Chrome, Chromium or Edge installed, that is used; otherwise the first browser call downloads a pinned Chrome for Testing build — roughly 190 MB, once — showing progress in the panel while it downloads, and verifies it before use. Expect that first call to take a minute.

Open http://localhost:3000, screenshot the checkout page at 375 px wide, and fix anything that overflows.

Memory, skills, sub-agents and planning

The agent can remember facts and preferences across conversations and recall them later, save a procedure it just worked out as a named skill and load it again (Memory & skills), hand independent subtasks to headless sub-agents, call agents you have installed as extensions, list its own tools and MCP servers, and keep the to-do plan described above.

Remember that this project uses pnpm, then split the migration into three sub-agents, one per package.

Memory, skills and planning never ask. Sub-agents run their own tool calls without prompting only under Auto-approve All.

Extensions

MCP servers, agents and modules from the Hive marketplace, the built-in catalog (Hugging Face, Notion, Atlassian, Google) and your own servers plug in under Settings → Extensions: Extensions.

Next

Extensions

When to read this: You want to give the agent more reach — MCP servers, remote agents and modules from the Hive marketplace, the built-in catalog, or a server of your own.

Settings → Extensions is one page with four parts: your Hive account, what is installed, the marketplace, and a form for custom servers. Plugins — WASM modules, from Hive or copied in by hand — are listed on their own page, Settings → Plugins. Enabled extensions show up on the new-conversation start screen and their tools become available on the next message.

Hive marketplace

Hive is the registry Chatty installs extensions from; the address it is talking to is shown next to Hive Account. Sign In or Register (username, email, password) to install from it and to run cloud modules; the session lasts thirty days.

Browse Marketplace searches the registry. Each result shows its type and pricing; Install downloads it into your module directory (the one set in the module settings) and adds it to your Installed list, Uninstall removes it. Chatty refuses a module whose name or version is malformed or whose download is over 64 MiB, and remembers each installed module’s checksum: if its file changes on disk afterwards, it no longer loads. Installed items carry two kinds of badge:

BadgeMeaning
MCP / Plugin / A2AWhat kind of extension it is — a tool server, a WASM plugin, or a remote agent
• LocalA module that runs on this machine
☁ Cloud / ☁ Cloud OnlyRuns on the Hive runner; cloud-only modules cannot be switched local
↗ ExternalAn MCP server or agent at an external URL
PaidNot free — check the pricing before enabling
Trust: signed / Trust: localWhere a loaded module’s code comes from: signed by its publisher and installed from Hive, or copied in by hand

Paid plugins are metered wherever they run, including when an agent spec lists them and when a delegated worker calls them. Each module’s free calls are used first (the count comes from Hive); after that a call needs a positive credit balance, and every call is reported to Hive. If Chatty cannot read your balance — you are signed out, your sign-in has expired, or Hive is unreachable — a paid call is refused with “Cannot verify credits … sign in to Hive” rather than run unbilled. Delegated workers reuse the sign-in the desktop app saved, so if the app has been idle for over an hour, open it once to refresh the session.

The Teams tab next to the plugin listing finds published agent teams and installs them with one click; see Install a team from the marketplace.

Each row has Enable / Disable (🟢 enabled, ⏸ disabled), and modules that support both modes offer Switch to Local / Switch to Cloud.

Built-in catalog

A handful of well-known services are pre-loaded under Installed, disabled until you click Enable:

IntegrationWhat the agent getsSign-in
Hugging FaceHub models, datasets and SpacesOptional — add an access token as the API key for private repositories
NotionPages, databases and commentsSign in to your Notion workspace when prompted
Atlassian (Jira + Confluence)Issues, comments and Confluence pagesAtlassian Cloud sign-in in the browser on first connect
Google Calendar, Gmail, Google DriveEvents, mail, filesSign in with your Google account when prompted

Sign-in tokens stay on your machine. The maintained list, with each service’s connection notes, is on the curated MCP catalog page.

Add a custom MCP server

Chatty connects to servers you run; it does not launch them.

  1. Start the server yourself and note its URL.
  2. Add Custom Extension → Add MCP Server.
  3. Give it a name (for example github-mcp), the URL (for example http://localhost:3000/mcp) and an optional API key, then Add.

The server appears under Installed with an ↗ External badge. The agent can list your servers at runtime, but any key you enter is masked — the model never sees the real value (Security & sandboxing).

Tip

Servers to try, with their start commands, are collected on the curated MCP catalog page. Write your own against the MCP specification.

Remote A2A agents and private networks

A remote A2A agent (installed from the marketplace or added as a custom extension) is called over HTTP, the same as an MCP server. By default it can only be reached at a public address or localhost: a name that resolves to your LAN, your Tailscale tailnet, or any other private range is refused, so a malicious or compromised marketplace agent can’t be pointed at your own network.

If you run an agent yourself on your LAN or tailnet, turn on its Private network toggle (next to Enable/Disable under Installed) to let that one agent reach a private address. It admits the same ranges as the browser’s Allow Browser Access to Private Network toggle: RFC-1918 LAN addresses, Tailscale’s CGNAT range, and IPv6 unique-local addresses. Cloud-metadata addresses (169.254.x.x) and IPv6 link-local addresses stay refused either way. A private-network agent still needs https://: plain http:// only ever works for localhost.

Turn this on only for an agent whose address you control. The check runs against whatever address the agent’s name resolves to at the moment of each call, not once when you add it — so if the name is ever pointed somewhere else (DNS rebinding), the opt-in lets that new address through too, private range or not.

Build your own module

Modules are small programs that run inside Chatty, locally or on the Hive runner. The developer guide Build a WASM module walks through it, with two worked examples: write a plugin (echo) and give an agent the plugin (benford).

A module is a plugin: tools an agent runs, never an agent of its own. Installing one adds it to the module directory and nothing more; an agent uses it once its spec lists the plugin under [[plugins]] and grants a subset of what the plugin asks for — an LLM call, a file read, billing, and so on. Anything not granted is refused to the plugin, not silently allowed. Settings → Plugins lists every plugin with its tools, what it requests, trust level and the agent specs that use it, each with what it actually grants; Settings → Agents shows the same requests-vs-grants breakdown for each spec’s plugins. A module you copy into the module directory yourself shows Trust: local: Chatty loads it because you put it there, but nothing vouches for it.

If a module fails to load — an invalid module.toml, a missing .wasm file, a name that clashes with another installed module, or an installed .wasm that changed on disk since it was installed (hash mismatch) — its row shows Failed to load: with the reason, instead of failing silently. Reinstall a module that fails with a hash mismatch.

Use a plugin from another MCP client

A plugin whose module.toml sets [protocols] mcp = true is also served to MCP clients outside Chatty, by the desktop’s module gateway (Enable module runtime in Settings → Plugins). Served that way there is no agent spec to grant from, so a plugin gets only logging and config by default. If it asks for an LLM call, a file read or billing, its row in Settings → Plugins shows a switch for each, off until you turn it on; switching one on reloads the gateway. The gateway starts the first time an agent delegates, and Settings → Plugins then shows where it runs.

The gateway has no network port. It listens on a Unix socket in a folder only you can open, and answers only a caller that sends the token Chatty writes there each time it starts the gateway:

  • Socket: gateway.sock in $XDG_RUNTIME_DIR/chatty-run. Without XDG_RUNTIME_DIR (macOS, some Linux setups) the folder is chatty-run in your cache folder: ~/Library/Caches or ~/.cache.
  • Token: gateway.token, next to the socket, readable only by you. Send it as Authorization: Bearer <token>; it changes every launch, so read it from the file each time.
dir="$XDG_RUNTIME_DIR/chatty-run"
curl -s --unix-socket "$dir/gateway.sock" http://localhost/mcp/echo \
  -H "Authorization: Bearer $(cat "$dir/gateway.token")" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

A client that can only reach an HTTP URL needs a local bridge from a port to the socket. The bridge answers anyone who can reach that port, so bind it to 127.0.0.1 and still send the token.

Chatty refuses to start the gateway when the chatty-run folder is someone else’s or can be opened by other users (anything but mode 700); remove the folder and it is recreated correctly. On Windows the gateway does not start yet: it needs an owner-only folder there, which Chatty does not create yet, so external MCP access (reaching a plugin’s tools from another MCP client) is macOS/Linux-only for now.

Next

Install a team from the marketplace

When to read this: Someone published an agent team for a job you have — a payment run, an architecture review, a data analysis — and you want to run it on your machine without copying spec files by hand.

A team on Hive is a published agent spec (its leader) plus the specs it delegates to that the same publisher published (its members). Each spec is signed by its publisher, and every plugin a spec uses is locked: pinned at publish time to one exact version and the SHA-256 of its bytes. Installing a team gets you exactly what was published, or nothing.

Find a team

  1. Settings → Extensions, and sign in to Hive under Hive Account (installing needs your account; browsing does not).
  2. Under Browse Marketplace, switch to Teams and search by name, publisher or task (an empty search lists every team).

Each team shows:

FieldWhat it is
Name, descriptionThe leader spec’s
by · v · installsThe publisher, the latest version, and how many people installed it
Team:The members that are installed with it, at their versions
Plugins (locked):Every plugin its specs use, at the locked version; Paid marks one that costs credits
Try:The publisher’s example prompt

Install it

Click Install team. Chatty then:

  1. fetches the leader’s spec and each member’s;
  2. checks each spec’s signature chain against the registry root keys built into Chatty, and that it is the name and version asked for. A spec that was changed after signing, or signed by a key the registry never certified, stops the install;
  3. downloads each locked plugin through the same signed install path as Install on the Plugins tab, and checks that its bytes have the SHA-256 the lock pins;
  4. writes the specs to your global agents folder (~/.local/share/chatty/agents/ on Linux, ~/Library/Application Support/chatty/agents/ on macOS);
  5. adds the leader to your roster.

Nothing is written until every check has passed. A confirmation then names the agents that were installed.

If a spec of the same name is already in your agents folder (your own, or part of another installed team), the install stops and tells you which file is in the way. Rename or remove it first.

Paid plugins. A team may use paid plugins. They install like free ones, but each call needs credits and the plugin’s billing grant under Settings → Plugins, the same as a paid plugin you install yourself. The confirmation names each one.

Paid teams (a fee per run) are not available yet. Such a team shows Paid team · not available yet, and installing it is refused.

Run it

Start a chat and type /agent . The leader is in the picker. Pick it and give it the task, e.g. /agent payments-lead Check vendors.csv before Friday's run. The leader delegates to its members as its spec says, and Settings → Agents lists every installed spec as served.

If you declared a roster yourself (virtual_agents in the module settings), Chatty adds the leader and the agents it delegates to there too, and removes them again when you uninstall.

Update or uninstall

  • Update team appears when a newer version is published. It runs the same checks and replaces the team’s specs and plugins with the new version’s.
  • Uninstall removes the team’s specs, the roster entries it added, and each plugin it installed that no other installed team still uses. A plugin you had installed yourself before the team stays.

From the terminal

chatty-tui --install-team payments-lead          # the latest version
chatty-tui --install-team payments-lead@1.0.0    # an exact version

This does the same checks with the Hive sign-in the desktop saved, prints what it installed, and exits. Run the team in the TUI with chatty-tui --broker, then /agent payments-lead <task>.

Memory & skills

When to read this: You want the agent to remember things between conversations, or to reuse a procedure it has worked out once.

Memory

Memory is on by default. The agent stores facts, preferences, decisions and project conventions as it works — you can also just tell it remember that we deploy from the release branch — and searches them when earlier context would help. Everything is kept in a local file on your machine (where); nothing is uploaded.

Out of the box, recall is full-text search. Semantic Search adds search by meaning, so how do we ship? finds the note about the release branch; it needs an embedding provider.

Settings → Memory

  • Enable Agent Memory — the master switch.
  • Purge All Memory — permanently delete every stored memory. This cannot be undone.
  • Enable Semantic Search, with an Embedding Provider (it can differ from your chat model’s provider) and an optional Embedding Model — leave it empty for the provider’s default.
  • Memory Browser — Load Memories, search them, Refresh, and Delete individual entries.

Note

Memory is shared across conversations and across the desktop and terminal apps, so a fact the agent learns in one chat is available in the next.

Skills

A skill is a named, reusable procedure — deploy-to-staging, write-unit-tests — that the agent can load and follow later. Ask it to save one (save what you just did as a skill called deploy-to-staging) and it writes the steps down under that name; on a later task it finds relevant skills through memory search and loads the instructions before starting.

You can also invoke a skill yourself: type / in the composer and pick it — skills appear in the picker with a skill badge.

A skill is a folder holding a SKILL.md file, in the Agent Skills format that Claude Code, Codex, opencode and Cursor also read. The folder name is the skill name; the description: line in the file’s frontmatter is what the picker shows:

---
name: deploy-to-staging
description: Build, tag and push the current branch to staging.
---

1. Run the tests …

Chatty looks for skills in two places:

  • Project skills, next to your code so they travel with the repository: .agents/skills/<name>/SKILL.md or .claude/skills/<name>/SKILL.md. Chatty searches the conversation’s working folder and every folder above it up to the root of the git repository.
  • Global skills, available in every project: ~/.agents/skills/<name>/SKILL.md or ~/.claude/skills/<name>/SKILL.md in your home folder. The path is the same on macOS, Linux and Windows (%USERPROFILE%\.agents\skills\ on Windows).

Use .agents/skills/ for new skills. Chatty reads .claude/skills/ as well, so skills you already have for Claude Code show up without copying them. When two skills share a name, the project skill wins over the global one, and .agents wins over .claude.

The / picker rescans these folders each time you open it, so a skill you add while Chatty is running shows up the next time you type /.

Next

Sub-agents

When to read this: You want the agent to split a job into parallel or isolated pieces, or you want to drive Chatty from scripts.

A sub-agent is a separate chatty-tui process the parent agent hands a task to, waits on, and reads the answer from. Each child has its own conversation, its own workspace copy, and the same configured models and tools. The parent asks for one through its invoke_agent tool, addressed to local-agent; the child reports its progress back over the local agent broker while it works. That broker hop was measured rather than assumed safe to add: paired against calling the child directly, its median added cost came out at 3 ms or less, well under the variance of a single model turn.

Why bother?

  • Parallelism — independent subtasks run at the same time.
  • Isolation — a child’s exploration and mistakes stay out of the parent transcript; only its final answer comes back.
  • Composition — one agent’s output can feed the next.
  • Focus — each child gets one narrow prompt.

Isolated file changes

Sub-agents work in the conversation’s workspace, the folder its folder chip shows, unless their team asks for isolation. A team whose team.json sets "isolate": true (a coding team would; for a roster in module_settings.json it is "team": { "isolate": true }) gives each spawned sub-agent its own git worktree on a new branch (sub-agent/<name>) instead, when the workspace is a git repository with at least one commit. That means two children editing the same file at the same time no longer silently overwrite each other — each keeps its own copy. The branch name is unique per repository: if sub-agent/<name> is already taken (for example, two sub-agent leaders on the same repo naming a worker the same thing), Chatty picks sub-agent/<name>-2, -3, and so on. When a child finishes, its changes are committed to its branch and the worktree is left on disk; the parent agent merges the branch to take the changes. The tool result includes a fenced evidence block naming the actual branch, its commit count, and a diff summary against the base branch — if the project declares a verification command, its exit code and last lines are in there too. A child that committed nothing gets no evidence block, since there’s nothing to merge. If a worktree can’t be created at all (a repository with no commits yet, for example), the delegation fails before the sub-agent starts, rather than silently falling back to a shared tree: you get a red Could not start card saying what failed and what to do, and the agent that asked stops instead of retrying.

Note

If the workspace isn’t a git repository, sub-agents fall back to sharing the parent’s tree as before, so parallel children editing files can still collide.

From the chat

Type /agent <your prompt> to launch a sub-agent inline and watch its progress in the transcript. /agent <name> <prompt> sends the prompt to a named agent instead: one of your agent specs (see Named workers and roles below; a built-in one such as researcher), or a remote agent you have installed as an extension. A name that is neither is just the first word of the prompt. An agent whose spec sets callers is a team member, not an entry point: /agent <member> is refused with a note naming the team to use instead (for example /agent crosscheck-lead), and the picker lists only team leads and standalone agents. In the desktop app, typing /agent (with the space) opens a list of the agents this conversation can reach; type to filter it, and Enter or Tab puts the chosen name in for you.

/agent hands the task to the same local agents the assistant itself delegates to, so the delegation row — and the swarm tree below, when that agent delegates in turn — looks exactly as if the assistant had made the call. If the agent fails or stops before answering, the row ends with the error.

Every local agent is a spec, whoever wrote it. The desktop lists them all on Settings → Agents — name, model, role, the plugins each runs with and what they may do, whether other agents may call it — along with any spec file that doesn’t load and why. In the terminal, /agents prints the same list.

The swarm tree (desktop)

When a sub-agent delegates in turn, its row in the desktop transcript opens into a live tree of agents. Each line shows the agent, its model (or the tool it is running right now), its status and its spend.

  • Click a line to open that agent’s transcript, read-only, in a side sheet. The sheet shows a breadcrumb (lead › writer-0 › drafter-2), the agent’s spec, model, status, turns and spend, its sub-agents and its tool calls. Once a run has finished, the sheet shows its conversation as chat — the prompt, the agent’s text and foldable groups of tool calls; a run still in progress, or one with no captured conversation, lists its tool calls with their arguments and the first lines of each result. A conversation too large to capture says so. The sheet scrolls, as do the footer popovers (agents, MCP, tools, PR checks). Click a sub-agent to go down, or a crumb to go back up. Press Escape or click outside to close it.
  • Fold an agent with its arrow, or the whole card from its header.
  • An agent with many sub-agents shows the first few plus any that are still running or failed, then a +N more done · show all line.
  • Stop on a running agent’s line, or Stop this agent in its sheet, stops that one agent and every agent it started. The rest of the swarm keeps running. The agent that called it is told cancelled_by_user and carries on without it. Any approval or question the stopped agents were waiting on is withdrawn, and the line shows as canceled.
  • If you stop a turn, runs still drawn as running show as canceled.

Every running agent at once

While any conversation has agents running, the status bar shows a N running chip next to the agents chip (amber, with how many are waiting, when one of them waits on you). Click it to open the running-agents panel: one row per live agent across all your conversations, with its conversation, the agents above it, whether it is working or waiting on your approval or answer, how long it has run, and what it has spent so far. Click a row to jump to that conversation with the agent’s transcript open; Stop on a row stops that agent and every agent it started, like Stop in the tree. The list updates as the agents report; nothing is polled.

A single delegation with no further delegation keeps its plain row. Its ↗ button (on the “Sub-agented” row, once its activity is unfolded) opens that agent’s transcript in the same side sheet; it is greyed out until the agent has started. While an agent is running, its transcript sheet has a message box under the transcript: what you send is read by that agent at its next tool call, not at the end of its run (in the terminal, /msg <agent> <text> does the same). In the terminal, /swarm prints the same tree as text, /agents running prints the running-agents list, and /stop <agent> stops one agent by the name /swarm shows.

Let the agent decide

With tools on (Agents & tools), the parent can ask for children itself when a task splits cleanly:

Task: "Refactor all modules and write tests for each"

→ sub-agent: "Refactor the authentication module and write tests"
→ sub-agent: "Refactor the billing module and write tests"
→ sub-agent: "Refactor the notifications module and write tests"
→ Parent merges the results into a final summary

Note

A child asks before its side effects the way the parent does. When a worker, however deep in the tree, wants to run a command or write a file, the request climbs the chain to your approval card. The card names the agent that asked and its path (data-coder-0 (root › coordinator › data-coder)). Your answer goes back down to that worker only. See Security & approvals.

Note

Children of one local model server queue rather than run all at once, so a local model is not thrashed by a wide fan-out. The limit is default_endpoint_budget in your module settings (hosted APIs such as OpenRouter allow four at a time).

From the terminal

# One headless call
chatty-tui --headless -m "Summarize the changes in the last 5 commits"

# Pipe input
git diff HEAD~3 | chatty-tui --pipe

# Chain: the first agent lists TODOs, the second works through the list
chatty-tui --headless -m "List all TODO comments in src/" | chatty-tui --pipe

--auto-approve skips approval prompts for scripted runs; --enable / --disable pick tool groups per run. Install, modes and keys: Terminal interface.

Add --broker to let that terminal leader spawn sub-agents of its own — without it, only the desktop app can delegate to local-agent:

chatty-tui --headless --broker -m "Refactor the auth module and write tests"

--broker works with --headless, --pipe, and the interactive TUI, and is Unix only.

Named workers and roles

Out of the box the parent can reach local-agent — your default model with the parent’s tools — the built-in agents (researcher, reviewer, writer and the crosscheck-lead / crosscheck-data-lead team leaders), and every spec of your own that doesn’t opt out with exposed = false under [swarm]. A spec that names its callers (a team’s internal members, such as crosscheck-judge) works only for those agents and is never offered to the parent. You can declare your own named workers, each with its own model, a role that limits what it may do, and standing instructions. The parent sees each one as a card — its name, model, role and the first sentence of its instructions — and picks by reading, the same way it would choose a colleague. Nothing else about the parent changes: the prompt and the tool stay identical whether the roster is empty or five deep.

Declare each worker as an agent spec: a small TOML file named after the worker, in your project’s .chatty/agents/ folder (or in Chatty’s data folder under chatty/agents/ to share it across projects — where that is):

# .chatty/agents/local-coder.toml
[agent]
name = "local-coder"
model = "qwen3:14b"
preamble = "You are the coder. Make the smallest change that makes the task's check pass, run it, and report the files you touched."

[tools]
profile = "coder"

[budget]
max_agent_turns = 30
# .chatty/agents/local-reviewer.toml
[agent]
name = "local-reviewer"
model = "gemma3:27b"
preamble = "You are the reviewer. Read the diff, run the tests, and report what you found; never edit the tree."

[tools]
profile = "reviewer"

A spec file is picked up as soon as it exists (on the desktop, Settings → Agents → Reload). To narrow the roster to just some of them, list them by name in module_settings.json, next to your other settings, and restart. This roster is used by the desktop app and by a terminal leader started with --broker:

{
  "virtual_agents": ["local-coder", "local-reviewer"],
  "team": { "verification": "python3 -m pytest -q" }
}

On the desktop, the enabled field is the Enable module runtime switch in Settings → Plugins. It loads WASM modules; agents do not need it, because the local agent broker runs either way.

A module_settings.json that still describes workers inline (the format before agent specs) no longer loads; move each entry into its own spec file.

FieldWhat it does
agent.nameWhat the parent addresses; also the branch name, sub-agent/<name>. Must match the file name.
agent.modelThis worker’s model, matched the way chatty-tui --model matches (id, name, or part of the id). Leave it out to use the default model. A worker on a different model server is queued on that server’s budget, not the parent’s.
tools.profileA role: coordinator, coder or reviewer. A role is the worker’s whole tool set; anything not in it — including every MCP tool — is gone, so a small model isn’t handed fifty tool schemas before it can read a file.
agent.preambleStanding instructions, added to the worker’s system prompt. Its first sentence is what the parent reads on the card, so lead with the role.
budget.max_agent_turnsHow many tool rounds this worker may take before it has to answer. Without it the worker has no turn cap and a 30-minute time budget. This is the worker’s own budget — the parent’s is Max Agent Turns under Settings → Code Execution.
tools.disableTool groups to remove on top of the role (shell, fs-write, git, …).

A spec in your project folder wins over one of the same name in the data folder, which wins over the built-in ones (the members of the teams below). chatty-tui --agent <name> runs the terminal app as any spec.

The three roles:

RoleCanCannot
coordinatorRead files, search, read git history and diffs, delegate to other workers, merge a worker’s branch, ask you a question.Edit, run commands, commit. It hands work out; it never does it.
coderEverything a coordinator can read, plus write files, run commands, commit on its own branch, run code, query data, remember and save skills.Delegate further: a coder does not fan out.
reviewerRead and search, read diffs (including another worker’s branch), run commands so it can run the tests, query data.Write, commit, or delegate. It reports; it never fixes.

A role only ever removes tools: it cannot turn on a tool group you switched off under Settings → Code Execution (or with --disable).

team.verification is one command for the whole roster. When a worker finishes, Chatty commits its branch and then runs that command itself in the worker’s tree — not through the worker — and puts the exit code and last lines into the evidence block the parent reads, next to the branch name, commit count and diff summary. That’s the parent’s proof that the coder’s “tests pass” is true. It is skipped for a worker whose role has no shell, since that worker could not have built anything for it to check.

A worker’s side effects come to you for approval like the leader’s own, whatever its depth (Security & approvals). In an unattended terminal run, --auto-approve lets them through. The full field reference, including how a worker is metered and started, is on the developer page: Named virtual agents.

Teams

--team <id> packages a roster like the one above with a leader and a verification command into one directory, so a run is reproducible and the leader has a role too: a named leader plus co-workers, each with its own model, role and standing instructions, defined once in teams/<id>/team.json, which names the leader’s spec and the workers’ specs. It implies --broker, and for that run the team’s agents replace whatever virtual_agents your module settings declare.

In an unattended run (--headless, --pipe), chatty-tui prints one line to stderr per agent, before the first turn, when that agent’s tool profile (for example coder or reviewer) wants the shell or git but the run has it off (enabled=false, git_enabled=false). It also prints a line when the team’s verification command is set but shell is off. The leader’s --enable, --disable and --only flags are forwarded to every worker, so --enable shell,git gives the whole team a shell and git, and the warnings follow those final settings. These are warnings only; the run goes on unchanged.

Two teams ship built in, both experimental: they are free, run on your own model, and become documented defaults only once a measured result supports them.

TeamStart it withWhat it does
crosscheck (Crosscheck)chatty-tui --team crosscheck -m "<task>"Several independent attempts at your task, and a judge picks the strongest; the reply says what it cost.
crosscheck-data (Crosscheck: Data)chatty-tui --team crosscheck-data -m "<question about your data>"The same, with data analysts as the attempts.

See Crosscheck for when it pays and what it costs. chatty-tui --team <id> runs a team from the terminal. The desktop has no team switch: the team’s leader is on the default roster, so /agent <leader> … runs it, since the leader is an agent like any other. Three standalone agents ship too: researcher, reviewer and writer.

--model runs every agent of the team on that model for the run, including agents whose spec names a model of its own; --tools and --preamble override the leader’s settings. Bring your own team by adding <workspace>/.chatty/teams/<id>/team.json, which overrides both the built-in preset and any team of the same id under your data directory. File format and search order: Teams.

A team can also make what one role hands the next explicit and checkable: team.json’s optional handoffs names a JSON Schema per role, as a path relative to the team directory. A worker running as that role must end its final answer with a fenced json block matching the schema — get it wrong once and Chatty sends the answer back listing the schema errors; get it wrong again and the delegation fails, with the errors visible to the leader. A team with no handoffs behaves exactly as before.

Next

Crosscheck: several attempts, one judged answer

When to read this: One answer from the model is too often plausibly wrong for the job — a figure from your data, a list, a fix whose tests must pass — and you would rather spend more tokens than check it yourself.

Crosscheck is a built-in team: several independent attempts at the same task, and a judge picks the strongest. It is free and runs on your own model. It is experimental: it ships so you can use it, and it becomes a documented default only once a measured result supports it (see What it is worth).

What it does

  1. Three attempts at once. The leader hands your task, word for word, to three solvers. Each runs as its own agent with its own context: none sees another’s work. They approach the task differently — one directly, one after writing a plan, one after deciding how it will check its answer — so their mistakes are less alike. They run at the same time when your model server takes several requests at once (see Speed).
  2. A check, when there is one. If the team declares a verification command (a test suite, a checker script), Chatty runs it on each attempt’s own copy of the project. An attempt that passes wins; the judge is not asked.
  3. Otherwise a judge picks. The judge reads every attempt’s final answer and its trace — the commands and code it ran and what they printed — and names one. It never writes an answer of its own: what you get is the chosen attempt’s answer, character for character. When all attempts give the same answer, no judge is needed. The judge never thinks out loud (thinking is off for it), which was both faster and more accurate in our tests.
  4. The cost is on screen. The reply starts with a line like 3 attempts + judge, 41,200 tokens (≈3.4× a single run), then the answer, how it was chosen, and each attempt’s answer.

Two versions ship:

TeamIdUse it for
CrosscheckcrosscheckAny task with one right answer you can describe: a figure, a name, a short fix.
Crosscheck: Datacrosscheck-dataQuestions about data files in your workspace. Its three attempts are data analysts that read the documentation that comes with the data first and compute with code.

Run it

From the terminal:

chatty-tui --team crosscheck-data -m "What was the average transaction value in March 2023, in EUR, rounded to 2 decimals?"

In the desktop app there is no team switch yet: name the team’s agents in virtual_agents in module_settings.json, restart, and send /agent crosscheck-lead <task> (or /agent crosscheck-data-lead <task>):

"virtual_agents": ["local-agent", "crosscheck-lead", "crosscheck-solver-direct",
  "crosscheck-solver-plan", "crosscheck-solver-verify", "crosscheck-judge", "crosscheck-writer"]

The team’s members are not offered to you one by one: they work only for their leader.

What it costs

About three and a half single runs: three attempts, plus a judge when they disagree. The banner shows the real figure for each run, measured against the attempts themselves. It is opt-in per conversation and never the default, because most tasks do not need it.

Use it when a wrong answer is expensive and the task has one right answer: a number from a dataset, a yes or no, a fix with tests. Skip it when the work is long and sequential (a refactor, a report built step by step): independent attempts do not help there, and coordination teams in general have not made sequential work more accurate.

Speed

The attempts run at once only if your model endpoint allows several requests at a time. Chatty sends one request at a time to a model server of your own (on this machine or your network) unless you raise its budget; a hosted API such as OpenRouter already gets four at a time. To raise it: set num_parallel in the provider’s extra_config, or default_endpoint_budget (or a per-endpoint entry in endpoint_budgets) in module_settings.json, to 3 or more. With a budget of one, the attempts queue and the run takes about three times as long.

What it is worth

In our tests, independent attempts plus a judge finished 8 to 22 points more tasks correctly than a single attempt on the same model, at 2 to 3.3 times the tokens. All runs used the local RedHatAI/Qwen3.8-27B-INT4 on vLLM, with both arms sharing the same harness, tools and data:

Task setSingle attemptCrosscheckGainCost
DABstep Hard, all 378 tasks77.2 %84.9 % (2 attempts + judge)+7.7 pointsabout 2×
DABstep, 80-task subset79.6 %88.75 % (3 attempts + judge)+9.2 pointsabout 3×
DABstep, 18 tasks where attempts disagree4 of 187 of 18+16.7 points2.1×
Same set, Crosscheck: Data, first 9 trials5 of 97 of 9+22 pointsabout 3.3×

The judge matters: on the subset’s disagreeing tasks it picked a correct answer 16 times out of 18, against 9 for a majority vote.

Read these as the size of the gain, not as scores. The first two rows used DABstep-specific helpers in both arms, which raises the absolute numbers but not the gain. The last two rows are small. It is one model in one domain so far, and the pre-registered gate on the 18-task run missed its no-harm check by one task, so Crosscheck ships as opt-in and experimental.

Tutorial: from one agent to a team

When to read this: You have Chatty installed and a model connected, and you want to see a team of agents do real work: a lead that plans and delegates, specialists that each do one job, and you in charge of anything that touches your machine. Ten minutes of reading, plus a few minutes of the agents working.

What you will do

You give a sales export to a three-agent team and ask it a real question: revenue fell in August. Why? A lead breaks the question down, an analyst answers it with SQL queries, and a reviewer works out the key numbers again before you see the report. On the way you will:

  1. set up the example;
  2. look at the agents Chatty ships;
  3. start the team with one command;
  4. watch the team work, live;
  5. approve the file a worker wants to write;
  6. read the result, and who spent what, per model;
  7. move one agent to a second, smaller model;
  8. build a two-agent team of your own.

Note

Agent teams are supported and experimental. The shipped teams show what Chatty can do with a team. They are not a claim that a team beats a single agent; that research comes later. Try them, and compare with a single agent on your own tasks.

Prerequisites

  • The Chatty desktop app, on Linux or macOS (Getting started). Teams need the local agent broker, which is not available on Windows yet.
  • One model that is good at tool calls. The screenshots use Qwen3.8-27B-INT4 served by a local vLLM, added as an OpenAI-compatible provider (Providers & models). A hosted model works just as well.
  • For step 7, a second model, for example qwen3:4b in Ollama.
  • A folder for the data. This page uses ~/sales. It does not need to be a git repository, and if it is one, nothing about it matters: the team works in the folder itself.

1. Set up

Write the team. The reviewer ships with Chatty; the lead and the analyst are two short files you write. Create ~/sales/.chatty/agents/ (the sample data goes in ~/sales below) and put these two files in it:

# ~/sales/.chatty/agents/data-lead.toml
[agent]
name = "data-lead"
description = "Team lead: answers a business question about a data file with an analyst and a reviewer"
preamble = """
You lead a data analysis and compute nothing yourself.
1. Turn the question into two to four sub-questions a script can answer: the totals, which segments changed and by how much, and whether volume, price or discount moved in each.
2. Ask data-analyst to answer them, naming the file.
3. Write the report: the answer in one or two sentences, then each finding with its numbers and its share of the change, then what to do next. Every number comes from the analyst, never from you. Where the data shows how much but not why, say so instead of guessing a cause.
4. Send reviewer the report and the file's path. On REQUEST_CHANGES, fix exactly what it lists, asking the analyst again only for a disputed number, and send the report back once.
5. Ask data-analyst to save the reviewed report, exactly as written, as report.md next to the data file.
Your final answer is the reviewed report, with any notes the reviewer still has."""

[tools]
profile = "coordinator"

[swarm]
delegates_to = ["data-analyst", "reviewer"]

[budget]
max_agent_turns = 16
# ~/sales/.chatty/agents/data-analyst.toml
[agent]
name = "data-analyst"
description = "Answers questions about a data file with SQL queries, and reports the exact numbers"
preamble = "You answer questions about data files with numbers you computed, never by eye. Compute every number with query_data (SQL over the file); look at the columns first. Break every change down by period and by each column that could explain it, and give each segment's share of the change. For each segment behind a large share, say whether volume, price or discount moved. Report numbers, not guesses. When you are asked to save a file, write exactly the text you are given."

[tools]
profile = "coder"
disable = ["shell"]

[swarm]
exposed = true

[budget]
max_agent_turns = 16

Add the team to your roster. Quit Chatty, then write module_settings.json (create the file if it is not there) and start Chatty again:

  • macOS: ~/Library/Application Support/chatty/module_settings.json
  • Linux: ~/.config/chatty/module_settings.json (or $XDG_CONFIG_HOME/chatty/module_settings.json)
  • Windows: %APPDATA%\chatty\module_settings.json — teams need the local agent broker, which is not available on Windows yet (see Prerequisites)
{
  "virtual_agents": ["local-agent", "data-lead", "data-analyst", "reviewer"]
}

A file at the wrong path is simply never read: Chatty starts with the default roster (local-agent and your own exposed specs) instead, silently. If /agent data-lead … below ends up answering as local-agent instead, re-check this path first — see Where Chatty stores data for every platform’s config and data directories.

Get the sample data. orders.csv has 793 orders from July and August: date, region, channel, product, units, price, discount, promo code and revenue. Something happened in August.

mkdir -p ~/sales && cd ~/sales
curl -LO https://boersmamarcel.github.io/chatty2/assets/samples/orders.csv

Turn on code execution. In Settings → Code Execution, turn on Enable Code Execution and set the approval mode to Always ask. Reading files and querying data never ask. Running a command or writing a file does, including when an agent deep inside the team wants to do it. The default mode, Auto-approve sandboxed, lets sandboxed commands and writes inside the workspace through without asking. With it, this run asks you nothing.

Point the chat at the folder. Start a new chat. Under the message box, next to +, is the folder chip: it shows the folder this conversation works in. Click it and pick ~/sales. Every agent the team starts reads and writes in this folder, so orders.csv has to be in it.

Check your setup. Three things, before you ask anything:

  1. The folder chip reads sales.
  2. orders.csv is in that folder: ls ~/sales lists it.
  3. Type /agent (with the space) in the message box. The list that opens shows data-lead, data-analyst and reviewer. If it does not, the roster from the first step was not read; see Common issues.

2. Meet the agents

Open Settings → Agents. Every local agent is a spec: a short TOML file that gives the agent a name, a role, a tool profile, plugins, the agents it may call, and a budget. The presets ship with Chatty. Your own specs go in <workspace>/.chatty/agents/.

This tutorial’s team is your two specs and the built-in reviewer:

AgentJobCan use
data-leadBreaks the question into parts a query can answer, delegates them, writes the report. It computes nothing itself.Reading files; calling its two workers and no one else
data-analystAnswers each part with SQL queries over the file and reports the exact numbers. At the end it saves the report.Querying data and writing files; no shell
reviewerChecks the report against the data. It works out key numbers again itself, edits nothing, and answers APPROVE or REQUEST_CHANGES.Reading files and querying data; no shell, no writes

Each line on the page also shows the agent’s model (none of the presets names one, so they run your default), its tool profile, which agents it may call, and any plugins it runs with. Settings → Plugins lists the WASM plugins an agent can load, with what each asks for and which agents use it. This team needs none; Give an agent a plugin shows how to add one.

3. Ask the team

Start a new chat and type:

/agent data-lead Revenue in orders.csv fell in August. Find out why.

/agent <name> gives the turn straight to that agent: your own model is not asked anything. Your message shows the workspace the team works in under it, so you can see at once that it is ~/sales. The lead gets the question and starts delegating.

If something in the setup is missing, Chatty says so before any agent starts, with a red Could not start card: what failed and what to do. No workspace, or code execution turned off, is caught this way. So is an agent that cannot be started at all; the agent that asked for it then stops, rather than trying to work around it.

4. Watch the team work

The turn opens into a live swarm tree. Each line is one agent: its model, or the tool it is running right now, then its status and the tokens it has used so far. Workers get numbered names (data-analyst-0), so you can tell a second call to the same role from the first.

Click a line to open that agent’s transcript in a side sheet: its spec, model, turns, spend per model, and every tool call it made with the input and result. The breadcrumb at the top (data-lead › data-analyst-0) takes you back up the tree.

5. Approve the report

When the reviewer has approved, the lead asks data-analyst to save the report as report.md. Writing a file is a change to your workspace, so it waits for you. The request travels up the chain, from worker to lead to you, and the card tells you who is asking and from where:

Click Approve (or press Alt+Y). Deny (Alt+Shift+N) sends the worker an error instead, and nothing is written. The decision goes back down the same chain, so only the worker that asked writes the file.

The analyst’s queries never asked: under Always ask, only commands and writes come to you. A worker that wants to run a command asks the same way, with the command on the card.

6. Read the result and the bill

After a few minutes the lead answers with the reviewed report. On the sample data, a good answer has three parts:

  • The size of the drop: August revenue is 43,886 against 53,251 in July, 17.6% down.
  • The biggest driver: fewer Pro orders (132 down to 91) at an unchanged price, most of them in the EU. EU Pro alone went from 47 orders to 14, about 6,600 or 70% of the drop.
  • The second driver: the SUMMER30 code, 30% off Starter bought online from 1 August. It gave away about 1,760 in discounts, and Starter volume did not grow.

The data shows how much and where, not why fewer people bought Pro. A good report says so and suggests what to check, rather than guessing. Reports differ from run to run in how they slice the numbers; in our runs every one found both drivers.

Scroll back up to the tree. When the run has finished, every line shows what that agent used, and the header shows the total for the whole team. The side sheet splits an agent’s spend by model. A model with a price in Settings → Models shows in dollars; a local model like the one here shows in tokens. The conversation’s cost in the sidebar includes everything its agents spent.

7. Mix models

Every preset names no model, so the whole team runs on your default. To change one agent, copy its spec into your workspace and add a model line. A spec in <workspace>/.chatty/agents/ replaces the preset with the same name.

The reviewer’s job is narrow: query the data, compare, give a verdict. A small local model can do it. Add qwen3:4b under Settings → Models (Ollama provider), then create ~/sales/.chatty/agents/reviewer.toml:

[agent]
name = "reviewer"
model = "qwen3:4b"
description = "Checks a report against its sources and answers APPROVE or REQUEST_CHANGES"
preamble = "Verify, do not trust. You check someone else's report against the sources it rests on, and edit nothing. Re-derive at least two of its key facts yourself from the source (query_data on a data file, read_file on a document); take results a named tool or plugin produced as given. Check that every claim is supported and none is overstated. The first line of your answer is APPROVE or REQUEST_CHANGES, then a numbered list: what you checked, or what must change."

[tools]
profile = "reviewer"
disable = ["shell"]

[swarm]
exposed = true

[budget]
max_agent_turns = 12

model is matched against your model list the same way as the model selector: by id, by name, or by part of the model identifier. Ask the same question again. The tree now shows two models, and the side sheet bills each agent by model. A small model on a CPU is slow, so expect the reviewer’s turn to take longer.

A cloud model as the lead. The opposite mix is common too: a strong hosted model plans and writes, and cheaper local models do the legwork. Add a model line to your data-lead.toml and point it at a hosted model from your list:

# ~/sales/.chatty/agents/data-lead.toml: as before, plus the model line
[agent]
name = "data-lead"
model = "claude-sonnet"   # any model in your list: its id, name, or part of its identifier
description = "Team lead: answers a business question about a data file with an analyst and a reviewer"
preamble = """
You lead a data analysis and compute nothing yourself.
1. Turn the question into two to four sub-questions a script can answer: the totals, which segments changed and by how much, and whether volume, price or discount moved in each.
2. Ask data-analyst to answer them, naming the file.
3. Write the report: the answer in one or two sentences, then each finding with its numbers and its share of the change, then what to do next. Every number comes from the analyst, never from you. Where the data shows how much but not why, say so instead of guessing a cause.
4. Send reviewer the report and the file's path. On REQUEST_CHANGES, fix exactly what it lists, asking the analyst again only for a disputed number, and send the report back once.
5. Ask data-analyst to save the reviewed report, exactly as written, as report.md next to the data file.
Your final answer is the reviewed report, with any notes the reviewer still has."""

[tools]
profile = "coordinator"

[swarm]
delegates_to = ["data-analyst", "reviewer"]

[budget]
max_agent_turns = 16

A spec is one complete file. It is not a patch on the preset, so a copy keeps every section. Only the lead’s tokens are billed at the hosted price, and the swarm tree and the sidebar show the split.

8. Build your own two-agent team

A team is just agents that are allowed to call each other. Here is a lead that turns the findings into a memo for your sales director, and a writer it delegates to. Create these two files in ~/sales/.chatty/agents/:

# memo-lead.toml
[agent]
name = "memo-lead"
description = "Turns analysis findings into a short memo for a manager"
preamble = "You write nothing yourself. Give memo-writer the findings you were given and ask for memo.md: at most 200 words, the answer first, then one action per finding with who should take it. Check the memo covers every finding, then answer with its text."

[tools]
profile = "coordinator"

[swarm]
delegates_to = ["memo-writer"]

[budget]
max_agent_turns = 6
# memo-writer.toml
[agent]
name = "memo-writer"
description = "Writes short internal memos into a file"
preamble = "You write short, plain internal memos. Write exactly the file you are asked for, using only the facts you were given, then reply with its text."

[tools]
profile = "coder"

[budget]
max_agent_turns = 4
  • delegates_to is the only thing that lets memo-lead call another agent. It can call memo-writer and no one else.
  • The coordinator profile can read and delegate, but it cannot write or run commands. The coder profile can write files. Each file it writes still asks you first.
  • [budget] caps each agent’s model turns, so a confused agent stops instead of running on.

Add "memo-lead" and "memo-writer" to virtual_agents in module_settings.json and restart Chatty: a roster you name is exactly those agents. Open Settings → Agents; both agents appear. Then paste the findings from step 6 into:

/agent memo-lead Write a memo to the sales director. Findings: <paste the answer and findings from the report>

When memo-writer wants to create memo.md, you get the same kind of card as in step 5. A minute later the memo is in ~/sales/memo.md: the answer first, then one action per finding, each with an owner.

Verify

  • Step 6’s report puts the drop at 17.6%, names EU Pro as the biggest driver (about 70%) and the SUMMER30 discount on online Starter as the second (about 27%).
  • The swarm tree shows data-lead with data-analyst and reviewer below it. After step 7, reviewer has a different model from the rest.
  • ~/sales/report.md exists after step 5, and ~/sales/memo.md after step 8; you approved both writes.
  • ~/sales has no .chatty/worktrees folder: this team works in your folder directly.

Common issues

You seeWhyFix
/agent data-lead … goes to the default agentThe team’s agents are not in virtual_agents, or the two spec files are not in ~/sales/.chatty/agents/Step 1: write both files, name all three in module_settings.json, then restart
A red Could not start ‘data-lead’ card: this conversation has no workspace folderThe chat has no folderPick ~/sales with the folder chip under the message box and send the message again
A red Could not start card: code execution is offCode execution is turned offTurn on Enable Code Execution in Settings → Code Execution and send the message again
The lead says it cannot find orders.csvThe chat’s folder is not the one with the fileCheck the folder chip and the workspace line under your message, then pick ~/sales
A red Could not start card that mentions a git worktreeA team with "isolate": true (a coding team) gives each agent its own git worktree, and the folder is a git repository without a first commitCommit something first, or pick a folder that is not a git repository. The data-analysis team does not isolate and never needs this
No approval card at allThe approval mode is Auto-approve sandboxed (the default), which lets writes inside the workspace throughSwitch to Always ask in Settings → Code Execution to see each write and command first
The reviewer appears twice in the treeIt asked for changes, and the lead fixed the report and sent it backThis is the review loop working. The lead sends the report back only once.
A run takes a long time on a local modelEvery agent’s turns share one model server, one request at a timeExpect five to ten minutes on a single local GPU. A hosted lead (step 7) makes it faster.
The small model’s answer is wrongSome roles need a stronger modelKeep small models on narrow roles like reviewer, and the lead and analyst on your strongest model

Next

  • More teams, all experimental: research-brief ships with this release (a folder of documents to a sourced brief, see Sub-agents), and analyst-panel, where three analysts answer on their own and an adjudicator picks one when they disagree.
  • Tutorial: a small agentic team: the terminal version, a coding team with its own playbook
  • Security & approvals

Tutorial: your first named worker

When to read this: You have used the agent and want to see delegation for real — one named worker with its own model and a role, doing one job for a leader — before building a whole team. Twenty minutes, nothing to install beyond Chatty and Ollama.

What you will end up with

A worker called local-reviewer that only reads and runs tests, a leader that finds it by name and hands it a review, and a transcript that shows the hand-off, the worker’s report, and the leader relaying it. Everything runs on your machine.

Prerequisites

  • Ollama running locally with two models pulled: one for the leader and one for the worker. This page uses qwen3:14b for the leader and qwen2.5-coder:14b for the worker. They need to fit in memory together (about 20 GB of GPU or unified memory); on a smaller machine use one model for both and read the note at the end.
  • The terminal app, chatty-tui (install). Delegation works from the desktop app too, but the terminal shows every hand-off as it happens, which is what you want the first time.
  • Linux or macOS. The local worker broker is not available on Windows yet.

Steps

1. Make a small project to review

Any folder with some code will do. To follow along exactly, create this one — a bank account with a bug in withdraw and a test suite that does not catch it:

mkdir -p ~/bank/tests && cd ~/bank && git init -b main
cat > bank.py <<'EOF'
class Account:
    def __init__(self, balance=0):
        self.balance = balance

    def deposit(self, amount):
        self.balance += amount
        return self.balance

    def withdraw(self, amount):
        # bug: overdraft is allowed and negative amounts are accepted
        self.balance -= amount
        return self.balance
EOF
touch tests/__init__.py
cat > tests/test_bank.py <<'EOF'
import unittest
from bank import Account

class TestBank(unittest.TestCase):
    def test_deposit(self):
        a = Account(10)
        self.assertEqual(a.deposit(5), 15)
EOF
printf '__pycache__/\n' > .gitignore
git add -A && git commit -q -m "Bank account with a withdraw bug"
python3 -m unittest discover -s tests -t . && echo "one test, passes, bug not caught"

It has to be a git repository: a worker that edits files gets its own branch, and even a read-only worker is told which branch it is looking at.

2. Declare the worker

A named worker is an agent spec: a small TOML file named after the worker. Put it in the project, where it is found first:

mkdir -p .chatty/agents
cat > .chatty/agents/local-reviewer.toml <<'EOF'
[agent]
name = "local-reviewer"
model = "qwen2.5-coder:14b"
preamble = "You are the reviewer. Read the code you are pointed at, run the tests, and report what you found as a numbered list; never edit files."

[tools]
profile = "reviewer"
EOF

Then list it in your module settings file, next to the rest of Chatty’s settings (where that is on your platform). On Linux:

mkdir -p ~/.config/chatty
cat > ~/.config/chatty/module_settings.json <<'EOF'
{ "virtual_agents": ["local-reviewer"], "team": { "isolate": true } }
EOF

If the file already exists, add the virtual_agents list and the team block to it rather than replacing it. There is no settings page for this yet. "isolate": true gives every worker its own git worktree, which step 5 looks at; without it a worker works in your workspace directly.

Four fields, and each one matters:

  • name is how the leader will address it, and the file’s name. Keep the local- prefix by convention: the leader’s tools list where each agent runs, and local means this machine.
  • model is the worker’s own model. It does not have to be the leader’s.
  • profile is the role. reviewer can read, search, look at diffs and run commands — enough to run a test suite — and cannot write, commit or delegate. The other two roles are coder and coordinator; Sub-agents has the full table.
  • preamble is the standing instruction. Its first sentence is what the leader sees on the worker’s card, so lead with the role.

3. Run a leader that can delegate

cd ~/bank
chatty-tui --broker --ollama --model qwen3:14b --auto-approve

--broker is the flag that makes the roster reachable: it starts the local worker broker for this session and publishes every worker in your module settings. --ollama finds your local models without any other setup, --model picks the leader’s, and --auto-approve lets the worker run its tools (the test suite) without a prompt you would not be able to answer on its behalf.

The welcome screen lists what is active — model, workspace, tool groups — and the status bar shows the branch, main.

4. Ask for the review

Type this as your first message:

Use list_agents to see who is on the team, then ask local-reviewer to review
bank.py and tests/test_bank.py and report any bugs and missing tests. Do not
read or fix the code yourself; relay the reviewer's findings.

Watch the transcript. First the leader looks at the roster — the folded tool line reads list_agents, and /verbose shows what it saw:

{
  "agents": [
    {
      "name": "local-reviewer",
      "origin": "local",
      "kind": "worker",
      "description": "A chatty agent in its own process, with its own workspace. Delegate a self-contained task to it and it works autonomously and reports back. Model: qwen2.5-coder:14b. Tool profile: reviewer. Role: You are the reviewer.",
      "enabled": true
    }
  ],
  "total": 1
}

That card is all the leader knows about the worker — the model, the role, and the first sentence of your preamble — and it is enough to choose by. Then the hand-off:

⟳ invoke_agent
[agent: local-reviewer] [local] ⟳ running

While that line is spinning, a second chatty-tui process is alive on your machine with --model qwen2.5-coder:14b --tools reviewer and your preamble, working through the files and running the tests in its own workspace. Its progress streams into the leader’s transcript as it goes. When it finishes, the line flips to ✓ completed, its report becomes the tool result, and the leader writes its answer from that — which should name the two problems in withdraw (negative amounts, overdraft) and the tests that are missing for them.

5. Look at what the worker left behind

git status          # clean: main is untouched
git branch          # main, sub-agent/local-reviewer-0
ls .chatty/worktrees/   # local-reviewer-0

With "isolate": true, every worker gets its own copy of the tree on its own branch, sub-agent/<worker>-<n> (the number counts that worker’s delegations in the session), whether or not its role can write. The reviewer’s copy is where it ran the tests. It committed nothing, so the reply carries no evidence block — that block only exists when there is something to merge. Give a coder role the same task and the reply ends with one: the branch, its commit count and a diff summary. That is what the next tutorial builds on.

Worktrees and branches are left for you to inspect and are never removed behind your back. When you are done with one: git worktree remove .chatty/worktrees/local-reviewer-0 && git branch -D sub-agent/local-reviewer-0 (in that order — git will not delete a branch a worktree still has checked out).

Verify

  • list_agents showed local-reviewer with Model: qwen2.5-coder:14b. Tool profile: reviewer. on its card.
  • The transcript shows invoke_agent → [agent: local-reviewer] [local] ⟳ running → ✓ completed.
  • The leader’s answer reports the overdraft and negative-amount bugs and the missing tests; git status is clean and git branch shows sub-agent/local-reviewer-0.

Common issues

  • list_agents shows nothing, or only local-agent. The leader was started without --broker, or the settings file is not where Chatty reads it. chatty-tui prints the config directory in --help under Prerequisites; check the file is there and is valid JSON.
  • The worker fails with an Ollama error such as llama runner process has terminated. The two models do not fit in memory together and Ollama fell back to CPU or ran out. Use one model for both ("model" on the worker set to the same tag as --model), or pick a smaller worker such as qwen3:4b.
  • The leader thinks for a very long time before delegating. qwen3 reasons before every answer. Either accept it, or use a model that does not (for example the coder model for the leader as well), or turn thinking off for that model in your desktop model settings by adding "extra_params": {"think": "false"} to its entry in models.json.
  • The worker asks a question. It cannot see your conversation, so the leader answers from the task if it can and otherwise the question reaches you as a clarifying prompt that takes over the input row. Answer it and the worker continues.

Next

Tutorial: a small agentic team

When to read this: You have run one named worker and want a team that can take a task from “fix this” to a merged, verified branch: a leader that only delegates, a coder, and a reviewer that does not take the coder’s word for it. About an hour, most of it watching.

What you will end up with

A team directory of your own — team.json plus a SKILL.md playbook — checked into a project, run with one command, that fixes the bank account bug from the first tutorial, has the fix reviewed against acceptance criteria, merges it, and proves it with the test suite. Then you will change the roster and see the difference.

Prerequisites

  • Everything from the first tutorial: Ollama with qwen3:14b and qwen2.5-coder:14b, chatty-tui, and the ~/bank project with its one passing test and unfixed withdraw.
  • A clean tree in ~/bank (git status shows nothing to commit).

How a team run works

Four things happen that a single worker never does:

  1. The leader has a role too — coordinator, which can read, delegate and merge but cannot edit or run commands. It has to work through its team.
  2. The coder gets its own branch (sub-agent/local-coder-0; the number counts that worker’s delegations in the session) in its own copy of the tree. When it finishes, Chatty commits that branch and appends an evidence block to its report: branch, commit count, diff summary.
  3. The verification command is run by Chatty, not by a worker, in the worker’s tree, and its exit code and last lines go into the same evidence block. A coder that says “tests pass” is checked, not trusted.
  4. The reviewer reads the branch itself — the diff, the tests — and its first line is a verdict. The leader merges only on APPROVE.

The playbook that sequences this is a skill the leader is told to follow on its first turn.

Steps

1. Write the team

A team is three things: the agents (each an agent spec), a team.json naming the leader and the roster, and the playbook. Write the three agents as specs, and a team directory naming them, in the project:

mkdir -p .chatty/agents .chatty/teams/bank-fix
cat > .chatty/agents/bank-lead.toml <<'EOF'
[agent]
name = "bank-lead"
preamble = "You lead a two-worker team and edit nothing yourself. Restate the task as acceptance criteria, delegate the change to local-coder, have local-reviewer check the coder's branch, and merge only on APPROVE."

[tools]
profile = "coordinator"
EOF
cat > .chatty/agents/local-coder.toml <<'EOF'
[agent]
name = "local-coder"
model = "qwen2.5-coder:14b"
preamble = "You are the coder. Work only in your own workspace, write a test for each acceptance criterion before making it pass, run the tests, do not commit, and report the files you changed with the test output."

[tools]
profile = "coder"

[budget]
max_agent_turns = 30
EOF
cat > .chatty/agents/local-reviewer.toml <<'EOF'
[agent]
name = "local-reviewer"
model = "qwen2.5-coder:14b"
preamble = "You are the reviewer. Read the branch's diff yourself, run the tests yourself, never edit the tree. The first line of your answer is APPROVE, REQUEST_CHANGES or BLOCKED."

[tools]
profile = "reviewer"
EOF
cat > .chatty/teams/bank-fix/team.json <<'EOF'
{
  "leader": "bank-lead",
  "agents": ["local-coder", "local-reviewer"],
  "verification": "python3 -m unittest discover -s tests -t . -v",
  "isolate": true,
  "skill": "bank-fix",
  "max_agent_turns": 50
}
EOF

What each field does, and why:

FieldHereWhy
agent.model in the worker specsqwen2.5-coder:14b on both workersA coding model for the code; the leader keeps qwen3:14b for planning. Each worker is metered on its own model’s server, so a worker on another machine would not queue behind the leader.
budget.max_agent_turns in local-coder.toml30A cap on the coder’s tool rounds. Without one a worker has no turn cap and a 30-minute time budget (--max-duration). This is the coder’s own budget.
verificationin the fileSo the task no longer has to say it. Chatty runs this in each worker’s tree at the end and puts the result in the evidence block.
isolatetrueEach worker gets its own git worktree and branch, so the coder’s edits never land in your checkout until the leader merges them. Without it every worker works in the workspace itself, which is right for analysis teams and wrong for a coding team.
max_agent_turns (top level)50The leader’s budget for the run. A delegating leader burns a turn per hand-off and per tool call. Without it the leader has no turn cap and a 30-minute time budget.
skillbank-fixThe playbook, next: the leader’s first turn opens with read_skill bank-fix and follow it.

Now the playbook. This is the part worth iterating on — it is where a team’s behaviour actually lives:

cat > .chatty/teams/bank-fix/SKILL.md <<'EOF'
# Skill: bank-fix

Deliver one bounded change with a coder and a reviewer.

1. Restate the task as a numbered list of acceptance criteria and record them with the todo tool.
2. Delegate to local-coder with one self-contained prompt: the criteria verbatim, and "work only in your own workspace, write a test for each criterion before making it pass, run the tests, do not commit, and report the files you changed and the test output".
3. The coder's answer ends with the branch its work was committed to (`sub-agent/<name>`). Do not fix the coder's work yourself.
4. Delegate to local-reviewer with one self-contained prompt: the criteria, the branch name, and "find the default branch with git_status, read the branch with the git_diff tool using range = <default>..<branch>, check every criterion has a test, run the tests, do not edit any file; first line of your answer is APPROVE, REQUEST_CHANGES with a numbered list, or BLOCKED: <reason>".
5. On REQUEST_CHANGES: delegate to local-coder again with the original criteria, the branch to continue from, and the reviewer's list; then review again. At most twice.
6. On APPROVE: merge the branch with the git_merge tool (no_ff = true). Then delegate once more to local-reviewer: "run the verification command on the current tree and report its result lines; first line PASS or FAIL".
7. Report: verdict, merge commit or why nothing merged, the criteria with their evidence.

Every prompt to a worker is self-contained: the worker sees none of this conversation.
EOF
git add .chatty/agents .chatty/teams && git commit -q -m "Add the bank-fix team"

Two rules in there carry most of the weight. Every prompt to a worker is self-contained — a worker starts with an empty conversation, so anything the leader does not put in the prompt does not exist. And do not fix the coder’s work yourself — the leader could not anyway (no shell, no writes), but a model will try to reason its way around a missing tool unless told not to.

2. Run it

Give the leader the task. The team file carries the verification command, so the task does not need to:

chatty-tui --team bank-fix --headless --auto-approve --ollama --model qwen3:14b \
  -m "Fix Account.withdraw in bank.py. Acceptance criteria: 1. withdraw raises ValueError when the amount is not positive. 2. withdraw raises ValueError when the amount exceeds the balance. 3. Otherwise withdraw subtracts the amount and returns the new balance. 4. tests/test_bank.py gains a test for each of criteria 1-3. 5. test_deposit keeps passing."

--team implies --broker. The list_agents card the leader reads says Model: qwen2.5-coder:14b. Tool profile: coder. for the coder. The task states the acceptance criteria explicitly because that is what the playbook expects: vague tasks make vague teams.

--headless prints only the leader’s final answer on stdout; the hand-offs go to stderr as they happen. To watch both, drop --headless and -m and type the task into the interactive app instead.

3. Read the run

On stderr you will see the shape of the playbook:

[tool: read_skill] ✓ completed
[tool: write_todos] ✓ completed
⟳ invoke_agent
[agent: local-coder] [local] ⟳ running
  …the coder's own tool calls stream here…
[agent: local-coder] ✓ completed
⟳ invoke_agent
[agent: local-reviewer] [local] ⟳ running
[agent: local-reviewer] ✓ completed
[tool: git_merge] ✓ completed
⟳ invoke_agent
[agent: local-reviewer] [local] ⟳ running
[agent: local-reviewer] ✓ completed

The coder’s report ends with the evidence block Chatty appended:

```evidence
branch: sub-agent/local-coder-0
base: main
commits: 1
diff --stat main..sub-agent/local-coder-0:
 bank.py            |  6 +++++-
 tests/test_bank.py | 15 +++++++++++++++
 2 files changed, 20 insertions(+), 1 deletion(-)
verification: python3 -m unittest discover -s tests -t . -v — exit code 0
test_deposit (tests.test_bank.TestBank.test_deposit) ... ok
test_withdraw_negative (tests.test_bank.TestBank.test_withdraw_negative) ... ok
test_withdraw_overdraft (tests.test_bank.TestBank.test_withdraw_overdraft) ... ok
test_withdraw_ok (tests.test_bank.TestBank.test_withdraw_ok) ... ok
----------------------------------------------------------------------
Ran 4 tests in 0.001s

OK
```

The reviewer’s report starts with APPROVE (or REQUEST_CHANGES and a numbered list, in which case the leader sends a fresh coder back to the branch — at most twice). The leader’s final answer on stdout gives the verdict, the merge commit and a criteria-to-evidence table.

Then look at the tree:

git log --oneline --graph | head -5     # a merge commit bringing in sub-agent/local-coder-0
git branch                              # main, sub-agent/local-coder-0, sub-agent/local-reviewer-0, -1
python3 -m unittest discover -s tests -t . -v   # four tests, OK
ls .chatty/worktrees/                   # local-coder-0  local-reviewer-0  local-reviewer-1

Every delegation is a fresh worker on a fresh branch in its own copy of the tree — the reviewer’s two (the review, then the verification pass) as much as the coder’s — and only the coder’s has commits. If the reviewer sent the coder back once, you will also see local-coder-1, told which branch to continue from. Nothing is cleaned up behind your back; the worktrees and branches stay until you remove them.

4. Change the team and watch the difference

Now that a run is reproducible, experiment. Each is one edit to a spec or team.json:

  • Add a role. A third worker, .chatty/agents/local-tester.toml with profile = "reviewer", listed in team.json’s agents, and a preamble that only writes and runs a negative probe, called by the playbook between coder and reviewer. Roles are what you make of the preamble; the tool profile just bounds them.
  • Starve the coder. Set its spec’s budget.max_agent_turns to 8 and watch it run out mid-task; the leader is told the delegation failed. What it does next is up to your playbook — add a line for it and see whether the leader follows.
  • Take the shell away from the reviewer. disable = ["shell"] under its spec’s [tools], on top of profile = "reviewer": it can still read the diff but no longer run the tests — and Chatty skips the verification command for it, since a worker that could not run anything has no build to check.

Keep the specs, SKILL.md and team.json in the repository. The team is part of how the project gets worked on, and a run with a different team file is a different experiment.

Verify

  • After step 2: a merge commit on main, four passing tests, sub-agent/local-coder-0 present, and an evidence block ending in exit code 0 in the coder’s report, with the coder running qwen2.5-coder:14b.
  • chatty-tui --team no-such-team … fails immediately, naming the two directories it looked in and the presets it knows. Your team is found because it sits in the first of those directories.

Common issues

  • The leader edits files itself or “cannot find” the coder. Its first turn did not follow the skill. Check skill in team.json matches the SKILL.md name and that the file is beside team.json; run without --headless to read what the leader saw.
  • The coder’s evidence block is missing. The coder committed nothing (it reported but did not write, or wrote in the wrong place). A worker that changes nothing gets no block, on purpose — there is nothing to merge. Tighten the coder’s preamble: work only in your own workspace.
  • git_merge reports a conflict. The leader stops and lists the files; it never resolves conflicts. Merge by hand, or reset and run again.
  • The reviewer approves a branch that does not do the job. Make the reviewer reproduce something: tell it to revert one line of the fix and confirm the new test fails. A reviewer that only reads will approve on the coder’s word.
  • A worker stalls at the model server. Two workers on one Ollama server queue rather than run at once — that is the endpoint budget protecting a local model from thrashing — so a run with a coder and a reviewer is sequential by design. Only the wall clock is affected.

Next

Tutorial: an autonomous dev team that builds software story by story

When to read this: You have run a small agentic team and want to see how far a team can go unattended: five agents that turn a product brief into tested, reviewed, committed code, one user story at a time, for hours. This is the experimental end of teams. Plan an evening of mostly waiting, and read Results, honestly before you start.

What you will end up with

A project directory with a five-agent team (devteam), a product brief, and a small shell loop that runs the team in iterations. After every iteration the loop runs the test suite itself and feeds the result into the next prompt, so a red suite gets repaired before anything new is built. When you stop it you have a backlog of user stories, Gherkin feature files, plans, code, unit tests, BDD tests and a git history with one commit per piece of work, on a green main.

The example product is logslice, a command-line tool that filters and summarises JSON-lines log files. Substitute your own brief.

Prerequisites

  • chatty-tui (install), version 0.7.9 or later. Everything this page relies on is in it: the sandbox notice, denying approvals nobody can answer, the team warnings and the leader’s --enable reaching the workers. On an older version, turn the shell and git on in the execution settings instead (see Run it in a box).
  • A model server: Ollama, or any OpenAI-compatible server (vLLM, llama.cpp, LM Studio). Use the strongest model you can run; this team makes dozens of tool calls per story. The results below came from a 27B model on one GPU.
  • Python 3.9 or later (with venv) and git in the environment that runs the team. The example product is written in Python, so the tests are pytest.
  • A container, or a throwaway VM or user account. The run uses --auto-approve: read step 2 before you start it.

How the team divides the work

AgentTool profileJob
dev-leadcoordinatorThe leader. Delegates, merges each worker’s branch, writes no code and runs no commands
product-ownercoder, shell offWrites user stories, Gherkin feature files and the ordered BACKLOG.md. Never writes code
developercoderWrites a short plan, then the code
testercoderWrites pytest unit tests and pytest-bdd steps for the feature file and runs the whole suite
code-reviewerreviewerReads the work against the acceptance criteria. First line of its answer: APPROVE or REQUEST_CHANGES

One run of the leader is one story: story, plan and code, tests, review, at most one fix round, merge, tick the story in BACKLOG.md. Each worker gets its own git branch and the leader merges after every worker, so the next worker builds on the previous one’s work. The mechanics are the ones from the previous tutorial.

Steps

1. Create the project

mkdir logslice && cd logslice
git init -q -b main
mkdir -p .chatty/agents .chatty/teams/devteam docs features src/logslice tests/unit tests/bdd
touch tests/__init__.py
printf '__pycache__/\n*.pyc\n.pytest_cache/\n*.egg-info/\n.venv\n.home\n' > .gitignore

You will create the Python virtual environment yourself in step 2, once, because the agents are told not to use the network. Write the product brief to README.md; the team reads it first. A brief states what the tool does and the quality bar, and nothing about stories, since breaking it down is the product owner’s job:

# logslice

A command-line tool that answers quick questions about JSON-lines log files:
one JSON object per line. Pure Python, standard library only. It must work on
files larger than memory (stream, never load the whole file).

* `logslice FILE...` (or `-` for stdin) prints the lines that match the filters.
* Filters: `--level warn`, a time window (`--since`, `--until`: ISO-8601 or
  relative like `15m`), `--where user.id=42`, and `--grep` on the message.
* `--count-by FIELD --top N` prints the most frequent values of a field.
* `--stats` prints counts per level, first and last timestamp, busiest minute.
* Lines that are not valid JSON are skipped and counted, never fatal.
* Gzip files are read transparently. Exit codes: 0 matched, 1 nothing, 2 usage error.

Quality bar: small functions, no network, no third-party runtime dependencies,
unit tests and pytest-bdd scenarios for every story.

Now the three project files that every agent reads. AGENTS.md is the team’s shared rulebook: Chatty loads it into every agent’s context, so the layout and the exact test command live here once:

# How this team works on logslice

Read `README.md` first: it is the product brief.

## Layout

* `BACKLOG.md`: ordered stories, each `- [ ] S00N: As a …, I want …, so that …`; tick `[x]` when done.
* `docs/stories/S00N-<slug>.md`: one story with its acceptance criteria.
* `features/S00N-<slug>.feature`: the same criteria as Gherkin. Strict Gherkin: every step starts
  with a capitalised Given/When/Then/And/But (pytest-bdd rejects lowercase `and`, and one bad
  file makes the whole suite fail to collect).
* `docs/plans/S00N.md`: the developer's short plan before coding.
* `src/logslice/`: the code (standard library only). Entry point `logslice.cli:main`.
* `tests/unit/test_*.py` and `tests/bdd/test_*.py` (pytest-bdd steps bound to `features/*.feature`;
  `pytest.ini` sets `bdd_features_base_dir = features`, so use `scenarios("<file>.feature")`).

## Running the tests

Always run the whole suite from the repo root:

    PYTHONPATH=src .venv/bin/python -m pytest -q

Do not install anything else and do not use the network.

## Rules

* One story per cycle. Small functions named for what they do.
* Never read or write anything outside the repo.
* Commit your own work with `git_commit`, naming the story id (`S002: report empty values`).
* A story is done only when the whole suite passes and the reviewer approved it.
* Main must be green and checked out at the end of every run. A red suite is repaired first.
cat > pytest.ini <<'EOF'
[pytest]
bdd_features_base_dir = features
pythonpath = src
EOF
cat > pyproject.toml <<'EOF'
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"

[project]
name = "logslice"
version = "0.1.0"
requires-python = ">=3.9"

[project.scripts]
logslice = "logslice.cli:main"
EOF

(Put the README.md and AGENTS.md text above into those files with your editor.)

2. Run it in a box

The loop below starts Chatty with --auto-approve, which lets every agent run shell commands and git operations without asking you. That is what makes an unattended run possible, and it means whatever the model decides to run, runs. Do not do this on your everyday machine. Use a container, a throwaway VM, or a separate user account that owns nothing you care about.

A container is the quickest. This image is all the team needs, apart from the chatty-tui binary, which you mount in:

cat > Dockerfile.devteam <<'EOF'
FROM ubuntu:24.04
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
      python3 python3-venv git ca-certificates bash && rm -rf /var/lib/apt/lists/*
EOF
docker build -q -t devteam-box -f Dockerfile.devteam .
docker run --rm -it --network host --user "$(id -u):$(id -g)" -e HOME=/work/.home \
  -v "$PWD":/work -v "$(command -v chatty-tui)":/usr/local/bin/chatty-tui:ro \
  -w /work devteam-box bash

Run mkdir -p .home before docker run; .home is already in .gitignore. --network host lets the box reach a model server on your machine. Everything from here on runs inside the box’s shell. Create the virtual environment there, so it matches the box’s Python:

python3 -m venv .venv && .venv/bin/pip install -q pytest pytest-bdd
git config user.name devteam && git config user.email devteam@localhost

The pip install needs network access once; after that the agents never do. The git identity is there so commits work in a fresh box.

Two things to know about the shell tool in a box:

  • The shell sandbox. Chatty runs shell commands inside a bubblewrap sandbox when it can. On Ubuntu 24.04 the default AppArmor setting blocks the user namespaces that sandbox needs, so commands run unsandboxed. Since 0.7.9, a headless run prints one line to stderr saying so and why. In a container that is expected, and the container is your boundary. If you would rather a run refuse to start unsandboxed, set "sandbox_required": true in execution_settings.json; Chatty then stops at startup with the reason, before any model call. See Security & approvals.
  • No one to ask. In an unattended run (--headless), an approval request nobody can answer is denied at once instead of waiting for five minutes (0.7.9 and later). --auto-approve is what stops requests from arising in the first place.

Workers read execution_settings.json themselves, so a worker’s shell and git come from those settings unless the leader’s flags are forwarded: since 0.7.9 the leader’s --enable, --disable and --only reach every worker. A headless team also prints one stderr line per agent whose profile wants the shell or git that the run has off, which is the line to look for if a worker says it has no shell.

3. Write the team

Five agent files and a team.json. The preamble is where a team’s behaviour lives, and several sentences in them that look fussy (the capitalised Gherkin, the red-suite rule, “merge after each worker”) each answer a failure from real runs.

cat > .chatty/agents/dev-lead.toml <<'EOF'
[agent]
name = "dev-lead"
description = "Leads one story through story, plan, build, tests and review, and merges the work"
preamble = """You lead a small software team building logslice (read README.md and AGENTS.md first). You write no code yourself: you delegate, merge and report. Run exactly ONE story per run, in this order:

1. product-owner: write or refine the user story and its acceptance criteria (story file + Gherkin feature file) and keep BACKLOG.md up to date.
2. developer: write the plan docs/plans/S00N.md, then implement it in src/logslice/.
3. tester: write unit tests and pytest-bdd steps for the story's feature file, run the WHOLE suite with the command in AGENTS.md, and report the pass/fail counts.
4. code-reviewer: review against the acceptance criteria; the first line of its answer is APPROVE or REQUEST_CHANGES.
5. If REQUEST_CHANGES: send the reviewer's list to developer (and tester if tests are affected), then ask code-reviewer once more. At most one fix round.

Every worker works on its own git branch. After EACH worker returns, merge its branch into main with git_merge before you delegate to the next worker. If a merge conflicts, ask that worker to rebase its work on main and try again.

RED SUITE RULE: if the prompt says the suite is failing, or you find main red, your FIRST actions are to delegate the repair: tester for test or feature-file problems, developer for code problems, with the exact failing output from the prompt in the task. Never start a new story while main is red.

When the story is done, ask product-owner to tick it in BACKLOG.md. Finish with a short report: story id and title, files changed, the suite result, the reviewer's verdict, and the next story id."""

[tools]
profile = "coordinator"

[swarm]
delegates_to = ["product-owner", "developer", "tester", "code-reviewer"]

[budget]
max_agent_turns = 80
max_duration = "75m"
EOF
cat > .chatty/agents/product-owner.toml <<'EOF'
[agent]
name = "product-owner"
description = "Writes user stories with clear acceptance criteria and Gherkin scenarios, keeps the backlog"
preamble = """You are the product owner of logslice (README.md is the brief, AGENTS.md the layout). You write user stories in the form "As a <user>, I want <capability>, so that <benefit>", each with 3-6 concrete, testable acceptance criteria, and the same criteria as Gherkin scenarios (Given/When/Then/And/But, capitalised: pytest-bdd rejects lowercase `and`; concrete example files and expected output) in features/S00N-<slug>.feature. Keep stories small: one capability each. Keep BACKLOG.md as an ordered list of small stories, the most valuable first. Write only docs, BACKLOG.md and .feature files; never code. Before you hand a feature file on, re-read every step line: each must start with a capitalised Given/When/Then/And/But. Commit your files with git_commit, naming the story id. Reply with the story id, its title and the files written."""

[tools]
profile = "coder"
disable = ["shell", "code-exec", "docker-exec", "fetch"]

[swarm]
exposed = true
callers = ["dev-lead"]

[budget]
max_agent_turns = 25
max_duration = "20m"
EOF
cat > .chatty/agents/developer.toml <<'EOF'
[agent]
name = "developer"
description = "Plans and implements one story in small, readable Python"
preamble = """You are the developer of logslice (README.md is the brief, AGENTS.md the layout and the test command). For the story you are given: first write a short plan in docs/plans/S00N.md (files, functions, edge cases), then implement it in src/logslice/ with the standard library only. Keep functions small; follow the rules in AGENTS.md. Make sure `logslice.cli:main` works as the entry point. Run the whole suite with the command in AGENTS.md before you finish; fix what you broke. Commit with git_commit naming the story id. Reply with the files changed and the suite result."""

[tools]
profile = "coder"
disable = ["fetch", "docker-exec"]

[swarm]
exposed = true
callers = ["dev-lead"]

[budget]
max_agent_turns = 35
max_duration = "25m"
EOF
cat > .chatty/agents/tester.toml <<'EOF'
[agent]
name = "tester"
description = "Writes pytest unit tests and pytest-bdd steps for a story and runs the whole suite"
preamble = """You are the tester of logslice (AGENTS.md has the layout and the exact test command). For the story you are given: write unit tests in tests/unit/test_<topic>.py for the functions the developer added, and pytest-bdd step definitions in tests/bdd/test_<story>.py bound to the story's features/S00N-<slug>.feature with scenarios("<file>.feature") (pytest.ini sets bdd_features_base_dir = features, so give only the file name). Use tmp_path for example files. Feature files are strict Gherkin: steps start with Given/When/Then/And/But, capitalised; if one does not parse (pytest says so at collection), fix the .feature file yourself rather than working around it. Run the WHOLE suite with the command in AGENTS.md. If a test fails because the code is wrong, say exactly which behaviour is wrong; do not change src/. Commit your tests with git_commit naming the story id. Reply with the files written and the exact passed/failed counts from the last run."""

[tools]
profile = "coder"
disable = ["fetch", "docker-exec"]

[swarm]
exposed = true
callers = ["dev-lead"]

[budget]
max_agent_turns = 35
max_duration = "25m"
EOF
cat > .chatty/agents/code-reviewer.toml <<'EOF'
[agent]
name = "code-reviewer"
description = "Reviews a story's code and tests against its acceptance criteria; APPROVE or REQUEST_CHANGES"
preamble = """You review logslice work and edit nothing. Read the story file, its feature file, the plan, the code and the tests. Run the whole suite with the command in AGENTS.md. Check that every acceptance criterion is implemented and tested and that the code is simple. The first line of your answer is APPROVE or REQUEST_CHANGES, then a numbered list: what you checked, or exactly what must change (file, function, behaviour)."""

[tools]
profile = "reviewer"
disable = ["fetch", "docker-exec"]

[swarm]
exposed = true
callers = ["dev-lead"]

[budget]
max_agent_turns = 20
max_duration = "15m"
EOF
cat > .chatty/teams/devteam/team.json <<'EOF'
{
  "name": "devteam",
  "leader": "dev-lead",
  "agents": ["product-owner", "developer", "tester", "code-reviewer"],
  "max_agent_turns": 80
}
EOF
git add -A && git commit -q -m "Setup: brief, team and tooling for logslice"

Differences from the team in the previous tutorial: there is no verification command or playbook skill here. The suite command lives in AGENTS.md and the leader’s preamble is the playbook, and the objective check happens in the loop, outside the team, in the next step. The [budget] in each spec caps that agent’s tool rounds and wall-clock time, so one stuck worker cannot eat the iteration.

4. Run the loop

One iteration is one headless run of the leader:

chatty-tui --headless --team devteam --max-duration 75m --enable shell,git --auto-approve \
  --ollama http://localhost:11434 --model qwen3:14b \
  -m "<prompt>"

For an OpenAI-compatible server, replace the provider flags with --openai-compat-url http://localhost:8000 --api-key KEY --model NAME. --enable shell,git turns on the tool groups the coder profile needs. --max-duration 75m is the wall-clock limit for the iteration.

A single iteration stops after one story, so a small loop drives the rest. What matters is the gate: the loop runs the test suite itself, so the next prompt is based on what the code does and not on what the team says it did.

cat > run-loop.sh <<'EOF'
#!/usr/bin/env bash
# run-loop.sh: run the devteam in iterations until the backlog is done or MAX_ITER is reached.
set -u
PROVIDER=(--ollama http://localhost:11434 --model qwen3:14b)  # or --openai-compat-url URL --api-key KEY --model NAME
MAX_ITER=${MAX_ITER:-10}
SUITE='PYTHONPATH=src .venv/bin/python -m pytest -q'

prompt='Ask the product-owner for a BACKLOG.md of 6-8 small stories and the story and feature file for S001, then run the full cycle for S001.'
for i in $(seq 1 "$MAX_ITER"); do
  echo "=== iteration $i $(date +%T)"
  chatty-tui --headless --team devteam --max-duration 75m --enable shell,git \
    --auto-approve "${PROVIDER[@]}" -m "$prompt" | tee "run-$i.log" | tail -n 20

  # Objective gate: the suite, not the team's own report.
  git checkout -q main
  if out=$(bash -c "$SUITE" 2>&1); then
    suite_ok=1
    status="The test suite passes: $(echo "$out" | tail -n 1)."
  else
    suite_ok=0
    status="The test suite is FAILING. Repair it first, before any new work. Hand this output to the tester or developer:
$(echo "$out" | tail -n 40)"
  fi
  echo "$status" | head -n 1

  if [ "$suite_ok" = 1 ] && ! grep -q '^- \[ \]' BACKLOG.md 2>/dev/null; then
    echo "Backlog complete."; break
  fi
  prompt="$status
Run the full cycle for the first unticked story in BACKLOG.md."
done
EOF
chmod +x run-loop.sh

Start it with ./run-loop.sh and leave it. run-1.log, run-2.log and so on hold each iteration’s output; git log --oneline and BACKLOG.md show progress. To stop, press Ctrl+C, then check that git status is clean and main is checked out. Add run-*.log, run-loop.sh and Dockerfile.devteam to .gitignore, so the loop’s own files do not dirty the tree.

The first prompt asks for the whole backlog and the first story’s full cycle. Every later prompt is the same sentence, with the gate’s result in front. If an iteration ends before any test exists, pytest exits non-zero and the gate reports the suite as failing; the next prompt then asks for the repair, which is harmless.

5. Read what it built

While it runs, the leader’s hand-offs stream to stderr as in the previous tutorial: invoke_agent, [agent: product-owner] ✓ completed, git_merge, and so on. Between iterations, look at the work itself:

cat BACKLOG.md                       # [x] for done stories, [ ] for open ones
git log --oneline | head             # one commit per piece of work, naming the story id
cat features/S001-*.feature          # the acceptance criteria as scenarios
PYTHONPATH=src .venv/bin/python -m pytest -q

A feature file the product owner writes looks like this:

Feature: Filter log lines by level

  Scenario: Only lines at or above the minimum level are printed
    Given a log file "app.log" with an info line and an error line
    When I run "logslice --level warn app.log"
    Then the output contains only the error line
    And the exit code is 0

Read the stories early. A story that is too large is the commonest reason for a slow run; edit BACKLOG.md yourself, commit, and the next iteration picks it up.

Lessons that make a long run work

Each of these cost real time in real runs.

  • Strict Gherkin. Steps must start with a capitalised Given, When, Then, And or But. pytest-bdd rejects a lowercase and, and a single unparseable feature file makes the whole suite fail to collect, which turns every test red and cost hours. Say it in the product owner’s preamble, in AGENTS.md and in the tester’s, and let the tester fix a bad feature file.
  • bdd_features_base_dir = features in pytest.ini. Without it the step files cannot find the feature files, and each agent invents its own path. With it, scenarios("S001-x.feature") works everywhere.
  • Repair a red suite first, with the output in hand. A red main poisons every later story. The loop hands the failing output to the leader, and the leader’s preamble says to delegate the repair to the tester or developer before anything else. Without the output, the team tends to spend its iteration investigating.
  • Keep stories small. One capability each. Small stories finish inside a worker’s turn budget, review cleanly and fail in ways that are easy to read.
  • Main green and checked out at the end of every iteration. The loop runs git checkout main before the gate, and AGENTS.md makes it a rule. The next iteration then starts from known ground.
  • Check with a script, not with the team. A team that reports “all tests pass” is sometimes right. The loop’s gate does not care.

Results, honestly

Two real runs of this team, both with a local model, RedHatAI/Qwen3.8-27B-INT4 on one GPU served by vLLM:

Run 1Run 2
Date26 Oct 202628-29 Oct 2026
Productenvchecklogslice
SetupFirst attemptWith the fixes from run 1
DurationAbout 16.5 h of run time over 16 iterations (an 11.5 h session and a 5 h continuation), including about 5 h lost to setup problems that are now fixed8 h, 7 iterations
Stories6 of 7 ticked2 of 8 done
Tests passing at the end131118
Commits5119

In run 2 the suite was red in iterations 1 and 2 and was repaired in iteration 3; main was green at the end. That is roughly 2.5 hours per story on this model.

What to take from that: throughput is limited by model speed. The team structure works, the loop recovers from a red suite, and the output is tested code in git, but a local model of this size is slow, and the stories it finishes per night are few. A faster or stronger model will do better; these numbers say nothing about one. Teams are experimental, so expect to read the logs, adjust the preambles and sometimes restart.

Verify

  • After step 4, iteration 1: BACKLOG.md lists six to eight stories, docs/stories/S001-*.md and features/S001-*.feature exist, and git log has commits naming S001.
  • After a successful iteration: PYTHONPATH=src .venv/bin/python -m pytest -q passes on main, and S001 is ticked in BACKLOG.md.
  • chatty-tui --team devteam --headless -m "hello" in the project directory finds the team. From another directory it fails naming the places it looked.

Common issues

  • A worker says it has no shell, or commands are refused. Check stderr for the per-agent warning that a profile wants the shell or git that the run has off. On releases that forward the leader’s flags to workers, --enable shell,git is enough; otherwise enable the groups in the execution settings.
  • Every test fails at collection. A .feature file does not parse. Run PYTHONPATH=src .venv/bin/python -m pytest -q by hand and read the first error; the usual cause is a lowercase and. Fix it, commit, and let the loop continue.
  • The iteration ends with nothing merged. The leader hit its turn or time limit mid-story. The next prompt asks for the first unticked story again, so work continues; if it keeps happening, split the story.
  • The model server queues requests. Workers on one local server run one after the other. That is expected and only costs wall-clock time.
  • A run refuses to start unsandboxed. You set sandbox_required. Fix the sandbox (Security & approvals) or unset it.

Next

Security & sandboxing

When to read this: You want to know what the agent can and cannot touch, what each approval mode does, and how secrets are handled. Switching tools on is covered in Agents & tools.

The workspace boundary

File, shell and git tools are confined to the Workspace Directory you set in Settings → Code Execution. The agent cannot read or write outside it. The per-chat folder picker and /add-dir widen that boundary explicitly, per conversation, and nothing else does.

Shell sandbox

On Linux and macOS, shell commands run in an isolated process that sees the workspace but not sensitive folders such as .ssh, .aws and .gnupg. On Linux the sandbox needs the bubblewrap package from your distribution; if it is missing, or cannot create the user namespaces it needs (Ubuntu 24.04’s default AppArmor setting blocks them), commands still run but count as unsandboxed, and the terminal dock’s Agent tab says not sandboxed. Headless and --pipe runs print one line to stderr, and the terminal UI shows a notice, saying why and how to fix it (for Ubuntu’s AppArmor case: an AppArmor profile that lets bwrap create user namespaces, or running in a container or VM). To refuse instead of falling back to the host, set "sandbox_required": true in execution_settings.json (off by default): chatty-tui then stops at startup with the reason, before any model call, and the shell never starts unsandboxed. Windows has no shell sandbox, so commands there are always unsandboxed — which matters for the approval modes below.

Two more switches on the same page: Network Isolation stops sandboxed commands from reaching the network at all, and Timeout / Max Output cap how long a command may run and how much output it may return.

Code execution isolation

The code runner has two paths. Simple Python runs on the host in a restricted interpreter with a memory cap and a stripped environment. Everything else — other languages, or Python that needs third-party packages — goes to a fresh Docker container that is isolated from the host filesystem and network, and only when Enable Docker Fallback is on. The cloud sandbox on Settings → Internet runs code on a remote service instead of your machine.

Approval modes

Settings → Code Execution → Approval Mode has three settings:

ModeWhat happens
Always Ask (Safest)Every side effect — file write, shell command, git change, spreadsheet edit — pauses for your approval, and you see the exact command or path first
Auto-approve Sandboxed (default)Shell commands run without asking when they are inside the sandbox, and file and spreadsheet writes inside the workspace apply immediately. Unsandboxed commands and git changes still ask
Auto-approve All (Dangerous)Everything runs without asking, including unsandboxed commands, git changes and sub-agents’ tool calls

A pending approval waits five minutes; if you do not answer, the call fails and the agent is told so. Reads, searches, queries, web fetches and the browser tools never ask in any mode. The terminal app uses y / n for the same prompts and --auto-approve to skip them.

Warning

Auto-approve All lets the model run any enabled tool without review. Use it only for a workspace you can afford to lose, and prefer a per-run --auto-approve in the terminal for scripted jobs.

Browser and web

By default the browser and fetch reach only localhost and workspace-local files; opening it to the public web or your own LAN is an explicit setting (Agents & tools). Either way, the same address filter refuses link-local and cloud-metadata addresses, so a page cannot redirect a fetch or a click into your machine’s or your cloud provider’s internal network. Clicking and typing outside localhost and the workspace ask for your approval every time, and the agent never types into a password or card-number field.

The agent’s standing instructions also draw a hard line around anything it reads from a page: text on a page — including text written to look like a system prompt, a task, or a message addressed to the agent — is treated as the page’s content, never as a new instruction. It can report that text back to you; it cannot act on it. That framing is what keeps a malicious or compromised page from hijacking the turn that’s looking at it.

Secrets

  • Settings → Secrets holds key–value pairs that are exported as environment variables in every shell session. The agent can use the names in scripts (os.environ["API_KEY"]) but never sees the values; they are not logged and are masked in tool output.
  • Provider keys are stored with your app settings, never in your project files, and are not exposed to the model.
  • MCP server keys are masked when the agent lists your servers: it may learn that a key exists, never the value.
  • No telemetry, no relay. Chatty sends traffic only to the providers, MCP servers, agents and websites you configure or ask it to use, plus GitHub for update checks. Sign-in tokens for catalog extensions stay on your machine.

Next

Terminal interface

When to read this: You want to use Chatty from a terminal — interactively, as a one-shot command, or in a pipeline — with the same providers and models as the desktop app.

Install

MethodHow
From the desktop appmacOS: Chatty menu → Install CLI…. Linux and Windows: Settings → General → Install CLI…. The same spot offers Reinstall CLI later
From the releaseThe desktop package already contains chatty-tui
From sourceBuild it yourself: Build and run

Where it lands: macOS puts a link in /usr/local/bin (you may be asked for an administrator password); Linux copies the binary to ~/.local/bin — add that folder to your PATH if chatty-tui is not found; Windows adds the app’s install folder to your user PATH, so open a new terminal afterwards.

Note

After a desktop update on Linux, the copy in ~/.local/bin is refreshed the next time the desktop app launches. /update inside chatty-tui does the same on demand.

Modes

ModeCommandUse
Interactivechatty-tuiFull-screen chat with a model picker, tool picker and approval prompts
Headlesschatty-tui --headless -m "question"One message in, the answer on stdout — for scripts and sub-agents
Pipecat notes.md | chatty-tui --pipestdin is the message; the answer goes to stdout
ACPchatty-tui acpServes the Agent Client Protocol on stdin/stdout, so an editor such as Zed or VS Code can run chatty as its agent (below)

Useful flags: --model <id or name> picks a model (exact id, then name, then substring); --enable / --disable switch tool groups for this run (shell, fs-read, fs-write, fetch, git, code-exec, docker-exec, ask-user, terminal; an unknown name is an error), and --only runs with exactly the groups named; --auto-approve skips every approval prompt (Security & sandboxing); --usage-file <path> writes what a headless or pipe run spent (tokens, model and tool calls, how it ended) to a JSON file (below). Full list: CLI flags.

Usage file

chatty-tui --headless --usage-file <PATH> -m "…" (or --pipe) writes what the run spent to PATH as one JSON object; stdout, stderr and the exit code are unchanged. It is written when the run ends however it ends (including SIGINT/SIGTERM/SIGHUP), and rewritten after every model call while it runs, so a run killed from outside leaves its latest totals with "exit": "running". Each write goes through a temporary file and a rename, so a reader never sees half an object.

{"schema":1,"input_tokens":550,"output_tokens":25,"cache_read_tokens":350,"cache_write_tokens":null,"reasoning_tokens":4,"model_calls":4,"tool_calls":2,"tool_calls_failed":1,"turns":3,"follow_up_passes":2,"duration_ms":18,"exit":"completed","model":"llama3.2"}
FieldMeaning
schema1; bumped only when a field changes meaning or goes away
input_tokensPrompt tokens over every model call, cached ones included
output_tokensGenerated tokens over every model call, reasoning included
cache_read_tokensPrompt tokens served from the provider’s cache (part of input_tokens); null when no call reported any
cache_write_tokensPrompt tokens written to the provider’s cache (part of input_tokens); null when no call reported any
reasoning_tokensOutput tokens spent reasoning (part of output_tokens); null when no call reported any
model_callsModel requests that completed and reported usage
tool_callsTool calls the model started
tool_calls_failedThose that ended in an error
turnsPasses: the task, then every prompt the run sent itself
follow_up_passesThe passes after the task’s (stall resumes, retries, nudges, finalizations)
duration_msWall-clock time since the run started
exitrunning, completed, deadline (the --max-duration budget was spent), error (non-zero exit) or cancelled (a signal)
modelThe model identifier the run used
delegated_by_agentAgent name → input_tokens, output_tokens and model_calls its delegations reported (already part of the totals); absent when the run delegated nothing

Totals are summed per model call across every pass, plus what delegated agents reported spending; tool_calls counts this agent’s own calls. A request cut off mid-stream reports no usage and is not counted.

Export a conversation

Export a conversation as ATIF, the trajectory format the desktop app also writes, to debug a run.

  • chatty-tui --headless --export-atif <PATH> -m "…" writes the finished conversation when the run ends, also when it fails or is stopped. - writes to stdout.
  • /export [path] in the interactive TUI exports the current conversation. Without a path it writes <config dir>/chatty/exports/<conversation-id>.atif.json and prints where.
  • chatty-tui --export <conversation-id> [--out <PATH>] exports a saved conversation without running anything.

A run that delegated exports as one tree. Each agent’s turns, reasoning, tool calls with their results, and token usage come right after the step that delegated to it. Each step names its agent by path, for example root/coder-1, and the delegation step that started it. The agents, their models, spend and final status (completed, failed or stopped) are listed together. best_of attempts and the judge sit under the best_of step, which records the attempt kept and the judge’s reason. If a worker’s conversation was over the capture size limit, or a stopped worker left none, the export says so. It does the same when the step token counts do not add up to the run’s total.

API keys and your configured secrets are replaced with [REDACTED] in every export. Text a tool returned is exported as the tool returned it, so check it before sharing a trajectory. --save-conversation is a different file: the restore format, not an analysis export.

Zero-config quick start

Talk to a running model server without opening the desktop app or storing a key:

# Ollama (discovers models at localhost:11434; pass a URL for another host)
chatty-tui --ollama
chatty-tui --ollama --model llama3.2

# vLLM, llama.cpp, LM Studio or any OpenAI-compatible server
chatty-tui --openai-compat-url http://localhost:8000
chatty-tui --openai-compat-url http://localhost:8000 --model my-model --api-key sk-...

Add --think true / --think false to force a reasoning model’s extended-thinking mode on or off for the session — useful here since a zero-config session has no saved model settings to toggle it in otherwise. Works with Ollama and OpenAI-compatible servers that support a reasoning parser (for example vLLM’s --reasoning-parser).

Welcome screen and status bar

An empty interactive session shows what is active: model and context window, workspace and git branch, enabled tool groups, internet capabilities (fetch, search, browser, cloud sandbox, MCP) and runtime features (memory, modules, remote agents). MCP, memory and embeddings load in the background — badges show ⟳ (for example [MCP ⟳]) and the status bar reads loading services… until they are ready.

The status bar always shows the app version, the working directory and the git branch when you are inside a repository, plus the branch’s pull request (#591 open ✓) when git integration is on. Footer hints switch to Enter queues · /now <text> interrupts · Ctrl+C stop · Ctrl+Q quit while a reply streams, and the status line adds · N queued once a message is waiting. Once a provider round-trip has completed, or a second has passed, streaming also gets a · turn 3 · 1:15 suffix — the running turn count and elapsed time — so a long unattended reply still shows it’s making progress. A scrollbar appears when the transcript overflows; scrolling up unpins auto-scroll, End re-pins it. While the agent works through a multi-step plan, the status bar also shows its position (●▸○ Plan 1 of 3 · <step title>), cleared when you send the next message. Once the agent has edited files, a change tray segment (4 files +58 −4) tallies every edit made so far in the conversation.

Tool calls in the transcript are folded by default — a single call shows the tool and its main argument on one line, the result trimmed to a few lines with a … +N lines marker, and a run of several calls that all succeed collapses further into one counted line (for example explored 3 files, ran 1 command). A settled call is one line with a ▸ marker; click it to open its result (▾), and click again to close it. The counted line is clickable too and lists the calls it covers. The call still running shows its whole command, wrapped over up to four rows. A command that needed your approval renders inline with its verdict (Approve [host] <command> — allowed), and a file edit gets its own stat line (± path +a −r). Errors are always shown in full and never folded. Press Ctrl+R or run /verbose to switch to the full, untrimmed input/output for every tool call; the footer hint shows which mode is active.

A multi-step plan renders as a single card instead — a goal, a progress count and one line per step (pending, in progress, done, blocked, with its reason) — redrawn in place as the plan advances rather than logging each update as its own row. /verbose still shows the raw plan tool calls.

Keys

KeyAction
EnterSend
/Command picker (↑/↓, Tab or Enter)
@File picker (type to filter)
PageUp / PageDown, Shift+↑/↓, mouse wheelScroll
EndJump to the bottom and resume auto-scroll
y / nApprove or deny a tool prompt
1-9Pick an option when the agent asks a clarifying question (ask_user)
tType a custom answer instead of picking an option
Ctrl+CStop streaming, or quit when idle
Ctrl+QQuit immediately
Ctrl+R / /verboseToggle full tool-call payloads on or off

A pending clarifying question replaces the input row and takes over the keyboard until answered — Ctrl+C/Ctrl+Q still work. Multiple questions are answered one at a time; press Esc while typing a custom answer to go back to the options.

The terminal app has a few commands of its own — /model, /tools, /modules, /update, /paste, /now, /unqueue, /export, /swarm, /msg, /stop, /quit — alongside the shared ones. All of them: slash commands.

/swarm prints the agent tree for the conversation’s latest delegation as an indented list, one row per agent: name, model, status (running, done or failed) and spend. With no delegation yet, it says so.

/msg <agent> <text> sends a message to a running agent, by the name /swarm shows. The agent reads it at its next tool call rather than waiting for its run to end.

/stop <agent> stops one agent of the running swarm, by the name /swarm shows, and every agent it started. The rest keeps running, and the agent that called it carries on without it.

/agents running lists only the agents still running, one per line: name, conversation, the agents above it, whether it is working or waiting on you (for an approval or an answer), how long it has run, and what it has spent so far. The desktop shows the same list across all conversations (see sub-agents).

Sending a message while a reply is still streaming doesn’t get refused — press Enter as usual and it queues, showing up in the transcript as Queued #1: <text>, Queued #2: <text> and so on, and runs as its own turn once the current one ends. /now <text> instead cancels the running turn right away and sends <text> next. /unqueue takes back the message queued last. Up to 5 messages can wait; a sixth is refused. Ctrl+C cancels the current turn but leaves the queue as it is — nothing runs until you send (or /now) again, which then runs the queue’s front first.

/online is an unfinished feature, off by default: it only does anything once Settings → Execution → Developer → Hosted Conversations is on (a moved conversation carries its transcript and nothing else yet). With it on, /online shows where the current conversation runs and, before anything moves, a table of what a move would and would not carry. /online <server-url> uploads the conversation’s history to that chatty-server and continues it there; /online off brings it back to this machine. Workspace files, attachments, MCP servers, memory, skills and provider API keys never leave this machine. The server needs a Hive sign-in: /online reuses the one saved by the desktop app (Settings → Extensions → Hive) without refreshing it, so sign in there first.

Pasting more than 6 lines or 800 characters replaces the pasted text in the input box with a short reference, [Pasted text #1 +45 lines], so a stack trace or log dump doesn’t fill the screen — shorter pastes (a URL, a path, a one-line error) are inserted as-is. The model still receives the full text when you send the message. Run /paste 1 to print a reference’s full text back to the transcript. The reference acts as a single character: the cursor steps over it, and backspace at its edge removes the whole thing.

Use chatty as an agent in Zed or VS Code

chatty-tui acp runs chatty as an Agent Client Protocol (ACP) agent, so an editor can use it as its coding agent. Each editor thread is its own chatty conversation, rooted at the folder you have open. It uses your chatty providers, models, tools and MCP servers. Tool calls, the files they touch and the agent’s todo plan show up in the editor, and when a tool needs approval the editor asks you with Allow / Deny.

The command. The editor starts chatty-tui itself, so give it the full path:

  • macOS app: /Applications/chatty.app/Contents/MacOS/chatty-tui
  • Linux: the chatty-tui binary inside the AppImage, or one you built
  • From source: cargo build --release -p chatty-tui, then target/release/chatty-tui

Put other flags before acp, e.g. "args": ["--model", "claude-sonnet", "acp"] or "args": ["--ollama", "acp"].

Zed

Zed supports ACP agents natively. Open Agent Settings → External Agents → Add Custom Agent, or add this to your settings file:

{
  "agent_servers": {
    "Chatty": {
      "type": "custom",
      "command": "/path/to/chatty-tui",
      "args": ["acp"],
      "env": {}
    }
  }
}

Then pick Chatty in the Agent Panel. dev: open acp logs shows the protocol messages; chatty’s own log goes to stderr, which Zed writes to its log.

VS Code

VS Code has no built-in ACP support, and chatty does not appear in Copilot Chat. Install an ACP extension instead, such as ACP Client (also on Open VSX for Cursor and Windsurf), and add chatty to your settings.json:

{
  "acp.agents": {
    "Chatty": {
      "command": "/path/to/chatty-tui",
      "args": ["acp"],
      "env": {}
    }
  }
}

Open the ACP panel in the Activity Bar and click Chatty to connect. Keep acp.autoApprovePermissions at ask so chatty’s approval prompts reach you; allowAll approves everything. ACP: Show Protocol Traffic shows the protocol messages.

Not supported yet

  • The model asking you a clarifying question (ask-user is off over ACP).
  • MCP servers configured in the editor (chatty uses its own).
  • Images in the prompt.
  • Reopening an earlier thread or session.
  • --team.

Other ACP editors should work the same way — JetBrains IDEs and Neovim (CodeCompanion, avante.nvim) among them — but only Zed and VS Code are described here.

Install a team

chatty-tui --install-team NAME[@VERSION] installs a published team from the Hive marketplace, with every spec and locked plugin signature-checked, then exits. See Install a team from the marketplace.

Shared configuration

chatty-tui reads the same settings as the desktop app — providers, models, tools, secrets and memory — so run the desktop app once to set things up, or skip that entirely with --ollama / --openai-compat-url. Where the files live: Advanced.

Next

Advanced

When to read this: You want to export conversations for fine-tuning, find where Chatty keeps its files, or understand how updates are installed.

Training-data export

Settings → Training Data turns on automatic export: with Auto-export ATIF or Auto-export JSONL on, every finished reply re-exports its conversation. Re-exporting replaces that conversation’s previous entry rather than duplicating it.

ATIF (Agent Trajectory Interchange Format) is structured JSON for agent pipelines, following the Harbor trajectory format: messages, tool calls, reasoning, timestamps, token counts, thumbs feedback and regeneration pairs (rejected versus chosen). One file per conversation. A conversation that delegated to agents includes their turns too, each under the step that delegated to it. The terminal guide describes the layout.

JSONL produces two append-only files:

  • sft.jsonl — supervised fine-tuning rows in ChatML, as accepted by OpenAI, Anthropic, Together AI and similar APIs. Tool calls can be included.
  • dpo.jsonl — preference pairs. Every time you press Regenerate, the original reply becomes the rejected answer and the new one the chosen answer.

Both land in the exports folder listed below.

Where Chatty stores data

Nothing leaves your machine unless a provider, extension or website you use receives it. Windows keeps everything under %APPDATA%\chatty\; macOS under ~/Library/Application Support/chatty/; Linux splits configuration from data as shown (respecting $XDG_CONFIG_HOME / $XDG_DATA_HOME).

WhatmacOSLinuxWindows
Settings, extensions~/Library/Application Support/chatty/~/.config/chatty/%APPDATA%\chatty\
Provider API keys, MCP and A2A tokens, Hive sign-inKeychain (login keychain)Secret Service (GNOME Keyring, KWallet)Credential Manager
Conversations (conversations.db)same folder~/.config/chatty/same folder
Training exports~/Library/Application Support/chatty/exports/~/.config/chatty/exports/%APPDATA%\chatty\exports\
Catalog extension sign-in tokenssame folder~/.config/chatty/same folder
Memory (memory.mv2)same folder~/.local/share/chatty/same folder
Global skills (see Memory & skills)~/.agents/skills/, ~/.claude/skills/same%USERPROFILE%\.agents\skills\, %USERPROFILE%\.claude\skills\
Installed modules~/Library/Application Support/chatty/modules/~/.local/share/chatty/modules/%APPDATA%\chatty\modules\
Downloaded Chrome builds~/Library/Application Support/chatty/browsers/<version>/~/.local/share/chatty/browsers/<version>/%APPDATA%\chatty\browsers\<version>\
PDF rendering library cache~/Library/Application Support/chatty/lib/~/.local/share/chatty/lib/%APPDATA%\chatty\lib\
Rendered math and diagram caches~/Library/Application Support/chatty/math_cache/, ~/Library/Application Support/chatty/mermaid_cache/~/.config/chatty/math_cache/, ~/.config/chatty/mermaid_cache/%APPDATA%\chatty\math_cache\, %APPDATA%\chatty\mermaid_cache\
Browser screenshots and console logs<workspace>/.chatty/browser/samesame
Project skills<workspace>/.agents/skills/, <workspace>/.claude/skills/ (and in parent folders up to the git root)samesame
Sub-agent worktrees (one per delegated worker of a team with "isolate": true, left in place; delete when done)<workspace>/.chatty/worktrees/<name>/samesame
Team directories (<id>/team.json + SKILL.md)<workspace>/.chatty/teams/, then ~/Library/Application Support/chatty/teams/same, then ~/.local/share/chatty/teams/same, then %APPDATA%\chatty\teams\
Context-window thresholds (token_tracking.json)same folder as settings~/.config/chatty/same folder
Terminal app binary/usr/local/bin/chatty-tui~/.local/bin/chatty-tuithe app’s install folder

The caches can be deleted at any time; they are rebuilt on demand (the first browser call after deleting the Chrome cache downloads it again). Settings and conversations are plain files, so back them up by copying the folder.

API keys and sign-in tokens

Provider API keys, MCP and A2A tokens and your Hive sign-in are kept in the operating system’s keychain, filed under chatty:<settings folder>. The settings files only name them ("api_key_ref": "provider/OpenRouter"), so copying or syncing the settings folder does not copy a key. On a new machine, enter the keys again in Settings.

A Linux machine without a Secret Service (a server, a container) has no keychain. Chatty then keeps the keys in secrets.json in the settings folder, readable only by you, and logs one warning saying so. To control this, set CHATTY_SECRET_STORE:

  • keychain — use the keychain or refuse to start; never write secrets.json.
  • file — always use secrets.json, without touching the keychain.

Runs without a person at the keyboard (chatty-tui --headless, --pipe, and ACP) wait at most 2 seconds for the keychain. If it does not answer, for example because no Secret Service session is unlocked, they fall back to secrets.json and print one warning: line on stderr. The desktop app and the interactive TUI keep waiting, so a slow first unlock does not switch them to the file.

Environment variables and command-line flags that pass a key to chatty-tui (for example --api-key) work as before and are never stored.

Auto-update

Chatty checks GitHub Releases shortly after launch and then hourly. When a newer version exists, it downloads in the background, verifies the file’s SHA-256 checksum, and the status footer changes to Click to restart and install v…. A Check for updates button in the same footer runs a check on demand.

Installing differs by platform:

  • macOS — the app quits, the bundle is replaced in place and the app relaunches.
  • Linux — the running AppImage is replaced and relaunched; a chatty-tui installed from the desktop app is refreshed on the next launch.
  • Windows — the installer runs silently and restarts the app.

If a download fails its checksum it is discarded and the footer shows Update failed; nothing is installed.

Next

Build and run in 10 minutes

When to read this: You have just cloned the repository and want the desktop app, the terminal app and the test suite working before you touch any code.

Goal

A compiled workspace, the terminal app answering a prompt from a local Ollama model with no API key, the desktop app open, and the same checks CI runs passing on your machine.

Prerequisites

  • Rust via rustup. The workspace is edition 2024 and declares rust-version = "1.94" in the root Cargo.toml; rustup default stable satisfies it.
  • Git and a checkout of https://github.com/boersmamarcel/chatty2.
  • Linux: Debian/Ubuntu packages for GPUI (make setup installs them). macOS: Xcode Command Line Tools. Windows: see the repo README.md.
  • Optional: Ollama for a zero-configuration local model.

Warning

A full cargo test --all-features needs about 16 GiB of target/ even with the workspace’s trimmed debuginfo profile (at Cargo’s defaults it is more than 28 GiB and does not finish on a 30 GB disk). Read Build disk usage before building on a small disk; sharing one CARGO_TARGET_DIR across worktrees is the biggest single saving.

Steps

1. Clone and install system dependencies

git clone https://github.com/boersmamarcel/chatty2.git
cd chatty2
make setup

On Linux, make setup runs scripts/setup-linux.sh: the lib*-dev package list from CI plus rustup target add wasm32-wasip2. On macOS and Windows it prints what to install by hand and adds the wasm32-wasip2 target.

2. Build the WASM modules and test fixtures

make wasm-modules

Some integration tests load real plugins: echo, benford and the adversarial fixtures under modules/fixtures/. make wasm-modules runs scripts/build-wasm-fixtures.sh, which builds them all and stages each at target/wasm-fixtures/<name>/<name>.wasm beside its module.toml. It is build output, so run it once after every fresh clone (and again after cargo clean).

3. Run the terminal app headless against Ollama

No provider configuration is needed: --ollama queries a running Ollama for its models and injects a provider and model list for the session only. Nothing is written to disk.

ollama serve                      # if it is not already running
ollama pull qwen2.5:0.5b          # any small model works
cargo run -p chatty-tui -- --ollama --model qwen2.5:0.5b --headless -m "Say hello in five words"

--ollama with no value means http://localhost:11434; pass a URL for a remote instance (--ollama http://remote:11434). --headless prints the full response to stdout and exits; logging is suppressed so stdout stays clean. Two related modes:

# Pipe mode: stdin is the prompt
cat src/main.rs | cargo run -p chatty-tui -- --ollama --model qwen2.5:0.5b --pipe

# Interactive TUI
cargo run -p chatty-tui -- --ollama

Without --ollama (or --openai-compat-url), chatty-tui reads the providers and models the desktop app saved, so either run the desktop app once and add a provider, or keep using the direct-connect flags. cargo run -p chatty-tui -- --help lists every flag; the CLI reference summarises them.

4. Run the desktop app

cargo run -p chatty-gpui          # or: make run-gpui

The desktop app detects a running local Ollama and lists its models, so the same zero-configuration setup works here. For hosted models, open Settings → Models and add an OpenRouter API key.

On a Linux machine with no session bus (a headless VM, a container), export XDG_RUNTIME_DIR first or X11 window creation fails with BadMatch:

export XDG_RUNTIME_DIR=/tmp/xdg-runtime-$(id -u) && mkdir -p "$XDG_RUNTIME_DIR" && chmod 700 "$XDG_RUNTIME_DIR"

5. Run the fast tests

make test-fast                    # cargo test -p chatty-core --lib

Most logic lives in chatty-core, so this is the inner loop while you iterate on tools, services and settings models. It needs no display server.

6. Run what CI runs

make ci

make ci is make wasm-modules, then cargo test --all-features, cargo fmt --check, cargo clippy --all-features -- -D warnings, and the reserved-symbol and rig-pin checks. The full suite is the one that needs the disk space above. Make targets & CI workflows lists every target.

Verify

  • Step 3 printed a reply and exited with status 0.
  • Step 4 opened a window with your Ollama models in the model picker.
  • Step 5 and step 6 finished green.

Checklist

  • make setup ran (or the equivalent packages and the wasm32-wasip2 target are installed)
  • make wasm-modules staged the modules under target/wasm-fixtures/
  • chatty-tui --headless answered a prompt
  • cargo run -p chatty-gpui opened the desktop app
  • make test-fast and make ci pass

Common mistakes

SymptomFix
A test fails with “run scripts/build-wasm-fixtures.sh”make wasm-modules
feature edition2024 is requiredToolchain too old; rustup default stable
No models configured from chatty-tuiAdd --ollama (or --openai-compat-url), or configure a provider in the desktop app first
Could not connect to Ollamaollama serve is not running, or the URL is wrong
BadMatch when the desktop window opens on LinuxExport XDG_RUNTIME_DIR as in step 4
No space left on device during make ciBuild disk usage; share a CARGO_TARGET_DIR
fatal error: 'memory' file not found or unable to find library -lstdc++cc/c++ resolve to clang; point them at gcc/g++ (update-alternatives on Debian/Ubuntu)
A SIGTRAP/SIGSEGV/SIGABRT under cargo testA pdfium call bypassing create_pdfium(); see Test

Next

Your first change: add a tool

When to read this: You have built and run Chatty and want to make a real, end-to-end change: expose a new capability the model can call.

Goal

A word_count tool the agent can invoke, wired the way every built-in tool is wired, with its error path routed through map_tool_error, its schema guarded by the Gemini-compatibility test, its name in the registry, and its row in the tools catalog. Same shape, bigger tool, next time.

Prerequisites

  • make test-fast passes on your checkout.
  • Read the tool error section of Contributing patterns once; it explains why step 2 is a hard rule.
  • Two files open for reference: remember_tool.rs (small, stateful) and execute_code_tool.rs (typed output). The skeleton below is theirs with the business logic removed.

Steps

1. Write the tool

Create crates/chatty-core/src/tools/word_count_tool.rs:

#![allow(unused)]
fn main() {
use rig_agent::tool::{Tool, ToolContext, ToolExecutionError};
use serde::{Deserialize, Serialize};

use crate::tools::ToolError;

#[derive(Debug, Deserialize, Serialize)]
pub struct WordCountArgs {
    /// The text to count
    pub text: String,
}

#[derive(Debug, Serialize)]
pub struct WordCountOutput {
    pub words: usize,
}

/// Count the words in a piece of text.
#[derive(Clone, Default)]
pub struct WordCountTool;

impl Tool for WordCountTool {
    const NAME: &'static str = "word_count";
    type Error = ToolError;
    type Args = WordCountArgs;
    type Output = WordCountOutput;

    fn description(&self) -> String {
        "Count the whitespace-separated words in a piece of text.".to_string()
    }

    fn parameters(&self) -> serde_json::Value {
        serde_json::json!({
            "type": "object",
            "properties": {
                "text": {
                    "type": "string",
                    "description": "The text to count"
                }
            },
            "required": ["text"]
        })
    }

    /// Keep the real failure text in front of the user and the model:
    /// rig's default `map_error` redacts it to "the tool failed".
    fn map_error(&self, error: Self::Error) -> ToolExecutionError {
        crate::tools::map_tool_error(Self::NAME, error)
    }

    async fn call(
        &self,
        _context: &mut ToolContext,
        args: Self::Args,
    ) -> Result<Self::Output, Self::Error> {
        if args.text.trim().is_empty() {
            return Err(ToolError::OperationFailed("text is empty".into()));
        }
        Ok(WordCountOutput {
            words: args.text.split_whitespace().count(),
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn counts_words() {
        let mut ctx = ToolContext::new();
        let out = WordCountTool
            .call(&mut ctx, WordCountArgs { text: "one two  three".into() })
            .await
            .unwrap();
        assert_eq!(out.words, 3);
    }

    #[test]
    fn empty_text_is_an_error_the_model_can_read() {
        let err = WordCountTool.map_error(ToolError::OperationFailed("text is empty".into()));
        let feedback = err.model_feedback().unwrap_or_default();
        assert!(feedback.starts_with("Error: word_count: "), "{feedback}");
    }
}
}

ToolError is the shared one-variant error in tools/mod.rs; use it unless your tool has genuinely distinct failure categories. Every JSON schema object needs a type (step 5 checks this).

2. Route the error path through map_tool_error

The map_error override above is not optional. rig’s default Tool::map_error redacts any source error to a per-kind message — for the common Other kind that message is the literal string "the tool failed", and that is all the model and the transcript ever see. map_tool_error(tool_name, error) in tools/mod.rs keeps the message and writes it as Error: {tool_name}: {message}.

That prefix is also load-bearing: the streamed tool result carries no error flag, so llm_service::tool_result_looks_like_error recognises a failure by the Error: prefix. A tool that bypasses map_tool_error is filed as a success, with its error text in output. Only route errors you authored through it; raw provider or third-party error text is what rig’s redaction exists for.

3. Export it

In crates/chatty-core/src/tools/mod.rs, next to the other modules:

#![allow(unused)]
fn main() {
pub mod word_count_tool;
// …
pub use word_count_tool::WordCountTool;
}

4. Register it

Tool registration is spread over three files in crates/chatty-core/src/factories/agent_factory/; the compiler walks you through each.

  1. tool_collector.rs — add a field to NativeTools, a .tool(t) call in apply_to_builder, and the field to the native_tools! macro (both its parameter list and the struct literal it expands to). rig registers typed tools with repeated .tool(T) calls, which is why the struct exists:

    #![allow(unused)]
    fn main() {
    pub word_count_tool: Option<WordCountTool>,
    // in apply_to_builder:
    if let Some(t) = self.word_count_tool {
        b = b.tool(t);
    }
    }
  2. mod.rs (from_model_config_with_tools) — construct the tool where the others are built, gated on whatever setting should enable it (Some(WordCountTool) for an always-on tool), and pass it in the single native_tools!(…) call.

  3. tool_registry.rs — add a word_count: bool flag to ToolAvailability and insert "word_count" in active_native_tool_names when it is set (or add the name to the always-on set at the top of that function). Then set the flag in both ToolAvailability { … } literals in mod.rs. These names are what list_tools reports, what the preamble’s tool summary lists, and what filter_mcp_tool_info uses to drop MCP tools that would collide with a native name — a tool that is registered but not named here is invisible to the model’s own inventory. The registry tests spell out every flag (all_flags_enabled_produces_superset), so the new field breaks them until you add it; if you chose the always-on set, always_includes_baseline_tools counts them.

Note

A delegated worker started with --tools <profile> sees only the tool names that profile lists in tool_profile.rs (coordinator, coder, reviewer; AGE-405). A new tool is invisible to every team worker until you add its name to the profiles that should have it — the read set if every role needs it, coder for a write-side tool. Leaders and plain sessions are unaffected.

5. Add the Gemini schema guard

Gemini rejects any schema node with an empty type. gemini_compat_tests in tools/mod.rs converts each tool’s definition to a Gemini Tool and walks the result. Add one:

#![allow(unused)]
fn main() {
#[tokio::test]
async fn word_count_tool_gemini_compat() {
    use crate::tools::word_count_tool::WordCountTool;
    check_gemini_compat(rig_agent::tool::tool_definition(&WordCountTool));
}
}

6. Add the catalog row

make docs-check-reference diffs every const NAME under crates/chatty-core/src/tools/ against the tools = [ table in scripts/gen-docs-reference.sh, so CI fails without a row. Add one (("word_count", "text", "word_count_tool.rs", "Always available") follows the existing shape), then make docs-gen regenerates the tools catalog.

7. Run it

cargo test -p chatty-core --lib word_count      # your tests + the gemini guard
make test-fast
cargo run -p chatty-tui -- --ollama --headless -m "Use the word_count tool on: the quick brown fox"
make ci

Verify

  • Both unit tests and word_count_tool_gemini_compat pass.
  • /tools in the TUI (or list_tools in a conversation) shows word_count.
  • Asking for a count produces a tool call in the transcript; an empty string produces Error: word_count: text is empty in the tool row, not “the tool failed”.
  • make ci and make docs-check-reference pass.

Checklist

  • impl Tool with const NAME, description, parameters, call
  • map_error delegates to map_tool_error(Self::NAME, error)
  • Exported from tools/mod.rs
  • NativeTools field, apply_to_builder call, native_tools! macro entry
  • Constructed in from_model_config_with_tools, flag set in both ToolAvailability literals
  • Name in active_native_tool_names
  • gemini_compat test added
  • Row in scripts/gen-docs-reference.sh, make docs-gen run
  • Side effects go through an approval store (below)

Approval

Tools with side effects — running commands, writing files — must ask before acting. Shell tools go through ExecutionApprovalStore (models/execution_approval_store.rs); file writes through WriteApprovalStore (models/write_approval_store.rs). Copy the pattern from shell_tool.rs or filesystem_write_tool.rs. Read-only tools like word_count skip approval.

Common mistakes

MistakeConsequenceFix
No map_error overrideModel and transcript see “the tool failed”Delegate to map_tool_error
Rewording the Error: prefixFailure filed as success, error prose in outputKeep map_tool_error’s format
Registered in tool_collector.rs but not named in tool_registry.rsMissing from list_tools; an MCP tool with the same name is not filteredAdd the name
Schema object without typeGemini rejects the requestRun the gemini_compat test
Forgot scripts/gen-docs-reference.shdocs-check-reference fails in CIAdd the row, make docs-gen
Routing a provider’s raw error through map_tool_errorLeaks text the model should not act onOnly your own error strings

Tutorial: write a plugin (echo)

When to read this: your first WASM plugin: three tools, built, installed, and called by an agent.

Full source: modules/echo/ (~80 lines). Also see modules/echo/README.md for build commands and the integration-test checklist.

Prerequisites: the wasm32-wasip2 target — make setup, or rustup target add wasm32-wasip2 — before the “Build and install” step below; without it cargo build --target wasm32-wasip2 fails with “the wasm32-wasip2 target may not be installed”.

What a plugin is

A plugin contributes tools to a chatty agent. It never runs a loop of its own and is never an agent itself: an agent spec lists it under [[plugins]], the agent’s model sees its tools as <plugin>__<tool>, and chatty calls the plugin when the model asks for one. The contract is the WIT world chatty:plugin@0.3.0 (WIT reference).

Exportecho’s answer
metadataname echo, version 0.2.0, no requested capabilities
list-toolsecho, reverse, count_words, each taking {"input": string}
invoke-toolruns one of them, or a typed error

Core: the Plugin trait

Every plugin implements three functions and wires them with export!:

#![allow(unused)]
fn main() {
use chatty_module_sdk::{
    export, Plugin, PluginMetadata, ToolCallRequest, ToolDefinition, ToolError, ToolResult,
};

pub struct Echo;

impl Plugin for Echo {
    fn metadata() -> PluginMetadata { /* … */ }
    fn list_tools() -> Vec<ToolDefinition> { /* … */ }
    fn invoke_tool(call: ToolCallRequest) -> Result<ToolResult, ToolError> { /* … */ }
}

export!(Echo);
}

Plugin and export! are generated by wit-bindgen from the WIT file, so the component’s export names always match what chatty loads.

Step 1 — Metadata

metadata says who the plugin is and which host capabilities it needs. name and version match [module] in its module.toml. echo only logs, and logging is always granted, so it requests nothing:

#![allow(unused)]
fn main() {
fn metadata() -> PluginMetadata {
    PluginMetadata {
        name: "echo".to_string(),
        version: "0.2.0".to_string(),
        description: "Reference plugin: echo, reverse and count_words tools".to_string(),
        requested_capabilities: vec![],
        config_keys: vec![],
    }
}
}

A plugin that calls the model through llm::complete would request Capability::Llm; one that reads [config] values, Capability::Config.

Step 2 — Define tools

list_tools advertises each tool with a JSON Schema for its arguments:

#![allow(unused)]
fn main() {
fn list_tools() -> Vec<ToolDefinition> {
    vec![
        tool("echo", "Returns the input string unchanged.", "String to echo"),
        tool("reverse", "Returns the input string with characters in reverse order.", "String to reverse"),
        tool("count_words", "Returns the number of whitespace-separated words in the input string.", "String to count words in"),
    ]
}
}

(tool is a small helper in src/lib.rs that builds the {"input": string} schema.)

Step 3 — Run a tool call

invoke_tool gets a ToolCallRequest: the tool’s name and the model’s arguments as one JSON object in arguments_json, shaped by the schema the tool declared. It returns the text the model sees, or a typed error the model reads as <kind>: <message>:

#![allow(unused)]
fn main() {
fn invoke_tool(call: ToolCallRequest) -> Result<ToolResult, ToolError> {
    chatty_module_sdk::log::info(&format!("echo: invoke_tool name={}", call.name));

    let run: fn(&str) -> String = match call.name.as_str() {
        "echo" => |input| input.to_string(),
        "reverse" => |input| input.chars().rev().collect(),
        "count_words" => |input| input.split_whitespace().count().to_string(),
        other => return Err(ToolError::unknown_tool(other)),
    };
    Ok(ToolResult::text(run(&input_argument(&call.arguments_json)?)))
}
}

input_argument parses arguments_json and answers ToolError::invalid_arguments when input is missing.

Full implementation → src/lib.rs

Build and install

cd modules/echo
cargo build --target wasm32-wasip2 --release
cp target/wasm32-wasip2/release/echo.wasm .

# Linux: install into chatty's module directory (Settings → Modules shows it)
cp -r . ~/.local/share/chatty/modules/echo/

module.toml beside the .wasm names it:

[module]
name = "echo"
version = "0.2.0"
wasm = "echo.wasm"

[capabilities]
tools = ["echo", "reverse", "count_words"]

[protocols]
mcp = true

Call it from an agent

A plugin does nothing until an agent loads it. Write a spec that lists it, e.g. .chatty/agents/echo-user.toml in your workspace:

[agent]
name = "echo-user"
preamble = "Use the echo tools when asked to transform text."

[[plugins]]
module = "echo"

Then:

chatty-tui --agent echo-user --headless -m "Reverse the word hello."

The model sees echo__echo, echo__reverse and echo__count_words beside its other tools; a call runs the plugin in-process, under its sandbox limits. The next tutorial gives a real agent a plugin: give an agent the plugin (benford).

Expose it to external MCP clients

With [protocols] mcp = true, the desktop’s protocol gateway (Settings → Plugins, module runtime on) also serves the tools to any MCP client that holds its launch token, over a Unix socket rather than a port (Use a plugin from another MCP client):

dir="$XDG_RUNTIME_DIR/chatty-run"
curl -s --unix-socket "$dir/gateway.sock" http://localhost/mcp/echo \
  -H "Authorization: Bearer $(cat "$dir/gateway.token")" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"reverse","arguments":{"input":"hello"}}}'

There is no OpenAI or A2A route for a plugin: it has tools, not a loop.

Automated tests

From the repo root:

make wasm-modules
cargo test -p chatty-protocol-gateway --test echo_plugin_e2e
cargo test -p chatty-tui --test plugins_headless

echo_plugin_e2e covers registry load, the three exports and the MCP route; plugins_headless runs chatty-tui --headless with a spec that calls echo__reverse against a scripted model.

Next steps

Tutorial: give an agent the plugin (benford)

When to read this: you have a plugin (or want one) and want an agent that uses it: a preamble that tells the model what to do, and the plugin’s tools to do it with.

Full source: the plugin in modules/benford/ and the agent spec below, which you write yourself (no preset ships it).

Prerequisites

Complete Tutorial: write a plugin first if you are new to the Plugin trait and module.toml. You need the wasm32-wasip2 target (make setup installs it) and a configured model provider.

The split: the loop is the agent’s, the tools are the plugin’s

A Benford audit is “compute the first-digit distribution, test it, write a report”. Two parts of that are arithmetic, one is judgement:

  • The plugin (benford) does the arithmetic, deterministically, in the WASM sandbox: compute_benford_distribution and chi_square_test.
  • The agent (auditor) is chatty’s own harness: a spec with a forensic-auditor preamble and plugins = [benford]. Its model decides when to call each tool and writes the report, with everything a chatty agent has: approvals, trace, usage and budgets.

The plugin never calls the model and never loops:

sequenceDiagram
  participant Model as auditor's model
  participant Harness as chatty harness (AgentSession)
  participant Plugin as benford plugin (WASM)

  Harness->>Model: preamble + question, tools incl. benford__*
  Model-->>Harness: call benford__compute_benford_distribution
  Harness->>Plugin: invoke-tool(compute_benford_distribution, numbers)
  Plugin-->>Harness: observed_counts, total_analyzed
  Harness->>Model: tool result
  Model-->>Harness: call benford__chi_square_test
  Harness->>Plugin: invoke-tool(chi_square_test, counts, total)
  Plugin-->>Harness: χ², risk level
  Harness->>Model: tool result
  Model-->>Harness: audit report

Step 1 — The plugin’s tools

benford is a plugin like echo: two tools, each parsing its JSON arguments and returning JSON. It requests no capability.

#![allow(unused)]
fn main() {
fn invoke_tool(call: ToolCallRequest) -> Result<ToolResult, ToolError> {
    let run = match call.name.as_str() {
        "compute_benford_distribution" => compute_benford_distribution,
        "chi_square_test" => chi_square_test,
        other => return Err(ToolError::unknown_tool(other)),
    };
    run(&call.arguments_json)
        .map(ToolResult::text)
        .map_err(ToolError::invalid_arguments)
}
}

Full source → src/lib.rs

Unit tests for the statistical functions run on the host target. The crate’s .cargo/config.toml defaults the build target to wasm32-wasip2, so pass the host target explicitly:

cd modules/benford
cargo test --target x86_64-unknown-linux-gnu   # or your host's triple

Step 2 — Build and install the plugin

One command, from the repo root, builds the plugin and installs it into the module directory the desktop app scans by default:

make example-plugin-benford

Set MODULE_DIR to install somewhere else, e.g. a workspace-local .chatty/modules/benford. Without make (or on Windows), do the same two steps by hand:

cd modules/benford
cargo build --target wasm32-wasip2 --release
mkdir -p ~/.local/share/chatty/modules/benford
cp target/wasm32-wasip2/release/benford.wasm ~/.local/share/chatty/modules/benford/
cp module.toml ~/.local/share/chatty/modules/benford/

Step 3 — The agent spec

An agent is a spec (TOML): who it is, what it is told, what it may use. Write this one to .chatty/agents/auditor.toml in a workspace (or <data_dir>/chatty/agents/auditor.toml):

[agent]
name = "auditor"
description = "Audits a list of amounts against Benford's Law with a chi-square test"
preamble = "You are a forensic auditor. Call benford__compute_benford_distribution with the numbers, then benford__chi_square_test with its observed_counts, and total_analyzed as total. Report the risk level, the chi-square statistic and the digits that deviate most. Every number comes from the tools, never from your own arithmetic."

[tools]
profile = "reviewer"
disable = ["shell"]

[[plugins]]
module = "benford"
version = "^0.2"
  • preamble is the system prompt. It names the tools the way the model sees them: <plugin>__<tool>.
  • [tools] narrows the native tools: a reviewer, and no shell.
  • [[plugins]] loads the plugin into this agent (one instance per agent), resolved by name in the module directory; version is a semver range. The spec is the plugin’s allow-list: a tool profile does not hide plugin tools.
  • No grants: the plugin only computes, so it needs nothing beyond logging, which is always on.

Step 4 — Run it

chatty-tui --agent auditor --headless \
  -m "Analyze these invoice amounts: 1234 4521 891 2340 567 8901 234 456 789"

The first digits are 1, 4, 8, 2, 5, 8, 2, 4, 7, so the plugin answers χ² ≈ 10.49 on 8 degrees of freedom: under the 15.507 critical value, risk LOW, digit 1 the most deviant. With nine numbers, that is the expected verdict; the report should say the sample is too small to show an anomaly.

crates/chatty-tui/tests/plugins_headless.rs (a_spec_with_the_benford_plugin_gives_the_chi_square_verdict) runs exactly this spec against a scripted model and checks the verdict the plugin hands back.

Step 5 — Other ways in

  • Over MCP, for an external client, the plugin’s tools alone: POST /mcp/benford on the desktop’s protocol gateway ([protocols] mcp = true). The caller orchestrates; there is no report. See the benford README.
  • There is no OpenAI or A2A route for the plugin: it is not an agent. The agent is the spec.

Design notes

  • Deterministic work in the plugin, judgement in the agent. The model never does the statistics; the preamble tells it to take the χ² and the risk level from the tool.
  • Typed errors. Bad arguments come back as invalid-arguments: …, which the model reads and can correct on its next call.
  • Nothing to re-implement. Before PL-U3 the module ran its own six-turn loop and fed tool results back as user messages; now the harness does the loop, so compaction, approvals, trace and budgets apply for free.

Further reading

Where do I…?

When to read this: You know the task but not which file or doc to open. This page is the index of the how-to guides; each guide follows the same shape (goal, prerequisites, steps, verify, checklist, common mistakes).

flowchart TD
  Start[What do you need?] --> UI{Change UI?}
  UI -->|Desktop view| V["chatty-gpui/src/chatty/views/ · add-gpui-view.md"]
  UI -->|Settings page| S["chatty-gpui/src/settings/views/"]
  UI -->|Terminal UI| T["chatty-tui/src/ui/"]
  UI -->|Slash command| SC["add-slash-command.md"]
  UI -->|No| LLM{Change LLM behavior?}
  LLM -->|New tool| Tool["chatty-core/src/tools/ · first-change.md"]
  LLM -->|New provider| Prov["ProviderType + agent_factory/ · add-provider.md"]
  LLM -->|Stream bug| SM["stream-manager.md · debug.md"]
  LLM -->|WASM plugin| WASM["build-wasm-module.md + tutorials"]
  LLM -->|No| Data{Change data/settings?}
  Data -->|New setting| Set["settings/models + repositories + GPUI settings"]
  Data -->|Persistence| Repo["repositories/ or settings/repositories/"]
  Data -->|No| Infra{CI / docs / release?}
  Infra --> WF[".github/workflows/ · ci-reference.md"]
  Infra --> Doc["docs/ + docs-site/ · contribute-docs.md"]
  Infra --> Rel["RELEASE_PROCESS.md"]

How-to guides

GuideUse it when
Add an LLM providerA new ProviderType, or deciding whether an OpenAI-compatible endpoint even needs one
Add a slash commandA new /command in the desktop and terminal pickers
Add a desktop GPUI viewA panel, dialog or widget in chatty-gpui
Build a WASM pluginA sandboxed agent module served by the protocol gateway
TestWhich tests to run, goldens, mocks, the WASM prebuild
DebugLogs, the render overlay, stalled streams, tool failures, cache hits
Build & packageLocal CI and platform packages
Release processCutting a version
Contribute to the docsEditing this site
For AI agentsThe workspace map coding agents read first

Tutorials (learn by building) live under Start here: your first change, write a plugin (echo), give an agent the plugin (benford).

Cheat sheet

I want to…Open
Read the user manualGetting started and the other user pages
Build the workspace for the first timeBuild and run
Understand the big pictureSystem overview
See component diagramsComponent map
Find a workspace crateReference: crates
Build a WASM pluginBuild a WASM plugin · echo tutorial · benford tutorial
Add an LLM toolYour first change: add a tool
Add a providerAdd an LLM provider
Add a slash commandAdd a slash command
Add a GPUI viewAdd a desktop GPUI view
Look up a persisted settingSettings schema
Fix stream/cancel bugsStream lifecycle · Debug
Fix rendering/layoutDebug · Rendering pipeline
Look up a tool nameTools catalog
Change delegation, the broker, virtual agents or teamsAgents and specs · Team smoke test · Sub-agents · tutorials: named worker, team
Run tests like CITest · Make targets & CI workflows
Look up a termGlossary
Research / reserved codeRESERVED.md
Report or fix a stale pageContribute to the docs · CONTRIBUTING.md

Add an LLM provider

When to read this: Integrate a new LLM API into Chatty — or find out that you do not need to, because the endpoint already speaks an API Chatty supports.

Goal

A new ProviderType that can be configured in Settings, builds a rig agent with the full native and MCP tool set, streams, reports token usage correctly, and declares sensible default capabilities for its models.

Prerequisites

Tip

You may not need a new variant. Any OpenAI-compatible chat-completions endpoint (vLLM, llama.cpp, LM Studio, Groq, Together, …) already works as ProviderType::OpenRouter with a custom base_url; that is exactly what chatty-tui --openai-compat-url injects. Add a variant only when the wire format, auth or usage accounting genuinely differ.

Steps

1. Add the ProviderType variant

In crates/chatty-core/src/settings/models/providers_store.rs:

  1. Add the variant. The enum is #[serde(rename_all = "snake_case")], so the variant name is the persisted JSON string (AzureOpenAI uses an explicit rename = "azure_openai"). Do not rename an existing variant without a #[serde(alias)]; users’ saved settings would stop loading.
  2. Add its arm to display_name().
  3. Add its arm to default_capabilities() — (supports_images, supports_pdf) for models created under it: (true, true), (true, false) or (false, false).
  4. Decide what counts as configured in configured_providers(). The default arm requires a non-empty api_key; Ollama is always included; Azure needs base_url plus a key or Entra ID.

2. Let the compiler find the other matches

cargo check --all-features

Every non-exhaustive match fails to compile. Today those are build_provider_agent in provider_builder.rs (step 3), the provider string in exporters/atif_exporter/steps.rs, and on the desktop the display-name-to-type mapping and provider list in settings/views/models_page/mod.rs and the per-provider model catalog in add_model_sheet.rs.

3. Build the agent

Add a match arm in build_provider_agent (crates/chatty-core/src/factories/agent_factory/provider_builder.rs). All tool construction happens before this function; the arm only creates the client, configures the builder and attaches tools. The Ollama arm is the smallest template:

#![allow(unused)]
fn main() {
ProviderType::MyProvider => {
    let key = api_key.ok_or_else(|| anyhow!("API key not configured for MyProvider"))?;
    let client = rig_core::providers::my_provider::Client::builder()
        .api_key(&key)
        .build()?;

    let mut builder = client
        .agent(&model_config.model_identifier)
        .preamble(preamble);
    if model_config.supports_temperature {
        builder = builder.temperature(model_config.temperature as f64);
    }
    if let Some(max_tokens) = model_config.max_tokens {
        builder = builder.max_tokens(max_tokens as u64);
    }

    let builder = native_tools.apply_to_builder(builder);
    let agent = build_with_mcp_tools!(builder, mcp_tools, native_tool_names);
    Ok(AgentClient { agent, task_controller, provider: ProviderType::MyProvider })
}
}

Points that differ per provider:

  • Client. Prefer a native rig_core::providers::* client when rig has one; otherwise reuse the OpenAI-compatible path with base_url. Check the rig pins in Cargo.toml before relying on a new provider module.
  • Auth. ProviderConfig carries api_key, base_url and a free-form extra_config map (Azure keeps its auth_method there). If a credential must be refreshed per request, wrap the HTTP client the way azure_auth_http.rs does rather than awaiting a token inside the stream handler; on_chunk is synchronous.
  • Temperature. Only when model_config.supports_temperature; reasoning models reject it.
  • OpenAI-family schemas. Call sanitize_mcp_tools_for_openai(mcp_tools) before attaching MCP tools, as the OpenRouter arm does.
  • Prompt caching. The OpenRouter arm builds from completion_model(..).with_prompt_caching() and a PromptCachingHttpClient; copy that only if the upstream honours cache_control breakpoints.

4. Check token-usage semantics

stream_prompt in llm_service.rs normalises every provider request’s usage with normalize_usage(UsageSemantics, …). It is currently hard-coded to UsageSemantics::InputIncludesCache because OpenRouter, Azure and Ollama all report OpenAI-style numbers (cached tokens inside prompt_tokens). A provider that reports cached tokens separately (Anthropic-native) needs InputExcludesCache, selected from agent.provider; otherwise input + cache_read + cache_write no longer equals the prompt and the cost and hit-rate figures are wrong. The LLM completion call usage log line is the check (Debug).

5. Settings UI (desktop)

6. Optional: model discovery

If the API lists models, add crates/chatty-gpui/src/settings/providers/<name>/sync_service.rs following ollama/sync_service.rs or openrouter/sync_service.rs. Keep the HTTP discovery itself UI-agnostic in chatty-core (settings/providers/ollama/discovery.rs is the precedent) so the TUI can reuse it for a direct-connect flag (discover_* + inject_discovered in chatty-tui/src/main.rs). Without discovery, users type model ids by hand.

7. Update the references

Add the provider to the provider-matrix block in scripts/gen-docs-reference.sh and run make docs-gen; describe it for users in Providers & models.

8. Test

cargo test --all-features
cargo clippy --all-features -- -D warnings

Then manually: configure the provider, add a model, send a message and watch it stream; trigger a tool call; attach an image or PDF according to the capabilities you declared.

Verify

  • ProviderType round-trips through serde with the string you intended (settings/models/mod.rs has the round-trip tests for the existing variants; add one).
  • A conversation streams text and completes a tool call.
  • The LLM completion call usage line shows input + cache_read + cache_write equal to the prompt size the provider reports.
  • Attachments are offered only for the capabilities you declared.

Checklist

  • ProviderType variant, display_name, default_capabilities, configured_providers rule
  • Every match updated (cargo check --all-features clean)
  • build_provider_agent arm: client, temperature gate, tools, MCP
  • Usage semantics correct for the provider’s accounting
  • Provider section in provider_keys_sheet.rs; controller persists it
  • Provider matrix regenerated; user page updated
  • cargo test --all-features and clippy pass; manual chat, tool call and attachment checks done

Common mistakes

MistakeDo this instead
Adding a variant for an OpenAI-compatible endpointOpenRouter + base_url
Renaming a persisted variantKeep a #[serde(alias)] for the old string
Setting temperature unconditionallyGate on supports_temperature
Skipping sanitize_mcp_tools_for_openai on an OpenAI-family APISanitise; the request fails on schema format fields otherwise
Leaving InputIncludesCache for an Anthropic-style APIChoose semantics by agent.provider
Refreshing a token inside the streamPer-request HTTP wrapper, like Azure
Checking capabilities from ProviderType at runtimeRead ModelConfig; provider defaults are for creation only

audience: [contributor, agent] source_files:

  • crates/chatty-gpui/src/chatty/views/chat_input/slash.rs
  • crates/chatty-gpui/src/chatty/controllers/app_controller/slash_commands.rs
  • crates/chatty-tui/src/engine/commands.rs related:
  • ./dev/reference/slash-commands.md
  • ./dev/start/first-change.md

Add a slash command

When to read this: Expose a new /command in the chat input. Desktop (GPUI) and terminal (TUI) register commands in different files — there is no shared command table.

Full lookup table: slash commands reference.

Two command shapes

ShapeExampleGPUI wiringTUI wiring
Immediate (no args)/compactPicker execute_immediately: true → ChatInputEvent::SlashCommandSelected → handle_slash_commandCommand variant, no Option<String>
Arg-based/agent <prompt>Picker inserts /agent (does not execute) → user sends → try_handle_arg_slash_commandCommand::Agent(Option<String>)

Skills from the project and global skill directories (.agents/skills/, .claude/skills/; see SkillService) appear in both pickers with a skill badge, rescanned each time the picker opens. Do not register a skill as a built-in command.

GPUI (desktop)

  1. Add a SlashCommand to SLASH_COMMANDS in crates/chatty-gpui/src/chatty/views/chat_input/slash.rs (command, description, insert_text, execute_immediately).
  2. Immediate commands: add a match arm in ChattyApp::handle_slash_command (crates/chatty-gpui/src/chatty/controllers/app_controller/slash_commands.rs).
  3. Arg-based commands: handle the prefix in ChattyApp::try_handle_arg_slash_command in the same file. Return true so the message is not forwarded to the LLM.
  4. ChattyApp already routes ChatInputEvent::SlashCommandSelected and intercepts Send for arg-based commands — no extra subscribe.

TUI (terminal)

  1. Add a Command variant and a parse_command match arm in crates/chatty-tui/src/engine/commands.rs.
  2. Handle the variant in the engine (same file / ChatEngine).
  3. The TUI slash menu builds from the same Command parse list plus skills (crates/chatty-tui/src/ui/input.rs). If the new command should appear in the / picker, add it there too.
  4. Add a parse test in crates/chatty-tui/src/engine/helpers.rs (parses_new_slash_commands).

TUI-only today: /model, /tools, /modules, /update, /quit, /exit. GPUI has no /quit (window chrome handles that).

Parity checklist

  • Same command string and argument syntax in both UIs, or document the exception in slash-commands.md (scripts/gen-docs-reference.sh)
  • Immediate vs arg-based behavior matches
  • Unknown /foo is ignored (TUI) or logged (GPUI) — never sent as a user message if you claimed it as a command
  • make docs-gen after changing the reference table

audience: [contributor, agent] source_files:

  • crates/chatty-gpui/src/chatty/views/
  • crates/chatty-gpui/src/chatty/views/sidebar_view.rs
  • crates/chatty-gpui/src/chatty/views/conversation_item.rs
  • crates/chatty-gpui/src/chatty/views/app_view.rs
  • crates/chatty-gpui/src/chatty/controllers/app_controller/mod.rs
  • docs/entity-communication.md related:
  • ./dev/architecture/entity-communication.md
  • ./dev/reference/event-catalog.md
  • ./dev/guides/add-slash-command.md

Add a desktop GPUI view

When to read this: Add a new panel, dialog, or persistent surface in the desktop app (chatty-gpui). Settings pages and the TUI live in different trees — pick from the table first.

Full event lookup: GPUI event catalog. Design rationale: entity communication.

Pick the right kind of UI

KindWhenCanonical exampleWhere it lives
Owned entity viewPersistent panel with state; talks to ChattyAppSidebarView, ChatViewcrates/chatty-gpui/src/chatty/views/, stored as Entity<T> on ChattyApp
Nested entityState owned by a parent view, not the appChatInputState (on ChatView), SystemTraceViewSame views/ tree; parent or ChattyApp subscribes
DialogModal overlay, opened on demandSearchConversationsDialog, ErrorLogDialogviews/, window.open_dialog
IntoElement widgetRender-once row/chrome, no EventEmitterConversationItem, AppTitleBar, StatusFooterViewviews/; callbacks must cx.emit on a parent entity
Settings pageSettings window, not the main chat chromesettings/views/models_page/crates/chatty-gpui/src/settings/views/
TUI viewTerminal UIRatatui widgetscrates/chatty-tui/src/ui/ — not GPUI

Transcript block types (transcript/) render history in the desktop app. Do not leak those types into chatty-core; persistence stays untyped (MessageEntry + system_trace JSON).

Walkthrough: owned entity (SidebarView)

SidebarView is the pattern to copy: typed events, Entity on ChattyApp, one cx.subscribe() in setup_callbacks(), composed in ChattyApp::render.

1. File and module

Create crates/chatty-gpui/src/chatty/views/your_view.rs (or a your_view/ directory with mod.rs if it will grow). Export from crates/chatty-gpui/src/chatty/views/mod.rs:

#![allow(unused)]
fn main() {
pub mod your_view;
pub use your_view::YourView;
}

2. Typed event enum + EventEmitter

#![allow(unused)]
fn main() {
/// Events emitted by SidebarView for entity-to-entity communication
#[derive(Clone, Debug)]
pub enum SidebarEvent {
    NewChat,
    OpenSettings,
    SelectConversation(String),
    DeleteConversation(String),
    ExportConversation(String),
    ToggleCollapsed(bool),
    LoadMore,
}

impl EventEmitter<SidebarEvent> for SidebarView {}
}

Add a variant when the view needs the app to do something. Exhaustive match in the subscriber is the checklist that nothing is unwired.

3. Struct, constructor, mutators

Keep view state on the struct. After mutating, call cx.notify() so GPUI re-renders. Emit from a method when the mutation is the user-visible action:

#![allow(unused)]
fn main() {
pub fn toggle_collapsed(&mut self, cx: &mut Context<Self>) {
    self.is_collapsed = !self.is_collapsed;
    cx.emit(SidebarEvent::ToggleCollapsed(self.is_collapsed));
    cx.notify();
}

pub fn set_conversations(
    &mut self,
    conversations: Vec<(String, String, Option<f64>)>,
    cx: &mut Context<Self>,
) {
    self.conversations = conversations;
    cx.notify();
}
}

4. Render: emit from the entity

In impl Render, clone cx.entity() once and use it from on_click closures. The view entity emits; ChattyApp handles:

#![allow(unused)]
fn main() {
impl Render for SidebarView {
    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let sidebar_entity = cx.entity().clone();

        v_flex().id("sidebar").child(
            Button::new("new-chat").label("New Chat").on_click({
                let entity = sidebar_entity.clone();
                move |_event, _window, cx| {
                    entity.update(cx, |_, cx| {
                        cx.emit(SidebarEvent::NewChat);
                    });
                }
            }),
        )
        // ...
    }
}
}

5. IntoElement children keep callbacks — route them through the entity

ConversationItem is #[derive(IntoElement)]. It cannot implement EventEmitter. It takes Arc<dyn Fn> setters, and the parent wires those to cx.emit:

#![allow(unused)]
fn main() {
ConversationItem::new(id.clone(), title.clone())
    .on_click({
        let entity = sidebar_entity.clone();
        let id = id.clone();
        move |_conv_id, cx| {
            entity.update(cx, |_, cx| {
                cx.emit(SidebarEvent::SelectConversation(id.clone()));
            });
        }
    })
}

Same shape for on_delete / on_export. Callbacks stay at the entity/widget boundary only.

6. Own it on ChattyApp

Field + construct in ChattyApp::new (crates/chatty-gpui/src/chatty/controllers/app_controller/mod.rs):

#![allow(unused)]
fn main() {
pub struct ChattyApp {
    pub chat_view: Entity<ChatView>,
    pub sidebar_view: Entity<SidebarView>,
    // ...
}

let sidebar_view = cx.new(|_cx| SidebarView::new());
}

Store WeakEntity<T> in globals (GlobalChattyApp), never a strong Entity — strong refs in globals create cycles.

7. Subscribe once in setup_callbacks()

#![allow(unused)]
fn main() {
cx.subscribe(
    &self.sidebar_view,
    |app, _sidebar, event: &SidebarEvent, cx| match event {
        SidebarEvent::NewChat => {
            app.start_new_conversation(cx);
        }
        SidebarEvent::OpenSettings => {
            cx.defer(|cx| {
                SettingsView::open_or_focus_settings_window(cx);
            });
        }
        SidebarEvent::SelectConversation(conv_id) => {
            app.load_conversation(conv_id, cx);
        }
        // ...
    },
)
.detach();
}

.detach() keeps the subscription alive. Prefer storing a Subscription field (_sub) when the subscriber is a short-lived nested view (see SearchConversationsView).

cx.defer() is required when the handler would re-enter an entity that is already being updated (OpenSettings, ChatInputEvent::ModelChanged, TraceEvent).

ChattyApp can subscribe to a nested entity without owning it, for example self.chat_view.read(cx).chat_input_state() → ChatInputEvent.

8. Compose in the parent’s Render

impl Render for ChattyApp lives in crates/chatty-gpui/src/chatty/views/app_view.rs. Clone the entity into the tree:

#![allow(unused)]
fn main() {
.child(self.sidebar_view.clone())
.child(self.chat_view.clone())
}

AppTitleBar and StatusFooterView are RenderOnce widgets constructed during render — they are not stored on ChattyApp.

9. Catalog a new event enum

If you added a new event enum (not just a variant on an existing one), add a row in the event-catalog.md block in scripts/gen-docs-reference.sh and run make docs-gen.

Dialogs

Two shapes, both opened with window.open_dialog:

ShapeExampleUse when
Stateless helperErrorLogDialog::openContents come from a global (ErrorStore) and do not need live input
Entity inside the dialogSearchConversationsDialog::open creates SearchConversationsView and .child(view.clone())Typing/filtering must update without rebuilding the dialog

Dialogs are not EventEmitter parents of ChattyApp. To trigger app-level work, upgrade GlobalChattyApp and emit on an owned view (search selection emits SidebarEvent::SelectConversation on sidebar_view), or call a ChattyApp method directly.

Nested entities

ChatView subscribes to SystemTraceView (TraceEvent) and to ArtifactView. Child → parent still uses EventEmitter / cx.subscribe(). That TraceEvent handler cx.defers into ChatView to avoid re-entrancy.

App-level actions (send message, new chat, persist feedback) still go up to ChattyApp (ChatViewEvent, ChatInputEvent, SidebarEvent). Do not add Arc<dyn Fn> from ChatView to ChattyApp.

Rules

  • Entity-to-entity communication is only EventEmitter / cx.subscribe(). No Arc<dyn Fn> between entities.
  • IntoElement / RenderOnce widgets may take callbacks, but those closures must entity.update(cx, |_, cx| cx.emit(…)) on a parent entity.
  • Store WeakEntity<T> in globals, never a strong Entity.
  • cx.notify() after mutating view state; cx.emit when a parent must act.
  • cx.defer() if you need to update an entity that is already being updated.
  • Always .detach() a subscription (or keep the Subscription handle).
  • Log UI-update failures with warn!() — do not .ok() them away.

Checklist

  • File under crates/chatty-gpui/src/chatty/views/ (settings → settings/views/)
  • Exported from views/mod.rs
  • Entity view: event enum + impl EventEmitter<…>
  • ChattyApp (or parent) owns Entity<T> and subscribes in setup_callbacks()
  • Composed in the parent Render tree
  • IntoElement children route clicks through cx.emit on the parent entity
  • New event enum listed in scripts/gen-docs-reference.sh + make docs-gen
  • No transcript block types imported into chatty-core

Common mistakes

MistakeDo this instead
set_on_select(Arc<dyn Fn>) between entitiesEvent variant + cx.subscribe
Strong Entity<T> in a GlobalWeakEntity<T> (GlobalChattyApp pattern)
Mutating a view from inside its own update / subscribe stackcx.defer
Putting a settings form in chatty/views/settings/views/
Building a TUI panel with GPUI widgetschatty-tui/src/ui/
Forgetting cx.notify()Re-render never runs; state looks stuck

Reference

  • Entity communication
  • GPUI event catalog
  • Existing emitters: SidebarEvent, ChatInputEvent, ChatViewEvent, StreamManagerEvent, TraceEvent
  • Layout/styling: .claude/skills/gpui and .claude/skills/gpui-component

Build a WASM plugin

When to read this: you want to ship tools that a chatty agent loads as a sandboxed WASM plugin.

Goal

A wasm32-wasip2 component targeting chatty:plugin@0.3.0, built from the repo template, installed in the module directory, and listed in an agent spec so the agent’s model can call its tools.

A plugin contributes tools; it never runs a loop and is never an agent. It implements the Plugin trait: metadata (name, version, the host capabilities it requests, the config keys it reads), list_tools and invoke_tool. The host provides one import per capability: llm::complete (a completion on the calling agent’s model; API keys stay on the host), config::get (the manifest’s [config] table), logging::log (always granted), file::read-bytes (reads under a manifest-granted root) and billing (paid plugins). The host links only the capabilities the agent’s spec grants ([[plugins]].grants, a subset of what metadata requests); an ungranted import answers capability <x> not granted to this agent. The contract is in the WIT reference.

Prerequisites

  • The wasm32-wasip2 target: make setup, or rustup target add wasm32-wasip2.
  • cargo-generate if you want to scaffold from the template (cargo install cargo-generate).

Steps

1. Scaffold

# From a clone of the repo
cargo generate --path templates/module --name my-plugin

# Or straight from GitHub
# cargo generate --git https://github.com/boersmamarcel/chatty2 --name my-plugin templates/module
cd my-plugin

2. Know the project layout

my-plugin/
├── Cargo.toml              # cdylib; standalone [workspace]
├── .cargo/config.toml      # default target = wasm32-wasip2
├── module.toml             # registry manifest
├── src/lib.rs              # impl Plugin + export!
└── my_plugin.wasm          # built artifact (next to module.toml)

The SDK is a path dependency when the plugin lives under modules/ in the repo:

[dependencies]
chatty-module-sdk = { path = "../../crates/chatty-module-sdk" }

The template’s module.toml declares name, version, description and wasm under [module], [capabilities] tools, [protocols] mcp (serve the tools to external MCP clients at /mcp/{name}) and [resources] (max_memory_mb, max_execution_ms; a manifest may only lower the host ceilings). [config] and [files] root are optional. Parsing is strict: an unknown key is an error that names it (the 0.2.0 keys chat and openai_compat included).

3. Implement Plugin

Fill in src/lib.rs: metadata, list_tools, invoke_tool, and export!(MyPlugin). A tool reads its arguments out of call.arguments_json (one JSON object, shaped by the schema it declared) and returns ToolResult::text(...) or a ToolError (unknown_tool, invalid_arguments, denied, failed); the model reads an error as <kind>: <message>.

A tool that needs the model requests Capability::Llm in its metadata and calls:

#![allow(unused)]
fn main() {
use chatty_module_sdk::{llm, Message, Role};

let messages = [Message::new(Role::User, question)];
let reply = llm::complete("", &messages, None).map_err(ToolError::failed)?; // "" = the calling agent's model
}

The call goes through the calling agent’s provider client and its usage is counted as the plugin’s own line in the turn. Worked examples: Tutorial: write a plugin (echo) and Tutorial: give an agent the plugin (benford).

4. Build and install

cargo build --target wasm32-wasip2 --release
cp target/wasm32-wasip2/release/my_plugin.wasm .

# Linux example; macOS is ~/Library/Application Support/chatty/modules/, Windows %APPDATA%\chatty\modules\
mkdir -p ~/.local/share/chatty/modules/my-plugin
cp -r . ~/.local/share/chatty/modules/my-plugin/

5. Give an agent the plugin

List it in a spec, e.g. .chatty/agents/my-agent.toml:

[agent]
name = "my-agent"
preamble = "Use my-plugin's tools to …"

[[plugins]]
module = "my-plugin"

and run chatty-tui --agent my-agent. The model sees each tool as my-plugin__<tool>.

6. Test your plugin

Unit tests for pure-Rust logic run on the host target inside the plugin directory (pass --target <your host triple>: the template’s .cargo/config.toml defaults to wasm32-wasip2, so a bare cargo test tries to execute the .wasm). For the load/export/MCP round trip and a real agent calling the plugin, the repo’s echo suites are the model to copy:

# From the repo root — builds every plugin and fixture
make wasm-modules
cargo test -p chatty-protocol-gateway --test echo_plugin_e2e
cargo test -p chatty-tui --test plugins_headless

Verify

  • chatty-tui --agent my-agent --headless -m "…" calls my-plugin__<tool>.
  • With the desktop’s module gateway on, tools/list on /mcp/my-plugin over its socket, with its launch token, shows your tools (if [protocols] mcp = true; see Use a plugin from another MCP client).
  • A plugin built against another WIT world is refused at load with module targets …; this chatty supports chatty:plugin@0.3.0 — rebuild it with the current SDK.

Checklist

  • wasm32-wasip2 target installed
  • metadata().name/version match [module] in module.toml
  • Every host import the plugin calls is in requested_capabilities (logging needs no request)
  • .wasm copied next to module.toml
  • An agent spec lists the plugin under [[plugins]], with grants naming the capabilities it may use
  • Host-target unit tests for your tool logic

Common mistakes

MistakeFix
The agent has no my-plugin__* toolsThe spec must list the plugin; the plugin must be in the module directory (Settings → Modules)
“rebuild it with the current SDK” at loadThe plugin was built against an older world; rebuild it against this repo’s SDK
Built for the host targetUse the .cargo/config.toml from the template, or pass --target wasm32-wasip2
.wasm name does not match [module].wasmThe manifest path is relative to module.toml
Calling llm::complete with a model id chatty does not havePass "" for the calling agent’s model
Expecting the plugin to be an agent (list_agents, /agent, A2A)A plugin is tools only; the agent is the spec that loads it
A tool name over the limit<plugin>__<tool> must be at most 64 characters of [a-zA-Z0-9_-]

Reference

TopicDoc
WIT types, host imports, the guest export, versioningWIT reference
Gateway routes, manifest fields, resource limits, module directory per platformPlugins
Crate stack diagramComponent map
Template sourcetemplates/module/
Reference pluginsmodules/echo/, modules/benford/
SDK rustdocchatty-module-sdk (cargo doc -p chatty-module-sdk --open)

Test

When to read this: You want to run the right part of the suite for the change you made, understand why CI runs tests the way it does, or add a test in the place the next reader will look.

Goal

Know which make target or cargo test invocation covers your change, and be able to update a characterization golden deliberately rather than by accident.

Prerequisites

  • Build and run: make setup done.
  • make wasm-modules has been run (scripts/build-wasm-fixtures.sh). It stages every fixture and the echo and benford plugins under target/wasm-fixtures/<name>/, which is git-ignored; the integration and conformance suites fail with a missing-file error without it.

How the suite is organised

WhereWhat it coversRun with
#[cfg(test)] mod tests beside the code, in every crateUnit tests. Most logic is in chatty-core, so most tests are toomake test-fast (cargo test -p chatty-core --lib), or cargo test -p <crate>
crates/chatty-core/tests/integration.rsThe public API from an external consumer’s point of view: settings model CRUD lifecycle, ConversationsStore operations and ordering, provider capability propagation into ModelConfig, Azure provider configuration filtering, JSON serialization round-trips, token budget snapshot calculations. No display servercargo test -p chatty-core --test integration
crates/chatty-core/tests/browser_live.rsEnd to end against a real Chrome, #[ignore]d because the first run may download the pinned buildcargo test -p chatty-core --features browser --test browser_live -- --ignored --test-threads=1
crates/chatty-gpui/tests/core_integration.rschatty-core types compiled with the gpui-globals feature: every impl Global still behaves, cross-crate interactions, token budget snapshots. Links GPUI but needs no live displaymake test-gpui
crates/chatty-protocol-gateway/tests/ (gateway_tests.rs, echo_plugin_e2e.rs)The gateway’s routes, and the echo plugin over MCP (a plugin has no OpenAI or A2A route)make test-gateway
crates/chatty-protocol-gateway/tests/conformance/Plugin evaluation plan S3, rows 3.5–3.14 (3.1–3.4, 3.8 and 3.9 went with chat, PL-U3): MCP against echo, benford and the fixtures, and A2A against a participant, including real clients (rmcp, the MCP Inspector via npx on Node ≥ 22.19, chatty-core’s A2aClient). A missing npx prints SKIP and passes; CI installs Node. Rows red today are #[ignore = "known defect: …"]cargo test -p chatty-protocol-gateway --test conformance; add -- --ignored for the red rows
gemini_compat_tests in crates/chatty-core/src/tools/mod.rsEvery tool schema converts to a Gemini Schema without empty type stringscargo test -p chatty-core --lib gemini_compat
crates/chatty-core/src/repositories/store_conformance.rsEvery RepositoryRegistry family (13, token_tracking the newest) plus ConversationRepository against one load/save/round-trip contract, run against the JSON/SQLite and in-memory backends; exported under the test-support feature so hive’s store runs the identical suite (AGE-280)cargo test -p chatty-core --lib conformance
scripts/team-smoke.shThe coder-reviewer test-fixture team end to end in a container against a local Ollama, scored by a verifier — the one check for a change to the broker, --team or the worker tree. Not in CIbash scripts/team-smoke.sh; see Team smoke test
Characterization goldens (three directories, below)The event sequence each frontend produces for a scripted streamsee below
crates/hive-billing-sdk/tests/Standalone SDK with its own Cargo.lock; not a workspace member. .cargo/config.toml defaults the crate to wasm32-wasip2, which has no test-harness support, so tests need an explicit host targetcd crates/hive-billing-sdk && cargo test --target x86_64-unknown-linux-gnu
modules/benford/src/lib.rs unit testsStandalone plugin crate, also outside the workspace and defaulted to wasm32-wasip2 for the same reasoncd modules/benford && cargo test --target x86_64-unknown-linux-gnu
crates/chatty-module-sdk/Standalone SDK crate consumed by every module; no tests of its own, so CI holds it to a clean wasm32-wasip2 clippy pass insteadcd crates/chatty-module-sdk && cargo clippy --target wasm32-wasip2 -- -D warnings
crates/chatty-wasm-runtime/tests/sandbox.rsPlugin evaluation plan S1, rows 1.1–1.14: fuel, wall-clock, memory, traps, file/config/billing imports, WASI surface, against real fixtures. Doubles as AGE-600’s trip-wire: its non-ignored rows (fuel exhaustion, wit-0.1/core-module load failures, trap-then-reuse, …) already run in cargo test --all-features, so a sandbox regression fails CI without a separate test. Rows red today are #[ignore = "known defect: …"]cargo test -p chatty-wasm-runtime --features test-support --test sandbox; add -- --ignored for the red rows

Steps

1. Inner loop

make test-fast                    # cargo test -p chatty-core --lib
cargo test -p chatty-core --lib my_module::   # one module

2. The crate you touched

make test-tui       # cargo test -p chatty-tui
make test-gpui      # cargo test -p chatty-gpui
make test-gateway   # cargo test -p chatty-protocol-gateway

3. Everything, the way CI runs it

make test           # cargo test --all-features

--all-features matters: the excel, docx, pdf, pptx, math-render, mermaid, duckdb and browser tool groups only compile with their feature on, so a plain cargo test never sees them. make ci adds formatting, clippy and the repository check scripts; see Make targets & CI workflows.

Note

Tests run in parallel. CI used to pass --test-threads=1 because chatty-core tests intermittently died with SIGTRAP. The cause was pdfium, which is not thread-safe, being used from several test threads at once; create_pdfium() now hands out a PdfiumHandle that serialises every use (AGE-176). If a SIGTRAP, SIGSEGV or SIGABRT comes back under cargo test, look for a new pdfium call that bypasses create_pdfium() rather than re-adding the flag.

4. Characterization goldens

Turn orchestration — which chunk produces which event, which chunks end a turn, that cancellation produces a cancelled outcome, that sub-agent progress is drained ahead of stream chunks — is pinned by golden files rather than by assertions scattered across tests.

The scripted streams live in chatty_core::services::stream_fixtures (scenarios(), clarification_scenario(), scripted_stream, assert_golden). The module is compiled under #[cfg(any(test, feature = "test-support"))]; chatty-tui and chatty-gpui enable chatty-core/test-support from their dev-dependencies only, so it never reaches a release build.

Both frontends and the core loop drive the same scenarios and record their own event type, so a divergence between the two UIs shows up as a difference between two golden directories:

Golden directoryRecorded byEvent type
crates/chatty-core/src/services/goldens/stream_loop/loop_callback_sequence_matches_goldens in stream_processor.rsStreamChunkHandler callbacks
crates/chatty-core/src/session/goldens/session/tests.rsSessionEvent
crates/chatty-tui/src/engine/goldens/engine/characterization.rsAppEvent
crates/chatty-gpui/src/chatty/controllers/app_controller/goldens/session_characterization.rsStreamManagerEvent

Each directory holds one file per scenario: text_only, tool_call_then_result, tool_error, approval_granted, approval_denied, clarification_requested, provider_error_mid_stream, cancelled_mid_stream, sub_agent_progress, token_usage_on_done.

When a golden fails, the assertion prints both sequences. If the change is deliberate, rewrite the files and explain the diff in the PR:

UPDATE_GOLDENS=1 cargo test -p chatty-core --lib loop_callback_sequence
UPDATE_GOLDENS=1 cargo test -p chatty-core session::
UPDATE_GOLDENS=1 cargo test -p chatty-tui characterization
UPDATE_GOLDENS=1 cargo test -p chatty-gpui characterization

Deliberately not pinned: the real-time interleaving of progress events with chunks (racy in production, so scenarios queue progress before the chunks it accompanies), and anything wall-clock based — the stall watchdog has its own tests in stream_processor.rs.

5. Where mocks live

  • crates/chatty-core/src/tools/test_helpers.rs — MockMcpRepository and MockA2aRepository, in-memory repositories with injectable load/save errors, #[cfg(test)] only. Used by the MCP and A2A tool tests.
  • Providers are not mocked; the scripted streams above stand in for a provider whenever a test needs “what the model sent”.
  • The desktop characterization test talks to real entities (a ChatView in a headless test window, a live StreamManager), not stubs, so what it records is what the UI receives.
  • browser_live.rs builds a throwaway workspace with tempfile rather than fixtures on disk.

Verify

  • make test-fast is green after a core change; the crate target is green after a frontend change.
  • make test (full, serialised) is green before you push.
  • A golden you changed on purpose has its diff explained in the PR.

Checklist

  • make wasm-modules has been run on this checkout
  • New logic in chatty-core has a unit test beside it
  • A new tool has a gemini_compat guard (see Your first change)
  • Turn-lifecycle changes re-recorded all three golden directories, not one
  • make test passes

Common mistakes

MistakeDo this instead
Deleting a golden file to make a test passUPDATE_GOLDENS=1 and explain the diff
Updating the TUI goldens but not the desktop ones (or the reverse)Re-run all three; the point is that they agree
cargo test without --all-features before pushingmake test
A signal (SIGTRAP/SIGSEGV/SIGABRT) under cargo testFind the pdfium call that bypasses create_pdfium(); do not add --test-threads=1
Running browser_live in CIIt is #[ignore]d on purpose; run it locally
Enabling test-support in a normal [dependencies] blockDev-dependency only

Debug

When to read this: Something is wrong at runtime — a stuck stream, missing or duplicated text, a layout glitch, a tool that “failed” with no message, a cache hit rate that never rises — and you need the switches that expose what happened.

Goal

Find the log line, overlay field or file that explains the symptom, using only what the binaries already emit.

Prerequisites

  • A debug build launched from a terminal (cargo run -p chatty-gpui / cargo run -p chatty-tui), so you can set environment variables and see stderr.
  • For rendering bugs: Rendering pipeline. For stream bugs: Stream lifecycle.

Steps

1. Turn on logs

Both binaries use tracing with an EnvFilter, so RUST_LOG narrows or widens what you see.

BinaryWhere logs goDefault level
chatty (desktop)stderr of the process. An ErrorCollectorLayer also copies every warn and error event into the in-app error log dialoginfo
chatty-tui (interactive)<data dir>/chatty/chatty-tui.log — ~/.local/share/chatty/ on Linux ($XDG_DATA_HOME if set), ~/Library/Application Support/chatty/ on macOS, %APPDATA%\chatty\ on Windows. The file is truncated on every startwarn
chatty-tui --headless / --pipeNowhere: logging is suppressed so stdout carries only the answer—

Useful filters:

RUST_LOG=debug cargo run -p chatty-tui                         # everything at debug
RUST_LOG="warn,chatty_core=debug" cargo run -p chatty-gpui     # core only
RUST_LOG="info,chatty_gpui::render=trace" cargo run -p chatty-gpui 2>&1 | tee /tmp/chatty-render.log

The desktop render path logs under the targets chatty_gpui::render::list, ::message, ::stream and ::handler; all four match chatty_gpui::render. Other environment variables the binaries read are in the environment variables reference.

2. The CHATTY_DEBUG_UI overlay

Set CHATTY_DEBUG_UI=1 (any value other than empty or 0) before launching the desktop app to get a monospace overlay in the top-right of the chat pane, one row per message. The flag is read once at process start and costs nothing when unset.

ChatView debug
  msgs: 4 visible / 5 total   awaiting: false   skeleton: false   filtered: 1
  [0] User       s=0 m=0 ti=0 c=23  trace=none
  [1] Assistant  s=1 m=1 ti=2 c=148  trace=live
FieldMeaning
visible / total / filteredMessages the list renders, all messages, and how many the empty-streaming-message filter hides
awaiting, skeletonis_awaiting_response() — waiting for the first token, thinking indicator shown
sis_streaming
mis_markdown
tiNumber of items in live_trace (streaming) or the SystemTraceView (finalized)
ccontent.len()
tracelive while streaming, open / empty once a SystemTraceView exists, none otherwise

awaiting: true together with s=1 c=0 on the last assistant row is the expected “waiting for the first token” state; the layout jump when the first token replaces the thinking indicator is what usually gets reported as “whitespace”.

3. Trace a rendering bug

Capture a log with the render targets at trace (the tee command in step 1), reproduce, then grep:

grep -E "render_message_list|render_message |interleave_segment|append_assistant_text|finalize_assistant_message" /tmp/chatty-render.log
grep '"conversation_id":"<id>"' /tmp/chatty-render.log     # one conversation

Events in the order they fire during a streaming turn:

EventFired fromUseful fields
start_assistant_messagechat_view/mod.rstotal_messages
append_assistant_textchat_view/mod.rsdelta_len, content_len_before, new_content_len, message_idx; a warn variant append_assistant_text dropped means no streaming parent existed
tool_call_startedchat_view/handlers.rstool_id, tool_name, text_before_len, had_trace_view, live_trace_items
render_message_listchat_view/mod.rstotal, visible, is_awaiting, thinking_visible
render_messagemessage_component.rsindex, role, is_streaming, is_markdown, should_interleave, content_len, trace_items, attachments
interleave_segmentmessage_component.rs (render_interleaved_content)one per tool call: tool_idx, tool_name, text_before_len, last_text_end, will_render_segment; then a final kind=remaining with remaining_after_tools_len
finalize_assistant_messagechat_view/mod.rshad_live_trace, cleared_streaming_cache, content_len

What to look for:

  • Whitespace during streaming. A render_message_list transition from visible=1 … is_awaiting=true thinking_visible=true to visible=2 … is_awaiting=false; the frame between them is the jump. With tools involved, render_message showing content_len=0 trace_items>0 followed by a large content_len is the reflow from “trace only” to “text + trace”.
  • Overlapping or duplicated text. Across the interleave_segment events of one message, text_before_len must be non-decreasing and each last_text_end must equal the previous text_before_len. If not, two segments render overlapping byte ranges.

Use the existing target convention when adding events (target: "chatty_gpui::render::<area>"), structured fields rather than formatted strings, trace! for per-render events, debug! for one-or-two-per-turn transitions, warn! only for states that should not happen.

4. Stuck or stalled streams

Every stream goes through StreamManager; the loop itself is run_stream_loop in stream_processor.rs and is shared with the TUI, so a lifecycle bug is a core bug.

  • Cancellation is a flag (Arc<AtomicBool>) checked at the top of every iteration, not a task drop. A “Stop” that does nothing means the flag never reached the loop, or a stale StreamEnded from a superseded stream (each registration carries an epoch; older epochs are ignored).
  • The stall watchdog wakes every STALL_TICK (5 s). After STALL_TIMEOUT (180 s) with no chunk and no sub-agent progress it logs Stream produced nothing for too long; ending the turn as stalled at warn and ends the turn with the error text The model stopped responding (no output for 3 minutes)… (the duration is the timeout that fired: while a tool call runs it is TOOL_STALL_TIMEOUT, 13 minutes). A long tool call is silence from the stream’s point of view, which is why the timeout is generous. --headless resumes a stalled turn itself — a short “continue” prompt on the same history, at most HEADLESS_STALL_RESUME_ATTEMPTS (2) times per task, logged as Auto-resuming the stalled turn (n/2). on stderr; the desktop and interactive TUI leave that to the human.
  • The expected event order for each scenario is pinned by the characterization goldens; diff a failing run against them (Test).

5. A tool “failed” with no message

If the transcript or the model sees the literal text the tool failed, the tool’s map_error bypassed map_tool_error. Every impl Tool must route errors through crate::tools::map_tool_error(Self::NAME, error), which writes Error: {tool_name}: {message}. That prefix is also the only failure signal the stream carries — llm_service::tool_result_looks_like_error sniffs it — so a tool that drops it is filed as a success and its error prose lands in output. See Your first change.

6. Prompt caching that does not hit

Each provider request logs one line:

LLM completion call usage turn=2 input=412 cache_read=18220 cache_write=0 output=96 hit_rate=98

input is the uncached share of the prompt, so input + cache_read + cache_write is the whole prompt regardless of provider convention. A hit_rate that stays low from the second request onwards means the prompt prefix is changing between requests — the preamble, the tool block, or the message history is not byte-stable. Never sum calls first and divide later; the rate is per request. Background in Token budget and the token-usage section of Contributing patterns.

7. Browser tool output

browser_console and browser_network return a short summary plus a dump_path. The full capture is written to <workspace>/.chatty/browser/console-<uuid>.log or network-<uuid>.log only when there is something to write; grep the dump when the summary says entries were elided. When a browser tool call appears in the transcript, the desktop docks a live screencast panel next to it, so you can watch the agent drive the page.

8. Reading a system_trace

Assistant messages persist a system_trace JSON column alongside the text in <data dir>/chatty/conversations.db (SQLite; same data directory as the TUI log above). Its shape is SystemTrace from models/message_types.rs:

{
  "items": [
    { "Thinking":  { "content": "…", "summary": "…", "duration": null, "state": "Completed" } },
    { "ToolCall":  { "tool_name": "read_file", "…": "…" } },
    { "ApprovalPrompt": { "…": "…" } },
    { "ClarificationPrompt": { "…": "…" } }
  ],
  "total_duration": null,
  "active_tool_index": null
}

The transcript renders these items; the ATIF and JSONL exporters read them. The tool-call and tool-result messages rig recorded for the turn are persisted as separate rows in front of the final text entry (they carry no trace), so when you count or export turns skip them with services::is_tool_message and count exchanges with services::exchange_count.

Verify

You can name the log line, overlay row or file that shows the bug, and the fix makes that evidence change.

Checklist

  • Reproduced with RUST_LOG narrowed to the relevant target
  • Rendering bugs: overlay and render trace captured for the same run
  • Stream bugs: checked the cancel flag, the epoch and the watchdog line before touching the loop
  • Tool failures: confirmed the Error: <tool>: prefix is present

Common issues

SymptomCheck
Stream never endsStreamManager status and cancel flag; watchdog warn after 180 s of silence
Text duplicatedConversation model vs ChatView state; interleave_segment accounting
Tool UI stuckThe ToolCallStarted and tool-result StreamManagerEvents reached the view for the active conversation
Whitespace or overlaprender_message_list transitions; markdown cache cleared on finalize
“the tool failed”Tool bypassed map_tool_error
Cache hit rate lowPrompt prefix changes between requests
No TUI log fileYou are in --headless or --pipe mode; logging is off there

Build & package

When to read this: Produce release artifacts or run CI locally.

Goal

The same compile, test and lint path GitHub runs, executed on your machine; and, when you need them, the platform packages the release workflow builds.

Prerequisites

Steps

Local CI

make setup        # Linux deps + wasm32-wasip2 (once)
make wasm-modules # WASM plugins and fixtures for tests
make ci           # matches the Rust path of GitHub Actions

make ci runs cargo test --all-features, cargo fmt --check, cargo clippy --all-features -- -D warnings, and the reserved-symbol and rig-pin scripts. GitHub skips that compile path when a PR only changes docs; use make docs and make docs-check for documentation-only work. Every target is listed in Make targets & CI workflows; which tests to run for a smaller change is in Test.

Platform packages

PlatformScript
macOS./scripts/package-macos.sh (.app bundle and .dmg)
Linux./scripts/package-linux.sh (.tar.gz)

Release builds write a separate target/release/ tree; only build one locally when you are packaging.

Cutting a release

Version bumps, changelog generation, tagging and the cross-platform release build are driven by labels on the PR and the Prepare Release workflow. See the Release process.

Docs site

make docs-gen
make docs
make docs-serve

Verify

make ci exits 0; a package script leaves its artifact in the location it prints.

Common mistakes

MistakeFix
Running cargo test without --all-features and calling it CIFeature-gated tools never compiled; use make test
Adding a release label after mergingLabels are read from the merged PR; see Release process

Release Process

When to read this: You are about to cut a Chatty release, or a release run did not produce the artifacts you expected.

Closed-system security contract

Only github.repository_owner and github-actions[bot] may arm releases. Forks and outside collaborators cannot:

  • Dispatch release.yml / prepare-release.yml manually
  • Apply privileged labels (ship:auto, release:patch|minor|major, cut-release)
  • Enable ship-auto auto-merge
  • Trigger prepare-release from a fork PR head

Linear projects Chatty auto-ship and Chatty tech debt membership is Marcel (and agents he runs). Weekly dependency-check filings go to Linear only.

Emergency rebuild (owner only), when a published release event did not fire. Dispatch against the tag ref (never from main with only an input):

gh workflow run release.yml --ref vX.Y.Z -f tag_name=vX.Y.Z

release.yml verifies the checked-out commit matches refs/tags/vX.Y.Z, asserts that commit is an ancestor of origin/main, and matches Cargo.toml before building.

Authz gate regression check: bash scripts/check-release-authz.sh (also run in CI).

Preferred paths (use these)

A. PR merge with a release label (human path)

  1. Open a PR to main with exactly one of release:patch, release:minor, release:major (owner-applied; unauthorized labelers are stripped by privileged-labels.yml).
  2. Merge when CI is green. ship-auto-guard enforces the path deny-list on any release-labeled PR.
  3. prepare-release.yml bumps Cargo.toml on a release/vX.Y.Z PR (cut-release, not release:patch), merges that PR (main is protected — a direct push gets GH006), generates the changelog, tags vX.Y.Z, creates the GitHub Release with that changelog as its body, and calls release.yml via workflow_call to build artifacts. Runs are serialised by a prepare-release concurrency group so two merges cannot race on the version bump or the tag.

On a PR branch, /create-release patch (or minor/major) only adds the label — it does not create a tag by hand.

B. Auto-ship (zero-human patch releases)

For low-risk work filed in Linear project Chatty auto-ship or Chatty tech debt (with ship:auto + owner:ai, not owner:human):

  1. Agent opens PR: branch auto/*, title auto: …, labels ship:auto + release:patch (owner/Actions only).
  2. ship-auto-guard.yml enforces patch-only + path deny-list; ship-auto-merge.yml enables squash auto-merge only for same-repo heads when the sender is the owner or the actor is github-actions[bot]. Later synchronize events from other same-repo actors do not re-arm; if auto-merge is already on, the prepare-release waiter still starts.
  3. When required checks are green, auto-merge squash-merges to main. The ruleset requires the branch to be current with main; update-next-queued-pr.yml presses “update branch” for the oldest auto-merge-armed PR whenever main moves (and every 30 min as a fallback), so PRs land one at a time without a human. It needs the AUTO_UPDATE_TOKEN secret: a fine-grained PAT scoped to this repo with contents + pull-requests write. GITHUB_TOKEN cannot be used because its branch updates do not trigger CI. A PR in that queue is also in care of pr-nurse.yml (hourly, after any failed CI run, or on dispatch): scripts/pr-nurse-triage.sh picks one PR that is conflicted with main or has a red required check; a Sonnet session resolves the conflict or fixes the check without committing; the workflow validates the tree (no conflict markers, none of .github/workflows/, RESERVED.md, scripts/check-*.sh, .cursor/rules/ touched) and pushes a nurse: commit with the same token. After two nurse commits on one PR, scripts/pr-nurse-decide.sh (AGE-705) decides what happens next from CI on that second push: still pending, it waits; green, it clears the attempt budget and leaves auto-merge armed; red, it disarms auto-merge and labels blocked:human. Removing blocked:human by hand also resets the budget, so the next run does not immediately re-label it. Arming auto-merge is the only way into care; the nurse never merges here.
  4. If Actions performed the squash (GITHUB_TOKEN does not emit pull_request.closed), ship-auto-merge dispatches prepare-release (bump=patch). Owner merges still use the pull_request closed path.
  5. Same prepare-release → bump PR → tag → release.yml pipeline as (A).

Never hand-tag for auto-ship.

Deprecated for routine releases: GitHub UI release

release.yml has three triggers: the workflow_call from Prepare Release, an owner-only workflow_dispatch (the emergency rebuild above), and the published event of a GitHub Release. There is no tag-push trigger: pushing a vX.Y.Z tag by hand builds nothing.

Publishing a release from the GitHub UI still works and is the way to ship custom release notes, but hand-editing Cargo.toml is easy to desync from the tag. Prefer (A) or (B).

  1. Update version in Cargo.toml

    vim Cargo.toml  # Set version = "0.1.21"
    git add Cargo.toml
    git commit -m "Bump version to 0.1.21"
    git push origin main
    
  2. Create release via GitHub UI:

    • Go to: https://github.com/boersmamarcel/chatty2/releases/new
    • Choose tag: v0.1.21 (or create new tag)
    • Target: main branch
    • Release title: v0.1.21 or custom title
    • Description: Write custom release notes
    • Important: Click “Publish release” (NOT “Save as draft”) — only the published event triggers the workflow
  3. Workflow runs automatically:

    • Validates the tag against Cargo.toml and origin/main
    • Builds for all platforms
    • Uploads artifacts to your release, preserving your release notes

What Gets Built

Each release includes:

  • Linux: chatty-linux-x86_64.AppImage
  • macOS: chatty-macos-aarch64.dmg (ARM, code-signed if secrets configured)
  • Windows: chatty-windows-x86_64.exe (Inno Setup installer)
  • Checksums: checksums.txt (SHA-256 for all files)
  • Release signature: checksums.txt.sig, an Ed25519 (minisign) signature over checksums.txt, once RELEASE_SIGNING_ENABLED is on — see release-signing.md

The desktop app’s auto-updater (crates/chatty-gpui/src/auto_updater/) polls the GitHub releases API for these assets, so a published release reaches existing installs without any further step. It verifies checksums.txt.sig against the release keys compiled into it before it trusts a hash, and on macOS checks the new bundle’s code signature and Team ID before the swap.

Version Validation

The workflow validates that:

  • Tag version (e.g., v0.1.21 → 0.1.21) matches Cargo.toml version
  • The tag commit is an ancestor of origin/main
  • If either check fails, the workflow fails with an error

Fix version mismatches:

# If you accidentally created tag v0.1.21 but Cargo.toml has 0.1.20:

# Option 1: Update Cargo.toml and re-tag
vim Cargo.toml  # Change to 0.1.21
git add Cargo.toml
git commit -m "Fix version mismatch"
git push origin main
git tag -d v0.1.21           # Delete local tag
git push origin :v0.1.21     # Delete remote tag
git tag v0.1.21              # Recreate tag
git push origin v0.1.21

# Option 2: Use correct version tag
git tag v0.1.20              # Tag matching Cargo.toml
git push origin v0.1.20

Code Signing (macOS)

See scripts/setup-codesigning.md for details.

Quick summary:

  • Without secrets: Ad-hoc signed (works locally, Gatekeeper warnings)
  • With secrets: Developer ID signed (no warnings, notarization optional)

Required secrets (optional):

  • MACOS_CERTIFICATE - Base64 P12 certificate
  • MACOS_CERTIFICATE_PASSWORD - P12 password
  • KEYCHAIN_PASSWORD - Random keychain password
  • MACOS_SIGNING_IDENTITY - Certificate name

Troubleshooting

Workflow fails at validation

Error: “Version mismatch! Tag version (X.Y.Z) does not match Cargo.toml version (A.B.C)” or “Tag … is not an ancestor of origin/main”

Fix: See “Version Validation” section above

Assets not uploaded

Common causes:

  1. Build job failed (check Linux/macOS/Windows build logs)
  2. Artifact upload failed (check artifact names match)
  3. fail_on_unmatched_files: true - files missing
  4. Prepare Release created the GitHub Release but release.yml did not build. Reusable workflows inherit the caller’s github.event_name, so the reusable-call path is marked with an explicit from_prepare: true input rather than keyed off the event name.

Rebuild an existing tag (owner only; runs the workflow from that tag):

gh workflow run release.yml --ref vX.Y.Z -f tag_name=vX.Y.Z

Debug:

  • Check the “Prepare release assets” step for warnings and the file list
  • Ensure build scripts produced expected files

macOS code signing fails

Error: “No identity found” or certificate import fails

Check:

  • Secrets are correctly set (base64 encoding, no extra newlines)
  • Certificate hasn’t expired
  • Keychain password is correct

Fallback: Remove/comment out the “Import code signing certificate” step to use ad-hoc signing

Monitoring Releases

  • Actions: https://github.com/boersmamarcel/chatty2/actions/workflows/release.yml
  • Releases: https://github.com/boersmamarcel/chatty2/releases

Best Practices

  1. Let Prepare Release bump the version — hand-edit Cargo.toml only for a UI release
  2. Use semantic versioning: MAJOR.MINOR.PATCH (e.g., 0.1.21, 1.0.0)
  3. Tag format: Use v prefix (v0.1.21) for consistency
  4. Release notes: Use the GitHub UI path for releases that need hand-written notes
  5. Test locally first: Run cargo build --release and ./scripts/package-macos.sh before releasing
  6. Check Actions: Monitor the workflow to ensure all platforms build successfully

Not yet automated

  • macOS Intel builds (only aarch64 is built)
  • Windows code signing (the installer is unsigned)

audience: [contributor] source_files:

  • crates/chatty-gpui/src/auto_updater/signature.rs
  • crates/chatty-gpui/src/auto_updater/release_keys.txt
  • crates/chatty-gpui/src/auto_updater/platform.rs
  • .github/workflows/release.yml related:
  • RELEASE_PROCESS.md

Release signing and update authenticity

When to read this: you are setting up or rotating the release key, a release failed at sign-checksums, or a user’s update was refused.

Security note (SEC-10, AGE-817)

checksums.txt proves a download is intact, but it comes from the same GitHub release as the binary. Anyone who can write the release (a leaked token, a compromised workflow) can replace both. Two checks close that gap:

  • Signed checksums, every platform. release.yml signs checksums.txt with an Ed25519 release key in minisign format and uploads checksums.txt.sig. The updater downloads both and verifies the signature against the public keys compiled into it (auto_updater/release_keys.txt) before it trusts any hash. A missing, malformed or foreign signature refuses the update; there is no unsigned fallback.
  • macOS Team ID before the swap. After the DMG is mounted and before anything is copied out of it, the install helper requires: the DMG passes Gatekeeper as Notarized Developer ID (its stapled ticket), the app inside passes codesign --verify --deep --strict, and its TeamIdentifier equals the Team ID compiled into the running app (CHATTY_MACOS_TEAM_ID, set by release.yml from the NOTARIZE_TEAM_ID secret). On refusal the helper leaves the installed app untouched and relaunches it; the reason is in ~/Library/Logs/chatty_update.log.

Not covered: Windows has no code-signing certificate and releases carry no build provenance (decided in AGE-736). An attacker holding the release private key can still ship a signed update; the key therefore lives only in a protected environment that a single job can read.

The one unpinned state

While release_keys.txt lists no key, the updater skips the signature check and logs No release signing key is pinned in this build. That is the state before the runbook below has run. A build without a compiled Team ID (local builds, or releases with MACOS_SIGNING_ENABLED off) skips the Team-ID check the same way: its own releases carry no Team ID.

Where things live

ThingWhere
Private keyRELEASE_SIGNING_KEY secret (base64 of the minisign secret-key file) in the release-signing GitHub environment; offline copy in your password manager
Public keyscrates/chatty-gpui/src/auto_updater/release_keys.txt, one RW... line per key
Switchrepo variable RELEASE_SIGNING_ENABLED (true = sign every release)
Signing jobsign-checksums in .github/workflows/release.yml
Key inventoryvault fabric-security §7

release.yml enforces the pairing both ways: with the switch on, a missing secret or a signature that no pinned key accepts fails the release; with the switch off, a pinned key fails the release. Either way no unsigned release reaches pinned clients.

Runbook: first key

Run on your own machine with minisign installed (brew install minisign or apt install minisign). None of these commands prints the private key.

  1. Generate the key in a private scratch directory, without a password (the CI job cannot type one; the protected environment is the access control):

    umask 077 && mkdir -p ~/chatty-release-key && cd ~/chatty-release-key
    minisign -G -W -p release.pub -s release.key
    
  2. Create the environment and set the secret. Restrict the environment to the refs releases run from (main for prepare-release.yml, v* tags for the published-release and dispatch paths). Do not add required reviewers: releases are cut automatically and would wait forever.

    gh api -X PUT repos/boersmamarcel/chatty2/environments/release-signing \
      -F 'deployment_branch_policy[protected_branches]=false' \
      -F 'deployment_branch_policy[custom_branch_policies]=true'
    gh api -X POST repos/boersmamarcel/chatty2/environments/release-signing/deployment-branch-policies \
      -f name=main -f type=branch
    gh api -X POST repos/boersmamarcel/chatty2/environments/release-signing/deployment-branch-policies \
      -f name='v*' -f type=tag
    base64 < release.key | tr -d '\n' \
      | gh secret set RELEASE_SIGNING_KEY --env release-signing --repo boersmamarcel/chatty2
    
  3. Flip the switch. From now on every release carries checksums.txt.sig (current installs ignore it; the signing job warns that no key is pinned yet).

    gh variable set RELEASE_SIGNING_ENABLED --body true --repo boersmamarcel/chatty2
    
  4. Pin the public key. Add the second line of release.pub (the one starting with RW) to release_keys.txt in a PR. Merge it after step 3, never before: a build that pins a key must never be followed by an unsigned release.

  5. Release. The signing job now also checks the signature against the pinned key. Installs that update to this release enforce the signature from then on.

  6. Store and clean up. Put release.key and release.pub in your password manager, then rm -P (macOS) or shred -u (Linux) both files. Ask the coordinator to add the key ID (minisign -V prints it) to the vault key inventory.

Rotation (two-key window, SEC-3)

  1. Generate the new key (step 1) and add its public line to release_keys.txt next to the old one. Release: installs now trust both.
  2. Replace the RELEASE_SIGNING_KEY secret with the new key (step 2, last command). Release: signed by the new key, accepted by every install that took step 1’s release.
  3. Remove the old line from release_keys.txt. Release.

On a suspected private-key leak, skip the window: pin only the new key, set the new secret, release at once. Installs still on old builds trust the leaked key until they update, so revoke the leaked secret immediately.

When a release fails at signing

  • RELEASE_SIGNING_ENABLED is true but the RELEASE_SIGNING_KEY secret is not set: step 2 did not run, or the secret was set at repo level instead of in the release-signing environment.
  • does not verify against any key in release_keys.txt: the secret and the pinned public key are from different pairs. Fix whichever is wrong; never remove the pin to get a release out.
  • release_keys.txt pins a release key but RELEASE_SIGNING_ENABLED is off: turn the switch back on.

Contribute to the docs

When to read this: You are adding or changing a page on this site and want to know which file to edit, how to preview it, and what CI checks before it merges.

Goal

A change to the right source file, previewed locally, passing make docs-check, landing in the same PR as the code it describes.

Prerequisites

  • mdBook (cargo install mdbook) for make docs / make docs-serve.
  • lychee (cargo install lychee --locked) and python3 for make docs-check-links and the other check scripts.

Steps

1. Find the file to edit

Most of the site is synced from the repository by make docs-sync. Editing the copy under docs-site/src/dev/ is lost on the next sync.

Built pageEdit instead
src/dev/architecture/*.mddocs/*.md
src/dev/adrs/*.mddocs/research/*.md
src/dev/research/modules/*.mddocs/research/modules/*.md
src/dev/agents.mdAGENTS.md
src/dev/reference/*.mdThe tables in scripts/gen-docs-reference.sh, then make docs-gen
src/dev/crates/*.mdcrates/*/README.md

Hand-written pages, edited in place under docs-site/src/: index.md, user/*, dev/start/*, dev/guides/*, dev/where-to-look.md, dev/crates.md, dev/contributing-patterns.md, dev/glossary.md, dev/ci-reference.md, dev/doc-frontmatter.md. CLAUDE.md and docs/INDEX.md are not synced.

2. Pick the page type

  • Tutorial (dev/start/): learn by building; ends with something running.
  • How-to (dev/guides/): one task, done. Use the template below.
  • Explanation (docs/*.md, synced to dev/architecture/): why it is built this way.
  • Reference (dev/reference/, dev/crates/, dev/ci-reference.md, dev/glossary.md): lookup tables.

Working notes — point-in-time plans, audits, bug lists — go to Linear, never into the repo. Experiment write-ups go to the private harbor-chatty repo.

3. Write to the template

Every page starts with the title and a one-line reader test:

# Add a widget

**When to read this:** You need a new panel in the desktop app.

How-to pages then follow goal · prerequisites · steps · verify · checklist · common mistakes, skipping a section only when it would be empty. Rules that keep the site coherent:

  • Links inside docs-site/src are relative to the page’s own directory (../architecture/stream-manager.md from a guide). Link Rust source files with full GitHub URLs (https://github.com/boersmamarcel/chatty2/blob/main/...), never with relative paths, since the page moves when it is published.
  • User guides (src/user/*) must not carry contributor material: no ticket ids, crate paths, .rs file names or GPUI internals. make docs-check-leakage fails otherwise.
  • Do not add docs.rs badges: no workspace crate is published, and the links 404.
  • Prefer describing the behaviour over citing the ticket that introduced it.

Callouts use GitHub’s alert syntax and render as boxes on the site:

> [!NOTE]
> Background the reader can skip.

> [!TIP]
> A shortcut.

> [!WARNING]
> Something that costs time or data if missed.

4. Optional frontmatter

A page may begin with a YAML block declaring audience, source_files and related. The schema and the lint are in Doc frontmatter schema.

5. Register a new page

A new docs/*.md needs a row in docs/INDEX.md and an entry in docs-site/src/SUMMARY.md; a new hand-written page needs the SUMMARY.md entry. make docs-check-nav fails when either is missing. A new tool, event, setting or env var needs a row in scripts/gen-docs-reference.sh; make docs-check-reference diffs those tables against the source.

6. Preview and check

make docs-serve        # docs-gen + docs-sync + mdbook serve, opens http://localhost:3000
make docs-check        # everything below
TargetChecks
make docs-check-linksBuilds the site, then link-checks the sources (lychee) and the relative links on the built site, where docs-sync has moved pages into new directories
make docs-check-navdocs/INDEX.md and SUMMARY.md list every page
make docs-check-frontmatterFrontmatter matches the schema
make docs-check-leakageUser guides carry no contributor material
make docs-check-referenceReference tables match tool_registry.rs, StreamManagerEvent, every EventEmitter enum, and the settings structs

CI runs the same scripts from .github/workflows/docs.yml. Docs-only PRs do not compile the workspace.

7. Ship it in the same PR

Same-PR rule. If a change alters a fact a page claims, update that page in the same PR. A page is stale when someone following it would do the wrong thing, look in the wrong place, or miss a step; internal refactors that keep documented facts true, formatting, comments and dependency bumps need no doc change. The follow-up workflows (update-agent-docs.yml, update-readme.yml) are a safety net, not a reason to skip this. Found a stale page you are not fixing? File a Doc drift issue. The full policy is in CONTRIBUTING.md.

Verify

make docs-check exits 0 and the page reads correctly at http://localhost:3000, in both light and dark theme.

Checklist

  • Edited the source file, not the synced copy
  • # Title and **When to read this:** line present
  • How-to sections in template order
  • Relative links within the site; GitHub URLs for source files
  • New page listed in SUMMARY.md (and docs/INDEX.md for docs/*.md)
  • make docs-check green
  • Docs change is in the PR that changed the behaviour

Common mistakes

MistakeDo this instead
Editing docs-site/src/dev/architecture/foo.mdEdit docs/foo.md; the sync overwrites the copy
Editing a reference/*.md table by handEdit scripts/gen-docs-reference.sh, run make docs-gen
A link that works in the repo but 404s on the siteRun make docs-check-links; it checks the built site
A plan or audit added under docs/Linear
Ticket ids or crate paths in src/user/Move the detail to a dev page; docs-check-leakage blocks it
New page without a SUMMARY.md entrymdBook does not render it; docs-check-nav fails

AGENTS.md

Research crates carry reserved symbols; read RESERVED.md before touching them. It also holds the issue-label and auto-ship rules. Ordinary chatty2 work is unaffected.

Quick-start guide for AI coding agents working in this repository. Optimized for limited context windows: read this first, then dive deeper via the links below.

For human-oriented documentation, see the user guides (published at https://boersmamarcel.github.io/chatty2/). The repo README.md is a short landing page (download, links, dev quick-start). For coding patterns see CLAUDE.md. For architecture, see docs/INDEX.md.


What is this repo?

Chatty — a desktop and terminal AI agent built in Rust. Two binaries (GPUI desktop app, Ratatui terminal app) backed by a shared, UI-agnostic core crate.

Workspace map

crates/
├── chatty-core/              # UI-agnostic: models, services, tools, settings,
│                             #   repositories, factories, exporters
├── chatty-gpui/              # Desktop binary `chatty` (GPUI)
├── chatty-tui/               # Terminal binary `chatty-tui` (Ratatui)
│                             #   also: --headless, --pipe, and acp (stdio) modes
├── chatty-terminal/          # Embedded terminal model: PTY + alacritty Term (no UI)
├── chatty-wasm-runtime/      # Wasmtime runtime for WASM agent modules
├── chatty-module-registry/   # Module discovery, manifest, lifecycle
├── chatty-protocol-gateway/  # HTTP gateway: OpenAI / MCP / A2A protocols
├── chatty-fabric/            # Broker's pure state: Directory, TaskTable, EdgeLog, Transport trait,
│                             #   EndpointBudget/RunPermit, swarm.rs (nested-run event forwarding)
├── chatty-module-sdk/        # SDK for `wasm32-wasip2` modules (standalone)
├── chatty-trace/             # Research: traces / ATIF / FeedbackFn (AGE-5, ships)
├── chatty-playbook/          # Research: ACE playbook (AGE-17, ships)
├── chatty-flow/              # Research: AFlow WorkflowRepr + IR (AGE-13, ships)
├── chatty-optimize/          # Research: GEPA/AFlow + paired stats + QA loaders
├── hive-client/              # Hive registry client
├── hive-e2e/                 # Nightly chatty × Hive contract/trust suite (ignored, run by plugin-e2e.yml)
└── hive-billing-sdk/         # Billing SDK (separate Cargo.lock)

modules/                      # Reference WASM agent modules (echo, benford)
docs/                         # Deep-dive architecture and design docs
scripts/                      # Packaging + setup scripts

Dependency direction: chatty-gpui and chatty-tui depend on chatty-core. chatty-core never depends on a UI crate. GPUI’s Global trait impls live behind the gpui-globals feature.

Where things live (cheat sheet)

You want to…Look here
Add an LLM providercrates/chatty-core/src/factories/agent_factory/ + ProviderType
Add an LLM toolcrates/chatty-core/src/tools/, register in agent_factory.rs
Add a serviceUI-agnostic → crates/chatty-core/src/services/. UI-specific → crates/chatty-gpui/src/chatty/services/
Add a settingmodel in chatty-core/src/settings/models/, repo in chatty-core/src/settings/repositories/, UI in chatty-gpui/src/settings/views/
Add a desktop viewcrates/chatty-gpui/src/chatty/views/ — emit events to ChattyApp
Add a terminal viewcrates/chatty-tui/src/ui/
Add a workspace-global singletonDefine in chatty-core, add impl Global in chatty-core/src/gpui_globals.rs
Find all process-wide staticsSingleton inventory at top of crates/chatty-core/src/lib.rs
Persist dataJSON repo in chatty-core/src/settings/repositories/, or SQLite in chatty-core/src/repositories/
Add a slash commandcrates/chatty-gpui/src/chatty/controllers/app_controller/slash_commands.rs

Build / test / lint — single source of truth

The CI workflow (.github/workflows/ci.yml) is the ground truth. If a command is not here, it is not what CI runs.

Docs-only PRs (and other diffs that do not match the Rust path filter in ci.yml) skip compile, test, and clippy. The required test check still goes green. Stale CI runs on the same PR are cancelled on the next push.

make setup            # one-time: install Linux deps + wasm32-wasip2 target
make build            # cargo build (debug)
make test             # cargo test --all-features -- --test-threads=1  (matches CI)
make test-fast        # cargo test -p chatty-core --lib  (quick inner loop)
make test-tui         # cargo test -p chatty-tui          (TUI changes only)
make test-gpui        # cargo test -p chatty-gpui         (GPUI changes only)
make test-gateway     # cargo test -p chatty-protocol-gateway  (gateway changes only)
make lint             # cargo clippy --all-features --all-targets -- -D warnings  (matches CI)
make fmt              # cargo fmt
make fmt-check        # cargo fmt --check
make wasm-modules     # build every WASM module and test fixture (needed by tests)
make wasm-template    # cargo-generate a module from templates/module and build it (needs cargo-generate)
make test-benford     # the benford plugin's own unit tests, on the host target
make lint-module-sdk  # clippy chatty-module-sdk for wasm32-wasip2
make test-billing-sdk # hive-billing-sdk's tests, on the host target
make docs-gen         # regenerate docs/generated reference pages
make docs             # sync + build mdBook site (docs-site/book/)
make docs-serve       # local preview at http://localhost:3000
make docs-check-links # lychee link check, sources + built site (AGE-117)
make docs-check-nav   # INDEX.md + SUMMARY.md drift check (AGE-116)
make docs-check-frontmatter  # optional YAML frontmatter schema (AGE-115)
make docs-check-leakage      # user guides must not carry contributor material
make docs-check-reference    # reference tables match tool_registry.rs and friends
make docs-check       # all of the docs checks above
make animations       # re-record README/docs GIFs (scripts/animations/README.md)
make ci               # everything the Rust CI path runs, locally, in order

Or use cargo directly:

cargo build
cargo test --all-features
cargo fmt --check
cargo clippy --all-features --all-targets -- -D warnings

Test-thread footgun (pdfium fixed, AGE-176; CI/make test still serialize, AGE-600)

chatty-core tests used to intermittently die with SIGTRAP because pdfium, which is not thread-safe, was driven from several test threads at once (concurrent FPDF_InitLibrary hits a Chromium CHECK = int3 = SIGTRAP; concurrent rendering corrupts its heap). create_pdfium() now returns a PdfiumHandle that holds a process-wide lock for as long as the caller uses pdfium (crates/chatty-core/src/services/pdfium_utils.rs), so never take a second handle while holding one on the same thread — that SIGTRAP is fixed. Despite that fix, ci.yml’s “Run tests” step still passes --test-threads=1 (GitHub-hosted runners have shown other, unrelated intermittent SIGTRAPs under parallel cargo test), and make test was brought back in line with it for the same reason (AGE-600) — don’t remove --test-threads=1 from either as if it were a leftover pdfium workaround. If a SIGTRAP, SIGSEGV or SIGABRT comes back, look for a new pdfium call that bypasses create_pdfium() first.

Windows footgun

ci.yml never compiles for Windows on a PR — windows-latest only runs in warm-release-cache, which is gated if: push && ref == main (a cache warmer, not a required check). A Windows-only compile error (e.g. code that should be #[cfg(unix)], like the broker/participant modules, AGE-339) rides straight to main and isn’t caught until build-windows fails during a release, shipping an assetless tag. If you touch platform-conditional code, don’t trust a green PR as proof Windows still builds.

Disk footgun

A full cargo test --all-features needs about 16 GiB of target/ even with the workspace’s trimmed dependency debuginfo ([profile.dev.package.*] in the root Cargo.toml); at Cargo’s defaults it needs 28+ GiB and can run a small disk out of space mid-link. See docs/build-disk-usage.md before building on a constrained sandbox or CI runner.

WASM module prebuild

Tests that load real plugins (echo_plugin_e2e, the sandbox, registry and conformance suites, plugins_headless) read them from target/wasm-fixtures/, which is build output. Build them (once per checkout) before running the full test suite; without them those tests fail at once with the path they looked at and this command:

make wasm-modules   # scripts/build-wasm-fixtures.sh; needs the wasm32-wasip2 target

Running

cargo run -p chatty-gpui              # desktop app
cargo run -p chatty-tui               # terminal app
cargo run -p chatty-tui -- --help     # CLI options (headless / pipe / direct providers)

Conventions to follow

These are the patterns the codebase already uses. Follow them; don’t invent new ones. See CLAUDE.md for full rationale and examples.

  • Event-driven entity communication — Use EventEmitter + cx.subscribe(). No Arc<dyn Fn> callbacks between entities. See docs/entity-communication.md.
  • Optimistic updates — Update the in-memory global immediately, then persist asynchronously with logged errors.
  • Entity refs in globals — Default to GlobalWeakEntity<T> (avoids circular ownership); use GlobalStrongEntity<T> only when the global must keep the entity alive itself (e.g. StreamManager, ModelsNotifier).
  • Stream lifecycle — All LLM streams go through StreamManager with cancellation tokens; the stream loop never updates UI directly, it emits events. Streams carry a monotonic epoch so a stale StreamEnded can’t tear down a newer turn, and a shared stall watchdog (chatty-core/src/services/stream_processor.rs) ends a turn after 180s of silence; --headless auto-resumes a stalled turn itself, up to HEADLESS_STALL_RESUME_ATTEMPTS (2) times, before failing the run. See docs/stream-manager.md.
  • Turn budget — TurnBudget (chatty-core/src/services/turn_budget.rs) is a rig hook every stream_prompt call registers: it tells the model how many tool turns are left once most of the budget is spent, and gives it one final tool-free call to answer instead of letting rig’s MaxTurnsError swallow an in-progress reply. max_agent_turns defaults to 0 (no cap, not the old 10); an unattended run with no cap (--headless, --pipe, a delegated worker) instead runs under a wall-clock budget, chatty-tui --max-duration <secs|30m|2h> (default 30m).
  • Error handling — Don’t .ok() away errors silently. Log as warn!() for non-critical paths; propagate with ? for critical I/O.
  • Tool errors — Every impl Tool’s error path must go through map_tool_error(tool_name, error) (chatty-core/src/tools/mod.rs), not rig’s default Tool::map_error, or the model/transcript only ever see the redacted string "the tool failed". See “Tool Error Reporting Pattern” in CLAUDE.md.
  • MCP API keys — When sending MCP config to the LLM, report has_api_key(), never the api_key field; a "****" (MASKED_API_KEY_SENTINEL) sent back by the model means “keep the stored value”. See “Security Practices” in CLAUDE.md.
  • Rust edition — 2024. Use LazyLock/OnceLock (std) rather than lazy_static/once_cell.
  • GPUI / gpui-component skills — When changing desktop UI, load .claude/skills/gpui and .claude/skills/gpui-component (vendored from npx skills add longbridge/gpui-component; lockfile skills-lock.json).
  • Desktop screenshot harness — A desktop UI change is not done until it has been seen running. .claude/skills/desktop-screenshot starts the app under Xvfb on an isolated config, drives it with XTest (x.py), and screenshots it; read the PNGs and attach them to the Linear issue. It also lists the gpui traps that only a live run shows.
  • Transcript blocks — Typed block/turn types in chatty-gpui/src/chatty/views/transcript/ render the transcript; persistence stays untyped (MessageEntry + system_trace JSON) in chatty-core. Don’t leak transcript block types into chatty-core. chatty-tui has its own parallel MessageBlock enum (chatty-tui/src/engine/mod.rs) matching the desktop’s behaviour (activity folds, inline approvals, plan, error, diff) without sharing widgets or a chatty-gpui dependency — see CLAUDE.md. The gpui transcript list uses gpui’s list/ListState (measured heights), not v_virtual_list (predicted heights) — see CLAUDE.md.
  • Tool failure signal is text, not a flag — map_tool_error()’s Error: {tool_name}: {message} prefix is the only thing that tells llm_service::tool_result_looks_like_error a tool call failed. Don’t drop or reword that prefix. See CLAUDE.md.
  • Stale docs — If a change alters a fact a page claims, update that page in the same PR. update-agent-docs.yml only safety-nets AGENTS.md / CLAUDE.md. See the Documentation section of CONTRIBUTING.md.
  • Experiments live in harbor-chatty, not here — pre-registrations, run reports, spikes, benchmark harnesses and their task fixtures go in the private harbor-chatty repo under experiments/. chatty2 keeps the research crates’ design notes (docs/research/modules/), process docs, baselines its own benches check against, and a short summary in the user guide for any result a shipped feature relies on. Never commit one-off helper scripts or agent work notes (implementation summaries, plans).

Known gotchas

  1. No chatty-gpui::chatty::* re-exports. crates/chatty-gpui/src/chatty/mod.rs used to re-export auth, exporters, factories, repositories, tools from chatty_core; those re-exports were removed so call sites import chatty_core::… directly. If grep finds no definition under chatty-gpui/, look in chatty-core/. scripts/check-no-core-reexports.sh (in CI) fails the build if they come back.

  2. Test parallelism. See “Test-thread footgun” above: the pdfium SIGTRAP is fixed, but CI and make test intentionally still pass --test-threads=1 for an unrelated runner flake — that isn’t a regression to “fix”.

  3. WASM plugin prebuild. The tests that load plugins fail immediately, naming the missing path and make wasm-modules, if you haven’t built them on this checkout.

  4. Linux system packages. GPUI needs a long list of lib*-dev packages. Run make setup (or scripts/setup-linux.sh) on a fresh machine.

  5. Standalone crates, own lockfiles, wasm32 by default. crates/hive-billing-sdk/, modules/benford/ and crates/chatty-module-sdk/ each declare their own [workspace] and Cargo.lock (intentional) — bump their deps in that lockfile, not the root one. hive-billing-sdk and benford also default to wasm32-wasip2 via their own .cargo/config.toml, which has no libtest runner, so cargo test there needs an explicit host target: cargo test --manifest-path <crate>/Cargo.toml --target x86_64-unknown-linux-gnu (make test-billing-sdk / make test-benford do this; CI runs the same, AGE-600).

  6. The gpui-globals feature. chatty-core types implement gpui::Global only when this feature is enabled. chatty-gpui enables it; chatty-tui does not. If a Global impl is missing, add it in crates/chatty-core/src/gpui_globals.rs.

  7. Sub-agent worktrees. Delegated workers (invoke_agent → local-agent or a named virtual agent; sub_agent was retired in AGE-303) each get their own git worktree under <workspace>/.chatty/worktrees/<name> on a sub-agent/<name> branch (AGE-314), passed to the child via chatty-tui’s --workspace <DIR> flag. <name> is a wish, not a guarantee: if that branch or directory already exists in the repository (another root process’s worker, or a tree left from an earlier run), the first free <name>-N (from 2) is used instead, and the evidence envelope names the branch actually created (AGE-402). Worktrees are left in place after a worker exits (never auto-removed) and are excluded via .git/info/exclude, not .gitignore — they won’t show in git status but can still accumulate on disk. In a git repository, a worktree that cannot be created now fails the delegation instead of silently sharing the tree; only a workspace that isn’t a git repo falls back to the old shared-tree behavior. See CLAUDE.md.

  8. Large module directories. Several complex areas have been split into sub-module directories (chat_view/, chat_input/, auto_updater/, trace_components/, transcript/, etc.). Start with the mod.rs and its module-level docstring to scope what you need before loading sibling files. The largest single files are message_ops.rs (~1260 lines) and main.rs (~1225 lines).

  9. rpptx is pinned to a fork. The root Cargo.toml patches rpptx, oxml-layout and oxml-pdf to boersmamarcel/rdocx via [patch.crates-io] (AGE-343/AGE-474) — the published rpptx = "0.12.1" doesn’t compile alongside usvg and mishandles real decks. cargo update will not move it off the fork; don’t “fix” the patch block without checking whether upstream has actually shipped a release with those fixes. See CLAUDE.md.

  10. The agent shell loads the login profile, but still starts --norc --noprofile. shell_service runs bash on a PTY (chatty-terminal) and sources /etc/profile and the first of ~/.bash_profile/~/.bash_login/~/.profile in its init, before the first prompt (output discarded, PS1 unset while it runs), so a project’s conda env or PATH additions reach the model’s commands. Command completion is read from OSC 133 / OSC 6973 marks in the byte stream, not printed marker lines. If you’re debugging a shell tool that behaves differently interactively vs. under the agent, check what the login profile does, not .bashrc (never sourced). See CLAUDE.md.

Deeper reading

TopicFile
System overview (diagrams)docs/system-overview.md
Component map (diagrams)docs/component-map.md
Crate split rationaledocs/workspace-crate-split.md
Stream lifecycledocs/stream-manager.md
Entity communicationdocs/entity-communication.md
Rendering pipelinedocs/rendering-system.md
Token budgetdocs/token-tracking.md
Agent memorydocs/agent-memory.md
Agents, specs & A2Adocs/agents-and-specs.md
WASM pluginsdocs/plugins.md
WIT referencedocs/wit-reference.md
Debugging the UI and streamsdocs-site/src/dev/guides/debug.md
Release processdocs/RELEASE_PROCESS.md
Coding patterns & behaviorCLAUDE.md

Cloud VM notes

Build/test/lint/run commands are unchanged — use the make targets above. These are only the non-obvious caveats of a headless cloud build VM.

  • Toolchain. The workspace is edition 2024 and declares rust-version = "1.94" in the root Cargo.toml (the research crates inherit it). If a build fails with feature edition2024 is required, the VM’s default toolchain is too old: run rustup default stable. CI uses dtolnay/rust-toolchain@stable, so it always runs ahead of the declared MSRV; if a dependency raises the floor, bump rust-version and name the dependency in the commit.

  • Use GNU cc/c++, not clang. The bundled DuckDB C++ build and the desktop binary link fail under the system clang (fatal error: 'memory' file not found, unable to find library -lstdc++). If you see either, run sudo update-alternatives --set cc /usr/bin/gcc and sudo update-alternatives --set c++ /usr/bin/g++.

  • The WASM fixtures under target/wasm-fixtures/ are build output and required by some integration tests; run make wasm-modules after a fresh checkout.

  • Disk. A full --all-features test build needs about 16 GiB of target/; see docs/build-disk-usage.md before running it on a small VM.

  • Running the desktop app headlessly. The VM has no GPU but software Vulkan (Mesa llvmpipe/lavapipe) works on the existing X11 display (DISPLAY=:1). Export XDG_RUNTIME_DIR first, e.g. export XDG_RUNTIME_DIR=/tmp/xdg-runtime-$(id -u) && mkdir -p "$XDG_RUNTIME_DIR" && chmod 700 "$XDG_RUNTIME_DIR", otherwise X11 window creation fails with BadMatch. chatty-tui needs none of this.

  • No LLM API key is configured. To chat from either frontend, use a local Ollama: ollama serve (systemd is not running, so launch it in a background/tmux session), ollama pull qwen2.5:0.5b, then ./target/debug/chatty-tui --ollama http://localhost:11434 --model qwen2.5:0.5b --headless -m "...". The desktop app auto-detects a running local Ollama and lists its models with no configuration.

  • Clippy is clean workspace-wide. cargo clippy --workspace --all-features --all-targets -- -D warnings passes (AGE-174). CI added --all-targets so tests/benches/examples are linted too — without it, lints inside tests/ go unreported (a finding sat unnoticed in chatty-protocol-gateway’s e2e test until this was added). make lint/make ci now pass --all-targets too (AGE-600), so a clean make lint matches CI’s clippy invocation exactly.

  • Local rustc lints strictly less than CI’s. This VM’s default toolchain (1.94.1) can be behind the stable CI uses (e.g. 1.98.1) — clippy findings visible only on newer stable (such as redundant glob imports) can pass locally and fail CI. Run rustup run stable cargo clippy ... before trusting a green local clippy if CI still fails.

System overview

When to read this: You need a one-page mental model of Chatty before diving into crate-level or file-level docs.

Chatty is a Rust agent framework with two user-facing frontends (desktop GPUI and terminal TUI) sharing one UI-agnostic core. Optional WASM modules and an HTTP gateway extend the same tool and conversation model to external protocols.

Three layers

flowchart TB
  subgraph frontends [Frontends]
    GPUI["chatty-gpui<br/>Desktop app"]
    TUI["chatty-tui<br/>Terminal + headless"]
  end

  subgraph core [chatty-core — shared brain]
    Models["Models & stores"]
    Services["Services"]
    Tools["LLM tools"]
    Factories["AgentFactory"]
    Repos["Repositories"]
  end

  subgraph extensions [Extensions]
    WASM["WASM modules<br/>chatty-module-*"]
    Gateway["protocol-gateway<br/>OpenAI / MCP / A2A"]
    Research["Research crates<br/>trace / playbook / flow / optimize"]
  end

  subgraph external [External]
    LLM["LLM providers"]
    MCP["MCP servers"]
    Docker["Docker sandbox"]
    Marketing["Marketing site<br/>github.com/boersmamarcel/chatty"]
    Docs["Developer docs<br/>GitHub Pages mdBook"]
  end

  GPUI --> core
  TUI --> core
  Gateway --> core
  WASM --> core
  Research --> core
  Factories --> LLM
  Tools --> MCP
  Tools --> Docker
  GPUI -.-> Marketing
  GPUI -.-> Docs

Role of each major crate

CrateRole in the system
chatty-coreSingle source of business logic: conversations, tools, LLM agents, settings persistence, sandbox, MCP, memory
chatty-gpuiDesktop shell: renders UI, owns StreamManager, routes entity events through ChattyApp
chatty-tuiTerminal shell + headless/pipe mode for scripting; also the worker process behind every delegation and, with --broker / --team, a leader of its own
chatty-wasm-runtimeWasmtime host for agent modules (wasm32-wasip2)
chatty-module-registryDiscovers, validates, and loads WASM module manifests
chatty-protocol-gatewayHTTP façade so external clients can call modules via standard APIs, and the ADR-0011 broker: local participants and named virtual agents (local-agent, a declared team) served at the same /a2a/{name} routes
chatty-fabricThe broker’s pure state (ADR-0020): node directory, task table, edge log, the Transport trait, and AgentOrigin; no axum, wasmtime, hive-client, gpui or reqwest
chatty-module-sdkAuthoring SDK for third-party WASM agents
chatty-trace / playbook / flow / optimizeResearch crates (self-improvement papers); see RESERVED.md
hive-client / hive-billing-sdkHive registry and billing integration

Dependency rule: frontends → core. Core never depends on GPUI or Ratatui. See workspace-crate-split.md for the feature flag that lets core types act as GPUI globals.

Startup sequence (desktop)

chatty-gpui/src/main.rs runs, in order:

  1. Create the Tokio runtime and enter it for the whole process lifetime.
  2. Initialise the settings repositories (RepositoryRegistry) and resolve — but do not open — the SQLite conversation database path (ConversationSqliteRepository::deferred).
  3. Start the GPUI Application; inside run, do only what the first frame needs: gpui_component::init, the theme registry (baked-in default applied now, theme pack loaded later), and every global the root view reads, registered with defaults (GeneralSettingsModel, ProviderModel, ModelsModel, McpServersModel, ExecutionSettingsModel, TokenTrackingSettings, GlobalStreamManager, approval stores, …). No I/O happens here.
  4. Open the window and create the ChattyApp entity; open_window paints the first frame synchronously.
  5. Only then spawn the async tasks that load settings JSON, conversation metadata (the first query opens the SQLite pool), token-tracking settings, the memory service, the skills listing and enabled MCP servers from disk, overwriting the defaults as they arrive. The updater poll and the MCP update channel start here too.

Since AGE-161 the rule is: anything that reads a file, touches the network or spawns a task belongs after open_window. Nothing on the UI thread waits for disk or network; each loader updates its global and refreshes the windows when done. boot_timing::checkpoint marks each stage (main_to_run, run_to_open_window, …) and log_summary prints the timeline at info level once the conversation list has loaded and the app is ready to send.

End-to-end message path

sequenceDiagram
  participant User
  participant UI as GPUI or TUI
  participant App as ChattyApp / ChatEngine
  participant SM as StreamManager
  participant AF as AgentFactory
  participant LLM as LLM provider
  participant Tools as Native + MCP tools

  User->>UI: Type message
  UI->>App: Submit event
  App->>AF: Build AgentClient
  App->>SM: Register stream
  App->>LLM: stream_prompt (multi-turn)
  loop ReAct loop
    LLM->>Tools: Tool call
    Tools-->>LLM: Tool result
  end
  LLM-->>SM: Stream chunks
  SM-->>UI: StreamManagerEvent
  UI-->>User: Render response
  App->>App: Persist conversation

Key design decisions

  1. Central controller. ChattyApp (chatty-gpui/src/chatty/controllers/app_controller/) owns the top-level view entities, subscribes to their events and coordinates services, stores and views. It is deliberately a “fat controller” so the event flow stays traceable in one place; the module is split by concern (message_ops, conversation_ops, slash_commands, export_ops).
  2. Event-driven communication. Entities talk only through EventEmitter / cx.subscribe(); there are no Arc<dyn Fn> callbacks between entities. See entity-communication.md.
  3. StreamManager owns the stream lifecycle. The stream loop never touches a view; it updates the Conversation and forwards chunks to StreamManager, which emits typed events that handlers route to views. Cancellation is a shared AtomicBool. See stream-manager.md.
  4. Global state via GPUI. App-wide state implements Global and is reached with cx.global(); entity references inside globals default to WeakEntity<T> so globals do not keep entities alive by accident.
  5. Provider abstraction. ProviderType::default_capabilities() seeds a new model, ModelConfig persists per-model capabilities, and AgentFactory builds the provider-specific client.
  6. Tools are rig Tool implementations registered by AgentFactory; side-effecting tools (shell, file writes) go through the approval stores first. The full list is the tools catalog.

Where to go next

QuestionDocument
Crate boundaries and the gpui-globals featureworkspace-crate-split.md
Component relationships & diagramscomponent-map.md
App components ↔ research modulesresearch/app-research-bridge.md
Entity events between GPUI componentsentity-communication.md
LLM stream lifecyclestream-manager.md
Agent memory & skillsagent-memory.md
Context window / token budgettoken-tracking.md
Agent quick-startAGENTS.md

Component map

When to read this: You need to see how runtime pieces connect — which entity owns what, where events flow, and which module to open for a given concern.

Workspace crate relationships

flowchart LR
  subgraph ui [UI layer]
    GPUI[chatty-gpui]
    TUI[chatty-tui]
  end

  subgraph core [chatty-core]
    direction TB
    CModels[models/]
    CServices[services/]
    CTools[tools/]
    CFactory[factories/agent_factory]
    CSettings[settings/]
    CRepos[repositories/]
    CSandbox[sandbox/]
  end

  subgraph wasm [WASM stack]
    Reg[module-registry]
    RT[wasm-runtime]
    GW[protocol-gateway]
    SDK[module-sdk]
  end

  GPUI --> core
  TUI --> core
  GPUI --> Reg
  GPUI --> GW
  GW --> Reg
  GW --> RT
  Reg --> RT
  SDK -.-> RT

  CFactory --> CTools
  CFactory --> CServices
  CServices --> CSandbox
  CSettings --> CRepos
  CModels --> CRepos

chatty-core internal modules

Each box is a top-level module under crates/chatty-core/src/.

flowchart TB
  subgraph core [chatty-core]
    models["models/<br/>Conversation, Message,<br/>ConversationsStore, approvals"]
    settings["settings/<br/>ProviderModel, ModelsModel,<br/>McpStore, JSON repos"]
    repositories["repositories/<br/>SQLite conversations"]
    factories["factories/<br/>AgentFactory per provider"]
    services["services/<br/>LLM, shell, MCP, math,<br/>browser (CDP), A2A client, sync"]
    tools["tools/<br/>LLM Tool impls"]
    sandbox["sandbox/<br/>Docker + Monty"]
    token_budget["token_budget/<br/>count, summarize, cache"]
    exporters["exporters/<br/>ATIF, JSONL"]
    auth["auth/<br/>Azure OAuth"]
  end

  factories --> tools
  factories --> services
  services --> sandbox
  services --> settings
  models --> repositories
  settings --> repositories
  token_budget --> models
  exporters --> models
ModuleResponsibilityDoes NOT contain
models/Pure data + in-memory storesUI, disk I/O
settings/Config models + JSON repositoriesGPUI views
repositories/SQLite + abstraction traitsBusiness rules
factories/Build AgentClient, register toolsStream UI updates
services/Long-running / stateful logicGPUI Render
tools/LLM-callable tool definitionsAgent loop itself
sandbox/Code execution isolationTool approval UI
token_budget/Context window accountingChat rendering

GPUI desktop architecture

ChattyApp is the hub; views emit events upward; services live in core.

flowchart TB
  subgraph views [chatty-gpui views]
    Sidebar[SidebarView]
    ChatView[ChatView]
    ChatInput[ChatInput]
    Settings[Settings pages]
  end

  subgraph controller [app_controller]
    ChattyApp[ChattyApp entity]
    MsgOps[message_ops]
    ConvOps[conversation_ops]
    Slash[slash_commands]
  end

  subgraph gpui_models [GPUI-specific models]
    StreamMgr[StreamManager]
    ErrorNotif[ErrorNotifier]
  end

  subgraph core [chatty-core]
    Stores[Global stores]
    AgentFactory[AgentFactory]
    ConvRepo[ConversationRepository]
  end

  Sidebar -->|SidebarEvent| ChattyApp
  ChatInput -->|InputEvent| ChattyApp
  ChatView -->|ChatViewEvent| ChattyApp
  ChattyApp --> MsgOps
  ChattyApp --> ConvOps
  ChattyApp --> Slash
  MsgOps --> AgentFactory
  MsgOps --> StreamMgr
  StreamMgr -->|StreamManagerEvent| ChattyApp
  ChattyApp --> ChatView
  ChattyApp --> Stores
  ConvOps --> ConvRepo
  Settings --> Stores

Entity event bus (simplified)

flowchart LR
  Sidebar -->|SelectConversation| App[ChattyApp]
  Input[ChatInput] -->|PressEnter| App
  App -->|append_text| ChatView
  StreamMgr[StreamManager] -->|TextChunk| App
  App -->|append_text| ChatView
  StreamMgr -->|StreamEnded| App
  App -->|finalize| ChatView
  App -->|refresh list| Sidebar

Full topology: entity-communication.md.

Stream lifecycle ownership

stateDiagram-v2
  [*] --> Active: StreamManager.register
  Active --> Active: TextChunk / ToolCall events
  Active --> Completed: LLM done
  Active --> Cancelled: user stop / cancel_flag
  Active --> Error: stream error
  Completed --> [*]: ChattyApp finalizes + saves
  Cancelled --> [*]: ChattyApp finalizes partial
  Error --> [*]: ErrorNotifier + UI message

The async stream loop only updates Conversation and StreamManager — never GPUI views directly. See stream-manager.md.

Tool execution path

flowchart TB
  LLM[LLM tool_call] --> Rig[Rig Agent loop]
  Rig --> ToolImpl[chatty-core Tool]
  ToolImpl --> Approval{Needs approval?}
  Approval -->|yes| ApprovalStore[ExecutionApprovalStore]
  ApprovalStore --> UI[GPUI prompt / TUI y/n]
  UI --> ToolImpl
  Approval -->|no| Execute[Run operation]
  Execute --> Shell[shell_service]
  Execute --> FS[filesystem tools]
  Execute --> MCP[McpService]
  Execute --> Sandbox[sandbox / execute_code]
  Execute --> Result[Tool output to LLM]

WASM module stack

flowchart LR
  Author[Module author] --> SDK[chatty-module-sdk]
  SDK --> WASM[".wasm artifact"]
  WASM --> Registry[module-registry]
  Registry --> Runtime[wasm-runtime]
  Runtime --> Tools[invoke_agent / list_agents]
  Gateway[protocol-gateway] --> Runtime
  External[External HTTP client] --> Gateway
  Runtime --> LLM[Host llm::complete import]
  LLM --> Providers[LLM providers]

See build-wasm-module guide (sequence diagrams) and agents-and-specs.md and plugins.md.

Research touchpoints

Production components above are where promoted research mechanisms land. For a bidirectional map (memory → M4 ACE, token budget → M3/M4, sub-agents → M2 AFlow, …) see research/app-research-bridge.md.

Workspace crate split

When to read this: You are about to add code and need to decide which crate it belongs in, or you need to know how chatty-core types become GPUI globals.

Chatty is a Cargo workspace. The three application crates:

CratePurposeGPUI dependency?
chatty-coreModels, services, tools, settings, repositories, factoriesNo
chatty-gpuiDesktop UI: views, controllers, stream manager, notifiersYes
chatty-tuiTerminal UI: single-session chat, headless/pipe modeNo

chatty-core never depends on either frontend. The dependency direction is:

chatty-gpui ──depends on──► chatty-core (with the gpui-globals feature)
chatty-tui  ──depends on──► chatty-core (without it)

The other workspace crates (WASM runtime, module registry, protocol gateway, module SDK, Hive clients, research crates) are listed in system-overview.md. One of them sits below chatty-core:

CratePurpose
chatty-fabricThe broker’s pure state (ADR-0020): who is on the fabric, which runs are in flight, the edge log, and the Transport trait invoke_agent/list_agents call through

Both chatty-core and chatty-protocol-gateway depend on chatty-fabric, so it depends on neither, and never on axum, wasmtime, hive-client, gpui or reqwest (crates/chatty-fabric/tests/no_heavy_deps.rs fails the build if it does). chatty-core payloads (InvokeAgentProgress, conversations, usage) cross it as serde_json::Value; chatty-core converts them at its edge (services::fabric_transport, from BI-4).

The gpui-globals feature

chatty-core types (stores, settings models, services) must implement GPUI’s Global trait for chatty-gpui to use cx.set_global() / cx.global(), but chatty-core must not link GPUI for the terminal build. The impl Global blocks therefore live in one conditional module:

# crates/chatty-core/Cargo.toml
[features]
gpui-globals = ["dep:gpui"]

[dependencies]
gpui = { workspace = true, optional = true }
#![allow(unused)]
fn main() {
// crates/chatty-core/src/lib.rs
#[cfg(feature = "gpui-globals")]
mod gpui_globals;

// crates/chatty-core/src/gpui_globals.rs
impl Global for crate::settings::models::GeneralSettingsModel {}
impl Global for crate::models::ConversationsStore {}
// … every type that needs cx.set_global()
}

chatty-gpui enables the feature in its chatty-core dependency; chatty-tui does not, and instead holds core services and models as fields on its ChatEngine.

To add a new global type: define it in chatty-core, add impl Global for crate::…::MyStore {} to gpui_globals.rs, then call cx.set_global(MyStore::new()) from chatty-gpui as usual.

Decision criteria

Ask: “Does this code need GPUI’s Context, Window, Entity, Render, or EventEmitter?”

  • Yes → chatty-gpui
  • No, but it is terminal rendering or TUI-only orchestration → chatty-tui
  • No → chatty-core

Anything that both frontends could run — stream processing, tool execution, settings persistence, token counting — belongs in chatty-core even if only one frontend uses it today. The ui-sync-check workflow flags a change in one UI crate without the other.

No re-exports of core modules

chatty-gpui and chatty-tui import chatty-core’s UI-agnostic modules (auth, exporters, factories, repositories, tools) as chatty_core::… at every call site rather than re-exporting them under a local path, so a definition’s crate is visible from its import; scripts/check-no-core-reexports.sh, run in CI, fails the build if either crate re-introduces such a re-export.

Note

If you grep for a definition under crates/chatty-gpui/src/ and find nothing, it lives under crates/chatty-core/src/.

Contributing patterns

When to read this: You are about to change Rust code in chatty-core or chatty-gpui and want to know which conventions a review will hold you to, and why.

Chatty is a GPUI desktop app with a shared, UI-agnostic core. Most of the rules below exist because GPUI’s entity/context model has sharp edges: ownership cycles are easy to create, async work has to re-enter the app through a context, and a swallowed error is invisible in a GPU-rendered UI. The rules are short; the “why” is what makes them stick.

The four non-negotiables

1. Entities talk through events, not callbacks

Entity-to-entity communication uses EventEmitter and cx.subscribe(). Never hand one entity an Arc<dyn Fn> pointing into another: the closure captures an entity handle, the handle keeps the other side alive, and you have built a reference cycle that GPUI cannot see. Events keep ownership one-directional and let the parent decide what a child event means. IntoElement components (plain structs such as ConversationItem) may still take on_click-style callbacks, but those callbacks must do nothing except cx.emit() on the owning entity.

#![allow(unused)]
fn main() {
// WRONG — callback captured by another entity, ownership cycle
sidebar.set_on_select(Arc::new(move |id, cx| app.load_conversation(id, cx)));

// RIGHT — child emits, parent subscribes
impl EventEmitter<SidebarEvent> for SidebarView {}

cx.subscribe(&self.sidebar_view, |app, _sidebar, event: &SidebarEvent, cx| {
    if let SidebarEvent::SelectConversation(id) = event {
        app.load_conversation(id, cx);
    }
})
.detach();
}

2. Weak entity references in globals, strong only when the global is the owner

crates/chatty-gpui/src/global_entity.rs provides two wrappers. GlobalWeakEntity<T> (new(WeakEntity<T>), try_upgrade() -> Option<Entity<T>>) is the default: the entity’s lifetime is owned by a window or a parent entity, and the global is just a lookup. GlobalStrongEntity<T> (new(Entity<T>), get() -> Option<Entity<T>>) is for the handful of entities that nothing else owns and that must outlive any single window — StreamManager and ModelsNotifier are the existing examples. Reaching for the strong wrapper by habit reintroduces the cycles rule 1 avoids.

#![allow(unused)]
fn main() {
// WRONG — the global now keeps a view alive after its window closes
pub type GlobalChatView = GlobalStrongEntity<ChatView>;

// RIGHT — weak by default; upgrade at the call site
pub type GlobalMyNotifier = GlobalWeakEntity<MyNotifier>;
cx.set_global(GlobalMyNotifier::new(entity.downgrade()));

if let Some(notifier) = cx.try_global::<GlobalMyNotifier>().and_then(|g| g.try_upgrade()) {
    notifier.update(cx, |_, cx| cx.emit(MyEvent::Ready));
}
}

3. No silent .ok() on UI updates

cx.update(...), entity.update(cx, ...) and friends return Result because the target may already be gone. Discarding that result with a bare .ok() hides the one signal you have when a background task outlives its view. Log it with warn! first. Propagate with ? instead when the failure is a real error (file I/O, downloads, persistence).

#![allow(unused)]
fn main() {
// WRONG — failure disappears
cx.update(|cx| cx.refresh_windows()).ok();

// RIGHT — logged, then discarded on purpose
cx.update(|cx| cx.refresh_windows())
    .map_err(|e| warn!(error = ?e, "Failed to refresh windows"))
    .ok();
}

4. Tool errors go through map_tool_error

Every impl Tool implements map_error by calling map_tool_error(Self::NAME, error) from crates/chatty-core/src/tools/mod.rs. rig’s default Tool::map_error redacts the source error to a per-kind string — for ToolErrorKind::Other that is the literal "the tool failed", which is all the model and the transcript would ever see. map_tool_error formats the message as Error: {tool_name}: {message} and classifies it (timeout, permission, not-found, network, other) for rig’s retry hint. The prefix is load-bearing: the streamed tool result carries no error flag, so llm_service::tool_result_looks_like_error decides success or failure by sniffing for Error:. Drop or reword it and every failure is filed as a success. Only route errors the tool authored itself through it; raw provider text is what rig’s redaction exists for.

#![allow(unused)]
fn main() {
// WRONG — model sees "the tool failed"
fn map_error(&self, error: Self::Error) -> ToolExecutionError {
    ToolExecutionError::from_error(error)
}

// RIGHT — tool name and real message survive; prefix stays sniffable
fn map_error(&self, error: Self::Error) -> ToolExecutionError {
    crate::tools::map_tool_error(Self::NAME, error)
}
}

Six everyday patterns

Globals. App-wide state (settings, model lists, stores) implements gpui::Global and lives in the app: cx.set_global(value) at startup, cx.global::<T>() to read, cx.update_global::<T, _>(|state, cx| ...) to mutate, cx.try_global::<T>() when it may not be initialised yet. chatty-core types get their Global impls in chatty-core/src/gpui_globals.rs behind the gpui-globals feature, so the terminal app never links GPUI.

#![allow(unused)]
fn main() {
cx.update_global::<ModelsModel, _>(|models, _cx| models.replace_all(new_models));
}

Event subscriptions. Define an event enum, implement EventEmitter<E> for the entity, emit with cx.emit(...), and subscribe from the interested entity. Always .detach() the subscription (or store the Subscription handle); a dropped subscription silently stops delivering.

#![allow(unused)]
fn main() {
cx.subscribe(&notifier, |app, _notifier, event: &ModelsNotifierEvent, cx| {
    if matches!(event, ModelsNotifierEvent::ModelsReady) {
        app.refresh_chat_input_models(cx);
    }
})
.detach();
}

Async work. A Tokio runtime is entered in main.rs for the whole process, so cx.spawn futures can await Tokio-based I/O directly. Inside the future you hold an AsyncApp, not an App: every touch of app state goes through cx.update(|cx| ...), and the closure returns a Result because the app may be shutting down. Detach fire-and-forget tasks; return the Task when the caller needs the result.

#![allow(unused)]
fn main() {
cx.spawn(async move |cx: &mut AsyncApp| {
    match GENERAL_SETTINGS_REPOSITORY.load().await {
        Ok(settings) => cx
            .update(|cx| cx.set_global(settings))
            .map_err(|e| warn!(error = ?e, "Failed to store settings"))
            .ok(),
        Err(e) => warn!(error = ?e, "Failed to load settings"),
    };
})
.detach();
}

The four contexts. App is the full application context (globals, entity creation) and is what main.rs and top-level constructors receive. Context<T> is App plus “you are inside entity T”: it adds cx.emit, cx.notify, cx.subscribe, and cx.entity(). AsyncApp is what a spawned future holds; it can only reach the app through cx.update. Window carries window-scoped operations (focus, sizing) and is passed alongside cx to render and constructor code. If a function needs to emit or notify, it must take Context<Self>; if it only reads globals, &App is enough and keeps it callable from more places.

Optimistic update. For anything persisted: mutate the global first, refresh the UI, then save asynchronously and log the failure. The user never waits on disk, and a failed save shows up in the log rather than as a stuck button.

#![allow(unused)]
fn main() {
cx.global_mut::<ModelsModel>().add_model(config);
let to_save = cx.global::<ModelsModel>().models().to_vec();
cx.refresh_windows();
cx.spawn(async move |_cx: &mut AsyncApp| {
    if let Err(e) = MODELS_REPOSITORY.save_all(to_save).await {
        error!(error = ?e, "Failed to save models");
    }
})
.detach();
}

Deferred updates. Calling entity.update(cx, ...) on an entity that is already being updated higher in the call stack panics with a double borrow. When an event handler needs to reach back into the entity that dispatched it, schedule the work with cx.defer(move |cx| ...); it runs after the current update returns.

#![allow(unused)]
fn main() {
let chat_view = self.chat_view.clone();
cx.defer(move |cx| {
    chat_view.update(cx, |view, cx| {
        view.chat_input_state().update(cx, |input, _cx| {
            input.set_capabilities(supports_images, supports_pdf);
        });
    });
});
}

Security in short

MCP server configuration reaches the model through tools, so secrets must never be serialised into tool output. Today McpServerConfig (crates/chatty-core/src/settings/models/mcp_store.rs) carries a single secret, api_key: Option<String>, sent as a bearer token by McpService. Two conventions keep it out of the transcript:

  • Never expose the value. LLM-facing summaries report has_api_key: bool (via McpServerConfig::has_api_key()), not the key. list_mcp_services (tools/list_mcp_tool.rs) is the existing example; its McpServerSummary has no key field at all, and test_list_masks_api_key pins that shape. Any new #[derive(Serialize)] tool output that wraps a server config follows the same shape.
  • Masked round-trips preserve the stored value. MASKED_API_KEY_SENTINEL ("****", same file) is the placeholder a model may send back when editing a server. A write path that receives the sentinel keeps the existing key rather than storing four asterisks; a path that creates a new server rejects it, because there is nothing to preserve.

Write operations on the MCP config take MCP_WRITE_LOCK so load → modify → save is atomic across tools. In logs, record has_api_key or key names, never values.

  • Entity communication — the full rationale for rule 1, with the component-vs-entity split.
  • Stream manager — how LLM streams are owned, cancelled and finalised through events.
  • System overview — the three-layer mental model these patterns live in.
  • Testing — what to run before a PR and how to reproduce CI’s single-threaded test run.
  • CLAUDE.md — the agent-facing version of these rules, with longer examples.

Entity Communication Pattern

When to read this: You are wiring a new GPUI view or notifier into ChattyApp and need the rule for how entities talk to each other.

Design Principle

All entity-to-entity communication in Chatty uses GPUI’s EventEmitter/cx.subscribe() pattern. No Arc<dyn Fn> callbacks are used between GPUI entities.

Why EventEmitter Over Callbacks

EventEmitterCallbacks (Arc<dyn Fn>)
Handler context&mut Self directly (subscriber closure)&mut App only — must clone entity + entity.update()
Wiring locationOne cx.subscribe() per entity in setup_callbacks()Scattered set_on_*() calls, one per action
Type safetyTyped enum — exhaustive match catches missing armsOption<Arc<dyn Fn>> — silent no-op if not wired
Adding a new actionAdd enum variant + match armAdd type alias + field + None init + setter + clone chain
TraceabilityEvents are #[derive(Debug)] — easy to logOpaque closures

Event Topology

ChatInputState  ──emit ChatInputEvent──────►  ChattyApp (subscriber)
SidebarView     ──emit SidebarEvent────────►  ChattyApp (subscriber)
StreamManager   ──emit StreamManagerEvent──►  ChattyApp (subscriber)
ChatView        ──emit ChatViewEvent───────►  ChattyApp (subscriber)
AgentConfigNotifier ──emit AgentConfigEvent─►  ChattyApp (subscriber)
ModelsNotifier  ──emit ModelsNotifierEvent─►  ChattyApp (subscriber)
SystemTraceView ──emit TraceEvent──────────►  ChatView  (subscriber)

The ChattyApp subscriptions are wired in ChattyApp::setup_callbacks(); ChatView subscribes to each SystemTraceView it creates.

Adding a New Event

  1. Add a variant to the entity’s event enum (e.g., SidebarEvent::RenameConversation(String))
  2. Emit it: cx.emit(SidebarEvent::RenameConversation(new_title))
  3. Handle it: add a match arm in the subscriber closure in setup_callbacks()

That’s it — no type alias, no field, no setter, no clone chain.

Boundary: IntoElement Components Keep Callbacks

GPUI’s EventEmitter can only be implemented by entities (types created with cx.new()). Render-once components (#[derive(IntoElement)]) like ConversationItem and ApprovalPromptBar cannot emit events.

They accept Arc<dyn Fn> callbacks, but these closures route through the parent entity to emit events:

#![allow(unused)]
fn main() {
// ConversationItem (IntoElement) accepts a callback...
ConversationItem::new(id, title)
    .on_click({
        let entity = sidebar_entity.clone();
        let id = id.clone();
        move |_conv_id, cx| {
            // ...which routes through SidebarView (entity) to emit
            entity.update(cx, |_, cx| {
                cx.emit(SidebarEvent::SelectConversation(id.clone()));
            });
        }
    })
}

This keeps callbacks at the entity/component boundary only — all inter-entity communication flows through events.

Subscription Pattern

Subscribers receive &mut Self directly, eliminating clone gymnastics:

#![allow(unused)]
fn main() {
// In ChattyApp::setup_callbacks()
cx.subscribe(&self.sidebar_view, |app, _sidebar, event: &SidebarEvent, cx| {
    match event {
        SidebarEvent::NewChat => { app.create_and_load_conversation(cx); }
        SidebarEvent::SelectConversation(id) => { app.load_conversation(id, cx); }
        SidebarEvent::DeleteConversation(id) => { app.delete_conversation(id, cx); }
        // ...
    }
}).detach();
}

How-to

Task-oriented steps (new file, event enum, ChattyApp subscribe, Render): Add a desktop GPUI view (mdBook: How-to guides → Add a desktop GPUI view).

StreamManager

When to read this: You are changing how a desktop LLM turn starts, streams, stops or finalises, and need to know which entity owns which piece of that lifecycle.

StreamManager (crates/chatty-gpui/src/chatty/models/stream_manager.rs) is a GPUI entity that owns the lifecycle of every in-flight LLM stream — status, cancellation, per-request token usage, trace — emits typed events for decoupled UI updates, and uses cancellation tokens for graceful shutdown. Because it is conversation-keyed, several conversations can stream at once: a background stream keeps accumulating while the UI shows a different conversation.

Key design principle: StreamManager does not accumulate response text. Streaming text lives only in Conversation.streaming_message inside ConversationsStore, so there is a single source of truth and no dual-write divergence.

Entity ownership

GlobalStreamManager (GPUI global, strong reference)
  └── Entity<StreamManager>
        ├── streams: HashMap<String, StreamState>   (one entry per active stream)
        │     ├── epoch: u64                       ← distinguishes re-registrations
        │     ├── status: StreamStatus
        │     ├── token_usage: Option<TokenUsage>  ← built from the per-request records
        │     ├── calls: Vec<ApiCallUsage>         ← one per provider request
        │     ├── trace_json: Option<Value>
        │     ├── task: Option<Task>
        │     ├── cancel_flag: Arc<AtomicBool>
        │     ├── pending_artifacts: Option<PendingArtifacts>
        │     ├── text batching buffer (pending_text, last_flush)
        │     └── text_batch: Option<SharedTextBatch>  ← the DesktopSink buffer
        │                                                 one layer upstream
        ├── pending_resolved_ids: HashMap<String, Arc<Mutex<Option<String>>>>
        ├── current_epoch: HashMap<String, u64>
        └── flush_timer: Option<Task<()>>          ← background flush, see below

ConversationsStore (GPUI global)
  └── HashMap<String, Conversation>
        ├── history, agent, model_id, title, token_usage, …
        ├── streaming_message: Option<String>   ← single source of truth for streaming text
        └── system_traces: Vec<Option<Value>>

ChattyApp (GPUI entity, window root)
  ├── Entity<ChatView>
  │     ├── messages: Vec<DisplayMessage>
  │     ├── conversation_id: Option<String>   ← which conversation the UI is showing
  │     ├── pending_approval: Option<PendingApprovalInfo>
  │     └── Entity<ChatInputState>            ← is_streaming; emits ChatInputEvent
  └── Entity<SidebarView>                     ← emits SidebarEvent

Event subscription chain

graph TD
    SM[StreamManager] -->|cx.emit StreamManagerEvent| CA[ChattyApp]
    CA -->|handle_stream_manager_event| CV[ChatView]
    CV -->|creates/updates| STV[SystemTraceView]
    STV -->|cx.emit TraceEvent| CV

    CIS[ChatInputState] -->|cx.emit ChatInputEvent| CA
    SB[SidebarView] -->|cx.emit SidebarEvent| CA
    CV -->|cx.emit ChatViewEvent| CA
    ACN[AgentConfigNotifier] -->|cx.emit AgentConfigEvent| CA
    MN[ModelsNotifier] -->|cx.emit ModelsNotifierEvent| CA

All entity-to-entity communication uses EventEmitter/cx.subscribe(); the subscriptions are wired in ChattyApp::setup_callbacks():

#![allow(unused)]
fn main() {
cx.subscribe(&manager, |app, _mgr, event: &StreamManagerEvent, cx| {
    app.handle_stream_manager_event(event, cx);
}).detach();
}

StreamManagerEvent variants

EventEmitted byHandler action
StreamStartedregister_stream, register_pending_streamMarks the conversation as streaming; sets ChatInputState.is_streaming = true (deferred)
TextChunkhandle_chunk (batched; first chunk immediate, then at most every FLUSH_INTERVAL = 20ms, backstopped by a background timer — see Text accumulation below)ChatView.append_assistant_text()
ToolCallStarted / ToolCallInput / ToolCallResult / ToolCallErrorhandle_chunkChatView.handle_tool_call_*()
ApprovalRequested / ApprovalResolvedhandle_chunkChatView.handle_approval_*()
ClarificationRequestedhandle_chunkRecords the block on the conversation’s streaming trace (so it survives a switch), then ChatView.handle_clarification_requested()
TokenUsagehandle_chunkNo-op (usage is processed at finalisation)
StreamEndedfinalize_stream, stop_stream, cancel_pending, stop_allResets streaming state; dispatches to finalize_completed_stream or finalize_stopped_stream; clears Conversation.streaming_message

Every event carries a conversation_id. The handler checks view.conversation_id() == Some(conversation_id) before forwarding to ChatView, so events for a conversation that is not on screen are skipped at the UI level while the data-level work (finalise, persist) always runs.

StreamEnded also carries the stream’s epoch. A protocol follow-up registers the next stream synchronously right after finalize_stream returns, but the event is delivered on the next effect flush; the handler ignores a StreamEnded whose epoch is not the conversation’s current one, so turn N’s end cannot tear down turn N+1.

Text accumulation: two buffering layers, one drained by the other

Conversation.streaming_message (inside ConversationsStore) is still the single source of truth for the accumulated response text — that part of the design is unchanged. What changed (AGE-166) is that neither sink is fed one raw LLM chunk at a time any more, because a raw stream can yield far faster than any display repaints. There are two buffers, and it matters which timer drains which:

raw SessionEvent::Text chunks, arriving as fast as the provider sends them
    │
    ▼
LAYER 1 — TextBatch, shared by DesktopSink and StreamState.text_batch
    Defined in stream_manager.rs; created per turn by run_llm_stream and
    handed to the manager with `attach_text_batch`. `DesktopSink` fills it
    with every raw chunk and flushes the whole accumulated batch as ONE
    SessionEvent::Text when `should_flush_text` says so — the turn's first
    chunk immediately (protects time-to-first-token), then at most once per
    FLUSH_INTERVAL. A non-text event (tool call, approval, TurnEnded, …)
    flushes first, unconditionally, before it is applied/forwarded.
    │
    │  one coalesced batch, not one call per raw chunk
    ▼
    ├──► ConversationsStore (one `update_global`), inside `TextBatch::drain`:
    │    conv.append_streaming_content(batch). Read when switching back to a
    │    background conversation, and at finalisation to move the full
    │    response into history.
    │
    └──► StreamManager.handle_chunk() (one entity update)
             LAYER 2 — StreamState.pending_text: buffers again and emits
             TextChunk when `should_flush_text` says so. Any non-text chunk
             flushes pending text first, so a tool-start row can never
             appear before the sentence that preceded it.

Both layers apply the same flush policy (should_flush_text in stream_manager.rs, pub(crate) so message_ops_internals.rs can share it) rather than two independently-tuned ones. Layer 1 exists to cut how often the hot path pays for an update_global + a StreamManager entity update; layer 2 is what still governs the UI-facing TextChunk emission rate.

Which timer drains which (AGE-372). StreamManager owns the only timer — flush_timer, spawned lazily by ensure_flush_timer and ticking every FLUSH_INTERVAL until no stream remains. It, and every other flush inside the manager, goes through one function, StreamManager::flush_text, which drains layer 1 first (into pending_text) and then emits layer 2. That is why the manager holds a handle on the sink’s batch at all: layer 1 is the layer that actually buffers, it lives inside the turn’s future, and

  • a pause after a burst leaves the tail in layer 1 with no further event to push it out — only the timer reaching into layer 1 paints it; and
  • stop_stream, cancel_pending and both register_*_stream supersede paths drop the turn’s task, taking DesktopSink and anything still in layer 1 with it. Each of them calls flush_text before the drop, so the tail reaches Conversation.streaming_message — which is exactly what finalize_stopped_stream persists — instead of being lost.

TextBatch::drain is the single path buffered text takes into the conversation, so a drain driven by the manager and a flush driven by the sink cannot disagree about what was applied.

At finalisation, finalize_completed_stream / finalize_stopped_stream read the accumulated text from Conversation.streaming_message, call conv.finalize_response() to move it into history, then clear streaming_message.

Sequence diagrams

Send message

sequenceDiagram
    participant U as User
    participant CIS as ChatInputState
    participant CA as ChattyApp
    participant SM as StreamManager
    participant CS as ConversationsStore
    participant CV as ChatView
    participant LLM as LLM API

    U->>CIS: Press Enter
    CIS->>CA: cx.emit(ChatInputEvent::Send)
    CA->>CS: active_id()

    Note over CA: Create cancel_flag

    CA->>CA: cx.spawn(async task)
    CA->>SM: register_stream(conv_id, task, cancel_flag)
    SM-->>CA: StreamStarted { conv_id }
    CA->>CIS: set_streaming(true)

    Note over CA: Inside async task:
    CA->>CS: conv.add_user_message_with_attachments()
    CA->>CV: add_user_message()
    CA->>CV: start_assistant_message()
    CA->>LLM: stream_prompt()

    loop Each raw chunk from LLM
        LLM-->>CA: SessionEvent::Text
        CA->>CA: DesktopSink.text_batch.push(text)
        Note over CA: buffered; only flushed per<br/>should_flush_text (first chunk,<br/>then every FLUSH_INTERVAL)
    end
    Note over CA,SM: On flush (first chunk, every FLUSH_INTERVAL,<br/>or before a non-text event):
    CA->>CS: conv.append_streaming_content(batch)
    CA->>SM: handle_chunk(conv_id, Text(batch))
    SM-->>CA: TextChunk { conv_id, text } (own batching)
    Note over SM: flush_timer ticks every FLUSH_INTERVAL and<br/>drains the sink's batch first, so a stall<br/>after a burst still paints (AGE-372)
    CA->>CV: append_assistant_text(text)

    LLM-->>CA: StreamChunk::Done
    CA->>CV: extract_current_trace()
    CA->>SM: set_trace() + finalize_stream()
    SM-->>CA: StreamEnded { Completed }
    CA->>CIS: set_streaming(false)
    CA->>CV: finalize_assistant_message()
    CA->>CS: session.finish_turn() — commits the reply, prices the usage (AGE-351)
    CA->>CA: Generate title, persist

Note

When no conversation is active yet, the same flow runs with three differences: the stream is registered with register_pending_stream(task, resolved_id, cancel_flag) under the key "__pending__", StreamStarted is emitted for "__pending__", and the async task first calls create_new_conversation(), writes the real id into resolved_id, and calls promote_pending(conv_id) before adding the user message. See Pending stream promotion.

Switch conversation during an active stream

sequenceDiagram
    participant U as User
    participant SB as SidebarView
    participant CA as ChattyApp
    participant SM as StreamManager
    participant CS as ConversationsStore
    participant CV as ChatView
    participant BG as Background stream (conv A)

    Note over CV: Currently showing conv A (streaming)

    U->>SB: Click conversation B
    SB->>CA: cx.emit(SidebarEvent::SelectConversation("B"))
    CA->>CA: load_conversation("B")
    CA->>CS: set_active("B")
    CA->>SM: is_streaming("B")? → false
    CA->>CV: set_conversation_id("B")
    CA->>CV: load_history(B's messages)

    Note over CV: Now showing conv B (not streaming)

    par Conv A continues in background
        BG->>CS: conv_A.append_streaming_content(text)
        BG->>SM: handle_chunk("A", TextChunk)
        SM-->>CA: TextChunk { conv_id: "A" }
        CA->>CV: view.conversation_id() == "B" ≠ "A"
        Note over CA: Event skipped (UI filter)
    end

    Note over U: User switches back to A

    U->>SB: Click conversation A
    SB->>CA: cx.emit(SidebarEvent::SelectConversation("A"))
    CA->>CA: load_conversation("A")
    CA->>CS: set_active("A"), get streaming_message
    CA->>SM: is_streaming("A")? → true
    CA->>CV: set_conversation_id("A")
    CA->>CV: load_history(A's messages)
    CA->>CIS: set_streaming(true)
    CA->>CV: start_assistant_message()
    CA->>CV: append_assistant_text(accumulated_content)

    Note over CV: Restored; new chunks match conv_id again

Stop stream

sequenceDiagram
    participant U as User
    participant CIS as ChatInputState
    participant CA as ChattyApp
    participant SM as StreamManager
    participant CS as ConversationsStore
    participant CV as ChatView

    U->>CIS: Click Stop
    CIS->>CA: cx.emit(ChatInputEvent::Stop) → stop_stream()
    CA->>CS: active_id() → conv_id
    CA->>CV: extract_current_trace()
    CA->>SM: set_trace(conv_id, trace_json)
    CA->>SM: stop_stream(conv_id)

    Note over SM: flush_text: drains the DesktopSink<br/>batch into the conversation and emits<br/>it, BEFORE the task is dropped (AGE-372)
    SM->>CS: conv.append_streaming_content(tail)
    SM-->>CA: TextChunk { conv_id, tail }

    Note over SM: Sets cancel_flag = true<br/>Sets status = Cancelled<br/>Drops task (backstop)

    SM-->>CA: StreamEnded { conv_id, Cancelled }
    CA->>CIS: set_streaming(false)
    CA->>CA: finalize_stopped_stream()
    CA->>CV: mark_message_cancelled()
    CA->>CS: Read streaming_message, finalize_response(partial_text)
    CA->>CS: conv.set_streaming_message(None)
    CA->>CA: persist_conversation()

Cancel pending (New Chat while a stream is starting)

sequenceDiagram
    participant U as User
    participant SB as SidebarView
    participant CA as ChattyApp
    participant SM as StreamManager
    participant CIS as ChatInputState

    Note over SM: __pending__ stream exists

    U->>SB: Click New Chat
    SB->>CA: cx.emit(SidebarEvent::NewChat)
    CA->>SM: cancel_pending()

    Note over SM: Sets cancel_flag for __pending__<br/>Emits StreamEnded { "__pending__", Cancelled }

    SM-->>CA: StreamEnded { "__pending__", Cancelled }
    CA->>CIS: set_streaming(false)

    Note over CA: Skip finalize_stopped_stream<br/>(no real conversation exists)

    CA->>CA: create_new_conversation()

Cancellation mechanism

StreamManager uses Arc<AtomicBool> cancellation tokens rather than dropping tasks:

cancel_flag = Arc<AtomicBool::new(false)>
    │
    ├── Shared with the stream loop, checked at the top of each iteration:
    │     if cancel_flag.load(Relaxed) { break; }
    │
    └── Owned by StreamState; set by stop_stream / cancel_pending / stop_all:
          state.cancel_flag.store(true, Relaxed);

The stream exits cleanly on its next iteration instead of being cut off mid-chunk. The task drop in stop_stream is a backstop for a loop that does not observe the flag in time.

Pending stream promotion

When sending a message creates a new conversation, the stream starts before the conversation id is known:

1. register_pending_stream()        → stored under the "__pending__" key
2. Async: create_new_conversation() → returns the real conv_id
3. promote_pending(conv_id)         → moves the entry from "__pending__" to conv_id

pending_resolved_ids keeps the Arc<Mutex<Option<String>>> so that stop_stream and is_streaming can match a pending stream to its resolved conversation id even before promote_pending runs.

Lifecycle: init and shutdown

Init (main.rs): StreamManager is created as a GPUI entity and stored as a strong Entity<StreamManager> in GlobalStreamManager (a GlobalStrongEntity); a weak reference would let the entity be dropped once the initialisation closure returns.

Shutdown (the Quit action): StreamManager.stop_all() sets every cancel flag, emits StreamEnded for each active stream, and clears the map.

Research connection (M0 Trace)

StreamState.trace_json and ATIF export (exporters/types.rs) are the production trace surface. M0 adds per-module attribution, round-trip Deserialize, and an opt-in Recorder — see app ↔ research bridge.

Rendering system

When to read this: You are changing how assistant text becomes GPUI elements — thinking blocks, code, math, mermaid — or debugging a re-parse on every frame.

Architecture overview

The rendering pipeline is a multi-stage transformation:

Raw LLM text
  → parse_content_segments()     extract <think> blocks
  → parse_markdown_segments()    extract fenced code blocks
  → parse_math_segments()        extract LaTeX expressions
  → CachedParseResult            structured, cacheable IR
  → render_from_cached()         GPUI element tree

Every message passes through the pipeline once (or incrementally while streaming). The result is cached as a CachedParseResult, so later GPUI frames read from the cache instead of re-parsing.

Key design principle: parsing and rendering are separated. Parsing lives in views/message_parsing.rs and views/math_parser.rs (pure functions, independently testable); GPUI element construction lives in views/message_component.rs and views/message_math_render.rs, with one component module per block kind (code_block_component.rs, math_renderer.rs, mermaid_component.rs). The caches are in views/parsed_cache.rs. All paths are under crates/chatty-gpui/src/chatty/.

flowchart TD
    CV["ChatView::render_message_list()"] --> RM["render_message()"]
    RM -->|streaming| BS["build_streaming_parse_result()<br/>incremental, reuses previous frame"]
    RM -->|finalized| BC["build_cached_parse_result()<br/>full parse, keyed cache"]
    BS --> IR["CachedParseResult"]
    BC --> IR
    IR --> RF["render_from_cached()"]
    RF --> Think["thinking block"]
    RF --> Code["CodeBlockComponent<br/>tree-sitter styles"]
    RF --> Math["render_math_segments()<br/>MathComponent + text"]
    RF --> Mermaid["MermaidComponent<br/>SVG or source fallback"]
    Math -.-> MRS["MathRendererService<br/>MiTeX → Typst → SVG"]
    Mermaid -.-> MMS["MermaidRendererService<br/>mermaid-rs-renderer → SVG"]

Pipeline stages

Stage 1: content segment extraction

parse_content_segments() splits the message at <think>, <thinking> and <thought> tag boundaries. An unclosed tag (common while streaming) is treated as an incomplete thinking block.

#![allow(unused)]
fn main() {
enum ContentSegment {
    Text(String),
    Thinking(String),
}
}

Stage 2: markdown segment extraction

parse_markdown_segments(text, streaming) uses CODE_BLOCK_REGEX to pull out fenced code blocks. In streaming mode it also recognises an opening fence with no closing fence yet (detect_incomplete_code_block()).

#![allow(unused)]
fn main() {
enum MarkdownSegment {
    Text(String),
    CodeBlock { language: Option<String>, code: String },
    IncompleteCodeBlock { language: Option<String>, code: String },  // streaming only
}
}

Stage 3: math segment analysis

parse_math_segments(text) scans each text segment for LaTeX delimiters — inline $…$ and \(…\); block $$…$$, \[…\], ```math, ```latex; and \begin{equation} / align / cases environments. It handles escaped dollars, nested environments with depth tracking, and decides block-vs-inline for $$ from the surrounding blank lines.

#![allow(unused)]
fn main() {
enum MathSegment {
    Text(String),
    InlineMath(String),
    BlockMath(String),
}
}

Stage 4: cache building

build_cached_parse_result() applies the expensive work once per message: tree-sitter syntax highlighting for code blocks (syntax_highlighter::highlight_code), math parsing for text segments followed by resolve_math_segments() — which asks MathRendererService::render_to_styled_svg_file() for every equation’s SVG path and stores it — and MermaidRendererService::render_to_svg_file() for ```mermaid blocks. Every path lookup happens here, not in the render pass (AGE-394).

#![allow(unused)]
fn main() {
struct CachedParseResult {
    segments: Vec<CachedContentSegment>,
}

enum CachedContentSegment {
    Text(Vec<CachedMarkdownSegment>),
    Thinking(String),
}

enum CachedMarkdownSegment {
    TextWithMath(Vec<CachedMathSegment>),
    CodeBlock(CachedCodeBlock),           // language + code + pre-computed styles
    IncompleteCodeBlock { language, code },
    MermaidDiagram { source, svg_path: Option<PathBuf> },
}

enum CachedMathSegment {                  // MathSegment + the resolved SVG path
    Text(String),
    InlineMath { latex, svg_path: Option<PathBuf> },
    BlockMath { latex, svg_path: Option<PathBuf> },
}
}

Stage 5: element construction

render_from_cached() dispatches each CachedMarkdownSegment to its component: CodeBlock and IncompleteCodeBlock to CodeBlockComponent (the latter with no highlight styles), TextWithMath to render_math_segments() (which interleaves MathComponent images with text, each built from its stored svg_path — the render pass never touches MathRendererService, so no digest, stat or Typst compile runs per equation per frame), and MermaidDiagram to MermaidComponent, which falls back to the monospace source when no SVG was produced.

Math SVGs come from MathRendererService in chatty-core (LaTeX → MiTeX → Typst → SVG, theme colour injected, cached on disk; see the Math Cache notes in CLAUDE.md). Mermaid SVGs come from MermaidRendererService (mermaid-rs-renderer, in-process, output sanitised and cached on disk).

Caching strategy

Cache key

#![allow(unused)]
fn main() {
struct ContentCacheKey(u64);  // FxHash(content + is_dark_theme + foreground colour)
}

The foreground colour is part of the key because the styled math SVG paths inside a result are colour-specific (ThemeKey::current(cx) gathers both theme inputs). The hash is FxHasher, not SipHash: it runs over the whole message on every frame the message is visible.

The key includes the theme mode because highlight styles are theme-dependent; a message that is viewed in both themes gets two entries.

Cache lifecycle

ChatView owns both caches and passes them into rendering through MessageRenderCaches:

#![allow(unused)]
fn main() {
struct MessageRenderCaches<'a> {
    parsed: &'a mut ParsedContentCache,              // finalized messages
    streaming: &'a mut Option<StreamingParseState>,  // the current stream only
}
}

Finalized messages are looked up in ParsedContentCache by ContentCacheKey; on a miss, build_cached_parse_result() runs once and the result is inserted.

Streaming messages use StreamingParseState (next section). When the stream finalises, the streaming state is dropped and the finalized content enters the persistent cache on its next render.

On each frame render_message_list() moves streaming_parse_cache out of self, hands it only to the message that is actually streaming (every other message gets a throwaway &mut None), and moves it back afterwards, so the incremental state is never shared between messages.

Eviction and invalidation

ParsedContentCache is bounded to MAX_ENTRIES (200) by insertion order.

EventAction
Conversation switch (load_history, clear_messages)parsed_cache.clear(), streaming_parse_cache = None
Stream finalisationstreaming_parse_cache = None
Theme changeNew ContentCacheKey; old entries age out

Streaming incremental reuse

While streaming, content only grows at the end. build_streaming_parse_result() exploits that with two levels of reuse.

Level 1 — content segments. If the segment count is unchanged and the content only grew, every segment except the last is cloned from the previous StreamingParseState; only the last is re-parsed.

Previous: [Thinking("..."), Text("stable text"), Text("growing...")]
Current:  [Thinking("..."), Text("stable text"), Text("growing... more")]
                                                       ↑ only this re-parsed

Level 2 — markdown segments. Within that last text segment, if the markdown segment count is unchanged, all but the last markdown segment are reused. Completed code blocks are matched by try_reuse_code_block() (language + code equality) so their tree-sitter styles are not recomputed.

#![allow(unused)]
fn main() {
struct StreamingParseState {
    result: CachedParseResult,
    content_len: usize,            // growth detection
    content_segment_count: usize,  // level-1 reuse
    last_text_md_count: usize,     // level-2 reuse
}
}

Transition cost. When a segment count changes — a fence closes, a think tag completes — the affected segment is re-parsed from scratch (parse_content_segment_streaming_fresh()) for that one frame, and incremental reuse resumes on the next.

Token tracking & context window display

When to read this: You need to know how the footer’s context-window estimate is computed, when it is refreshed, and how the provider’s real counts get folded in.

Chatty estimates token usage before each LLM call and updates the estimate with the provider’s real counts after the response. What happens when the estimate crosses the budget is described in context-compaction.md.

Architecture

send_message() / handle_regeneration()
  └─ run_llm_stream()
       ├─ gather_snapshot_inputs()        # GPUI thread — reads globals, warms cache
       │    └─ tokio::spawn_blocking      # background — BPE-counts history + user message
       │         └─ GlobalTokenBudget::publish()   # writes to watch::Sender
       │              └─ main.rs watcher task      # detects channel change
       │                   └─ cx.refresh_windows() # triggers re-render
       │                        └─ TokenContextBarView reads receiver.borrow()
       │
       └─ stream_prompt()                 # starts in parallel with token counting
            └─ finalize_completed_stream()
                 └─ GlobalTokenBudget::update_with_actuals()  # patches snapshot with real counts

Core types live in crates/chatty-core/src/token_budget/ (snapshot.rs, counter.rs, cache.rs, summarizer.rs); the GPUI glue is crates/chatty-gpui/src/chatty/token_budget/manager.rs and the footer view is crates/chatty-gpui/src/chatty/views/footer/token_context_bar_view.rs.

Data flow: estimated snapshot

1. gather_snapshot_inputs() — GPUI thread, synchronous

Reads from globals before handing off to the background thread:

  • Active conversation’s model_identifier, max_context_window, preamble
  • response_reserve from TokenTrackingSettings (default 4096)
  • Tool count from ExecutionSettingsModel + enabled MCP servers (build_tool_hint())
  • Warms GlobalTokenBudget::cache for preamble and tool tokens (hash-checked; BPE only when content changes)

Returns None (skips counting) if max_context_window is not configured for the model.

2. compute_snapshot_background() — tokio::spawn_blocking

Runs BPE token counting off the UI thread:

  • Preamble — counted via BPE if the cache is cold; reused if the hash matches
  • Tool definitions — counter.estimate_tool_tokens(tool_count), a per-schema estimate rather than a count of the real schemas
  • Conversation history — count_history(): a BPE count of the text content of every rig_core::completion::Message entry, with non-text parts charged a fixed per-item estimate (see “Known limitations”); counted fresh every turn
  • Latest user message — plain text from UserContent::Text variants (extract_user_message_text()); images and PDFs are skipped

Publishes the completed TokenBudgetSnapshot to GlobalTokenBudget::sender.

3. Watch channel → window refresh

A task spawned in main.rs loops on receiver.changed().await and calls cx.refresh_windows() whenever a new snapshot arrives, bridging the tokio channel into GPUI’s render cycle so TokenContextBarView (a RenderOnce element) re-renders with fresh data.

Data flow: actual counts

After the stream completes, finalize_completed_stream() takes the provider’s real counts from StreamEnded { token_usage: Option<TokenUsage>, .. } and calls:

#![allow(unused)]
fn main() {
cx.global::<GlobalTokenBudget>().update_with_actuals(input_tokens, output_tokens);
}

update_with_actuals() uses watch::Sender::send_modify to patch the existing snapshot in place, setting actual_input_tokens and actual_output_tokens. The watcher sees the change and triggers another re-render, so the popover shows actual counts next to the estimate.

Usage lines and cost (AGE-682)

A usage record holds facts, not prices. Every per-request ApiCallUsage, and every TokenUsage line built from them, carries its token counts plus:

  • model: Option<ModelRef { provider, model_id }> — the provider and the model id as sent to it, stamped by llm_service::stream_prompt from AgentClient::model_ref();
  • at — when the request (or the line’s last request) finished;
  • duration_ms — how long it took.

Cost is computed when the lines are read, by one function: token_usage::price(lines, &PriceBook) -> Cost. PriceBook looks up (model, at) and returns a TokenPricing; locally it is ModelsModel::price_book(), the roster’s current ModelConfig::token_pricing(). A line with per-request records is the sum of its requests, each priced at its own model. A line whose model is unknown or has no prices is unpriced: its estimated_cost_usd is None and Cost::unpriced_lines counts it; it is never shown as $0.

AgentSession::finish_turn prices every line of the turn with the session’s book (set by the owner with set_price_book) plus the conversation’s bound model, and caches the result in estimated_cost_usd; ConversationTokenUsage::total_estimated_cost_usd is the sum of those caches, so pricing the persisted lines with the same book reproduces it exactly. Nothing on the A2A wire carries a price: metadata.usage holds the token totals and lines, each with its model, tokens, at (unix ms) and durationMs.

There is no backward compatibility: a line persisted before AGE-682 loads with model: None, its stored cost is dropped on load, and it shows as unpriced.

A context compaction’s summary call (AGE-683) is counted with the turn it ran in, as its own ApiCallUsage naming the utility model that served it.

The local spend gate (DP-3)

A task’s own dollar budget is enforced in-process by LocalSpendGate (services/spend_gate.rs, production code). It holds the usage lines the run has seen so far — its own model calls and every callee’s reported lines — and prices them on read with the same price(lines, &PriceBook) the display uses. Its cap is the spec’s [budget] cap_usd (per task; not TokenTrackingSettings.cap_usd, the hosted monthly cap below), narrowed by what a caller had left when it delegated the run. With no cap the gate is inactive. A line whose model the book does not price adds nothing: the gate is inactive for that line, and the broker’s edge-log row for that call says usd: unpriced.

The gate is the dollar part of the run’s RunBudget (services/run_budget.rs), which also counts tool turns and the clock. invoke_agent sends what is left with every call (InvokeAgentParams.remaining); the broker narrows the call chain with it and refuses a call whose turns, time or dollars are used up with budget_spent: turns|seconds|usd before anything is spawned. The callee runs under the tighter of its own spec’s budget and what its caller left it, carried on its task frame (budget). chatty-tui’s headless runner keeps the budget current (its turn cap and clock, the tool turns spent, its own usage lines, priced with the roster’s book); invoke_agent records each callee’s lines as the delegation ends.

Research connection (GEPA / ACE)

Context headroom and token cost feed optimizer economics (cost model) and ACE playbook growth bounds. See app ↔ research bridge.

TokenBudgetSnapshot

#![allow(unused)]
fn main() {
pub struct TokenBudgetSnapshot {
    pub computed_at: Instant,
    pub model_context_limit: usize,          // from ModelConfig.max_context_window
    pub response_reserve: usize,             // from TokenTrackingSettings
    pub preamble_tokens: usize,              // BPE or cache
    pub tool_definitions_tokens: usize,      // estimated from tool count
    pub conversation_history_tokens: usize,  // BPE of history *before* current message
    pub latest_user_message_tokens: usize,   // BPE of current user text only
    pub actual_input_tokens: Option<usize>,  // set after stream ends
    pub actual_output_tokens: Option<usize>,
    pub conversation_id: String,
}
}

Key derived values:

MethodFormula
effective_budget()model_context_limit - response_reserve
estimated_total()preamble + tools + history + user_msg
remaining()effective_budget - estimated_total
utilization()estimated_total / effective_budget (clamped 0–1)
estimation_delta()actual_input - estimated_total (signed; Some only when actuals present)

check_pressure(snapshot, settings) turns a snapshot into a ContextPressureEvent (HighPressure at high_threshold, CriticalPressure at critical_threshold).

TokenCounter — accuracy notes

Uses tiktoken-rs static BPE instances (initialised once globally, ~50 ms first call):

EncodingModels
o200k_basegpt-4o*, o1*, o3*, o4*
cl100k_baseEverything else (Claude, Gemini, Mistral, Ollama, GPT-4)

Accuracy by provider:

  • OpenAI cl100k/o200k families — exact
  • Claude — ±5% (cl100k approximation)
  • Gemini — ±10–15% (SentencePiece differs significantly; always labelled ~)
  • Mistral / Ollama — ±5–10%

Known limitations:

  • The latest user message component skips images and PDFs entirely — extract_user_message_text() only concatenates UserContent::Text variants. History counting differs: TokenCounter::count_message() walks each message’s content parts and BPE-counts the text (plain text, tool-result text, tool-call name and arguments), substituting a fixed NON_TEXT_CONTENT_TOKENS estimate (1000) for each image, document, audio or video part and each non-text tool-result item. Before AGE-229 it JSON-serialised and fully BPE-tokenized the base64 payload, which was slow and produced a number unrelated to what a provider actually bills (images are priced per pixel or tile). Either way, a conversation with many large images is only ever a rough estimate.
  • History is counted before the new user message is appended to the conversation model, so the snapshot reflects the state at send time.
  • Tool results in history (e.g. web fetch responses) are counted, via count_message()’s per-content-part walk rather than a full-message JSON serialisation.
  • count_history() still re-tokenizes every entry on every call. chatty_core::token_budget::cache::HistoryTokenCache caches each entry’s count keyed by its index and a content hash, so a caller that holds one across turns recounts only the entries that changed. As of AGE-229 it exists and is unit-tested in chatty-core, but compute_snapshot_background() in the desktop manager does not yet hold one, so GlobalTokenBudget still does a full recount on every send.

CachedTokenCounts

Preamble and tool tokens rarely change between turns. The cache stores the last BPE count and invalidates on content-hash mismatch:

cache.preamble_tokens(&preamble_str, &counter)
    → hash(preamble) == stored_hash? return cached_count : recount + store

Tool tokens use a compact hint string encoding the tool configuration (build_tool_hint()), hashed the same way.

TokenTrackingSettings global

#![allow(unused)]
fn main() {
pub struct TokenTrackingSettings {
    pub enabled: bool,                          // show bar (default: true)
    pub response_reserve: usize,                // output headroom (default: 4096)
    pub high_threshold: f64,                    // amber at 0.70
    pub critical_threshold: f64,                // red at 0.90
    pub auto_summarize: bool,                   // auto-summarize at critical (default: false)
    pub summarization_model_id: Option<String>, // override model for summarization
    pub cap_usd: Option<f64>,                   // hosted monthly spend cap, USD (default: None)
}
}

Note

Since AGE-381, TokenTrackingSettings is the token_tracking settings family: token_tracking_repository() (TokenTrackingRepository, JSON at token_tracking.json in the config directory, in-memory in tests) is a field on SettingsSnapshot / SettingsDelta and is covered by the AGE-280 conformance suite. main.rs registers TokenTrackingSettings::default() as the global for the first frame and a spawned task replaces it with the loaded file (defaults on a missing or unreadable file). No settings page edits it yet; hive stores the same struct as the user’s JSON. cap_usd is omitted from the output when unset, so a file written before the field existed round-trips unchanged.

cap_usd (AGE-416 / ADR-0010) is only ever set by hive. Nothing in chatty2 reads it: the enforcement is a SpendGate (services/spend_gate.rs) that hive implements and sets on AgentBuildContext.spend_gate, which invoke_agent asks before it starts a delegation. With no gate — the desktop, chatty-tui — there is no check. A spec’s per-task cap_usd is the local spend gate’s, above, not this one.

A delegation’s spend is part of the conversation’s total: invoke_agent reads the worker’s metadata.usage lines off the terminal status and AgentSession records each as a TokenUsage line marked delegated_to, priced at the model the line names (AGE-415, AGE-682). Nested leaders forward their workers’ lines with their own, merging only lines on the same model. context_tokens and last_usage stay the leader’s own. Details in agents-and-specs.md.

Read by:

  • gather_snapshot_inputs() — response_reserve
  • check_pressure() — high_threshold, critical_threshold
  • finalize_completed_stream() — auto_summarize
  • TokenContextBarView — should_show_bar() (hides the bar when enabled = false)

The bar shows one segment per snapshot component — preamble, tools, history, latest message, remaining — each sized as its share of effective_budget(). The border switches to the warning colour at high_threshold and the critical colour at critical_threshold.

The popover lists the estimate and utilisation, one row per component, the provider’s actual counts with the signed estimation delta once the stream has ended, and session totals (cumulative input/output/cache tokens and cost from ConversationTokenUsage, which is separate from the snapshot). Manual summarisation is the /compact slash command, not a button in the bar.

Stale snapshot guard: read_budget_snapshot() checks snap.conversation_id == active_conversation_id. On conversation switch, load_conversation() calls GlobalTokenBudget::clear() (publishes None), so the bar shows an empty state until a snapshot for the new conversation arrives.

Context compaction

When to read this: You need to know what Chatty does to a long conversation history before it goes to the model, and what /compact and auto-summarise change.

Two mechanisms keep a conversation inside the model’s context window: the per-request guard, which never calls the model and runs before every model call of a turn, and summarisation, which rewrites the persisted history and runs only on demand or at critical pressure.

flowchart LR
    hist["Conversation history"] --> guard["ContextShaper hook<br/>per model call, in memory"]
    guard --> req["Request to the model"]
    hist -. "/compact, or auto-summarise<br/>at critical pressure" .-> sum["summarize_oldest_half()<br/>one LLM call"]
    sum --> rep["Conversation::replace_history()<br/>persisted"]
    rep --> hist

The per-request guard

Every chat agent carries a ContextShaper (crates/chatty-core/src/services/context_shaper.rs) as a rig AgentHook. Before each model call of a run — the first one and every call after a tool round-trip — the hook measures the history that call would send and, only when the request would not fit the model’s window, hands rig a shorter history for that one request (RequestPatch::history). The stored conversation is untouched, and a request that fits goes out byte for byte as recorded, so consecutive requests under budget share their prompt-cache prefix.

The guard runs inside the tool loop because that is where a turn’s context grows: a headless task is one user turn and up to max_agent_turns model calls, each appending a raw tool result. Before AGE-504 the shaper ran once per user turn, before the stream, and 62 of 165 GAIA trials died mid-loop on maximum context length is 32768 tokens.

Budget. In tokens, counted with the model’s TokenCounter (a typed tool’s JSON result counts by its serialized text, which is what the provider bills):

context_window − headroom − response_reserve − base − prompt
  • context_window: the model’s max_context_window; an assumed 32 768 when the model has none configured (CLI-discovered and headless models).
  • headroom: a tenth of the window, for what the count cannot see — the chat template’s per-message wrapping and the gap between the tiktoken estimate and the model’s own tokenizer (about 2k tokens on a 32k Qwen request with the full tool set).
  • response_reserve: the model’s max_tokens, or 4 096.
  • base: the preamble plus the tool schemas, measured once when the agent is built (calibrate_context_shaper in factories/agent_factory/mod.rs). With the full native tool set this is around 16k tokens — the tool block alone is ~52 KB of JSON.
  • prompt: the message the call is about to send (a user message or the latest tool results). It is never shaped.

Stages. Applied in order, cheapest information loss first, stopping as soon as the history fits. The most recent 8 messages are what the model is working with and stay whole for as long as possible.

#StageEffect
0Terminal contextStub every <terminal_context> snapshot a desktop user turn carried (AGE-587) but the newest one: [older terminal snapshot of "…" removed to save context]
1CapStub every tool result over 8 KB outside the tail ([tool result truncated — N chars] plus a 200-char preview)
2CompactReplace every tool result outside the tail with a one-liner ([compacted] …, 120 chars)
3SnipKeep the first 2 and last 8 messages, replace the middle with one marker; both ends of the cut move off a tool round-trip first (see below)
4Cap the tailStub, then one-line, tool results inside the tail, oldest first; the last message is never touched

A history still over budget after stage 4 is sent as is (a warning is logged): without summarising there is nothing left to take.

Neither half of a tool round-trip is dropped alone. Because the guard runs on every model call of a run, stage 3’s cut usually falls inside a live tool loop, where the message at the tail boundary is a tool result and the one before it is the assistant tool_calls message it answers. Splitting the two is a 400 from every OpenAI-compatible provider — messages with role 'tool' must be a response to a preceding message with 'tool_calls' — on the request the guard itself builds. So the kept tail starts no later than the assistant message issuing the calls it answers, and the kept head ends no later than the last call the snip still answers; the boundaries only ever move backwards, keeping a message or two more than asked. A result whose call is nowhere in the history is left where it is: that history was already malformed, and moving the cut cannot repair it (AGE-512; the companion repair at recording time is AGE-485).

Both rules live in one validator, services::enforce_tool_round_trips, and the guard runs it on every outgoing request’s history — not just the ones it shaped — so a conversation persisted malformed by anything else is repaired on its way out too (AGE-513). A history that is already intact is returned untouched, which is what keeps the byte-for-byte property above. The check takes the ids answered by the request’s prompt, which rig passes separately from the history: mid-tool-loop that prompt is the result answering the history’s last call, and synthesizing a second answer for it would be its own 400.

The one result that can never fit. The prompt — the latest tool results — is never shaped, and a single result can be larger than the whole budget (a 237 KB shell dump was, in the AGE-500 rerun). That is caught where the result is recorded: the same hook’s on_tool_result cuts a result over two fifths of the history budget (floor 512 tokens) down to the cap, once, with a header saying how much was kept. The cut form is what the transcript shows, what the conversation persists and what every later request carries, so the append-only property holds for it too. On a 32k model with the full tool set that cap is ~4k tokens (~16 KB); on a 200k model it is ~70k tokens and never bites in practice. Once a run is over budget the tail boundary moves by one message per call, so requests stop sharing a prefix until it fits again — the price of not crashing, and what the planned engine below removes.

Summarisation

summarize_oldest_half (crates/chatty-core/src/token_budget/summarizer.rs) takes the first len / 2 messages, renders them as a plain-text transcript (non-text content is described, not embedded), and asks the conversation’s own agent for a dense bullet summary that preserves decisions, code and paths, commands and outcomes, and open items. The result replaces those messages with one user message:

[CONVERSATION SUMMARY — N messages compressed]

- …

followed by the untouched second half. Conversation::replace_history(new_history, midpoint) persists it, carrying the kept tail’s metadata (traces, attachments, timestamps, feedback) across. Histories shorter than 4 messages are refused and a failed LLM call leaves the conversation unchanged.

It is reached from two places:

  • /compact in either frontend (app_controller/slash_commands.rs, chatty-tui/src/engine/commands.rs), which posts an info line with the number of messages summarised and the estimated tokens freed.
  • Auto-summarise on the desktop: after a turn completes and the token snapshot has been patched with the provider’s actual counts, finalize_completed_stream checks TokenTrackingSettings.auto_summarize (default false) and the snapshot’s CriticalPressure status (default 90 % of max_context_window − response_reserve), and runs the summariser in the background. See token-tracking.md for how the snapshot is computed.

TokenTrackingSettings.summarization_model_id is stored but not honoured: summarize_with_model is a stub that always errors, so summaries always use the conversation’s model.

Warning

The midpoint cut ignores exchange boundaries. Tool turns are persisted as rig produced them (assistant tool call, user tool result, final text), so a cut can land between a tool call and its result; OpenAI-compatible endpoints reject the orphaned call on the next request. Prefer /compact when the recent history is plain text.

Planned

A generational compaction engine is designed but not implemented (AGE-248): a trigger on the token estimate, cuts only at exchange boundaries with the most recent exchanges kept whole, a structured summary rendered as a collapsible card, summarisation on a cheaper utility model, compaction events on both frontends, and auto-summarise on by default. It persists its result, so consecutive requests share a stable prompt-cache prefix even over budget; the per-request guard stays as the last line of defence inside a turn.

Agent Memory System

When to read this: You are touching the remember / search_memory / save_skill tools or the MemoryService behind them.

Chatty includes a persistent memory system that allows the AI agent to store and recall information across conversations and app restarts. It is built on memvid-core, a lightweight vector database with hybrid similarity + full-text search.

Overview

┌─────────────┐       ┌───────────────┐       ┌──────────────┐
│  LLM Agent  │──────▶│ MemoryService │──────▶│  memory.mv2  │
│  (rig-core) │◀──────│  (singleton)  │◀──────│  (on disk)   │
└─────────────┘       └───────────────┘       └──────────────┘
    uses tools:            dedicated memvid       binary file,
    RememberTool           thread behind an       persisted in
    SearchMemoryTool       async command API      data directory
    SaveSkillTool

The agent has three tools backed by this store:

ToolPurpose
rememberStore a piece of information with optional title and tags
search_memoryRetrieve relevant memories via natural-language query
save_skillStore a reusable multi-step procedure as a memory entry

Storage

Memories are stored in a single binary .mv2 file (memvid format) in the platform-specific data directory:

PlatformPath
Linux~/.local/share/chatty/memory.mv2 (or $XDG_DATA_HOME/chatty/)
macOS~/Library/Application Support/chatty/memory.mv2
Windows%APPDATA%\chatty\memory.mv2

The file is created lazily on the first remember call and committed to disk after every write.

How It Works

Storing Memories

The RememberTool accepts:

  • content (required) — the information to store
  • title (optional) — a short label (e.g., “User prefers dark mode”)
  • tags (optional) — key-value metadata for categorization (e.g., {"project": "chatty", "topic": "ui"})

Content is stored as UTF-8 bytes with attached metadata via memvid-core’s put_bytes_with_options().

Searching Memories

The SearchMemoryTool accepts:

  • query (required) — a natural-language search string
  • top_k (optional) — max results to return (default: 5, range: 1–20)

Search uses memvid-core’s full-text index (the lex feature). When embedding_enabled is on and an embedding provider is configured, remember also stores an embedding (remember_with_embedding) and search_memory queries the vector index (search_vec) instead. Each result includes the stored text, optional title, and a relevance score.

Automatic Recall

The system prompt instructs the agent to call search_memory proactively whenever a question might benefit from stored context (preferences, prior decisions, project conventions), and to call remember — not just say “noted” — whenever the user asks it to remember something.

Architecture

Initialization

  1. At app startup, the memory_enabled setting is checked (enabled by default)
  2. If enabled, MemoryService::open_or_create() is called asynchronously
  3. The service is stored as a global singleton via cx.set_global()
  4. When an agent is created (via AgentFactory), the memory tools are conditionally injected only if the service exists

Key Types

crates/chatty-core/src/
├── services/
│   └── memory_service.rs    # MemoryService, MemoryHit, MemoryStats
└── tools/
    ├── remember_tool.rs     # RememberTool (rig_agent::tool::Tool impl)
    └── search_memory_tool.rs # SearchMemoryTool (rig_agent::tool::Tool impl)

MemoryService — the core service. It owns a dedicated OS thread that performs every memvid operation (open, index setup, search, put, commit) and talks to it over a command channel, so the async executor is never blocked and tantivy’s thread-sensitive index state stays on one thread:

#![allow(unused)]
fn main() {
#[derive(Clone)]
pub struct MemoryService {
    cmd_tx: mpsc::Sender<MemoryCommand>,
    path: PathBuf,
}
}

Public API:

MethodDescription
open_or_create(data_dir)Open existing or create new .mv2 store
remember(content, title, tags)Store a memory entry
search(query, top_k)Search memories by natural language
stats()Get entry count and file size
clear()Remove all stored memories
delete(frame_id) / list_memories(query, limit)Used by the Settings → Memory browser
remember_with_embedding() / search_vec()Vector variants used when semantic search is enabled

MemoryHit — a single search result:

#![allow(unused)]
fn main() {
pub struct MemoryHit {
    pub text: String,
    pub title: Option<String>,
    pub score: f32,                    // serialised as `relevance_score`
    pub source: Option<MemoryHitSource>,
    pub frame_id: Option<u64>,         // memvid frame id, not shown to the LLM
}
}

Graceful Degradation

  • Searching an empty store returns an empty result set (no errors)
  • If memory initialization fails, the agent simply runs without memory tools
  • The tools are only registered when MemoryService is available

Configuration

Memory is toggled via the memory_enabled field in ExecutionSettingsModel:

#![allow(unused)]
fn main() {
pub struct ExecutionSettingsModel {
    /// Enable persistent agent memory (remember/search_memory tools).
    #[serde(default = "default_true")]
    pub memory_enabled: bool,
}
}

This can be changed in the Settings → Memory page. When disabled, the memory service is not initialized and the agent has no memory tools.

Research connection (ACE / M4)

The memory + [SKILL] split is the production substrate for ACE playbooks. chatty-playbook will add Reflector/Curator delta ops and deterministic merge on top of this store — see the app ↔ research bridge.

Dependencies

memvid-core = { version = "2.0.139", default-features = false, features = ["lex", "temporal_track"] }

The lex feature enables lexical (full-text) indexing; temporal_track is enabled for memvid’s temporal search request options, which Chatty currently leaves unset.

Agents and specs

When to read this: You need to know how a conversation reaches an agent — a remote A2A service or a local agent spec the broker runs — how list_agents, invoke_agent and /agent find it, and where the broker and gateway fit.

WASM plugins are not agents: they are tools inside one, and have their own page, plugins.md.

Overview

Chatty supports two kinds of agents that can be invoked during a conversation:

Agent typeWhere it runsHow it’s calledConfigured in
Remote A2AExternal HTTP serviceDirect HTTP to the remote URLSettings → Extensions
Local agentA chatty-tui worker the broker spawns from an agent specVia the local Protocol Gateway / brokerAgent specs (.chatty/agents/), listed on Settings → Agents

Both are unified behind the same tools (list_agents, invoke_agent) and the same A2A JSON-RPC protocol, so the LLM does not need to know which kind it is talking to.

There is one kind of local agent (PL-U5): a spec, run by chatty’s harness, whether it came from the workspace, the data directory or the presets. A WASM module is not an agent (PL-D1 option B): it is a plugin, whose tools an agent spec loads into its own agent (plugins.md). No code path lists, routes or invokes a module as an agent, and module.toml refuses the keys that used to say it was one.

The local roster

The broker serves the roster: the specs module_settings.json’s virtual_agents names (a --team names its own), or — when nothing is declared — local-agent and every exposed spec of your own (workspace and data directory) and the built-in presets, first definition of each name (chatty_core::agent_spec::{load_roster, roster_names, exposed_specs}). So the researcher preset is on the default roster and an agent like any other, list_agents lists it, invoke_agent reaches it at /a2a/researcher, and /agent researcher … runs it. A spec that names its callers (a team-internal worker such as crosscheck-writer) is served for its lead but never offered to the root’s list_agents/invoke_agent. A spec file that does not load is left out of the roster with a warning; Settings → Agents and /agents show it with its error.

On the desktop, “the workspace” a conversation’s own list_agents/invoke_agent//agent resolve the roster from is its own working directory when it has one, else the shared default (chatty_core::agent_spec::roster_workspace) — the same value the broker itself resolves from when its gateway (re)builds. A conversation whose own resolved workspace does not match the workspace the running broker was actually built for gets no local agents rather than one it cannot reach (roster_workspace_matches, AGE-719): no broker running yet is not a disagreement, but a live one built for a different workspace is.

/agent and /agents

/agent <name> <prompt> resolves <name> the same way in both frontends (chatty_core::services::agent_command::resolve_agent_command): an enabled remote A2A agent first (as in invoke_agent), then a spec on the local roster; otherwise the whole text is the prompt for the default sub-agent. A remote agent is called over A2A. On both the desktop and chatty-tui, a spec — and the default sub-agent, as local-agent — is a turn of the conversation handed to that agent through the conversation’s own broker (TurnInput::delegation, chatty_core::session::Delegation, AGE-744/AGE-747): the model is not asked, the turn is the one invoke_agent call, so the delegation row and its swarm tree show exactly as for a model-issued call, and a worker that fails or exits ends the row with its error. It needs the conversation’s broker, like invoke_agent; the desktop publishes one whether or not the module runtime is on (AGE-759). There is no subprocess path: chatty-tui --agent <name> is still how you start the terminal app itself running as that spec, but /agent inside a running conversation never shells out to it. The desktop’s lazy broker hands the root its gateway’s direct transport (LazyGatewayBroker::transport), as a --broker chatty-tui root’s does. chatty-tui’s /agents lists the remote agents, the roster’s specs with model, profile, plugins and grants, and every spec file the roster leaves out with the reason; the desktop shows the same on Settings → Agents (read-only: edit a spec in its TOML file, then Reload).

Remote A2A agents

Configuration

Remote agents are added under Settings → Extensions (listed again on Settings → Agents) and persisted to a2a_agents.json via A2aJsonRepository.

Data model (A2aAgentConfig in crates/chatty-core/src/settings/models/a2a_store.rs):

#![allow(unused)]
fn main() {
pub struct A2aAgentConfig {
    pub name: String,           // User-visible name, also the invocation key
    pub url: String,            // Base URL (e.g. "https://hive.dev/a2a/voucher-agent")
    pub api_key: Option<String>,// Optional Bearer token
    pub enabled: bool,          // Toggle on/off
    pub skills: Vec<String>,    // Cached from agent card discovery
    pub allow_private_network: bool, // AGE-806: opt this agent into RFC-1918/CGN/ULA
}
}

Runtime connection status is tracked in A2aAgentsModel (a GPUI global) but not persisted — it is refreshed at startup by fetching agent cards.

Reaching a private network (AGE-806)

Every call goes through the SSRF guard’s GuardedResolver (crates/chatty-core/src/services/ssrf_guard.rs) with an AddressPolicy chosen per call from config.allow_private_network (A2aClient::http_for), not once at client construction: a2a_peer (default) or a2a_peer_with_bypass(true) (opted in). Both resolve a name once and check the addresses actually dialed — never a separate pre-check — so the rebinding protection from AGE-537/AGE-767 holds either way; the opt-in only widens which addresses the check admits.

With the opt-in, a name that resolves into RFC-1918, 100.64.0.0/10 (CGN/Tailscale) or ULA is admitted, using the same bypass semantics as the browser tool’s per-workspace toggle (check_public_host_with_bypass, AGE-459). A configured IP literal was already admitted unconditionally before this flag existed (the address is the user’s explicit choice, not resolved). Link-local (169.254.0.0/16, cloud metadata, and fe80::/10) is refused regardless of the flag, and localhost still means loopback and nothing else. The AGE-756 TLS rule is unchanged: plain http:// only ever works for a loopback host string, so a private-network agent reached by name needs https://.

Protocol

Remote agents implement the A2A protocol. Chatty is an A2A client (crates/chatty-core/src/services/a2a_client.rs).

Agent card discovery — GET <base_url>/.well-known/agent.json, with Authorization: Bearer <api_key> when configured. Chatty reads name / displayName, description, skills[].name (cached into A2aAgentConfig.skills) and capabilities.streaming.

Non-streaming (message/send):

POST <base_url>
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "message/send",
  "params": {
    "message": { "parts": [{ "type": "text", "text": "<prompt>" }] },
    "taskId": "<uuid>"
  }
}

Response text is extracted from result.artifacts[0].parts[0].text.

Streaming (message/stream): same body with "method": "message/stream". The response is an SSE (text/event-stream) stream where each event carries a JSON-RPC result:

data: {"jsonrpc":"2.0","id":1,"result":{"id":"task-123","status":{"state":"working"},"final":false}}

data: {"jsonrpc":"2.0","id":1,"result":{"id":"task-123","artifact":{"parts":[{"type":"text","text":"Hello"}],"index":0,"lastChunk":false}}}

data: {"jsonrpc":"2.0","id":1,"result":{"id":"task-123","status":{"state":"completed"},"final":true}}

Events are parsed into A2aStreamEvent:

VariantWhen
StatusUpdate { state: "working", message }Agent is processing; optional progress text
ArtifactUpdate { text, last_chunk }A chunk of the agent’s response
StatusUpdate { state: "completed", is_final: true }Terminal — stream ends
StatusUpdate { state: "failed", message }Terminal — error

If the server answers with a Content-Type other than text/event-stream, the client falls back to treating the body as a non-streaming message/send response.

Protocol gateway

The gateway (chatty-protocol-gateway) is a local HTTP server on a Unix socket, never a TCP port (ADR-0021 § 4): gateway.sock (the desktop) or gateway-<pid>.sock (a --broker leader) in $XDG_RUNTIME_DIR/chatty-run, else <cache dir>/chatty-run. That directory is created 0700 and checked before use (owned by this user, no group or other bits), or the gateway does not start. Every route requires the per-launch token, which is written to gateway.token (0600) beside the socket and never passed in argv. It serves every loaded plugin’s tools over MCP (plugins.md), and the broker’s agents (local participants and virtual agents) over A2A:

MethodPathProtocolDescription
GET/—JSON index of all modules and endpoints
GET/.well-known/agent.jsonA2AAggregated agent card (participants and virtual agents)
GET/a2a/{agent}/.well-known/agent.jsonA2APer-agent card
POST/a2a/{agent}A2AJSON-RPC: message/send, message/stream, tasks/get
POST/mcp/{module}MCPJSON-RPC: tools/list, tools/call, initialize
GET/mcp/{module}/sseMCPHTTP+SSE transport: the event stream
POST/mcp/{module}/sse?sessionId=…MCPHTTP+SSE transport: a client message (answered on the stream)

A plugin is served over MCP only, and only when its [protocols] mcp is set (otherwise 404). There is no OpenAI route and no A2A route for a plugin: it has tools, not a loop (PL-U3). Every route refuses a non-loopback Host or Origin with 403 (DNS rebinding) and a body over 10 MiB with 413. Each module has its own lock, so a slow call to one never holds up another; a guest reply over the 1 MiB output cap is a 502. tools/call hands the guest the arguments object JSON-encoded once, as tool-call-request.arguments-json. The per-route details are in crates/chatty-protocol-gateway/README.md.

A2A via the gateway

/a2a/{agent} reaches a local participant or a virtual agent (below); a message’s text parts are all joined into the task’s prompt. A module whose Hive metadata says execution_mode = "remote" is still forwarded to the hive-runner until PL-H8b removes that path.

GET /.well-known/agent.json returns a gateway-level card ({"schema_version": "0.1", "gateway": true, "agents": [...]}) listing every registered participant and virtual agent, each with its origin. A plugin is never on it.

Local participants (ADR-0011)

{agent} in the A2A routes above also resolves a local participant: a worker process the broker spawned on a connection it made for it, which published an agent card and answers tasks over that connection. ADR-0011 routes all fleet coordination — local and hosted — through this one broker rather than through a second fan-out path, so a child process and a hosted worker are the same thing to an A2A caller. Participants are looked up first, so a live process shadows a virtual agent of the same name.

The connection is the identity (ADR-0020, AGE-635). The broker admits a node, which names it <spec>-<n> (local-coder-0), creates a socketpair and hands one end to the child at descriptor 3 (--participant-fd 3); the child marks it close-on-exec as main’s first statement, so no shell or tool it starts inherits it. The child’s session.hello names nothing — its card’s name is ignored — and the hello’s result tells it its name, scope and owner. Nothing registers any other way: the shared participant socket stays bound and refuses every connection (a hello with an error for its id, anything else by closing it), so no local process can take a name the broker is about to route a task to.

The connection carries newline-delimited JSON, protocol v3 (ADR-0021 § 1, one FrameCodec per connection): every line is a request {"v":3,"id":…,"method":…,"params":…}, a result {"v":3,"id":…,"result":…}, an error {"v":3,"id":…,"error":{"kind":…,"message":…}} or a notification {"v":3,"method":…,"params":…}. Each side numbers its own requests; results, errors, req.progress and task.event name the receiver’s request, and req.cancel the sender’s. The broker sends work as task.run; the worker reports on it with task.event (kind status, artifact or swarm) and ends it with the task.run’s result, and the broker stops it with req.cancel. A method the peer may not send, a reused in-flight id or a line that does not decode closes the connection without a reply; a response naming nothing in flight is dropped. Every payload is typed (chatty_fabric::wire): an unknown field, a duplicate key or an unknown error kind does not decode either. There is no older version to fall back to. The gateway maps a task’s messages onto A2A status and artifact updates.

Workers call over the same connection (ADR-0020, BI-4, AGE-636). A worker’s invoke_agent, list_agents and send_message reach local roles and the broker’s directory as requests on its own connection, never over loopback HTTP:

DirectionMessageFields
worker → brokeragent.invoke, agent.list, mailbox.post requestsid (the worker’s, never reused on the connection), params (none for agent.list)
broker → workerreq.progress notificationid, event — {"Admitted": "…"} once the callee’s node is admitted, {"Step": "read_file"} for a line about the callee’s work, {"Text": "…"} for its answer as it streams
broker → workerresultid, result — for agent.invoke {success, response, error?, metadata?}, the callee’s terminal status as an A2A caller reads it (usage, trace, conversation and evidence ride in metadata); for agent.list the aggregated card’s agents array; for mailbox.post {"status":"pending","id":"msg-1"} or {"status":"refused","reason":"not_on_tree"} (see send_message)
broker → workererrorid, error: {kind, …, message} — the call could not run (unknown_agent, refused, spawn_context_refused, delegation, …); the kind’s fields ride beside it and message is display text only
worker → brokerreq.cancel notificationid — the worker withdraws its call, its approval or its question
worker → brokerhuman.approve requestid, params: {kind: exec|write, command_or_path, diff_stat?} — an approval only the root answers (EN-2a); the result is "approved" or "denied". The broker stamps the asker itself and never sends this to a worker
worker → brokerhuman.ask requestid, params: {questions: [{id, question, options}], origin?} — a question (EN-2b); the result is the answers, [{id, answer, custom}], or an error when nobody up the chain answered. The broker stamps the asker itself
broker → workerhuman.ask requestid (the broker’s), params: {question, request} — a callee’s question relayed to its caller, question the broker’s id for it and request as the asker sent it, asker stamped; the result is {"answers": […]} or "escalate"
broker → workerreq.cancel notificationid — the broker withdraws a relayed human.ask (or a task.run)
worker → {"v":3,"id":2,"method":"agent.invoke","params":{"agent":"local-reviewer","prompt":"review it","handle":null,"include_trace":false}}
broker → {"v":3,"method":"req.progress","params":{"id":2,"event":{"Step":"read_file"}}}
broker → {"v":3,"id":2,"result":{"success":true,"response":"Looks good.","metadata":{…}}}

The call says nothing about its caller: the broker runs it as the node the connection names (whose endpoint permit it releases, the edge log’s from), and writes one edge-log row per invoke_agent call when it ends and one message row per send_message call (<data_dir>/chatty/fabric/edges-<pid>.jsonl; list_agents is a directory read, not an edge, and writes none). Several calls can be in flight in one task; replies match by id, in whatever order the calls finish. A callee whose task failed is a result with success: false, not an error, so the caller renders it exactly as a failed A2A task. When a worker’s connection closes, the broker cancels every call still in flight on it, which reaps the workers those calls started — so cancelling a leader’s task reaps its whole subtree (invariant 11).

A question is a request that climbs the caller chain (ADR-0021 § 2, EN-2b, AGE-771). A worker’s ask_user parks its task on a human.ask request and gets the answers as its result. The broker overwrites the request’s asker with the connection’s admitted name and the chain of the call running it, gives the question an id of its own (question-N), and relays it to whoever made that call: a calling worker gets it as a broker→worker human.ask, the root as CallEvent::Ask { id, request }. A worker has no human, so it answers every relayed question escalate, and the broker forwards the original request, first stamp intact, to the next caller up — the root sees the agent that asked, never a relayer (escalated_question_keeps_first_stamp). The root’s invoke_agent asks it on its own clarification store, each question headed by the stamp and shown literally (ClarifyingQuestion::forwarded), and answers with Transport::answer(id, answers), which reaches only the request that asked. A question a worker relays from a third-party A2A peer carries that peer’s origin, set by invoke_agent’s client code through Transport::ask, never by ask_user’s arguments. When the asker withdraws its question (req.cancel), or the call running it ends or is cancelled, the broker sends req.cancel for the copy relayed to a worker, or CallEvent::InputWithdrawn { id } for one at the root (callee_cancel_withdraws_relayed_question):

leaf   → {"v":3,"id":2,"method":"human.ask","params":{"questions":[{"id":"q1","question":"Which database?","options":[]}]}}
broker → {"v":3,"id":3,"method":"human.ask","params":{"question":"question-1","request":{"questions":[…],"asker":{"agent":"leaf-0","chain":["root","mid","leaf"]}}}}
mid    → {"v":3,"id":3,"result":"escalate"}
broker → {"v":3,"id":2,"result":[{"id":"q1","answer":"SQLite","custom":false}]}

The root sees every nested run (TB-1, AGE-663). A run a worker’s call starts is nested under the root’s delegation. When the root’s call is listening — every root invoke_agent over its broker is — the broker sends that run’s task.run with "swarmEvents":true, and the worker reports its own turns and tool events as task.events of kind swarm beside its usual ones ({"v":3,"method":"task.event","params":{"kind":"swarm","id":1,"event":{"kind":"tool_call_started","id":"call-1","name":"read_file"}}}). The broker forwards them to the root’s call, tagged with (root_task_id, node, chain) from its own task table — never from the event, whose extra fields are dropped when it is parsed — together with what it reads off the run’s own frames: its text as a byte count ({"kind":"text","bytes":15}; the text itself stays with the run’s caller), its usage whole, and its end. It batches per node and flushes at most once every 250 ms, one batch per node, the last before the root’s result; nothing is forwarded per token. The root’s session emits each batch as SessionEvent::SwarmEvent — a view of the tree, not a bill: the delegation’s own progress and usage still arrive as SessionEvent::Delegation. A task nobody forwards gets no swarm events, so its wire is unchanged (nested_events_reach_the_root_tagged, forwarding_is_bounded, worker_cannot_forge_tags).

The swarm tree and its ATIF export (TB-2, AGE-664). chatty_core::services::swarm_trace::SwarmTrace folds a turn’s SessionEvents — the root’s own tool calls and usage, and every SwarmEvent batch — into a tree of AgentNode {name, spec, model, turns, tool_calls, usage, status} rooted at the turn. The root’s own callee is not forwarded; it is built from its delegation’s progress: its node from Started, its usage from Finished, and its tool calls from the step lines less those its descendants’ batches account for (the steps carry the descendants’ too). Its calls carry no id, arguments or result across the hop, and it is named by its spec until the edge log names its node. A frontend builds the tree live: SwarmTrace::new(), then apply(&event) for each event in order, redrawing when revision() moves. After the turn the broker’s edge log rows (apply_edge, or SwarmTrace::from_edges(rows, events) in one call) place a run under the right one of two same-spec siblings and add the calls a worker had refused as Refused nodes. A run reports its usage once, with its own workers’ already folded in (AGE-415), so a node’s own spend is what it reported less what its children reported, per model: nobody is billed twice, and the nodes sum to what the root’s conversation records (swarm_tree_spend_sums).

The full-run ATIF export (AGE-859). Every delegated task captures its conversation (RC-0; the broker sets capture_conversation on every task). A nested run’s conversation reaches the root as SwarmItem::Conversation, and the root’s callee’s arrives as InvokeAgentProgress::Conversation, sent just before Finished. Neither is ever in the JSON a model sees. The session’s SwarmTrace keeps them. finish_turn persists the turn’s AgentRecords on its trace under agents. exporters::export_run, which the CLI and the desktop share, writes one document. The root conversation’s steps come first, and each agent’s steps follow the step that delegated to it. Every step’s extra.agent names its agent by path (root/coder-1), and extra.parent_step names the delegation step. A worker’s steps are its captured conversation: turns, reasoning, tool calls and results. Its own usage is its last step’s metrics. A conversation over the 32 MiB cap is replaced by a step with extra.cut_bytes, followed by its tool calls from the trace. A best_of step records extra.best_of (chosen, selected_by, reason, judge). The agents, with their parents, own usage (tokens per model, never a price) and status, are in extra.swarm. When step usage does not sum to final_metrics, a delegation step has no child trajectory, or a conversation was cut or never captured, extra.incomplete lists the reasons. export_swarm writes the same layout from a live SwarmTrace, and swarm_tree_from_atif reads it back into the same tree (swarm_atif_round_trip). Known gap: hosted (Firecracker) workers do not yet return their conversation to the broker (AGE-854 family).

Spawn context: sub-leaders use the root broker (ADR-0020 invariants 5–6, BI-5, AGE-637). Only a root process starts a broker. A sub-leader — a worker whose spec delegates in turn — is spawned with no --broker, and --broker inside a worker (--participant-fd) starts nothing: its calls go over its connection like any worker’s. What its own broker used to decide travels on the spawn request as a SpawnContext {workspace_root, base_branch, roster, verification, endpoint} (on the invoke_agent call’s params as spawn_context, and on the child’s task.run). The broker sets it from the calling node’s own context: the node’s own worktree and branch and the roster it was given, with the verification command and endpoint the root’s settings give the agent being spawned. So a sub-leader’s worker gets its tree under the sub-leader’s tree, on a branch that starts at the sub-leader’s branch, and its evidence diffs against that branch (grandchild_branches_from_its_subleader). A call that brings a context of its own is clamped: accepted only if workspace_root lies inside the caller’s own tree, base_branch is the caller’s, roster is a subset of the caller’s, verification is the root’s (or none) and endpoint is the root’s for that agent. Anything else ends the call with an error of kind spawn_context_refused, whose message is {field, reason} naming the field (spawn_context_is_clamped), so a team.json a model wrote into its worktree cannot loosen anything. A call to a virtual agent outside the caller’s roster is refused. Usage folds up both hops through the root (usage_folds_across_two_hops).

A worker connects before it builds its agent: chatty-tui --participant-fd says session.hello, gets its result, and only then builds the agent with the connection’s transport (WorkerConnection::transport, AgentBuildContext::fabric_transport), so its tools hold the connection from the first turn. The in-process chatty-tui root reaches its own broker the same way minus the socket: LazyBroker::transport hands invoke_agent and list_agents a DirectTransport into the broker. Remote agents stay on A2aClient. The gateway counts HTTP requests for roles and the directory (RouteCounter); in a swarm of workers both stay at zero (invariant 4, no_worker_call_uses_loopback). The connection is the liveness signal: closing it deregisters the participant and fails every task it still owed. A worker’s ask_user parks its task on a human.ask request the broker relays up the caller chain, and the answers come back as its result (EN-2b). The messages and the mapping are documented in crates/chatty-protocol-gateway/README.md.

The participant path is Unix-only. The hosted transport is Firecracker vsock, which arrives here as an ordinary stream: both serve_connection and ParticipantConnection take any AsyncRead + AsyncWrite, so the frames, the hello and the liveness rule are shared rather than reimplemented (AGE-307, in boersmamarcel/hive; hive moves to v3 with HS-4a, after ADR-0021 step 3).

LLM-facing tools

list_agents and invoke_agent are registered by AgentFactory in every conversation.

list_agents

One flat list of everything addressable, each entry saying whose machine it runs on:

{
  "agents": [
    { "name": "leased-vm", "origin": "fleet", "kind": "worker", "description": "...", "enabled": true },
    { "name": "local-agent", "origin": "local", "kind": "worker", "description": "...", "enabled": true },
    { "name": "voucher-agent", "origin": "remote_configured", "kind": "remote", "url": "https://...", "enabled": true, "has_api_key": true, "skills": ["..."] }
  ],
  "total": 3,
  "note": "To invoke an agent, use the `invoke_agent` tool..."
}

Two sources feed it. Settings give the configured remotes and the roster’s specs (names only, as a stand-in until the broker answers). The broker’s aggregated card gives whatever registered since — a worker spawned a minute ago is addressable, and only the broker knows it exists. A name in both keeps the settings label, because what the user configured is the more informative answer. A gateway that is off or slow to answer is not an error: the list is then what settings know. API key values are never exposed to the LLM — only has_api_key: true/false.

origin — whose machine it runs on (ADR-0011 C5)

OriginMeansInside the fleet?
locala process on this machine: a spawned worker running a specyes
fleetelsewhere in this user’s fleet — a leased microVM registering over vsockyes
remote_configureda URL from Settings → A2A Agents: a third party, chosen deliberatelyno
discoveredlearned from another agent’s card rather than configuredno

The label is a property of the registration, not of the card: a participant describes itself, and the broker says where it came from, or the label would be worth nothing. AgentOrigin is defined once, in chatty-fabric: chatty-protocol-gateway serves it and chatty-core reads it, and both depend on that crate.

Nothing publishes discovered yet: chatty has no peer-discovery hop. The label exists so that one cannot be added without deciding what it means.

invoke_agent

{ "agent": "local-agent", "prompt": "Hello, agent!" }

Resolution order: remote A2A agents first (a remote agent shadows a local spec with the same name), then the roster’s specs — local-agent and the rest, the broker’s local workers (below). Any other name is NotFound, listing what is available; a plugin’s name is never an agent’s. Every path streams through A2aClient::send_message_stream(); progress (InvokeAgentProgress) is forwarded to the UI so the user sees intermediate output while the tool call is in flight.

Before a prompt leaves the fleet. With warn_on_external_agent on (execution settings, off by default), invoke_agent says on the progress channel that the prompt and anything quoted in it are about to go to an agent whose origin is not local or fleet, naming the agent, the URL and the origin. It only says so: whether an external agent should need an allowlist, a one-time confirmation, or nothing at all is a product decision that has not been made, and this is the hook it will hang from.

include_trace (AGE-467, default false): a leader that delegates the same task to several workers and wants to judge how each one got its answer, not just what it concluded, sets include_trace: true on the call. InvokeAgentOutput.trace is then the worker’s compacted tool-call trace — otherwise the field is absent from the JSON the model sees, so an ordinary delegation costs no more context than it did before this existed.

send_message

A worker tells the agent that gave it its task something besides its final answer (tree messages, TM-1, AGE-654). The tool is registered for every worker with a broker-made connection, a leaf coder or reviewer with no delegates_to included — it needs an owner, not delegation rights — and every tool profile allows it. The root has no owner and no tool.

{ "to": "root", "text": "the tests pass; starting on the docs" }

It travels as a send_message call on the worker’s connection and returns at once: {"status": "pending", "id": "msg-1"} or {"status": "refused", "reason": …}. It never starts a run and never interrupts one.

  • Recipient. The broker reads the sender from the connection and its owner from the directory; to must be that owner’s name. The owner is whoever spawned the worker: the node whose invoke_agent started it, recorded when the broker admits it, or root (ROOT_NAME) when the root asked. The hello’s result says which, and the to parameter’s description repeats it; a sub-leader’s workers message the sub-leader, never the root past it. A sibling, the sender itself, a name nobody has or a node of another conversation is not_on_tree; an owner that has ended is recipient_ended. Messages to a worker’s live handles come with resumable conversations (RC-3).

  • Bounds. An accepted message waits on the recipient’s chatty_fabric::PendingList: at most 64 KB per recipient, and at most 8 KB per sender per run of the recipient. A message over either bound is over_allowance — refused whole, never truncated.

  • Delivery (TM-2, AGE-655) of a message to an owner happens at exactly two points, never mid-run: appended to the next invoke_agent result the recipient receives, as messages: [...] in InvokeAgentOutput (absent when empty; a failed delegation lists them after its error), or prepended to the recipient’s next run — the root’s next human turn (Transport::take_run_messages via LazyBroker::take_run_messages, kept in history with the turn), a node’s next task text. Each message is delivered once, as untrusted data:

    <message from="local-coder-2" untrusted="true">…</message>
    

    with < and > in the body escaped (chatty_fabric::wrap_message). A message grants nothing: the recipient’s tools, budget and approval policy are what they were.

  • Mid-run messages (TM-5, AGE-750). Two senders reach a running agent sooner: you, to any agent of your swarm (/msg <agent> <text> in chatty-tui, or the message box under a running agent’s transcript sheet on the desktop), and an agent to its own child. The message reaches the recipient at its next tool call: the worker takes it between tool rounds (mailbox.take) and it rides at the end of that round’s tool result, wrapped and escaped as above, so it is in the next model request and never in one already streaming. If the agent makes no further tool call, it opens the agent’s next task. It grants nothing, and it costs one prompt-cache break (new bytes in the middle of the run). Each delivery writes a message row with outcome delivered_mid_run. Sideways messages between siblings stay refused.

  • Drops. When the recipient ends, what is still waiting for it is dropped, one edge-log message row per message with outcome dropped.

  • Neutral description. The description says what the tool does and names neither relaying nor siblings (golden: crates/chatty-core/src/tools/goldens/send_message_description.txt): the F3 gate counts messages that name another worker, and must count demand, not instruction.

local-agent — a chatty agent in its own process

invoke_agent { "agent": "local-agent", "prompt": "…" } asks the broker for a worker. The gateway spawns chatty-tui --participant-fd 3 on a connection it made, the child says hello, and its turn comes back as A2A status and artifact updates. This is One fan-out path, a public wire format, and a place to put discovery, budgets and the ledger.

The child maps its SessionEvents to frames with chatty_protocol_gateway::worker::TaskMapper (the worker feature) — tool starts and finishes become working status messages (and so do the steps of anything the child itself delegated, InvokeAgentProgress::Step, so a grandchild’s tool calls reach the leader one line each), assistant text becomes artifact chunks, and the turn’s token usage rides in the terminal status’s metadata under usage (A2A has no usage concept; usage belongs to the ledger). It goes as lines, one per model, each naming its model and carrying tokens and time but no price (AGE-682). The lines already include whatever the child itself delegated (merged only with lines on the same model), and the parent’s invoke_agent folds them into its own conversation as usage lines marked delegated_to, priced in finish_turn at the model each names — so a leader’s total_cost carries the whole tree below it, and the bill follows the bearer (AGE-415). The same terminal status carries a second, independent key, trace (AGE-467): the worker’s compacted tool-call trace — one ### <tool> (ok|FAILED|no result) block per call it made, with the call’s input and output or error, capped and (past 40 calls or 12 000 characters) trimmed from the middle — present only when the mapper saw at least one tool call. It rides unconditionally; whether it reaches the model is invoke_agent’s call, gated on include_trace (below), so a plain delegation’s wire cost is unchanged. The mapper and the one-task loop around it live beside the broker’s own half of the protocol, not in this crate, because a microVM’s chatty-server is a worker too and the parent must not be able to tell the two apart. crates/chatty-tui/src/participant/equivalence.rs asserts the parent’s tool-call trace carries every tool call the child reported, for every scripted scenario — how ADR-0011’s first kill criterion is checked in CI rather than by inspection.

A worker’s ask_user does not end at the worker. It is a human.ask request the broker relays up the caller chain, each worker escalating it, until it reaches the root’s ask_user popover with the asking worker named; the answers come back as the request’s result (ADR-0021 § 2, EN-2b). crates/chatty-tui/src/participant/input_required_chain.rs runs a parent → child → grandchild chain over a real socket and asserts the grandchild’s question reaches the parent’s popover and its answer comes back.

That chain is carried by message/stream. A caller that started the task with plain message/send has a single reply object with no room for a non-terminal update, so a worker parking under it is asking someone who cannot hear. The broker ends such a task immediately and quotes the question in the failure, rather than letting it wait out the worker’s clarification timeout (AGE-321) — the caller learns what was wanted and can ask again over message/stream. Holding the task open for tasks/get polling would make non-streaming callers first-class and is the A2A-shaped answer; it was weighed and not taken, because it makes the broker stateful for open tasks and every delegation path here streams.

A worker works in its caller’s tree — the conversation’s workspace, passed as its --workspace and cwd — unless its team asks for isolation (AGE-822): isolate in the team.json that claims the agent by name, else module_settings.team.isolate. Off by default; a coding team turns it on. Only then does the embedder hand the runner a workspace factory, and only then does BI-5’s nesting below apply. An isolated worker runs in its own git worktree under the conversation’s workspace (ADR-0012), through chatty_core::services::worker_tree: .chatty/worktrees/<name> on branch sub-agent/<name>, where <name> is the participant name (local-coder-0) unless that branch or directory already exists in the repository — another root process on the same repository counts its own workers from zero, and a tree left from an earlier run keeps its branch — in which case it is the first free <name>-N (AGE-402). A sub-leader’s worker gets its tree under the sub-leader’s, branched from the sub-leader’s branch, and its evidence is measured against that branch (BI-5). The evidence envelope appended to the worker’s answer names the branch actually created. In a repository, a tree that cannot be made fails the delegation — a repository with no commits yet fails before anything is made, since its branch is unborn — and only a workspace that is not a repository runs an isolated team’s workers unisolated.

A worker that never started — its tree, its spawn or its hello failed — is a setup problem only the user can fix (AGE-822). The broker’s result for it carries the typed worker_start_failed: '<agent>' could not be started: <why> (chatty_fabric::worker_start_failed); invoke_agent turns it into the terminal, non-retryable InvokeAgentError::WorkerStartFailed; the StopOnWorkerStartFailure hook (services::worker_start) ends the caller’s run at its next model call, with a Could not start card (what failed, what to do) as its answer; a sub-leader’s TaskMapper fails its own task with the same typed text, so every caller up the tree stops; and the desktop draws the card as an error alert. /agent checks the same way before it spawns anything (agent_command::preflight): no workspace, or code execution off, is a card instead of a delegation, and a turn that goes ahead shows its workspace under the command.

Per-endpoint concurrency budget (ADR-0011 C6). Workers all talk to the same model server, so the broker holds a semaphore per endpoint — the server’s base URL, not a model and not a worker — and a task waits for a slot before a child is spawned. On a local Ollama, three concurrent workers on one loaded model is not three times the throughput; it is the fourth request evicting the weights the first three are using. Waiters are served first come, first served.

A permit covers a run’s model calls, not a worker’s whole life (ADR-0020 §3.5, BI-6). A run takes its slot just before its worker is spawned and holds it while it talks to its model. When it calls another agent (invoke_agent over its connection), the broker releases its slot until the call is answered; the result that brings the run’s outstanding calls back to zero waits in the endpoint’s queue for a slot before it is delivered, since that result is what starts the run’s next model call. So a sub-leader and its child on the same budget-1 endpoint both complete: the sub-leader is not holding the slot its child needs. If the run’s caller gives up while that result waits (a cancel, a deadline), the wait is dropped and the run makes no model call. The slot is released for good when the worker is reaped. The state machine is chatty_fabric::RunPermit (Holding → Released{outstanding} → Reacquiring → Holding) on chatty_fabric::EndpointBudget.

The size, in order: an explicit override in endpoint_budgets in module_settings.json (keyed by base URL, e.g. http://localhost:11434; no UI yet), then what the provider reports about itself (num_parallel in its extra_config, a local Ollama’s OLLAMA_NUM_PARALLEL from the environment, or 4 for a hosted API: OpenRouter at its own address, a public host, an Azure deployment), then default_endpoint_budget, which is 1. Every wait is a tracing event carrying the endpoint, its limit and the queue depth at that moment, so a budget that is too tight looks like a queue that never empties.

A server on this machine or the local network (a vLLM behind the OpenRouter-compatible type, say) reports nothing and gets the default of 1. Set num_parallel on its provider, or an endpoint_budgets entry, to let a server that batches serve several workers at once.

Headless leaders need --broker (AGE-376). The wiring above is started by chatty-gpui’s module-settings controller, so only the desktop had a local-agent to delegate to — a chatty-tui --headless or --pipe leader (and the harness-driven chatty behind a benchmark, e.g. the Harbor adapter, AGE-285) had none. chatty-tui --broker runs the same gateway and socket in-process: an ephemeral HTTP port, a participant socket suffixed with this leader’s pid (so two headless leaders on one host never collide), and a local-agent LocalRunner sized against the same per-endpoint budget as the desktop. It carries no WASM module registry of its own — --broker exists to make local-agent reachable, not to load modules — and it is valid with --headless, --pipe and the interactive TUI alike. Workspace isolation is the same as the desktop’s: workers work in --workspace (or the persisted workspace), and a team with "isolate": true gives each a git worktree there when it is a git repository. When the turn (or the session) ends the gateway stops serving; any worker still running is reaped by the runner the same way it always is. --broker never writes module settings: its ephemeral port lives only in this run’s agent-build context, so /modules in the same session still saves exactly what was on disk (AGE-382).

The broker starts lazily, on first use (BI-2, AGE-634). Neither host starts the broker at boot: --broker/--team (chatty-tui) and the desktop’s module settings only prepare it — a chatty_core::services::lazy_broker::LazyBroker — and the first list_agents or invoke_agent call is what actually binds its socket and TCP port, through LazyBroker::ensure_started. The desktop prepares one whether or not the module runtime is on: that switch gates WASM modules only. With it off, the broker’s gateway loads no module and binds no TCP port — the root reaches it over LazyBroker::transport alone — and a broker that fails to start puts its reason in the invoke_agent error (AGE-759). Later calls reuse the same broker (memoized). A test can read LazyBroker::bound_addrs() to see whether anything is bound yet without triggering a start.

Named virtual agents (ADR-0011 C10, AGE-377; agent specs, AGE-614). Every worker used to be the same: the roster’s default model with the leader’s tools. A team is now a list of agent specs (below), each published by the broker under its own name with its own model and tool set. module_settings.json names them:

{ "virtual_agents": ["local-coder", "local-reviewer"] }

A file that still lists agent objects here (the old VirtualAgentConfig shape) fails to load with an error naming virtual_agents; there is no compatibility shim.

Agent specs (PL-D2, AGE-614). An agent spec is the one declarative definition of an agent, the same for a worker, a --team leader and chatty-tui --agent <name>. TOML on disk, the same shape as JSON on the wire:

[agent]
name = "local-reviewer"            # the A2A address, /a2a/{name}
description = "Verifies a worker's branch"
model = "gemma4:26b"               # resolved like --model; absent = the roster's default
preamble = "Verify, do not trust, verdict first. …"

[tools]
profile = "reviewer"               # coordinator | coder | reviewer
disable = ["fetch"]                # tool groups, narrows only
skills = ["coder-reviewer"]        # read_skill names, named in the preamble

[[plugins]]                        # its tools become this agent's (PL-U2)
module = "benford"
version = "^0.2"                   # checked against the module's [module].version
grants = ["llm"]                   # a subset of what it requests; logging is always granted
limits = { max_execution_ms = 5000 }  # lowers the module's [resources], never raises them

[swarm]
delegates_to = ["local-coder"]     # whom it may call (names or * globs); empty = no agent tools
exposed = true                     # others may call it (the default)
callers = ["coder-reviewer-leader"]  # optional: only these may call it

[budget]
max_agent_turns = 30               # 0 = uncapped; the deadline applies
max_duration = "30m"
cap_usd = 2.0                      # per task, the spend gate's cap

A spec is looked up in <workspace>/.chatty/agents/<name>.toml, then <data_dir>/chatty/agents/<name>.toml (Linux: ~/.local/share/chatty/agents/), then the presets compiled into crates/chatty-core/agents/; the first match wins and shadows the rest (chatty_core::agent_spec::{load_agent_spec, list_agent_specs}). Unknown fields are an error naming the field (extra_args is gone), and validation reports every problem at once: bad name, unknown profile, unknown tool group, bad model reference, duplicate plugin, bad duration or cap. AgentBuildContext::from_spec is the one place a spec becomes what an agent is built with: the role, the execution settings narrowed by disable and max_agent_turns and then gated, the skills line, and the run’s RunBudget from max_agent_turns and cap_usd (a LocalSpendGate, see token-tracking.md). A worker receives its whole spec on its argv as --agent-json <spec> and builds itself through that same function.

FieldMeaning
agent.nameThe name invoke_agent addresses, served at /a2a/{name}; lowercase letters, digits, -, _, and the file’s own name.
agent.modelOptional. Resolved as chatty-tui --model resolves it (id, then name, then a substring of the model identifier). Absent: the roster’s default.
agent.preambleOptional. The role’s standing instructions, appended to the system prompt after the base preamble, before the tool summary.
tools.profileOptional. A named tool profile: coordinator, coder or reviewer — see below.
tools.disableOptional. Tool groups switched off: shell, fs-read, fs-write, fetch, git, code-exec, docker-exec, ask-user, terminal (_ works for -). Composes with the profile: it can narrow it further, never re-enable a tool the profile excludes.
tools.skillsOptional. Skills the role is told to read_skill before it starts.
swarm.delegates_toOptional. The agents it may call, by name or * glob ("*-reviewer"). Non-empty is what gives an agent list_agents and invoke_agent, whatever its profile; empty or absent, it has neither (PL-S2 DP-1).
swarm.exposedOptional, default true. false: no other agent may call it.
swarm.callersOptional. When set, only these agents (names or * globs) may call it. chatty_core::services::delegation_policy::may_call checks both specs: the caller’s delegates_to, then the callee’s exposed and callers. The broker asks it on every worker’s invoke_agent before anything is spawned, and on the root’s when the root runs as a spec (a --team leader, an --agent <spec> root; a plain root may call anyone), then refuses a call that closes a cycle or goes deeper than 4 levels below the root; the calling model reads Error: invoke_agent: not_listed: …, cycle: root → a → b → a or too_deep: depth 5 > max 4 (PL-S2 DP-2).
budget.max_agent_turnsOptional. The agent’s own turn budget (AGE-440). Absent: an unattended run has no turn cap and a 30-minute time budget.
budget.max_durationOptional. The wall-clock budget, as --max-duration writes it.
budget.cap_usdOptional. Dollars one task may spend before invoke_agent refuses to start another delegation (budget_spent: usd).

A called agent runs under the tighter of its own [budget] and what its caller has left (PL-S2 DP-3): turns = min(max_agent_turns, caller's turns left), deadline = min(now + max_duration, the chain's deadline), usd = min(cap_usd, caller's dollars left), where what the caller has left counts what it already spent, its callees’ usage included. The broker refuses a call once any of them is used up, before anything is spawned, and stops a callee still running past its deadline (plus a tenth of the budget, 5 s to 2 min) with a failed result. | plugins | Optional. WASM modules whose tools this agent runs in-process — see plugins.md. |

Roles: a profile and a preamble (ADR-0011 C11, AGE-405). tools.disable removes whole tool groups, which is the wrong grain for a role — a reviewer wants git_diff but not git_commit. tools.profile names a profile instead: an allowlist of tool names, and the worker’s whole tool set. Anything the profile does not name is dropped, MCP tools included, which is most of the point — a 4B coder used to be handed 53 tool schemas (~13k tokens) before it could read a file. A profile only ever removes tools: it cannot turn on a group the execution settings switched off. A profile does not decide delegation: list_agents and invoke_agent come with a non-empty swarm.delegates_to, on any profile or none, and no profile removes them.

ProfileWhat it can call
coordinatorThe read set below, plus the todo plan (write_todos, update_todo, verify_completion) and git_merge (AGE-404: how a leader without a shell takes a worker’s branch; on a conflict the tool lists the conflicting files and leaves the tree for the leader to report). It does not edit; the coder-reviewer-leader preset delegates because its spec lists local-coder and local-reviewer.
coderThe read set, plus the filesystem-write tools, the shell, the writing half of git (git_add, git_create_branch, git_switch_branch, git_commit, git_merge), execute_code, the data-query tools (query_data, describe_data, profile_data, file_structure_detector) and the memory tools (remember, save_skill, search_memory; AGE-456).
reviewerThe read set, plus the shell so it can run the tests and the data-query tools (query_data, describe_data, profile_data, file_structure_detector) so it can independently re-derive a claimed data-derived value. No writes, no commits.

The read set every profile starts from is read_file, list_directory, glob_search, search_code, git_status, git_log, git_diff (which takes a base..head range, so a reviewer reads main..sub-agent/<name> without a shell), read_skill, and ask_user — every profile keeps that last one, or a worker could no longer park a question on its leader (AGE-306). The todo plan is the leader’s and the unprofiled main agent’s: a worker gets one bounded task and does not plan it again (AGE-479). The profiles live in chatty_core::factories::tool_profile; a spec (or chatty-tui --tools) naming an unknown profile is refused rather than starting a worker with every tool there is.

preamble is the other half of a role: without it a reviewer only knows it is a reviewer if the leader says so in the task, which is exactly how a reviewer came to approve a one-line branch on the coder’s word. It lands in the worker’s system prompt ahead of the tool summary, and its first sentence goes on the agent’s card so the leader can pick by reading.

An empty or absent list is every exposed spec, local-agent first (“The local roster” above). A role is a spec and nothing else: invoke_agent takes no model or role parameter, so the leader’s tool schema and prompt prefix are identical whatever the team, and each agent’s card — what list_agents shows — says which model it runs, which tool profile or tool groups it has, and the first sentence of its preamble, so the leader chooses by reading rather than guessing. Each agent is metered on the endpoint of its model (chatty_core::services::worker_endpoint), so a reviewer on another server does not queue behind the coder, while two agents on one server share that server’s budget. Both frontends build their runners from chatty_core::services::virtual_agents::resolve_virtual_agents; a --broker leader started with --ollama, --openai-compat-url or --api-key forwards those flags to every child (a Harbor sandbox has no providers.json for a child to read), and a settings-configured desktop leader forwards nothing. Settings → Agents shows the roster and every spec file; virtual_agents in the JSON is how to narrow it.

The evidence envelope (ADR-0011 C12, AGE-406). A worker’s report is the worker’s account of what it did; the runner can say what it actually left behind, and it does. When a delegated task ends the runner commits the worker’s worktree and, before the caller sees a terminal event, reads the tree back: the branch, how many commits it carries over the default branch (main or master, whichever exists), git diff --stat <default>..<branch>, and — when the team declares a verification command — that command’s exit code and last 20 lines, run with the shell in the worktree under a five-minute timeout. That envelope is appended to the worker’s answer as a fenced evidence block and carried, structured, on the terminal status’s metadata.evidence, so a trace or Harbor’s ATIF reads it without parsing prose. It replaces the branch hint of AGE-399. A worker that committed nothing gets no envelope at all — a read-only reviewer must never be handed something to merge — and nothing is ever merged automatically; merge policy belongs to the flow.

The verification command is the team’s, not an agent’s, and lives next to virtual_agents in module_settings.json:

{
  "team": { "verification": "python3 -m unittest discover -s tests -t . -v" }
}
FieldMeaning
team.isolateOptional, default false. Whether each worker of a roster no team claims gets a git worktree of its own (AGE-822).
team.verificationOptional. A shell command the runner runs in each worker’s worktree once its task ends, whose exit code and output tail go into the evidence envelope. Absent: the envelope carries branch, commits and diff stat only.

It is skipped for any agent whose profile has no shell: a worker that could not run commands produced no build, so running the suite in its tree would report the leader’s own state back as the worker’s. tools.profile and tools.disable compose (AGE-452), so both have to allow it: a reviewer runs the suite unless disable also names shell, a coordinator never does regardless, and with no profile named, disable containing shell alone is what skips it. The command is a plain subprocess in a process group of its own, not one of the agent’s tools — the point of the envelope is that it is the runner’s fact and not the worker’s account of one — and the timeout kills that whole group, so a suite that hangs cannot outlive the delegation that started it.

Teams

The team directory (ADR-0011 C13, AGE-407). Everything above that makes a working team — the roster in module_settings.json, its team.verification, the leader’s role, a skill the leader follows and a turn budget in execution_settings.json — used to live in four files and a shell script. A team directory is that in one place a Harbor arm can upload and a run can reproduce: teams/<id>/team.json with SKILL.md beside it.

team.json is a thin file of agent spec names (AGE-614):

{
  "leader": "coder-reviewer-leader",
  "agents": ["local-coder", "local-reviewer"],
  "verification": "cargo test --all-features -- --test-threads=1",
  "skill": "coder-reviewer",
  "max_agent_turns": 50
}
FieldMeaning
leaderThe spec the leader runs as. --model, --tools and --preamble still beat its fields; --model also replaces every roster member’s model for the run, a pinned one included (Team::run_roster, AGE-808).
agentsThe roster, by spec name. Replaces module_settings.virtual_agents for the run.
verificationOptional. The team’s verification command (team.verification above) for the run.
isolateOptional, default false. Each worker gets a git worktree of its own instead of the conversation’s workspace (AGE-822). A coding team sets it; an analysis team works where the user’s files are. Copied into module_settings.team.isolate for a --team run.
skillOptional. The skill the leader is told to follow: its first turn opens with read_skill <skill> and follow it, plus the verification command when one is declared, since a coordinator leader has no shell and can only delegate the check. read_skill serves the SKILL.md beside team.json ahead of the skill directories.
max_agent_turnsOptional. The leader’s turn budget for the run, ahead of the leader spec’s own; without either a headless leader has no turn cap and a 30-minute time budget. A worker’s budget is its own spec’s.
handoffsOptional. Role → a JSON Schema its handoff must match, as a path relative to the team directory (see Typed handoffs below).

A team.json in the old shape — a leader object, agent objects in agents — fails to load with an error naming the field.

Typed handoffs (TD-2, AGE-693). A team can make what one role hands the next explicit and checkable. handoffs names a JSON Schema per roster role:

{
  "leader": "coordinator",
  "agents": ["coder", "reviewer"],
  "handoffs": {
    "coder": "schemas/change.json",
    "reviewer": "schemas/review.json"
  }
}

Every schema is read and compiled when the team loads; a missing file, a file that is not JSON, a schema that does not compile (remote $refs are not fetched), or a role that is not in agents fails --team before anything runs. A worker running as a role with a schema is told the schema with its task and must end its final answer with exactly one fenced json block matching it:

  • valid: the parsed JSON comes back on the leader’s invoke_agent result as handoff;
  • invalid, the first time: the worker gets one follow-up turn listing the schema errors;
  • invalid again: the task fails and the leader’s model sees Error: invoke_agent: handoff_invalid: <role>: <errors>.

A schema may add read rules, "x-must-be-read": {"coder": ["files_changed"]}: each value of the coder’s latest files_changed must appear in this role’s handoff. The leader checks them and records a miss as the handoff_misread failure tag, with no retry. A headless --team leader’s --usage-file carries handoff_invalid_by_role (role → invalid answers) and failure_tags, and every run that delegates carries delegated_by_agent (agent → the tokens it reported), each absent when empty. A team without handoffs behaves exactly as before, down to the bytes on the worker’s socket.

chatty-tui --team <id> runs as that team’s leader. It implies --broker, declares the roster from the team file (nothing is written back to module_settings.json), runs as the leader’s spec (an explicit --model/--tools/--preamble beats it), sets the turn budget, and opens the first turn with the skill instruction. Valid with --headless, --pipe and the interactive TUI. The id is looked up in <workspace>/.chatty/teams/<id>/, then <data_dir>/chatty/teams/<id>/ (Linux: ~/.local/share/chatty/teams/), then the presets compiled into the binary; the first directory with a team.json wins, and a malformed file there is an error rather than a fall-through to the preset. The specs it names are looked up the same way as any spec, so a workspace spec shadows the preset one. The loader is chatty_core::services::team::load_team; the presets are crates/chatty-core/teams/ and crates/chatty-core/agents/.

Two presets ship, both experimental: teams are supported, and a team becomes a documented default only after a benchmark shows it beats a single agent (PL-S8). Their specs name no models. Besides them, three standalone specs ship: researcher, reviewer and writer.

Team (display name)LeaderAttemptsJudge
crosscheck (Crosscheck)crosscheck-leadcrosscheck-solver-direct, -plan, -verifycrosscheck-judge
crosscheck-data (Crosscheck: Data)crosscheck-data-leadcrosscheck-data-direct, -plan, -verifycrosscheck-judge

Crosscheck (AGE-853) is several independent attempts at one task, and a judge picks the strongest. The fan-out and the selection are not the leader model’s: the leader’s spec declares [swarm.best_of] (solvers, judge, each named in delegates_to), which offers it the best_of tool (chatty_core::tools::best_of_tool). One best_of call sends the task word for word to every solver at once over the leader’s own invoke_agent (with include_trace), each its own worker with its own context; they run in parallel as far as the endpoint budget allows. Each solver ends with FINAL ANSWER:. An attempt whose evidence block shows the team’s verification command exiting 0 wins outright; between several passing attempts, or with no verifier, identical normalised answers need no judge, and otherwise the judge gets the task and every candidate’s answer, reply and trace and names one by number (schemas/judge.json: choice, reason). The answer returned is the chosen solver’s, character for character; a judge that fails falls back to the most common answer. The result carries a cost line, “3 attempts + judge, N tokens (≈X× a single run)”, against the attempts’ mean, from the usage InvokeAgentOutput::usage now carries. The judge’s spec sets [agent] think = false, which overrides the run’s --think for that agent. crosscheck-writer writes the answer when the task names a file (the leaders are coordinators). The two teams differ only in their solvers: Crosscheck: Data’s are data analysts that read a BRIEF.md and the data’s documentation first and compute with code. A preset whose team.json sets "experimental": true is left out of --team listings unless named by its exact id, and running it prints one line pointing at the docs-site’s Crosscheck page, which also summarises the measurements (the pre-registrations and run reports are in harbor-chatty (private), experiments/reports/).

chatty-tui --team crosscheck-data --headless --model <model> \
  -m "What was the average transaction value in March 2023, in EUR?"

The coder-reviewer team (coordinator leader, local-coder, local-reviewer, its SKILL.md) is a test fixture only, crates/chatty-tui/tests/fixtures/team-workspace/: spec_golden’s recorded contexts and the team-mechanics tests load it from there.

Ollama thinking models as leaders (AGE-400). A thinking model such as qwen3 sometimes writes its tool call inside the thinking channel; Ollama surfaces tool calls only from content, so the call is lost and the model’s answer arrives empty. On a leader or reviewer that shows up as a delegation chain that stops without a word. The roster entry’s extra_params.think is Ollama’s per-request think switch ("true" or "false", sent as the request’s top-level think field); set it to "false" on any Ollama model that coordinates or reviews, in models.json:

{ "id": "qwen3-14b", "provider_type": "ollama", "model_identifier": "qwen3:14b",
  "extra_params": { "think": "false" } }

Absent, Ollama’s model default applies. Other providers ignore the key. The desktop’s model dialog has no field for it and rewrites extra_params when a model is saved there, so a model edited in the dialog needs the key put back by hand.

Plugins

When to read this: You are writing, installing or debugging a WASM plugin, or need to know how a plugin’s tools reach an agent and where the runtime, registry and gateway fit.

Authoring one? Start with Build a WASM plugin (quick start and host-LLM sequence diagrams); the WIT types are in wit-reference.md. Agents — including the specs that use plugins — are on agents-and-specs.md.

What a plugin is

A WASM module is a plugin: it contributes tools to a chatty agent and never runs a loop of its own (PL-D1 option B). The agent is always an agent spec run by chatty’s harness; a spec’s [[plugins]] load the plugins it uses, one instance per agent, and the model sees their tools as <plugin>__<tool> (“A plugin’s tools as the agent’s own” below). A plugin is never listed or invoked as an agent itself.

Architecture stack

┌──────────────────────────────────────────────────────────┐
│                    chatty-module-sdk                      │
│  (Rust SDK for plugin authors, targets wasm32-wasip2)    │
│  Provides: Plugin trait + export! (generated from WIT),  │
│            llm::complete, config::get, log::info, etc.   │
├──────────────────────────────────────────────────────────┤
│                   chatty-wasm-runtime                     │
│  (Wasmtime-based host: loads chatty:plugin@0.3.0 .wasm,  │
│   implements imports, calls guest exports with           │
│   fuel/memory/timeout limits)                            │
├──────────────────────────────────────────────────────────┤
│                  chatty-module-registry                   │
│  (Discovers modules on disk, parses module.toml,         │
│   manages load/unload/reload lifecycle)                  │
├──────────────────────────────────────────────────────────┤
│   chatty-core (plugin_tool)   │  chatty-protocol-gateway  │
│   a spec's plugins as rig     │  the same tools to        │
│   tools of its agent          │  external MCP clients     │
└──────────────────────────────────────────────────────────┘
CrateRole
chatty-module-sdkGuest-side SDK for plugin authors: the WIT types, one module per host capability, and wit-bindgen’s own Plugin trait and export! macro
chatty-wasm-runtimeWasmtime host: loads chatty:plugin@0.3.0 components (and refuses every other world), implements the host imports (llm, config, logging, file, billing), enforces resource limits
chatty-module-registryDiscovery (scan_directory), lifecycle (load/unload/reload), manifest parsing
chatty-coretools::plugin_tool (a spec’s plugins as rig tools), PluginLlmProvider (a plugin’s llm::complete on the agent’s provider), the A2A client and agent tools
chatty-protocol-gatewayHTTP server (axum) serving plugin tools over MCP, and local participants and virtual agents over A2A

WIT contract

The host–guest interface is wit/chatty-plugin.wit (package chatty:plugin@0.3.0); wit-reference.md has the full type reference. A component targeting any other world (the old agent-shaped chatty:module@0.2.0 included) is refused at load with module targets …; this chatty supports chatty:plugin@0.3.0 — rebuild it with the current SDK.

Host imports, one interface per capability (a plugin lists the ones it needs in metadata().requested-capabilities; the host links only what the agent’s spec grants, and refuses the rest with capability <x> not granted to this agent, PL-U4):

InterfaceFunctionPurpose
llmcomplete(model, messages, tools)A completion on the calling agent’s model, through its provider client
configget(key)Read the manifest’s [config] table
logginglog(level, message)Structured logs to the host’s tracing (always granted)
fileread-bytes(path)Read under the manifest’s [files] root; a plugin without [files] reads nothing
billingacquire-session(estimated_tokens), report-usage(input, output)Paid plugins; reserves and settles credits against a Hive-signed session token

Guest export plugin:

FunctionPurpose
metadata() → plugin-metadataName, version, description, requested capabilities, config keys
list-tools() → definitionsThe tools the plugin provides
invoke-tool(tool-call-request) → result<tool-result, tool-error>Run one tool call; a typed error (unknown-tool, invalid-arguments, denied, failed) reaches the model as <kind>: <message>

Module directory layout

<module dir>/
├── echo/
│   ├── module.toml          # Manifest (required)
│   ├── echo.wasm            # WASM component binary
│   └── .chatty-install.json # Install record (only for modules installed from Hive)
└── benford/
    ├── module.toml
    └── benford.wasm

The directory is configurable via module settings. Platform defaults:

PlatformPath
macOS~/Library/Application Support/chatty/modules/
Linux~/.local/share/chatty/modules/ (or $XDG_DATA_HOME/chatty/modules/)
Windows%APPDATA%\chatty\modules\

Module manifest (module.toml)

[module]
name = "echo"
version = "0.2.0"
description = "A simple echo plugin"
wasm = "echo.wasm"              # Plain relative path inside this directory (no `..`, no absolute)
# execution_mode = "local"      # "local" | "remote" | "remote_only"; remote modules run on the hive-runner (PL-H8 retires this)

[capabilities]
tools = ["echo", "reverse"]     # Tool names the plugin exposes

[protocols]
mcp = true                      # Serve the tools to external MCP clients at /mcp/{name}

[resources]
max_memory_mb = 64              # Memory cap (0 = use default: 256 MiB; may only lower)
max_execution_ms = 30000        # Per-call timeout (0 = use default: 60 s; may only lower)

[config]                        # Optional: string → string values the guest reads via config::get
greeting = "hello"

[files]                         # Optional: the only directory file::read-bytes may read
root = "weights"                # Plain relative path inside this directory

Parsing is strict: an unknown key or table (including the agent-world keys [capabilities] chat and agent, and [protocols] openai_compat and a2a, removed by PL-U3 and PL-U5 — a plugin is never an agent), an execution_mode other than local/remote/remote_only, a non-string [config] value, or a wasm/[files].root path that is absolute or uses .., \ or : is a manifest error. A [resources] value above a host ceiling is clamped to it, with a warning on the manifest (ModuleManifest::warnings). Installing from Hive writes module.toml from the Hive manifest and copies only tools and mcp, so a Hive-installed module never becomes an agent whatever its Hive manifest still says.

ModuleRegistry::scan_directory visits module directories in name order and returns a ScanReport { loaded, remote, failed }: every directory that did not load is in failed with its reason, and remote modules are listed apart from local loads. Two directories declaring the same name: the first by directory name wins, the second is a failure (so is load of a name already registered from another directory). The desktop’s installed extensions list shows a module’s failure reason under its row.

Install hardening (PL-H5a). chatty_core::install checks a registry-supplied module name against the registry’s rule (^[a-z][a-z0-9-]{1,48}[a-z0-9]$, no --) and the version as semver before anything touches the filesystem, installs into the configured module_dir, and caps the download at hive_client::MAX_DOWNLOAD_BYTES (64 MiB) while it streams. Each WASM install writes .chatty-install.json ({sha256, trust_level, publisher_key_id}) beside the module; the registry hashes the .wasm bytes it is about to compile against that record at every load and refuses a mismatch (hash mismatch …, shown as Failed to load:). A module without a record — copied in by hand — loads as TrustLevel::Local (ModuleRegistry::trust_level), and Settings → Plugins marks it copied in by hand.

Signed installs (PL-H5). Every registry download is verified against a registry root public key chatty holds, never one the registry sends (hive_client::verify, which mirrors hive’s hive-verify byte for byte; crates/hive-client/tests/vectors/ are hive’s shared vectors). The root certifies the publisher’s key, the publisher’s key signs a canonical manifest {capabilities, name, sha256, version, wit_version}, and the manifest’s sha256 must match the downloaded bytes and its name/version the module asked for. A download missing any of the four X-Hive-* chain headers, or failing any link, is refused; module.toml takes its tools from the signed capabilities. Which root keys are trusted (hive_client::trust): the compiled production list (PRODUCTION_ROOT_PUBLIC_KEYS, empty until pinned, so the production registry is refused until then), or CHATTY_HIVE_ROOT_KEY for a local (loopback) registry such as the compose stack — one key, which replaces the compiled list for that registry rather than adding to it; the override is ignored for any other host. A registry with no trusted root refuses every download before the request (no trust on first use).

Root key rotation (SEC-3, AGE-816). PRODUCTION_ROOT_PUBLIC_KEYS is a list, not a single key, so a certificate verifies if any listed root signed it (hive_client::verify::verify_download_any). That is what lets a rotation happen without a flag day: for the length of the rotation window the list holds two keys, current and next.

  1. Add next to PRODUCTION_ROOT_PUBLIC_KEYS and release chatty. Both current and next verify from this point on, so every chatty install can already accept certificates signed by either one.
  2. Switch the registry to signing new publisher certificates with next.
  3. In the following chatty release, remove current from the list. Only next verifies after that; any certificate still outstanding under current must have been re-issued under next before this release ships.

There is no third state: the list holds one key outside a rotation window, two only during one. Nothing falls back to an unlisted or expired root.

Resource limits

Every plugin runs inside a sandboxed Wasmtime instance (crates/chatty-wasm-runtime/src/limits.rs). Every limit is per call: fuel is refilled and the deadline re-armed before each export call. The defaults are the host ceilings; a manifest’s [resources] may only lower them — a larger value is clamped down to the ceiling.

LimitDefault = ceilingEnforcementError
Fuel10¹² units per callWasmtime fuel (≈1 unit per Wasm instruction)fuel exhausted
Wall clock60 s per call, host time includedEpoch interruption (10 ms ticks); host imports (llm::complete, file::read-bytes, billing) stop waiting at the deadlinedeadline exceeded
Memory256 MiBStore memory limitermemory limit
Output1 MiB per callSize of each export’s return valueoutput too large

The fuel ceiling (AGE-708) is sized so a pure CPU-bound guest is bounded by the 60 s wall-clock ceiling, not by fuel: on this host, Wasmtime fuel runs at roughly 1.5 × 10¹⁰ units/s for a tight arithmetic loop, so 10⁹ (PL-D3’s original figure) was exhausted in well under a second — 10¹² keeps a pure spin running for over a minute of fuel, past the 60 s wall clock.

metadata and list-tools get a 1 s wall-clock budget. A guest trap or panic fails the call with guest trap: <message> (the panic message is read from the guest’s stderr) and never takes the host down; the trapped instance is dropped and the plugin re-instantiated on its next call, so guest statics start over. Callers can match the kind with err.downcast_ref::<chatty_wasm_runtime::CallError>(); the guest’s own tool-error is a ToolFailure (<kind>: <message>).

A plugin’s tools as the agent’s own

Each [[plugins]] entry is loaded when the agent is built: the module directory (module_settings.module_dir) is searched for a module.toml whose [module].name is module, its version is checked against version, and one instance is made for this agent, with the module’s [config] (the spec’s config on top) and [files].root, and its [resources] lowered by the spec’s limits. Every tool its list-tools names is registered next to the native tools as <module>__<tool> — echo__reverse — because OpenAI-wire providers (OpenRouter, Azure) refuse any tool name outside ^[a-zA-Z0-9_-]{1,64}$, so the dotted form is only what the transcript shows (“Ran echo.reverse”). A call goes straight into the instance under PL-H1’s per-call limits — about 40 µs, against about 1 ms through the gateway’s /mcp/{module} — and a trap, deadline or guest error comes back to the model as the tool’s error, with its reason, and the turn goes on. The spec is the plugin’s allow-list: a tool profile does not remove it. The plugin is linked against only the capabilities the spec grants (PL-U4); an ungranted one it calls is refused with capability <x> not granted to this agent, which the model reads, and a grant the plugin does not request fails the build as a spec error. A call would ask for approval only if the spec granted a side-effecting capability; none of v1’s is (file is read-only, and llm and billing cost money but count against the budget). Under --tool-loading dynamic each plugin is one load_tools group named after it. What a plugin spends through llm::complete runs on the calling agent’s model and is recorded as its own usage line on the turn, naming the plugin and the model that served it. A plugin that does not load fails the agent’s build. The desktop runs spec agents as chatty-tui workers, which load their plugins the same way; it no longer adds its modules to the MCP server list — /mcp/{module} is for MCP clients outside chatty.

A paid plugin (one installed from Hive with pricing_model = "paid") is metered the same way whichever path calls it, a spec’s <module>__<tool> or the gateway’s /mcp/{module}: one ModuleMeter (hive-client) admits the call before the guest runs and reports it to Hive after it answers. The publisher’s free calls (free_tier_calls, counted against the calls Hive has on record for the user) run at any balance; after those the balance must be positive, and a balance that cannot be read (signed out, sign-in expired, registry down) refuses the call. A worker uses the desktop’s sign-in without refreshing it, and sends each paid call’s report before it answers. The billing capability’s host import is not wired yet: acquire-session answers “billing not configured on host” until ADR-0024 settles how a plugin-driven session is priced.

A spec opts in by listing the plugin; nothing else does. Installing a plugin from the Hive marketplace puts it in the module directory and nothing more: it is not an agent and joins no agent until a spec names it.

Settings → Plugins

The desktop’s Plugins page lists every module in the module directory: version, directory, whether it came from Hive or was copied in by hand, its trust level (PL-H5a), its tools, whether it runs locally and is served over MCP, a load failure if any, and which agent specs use it with the capabilities each one grants. The spec is where an agent’s plugin is granted anything. For a module the gateway serves with no spec (SEC-11, AGE-815) the page is where the user grants: each requested capability has a row, config on by default and llm, file and billing as switches that are off until turned on. The grants live in .chatty-grants.json beside .chatty-install.json (ModuleGrants) and are read at every load; a switch rewrites the file and rescans. A module installed before this has no file, so it simply starts with the defaults.

Security note. A plugin served on its own is untrusted code with no human-written spec vouching for it. It can always log and read its own [config]; it can spend your LLM quota (llm), read files under its [files] root (file) or bill (billing) only after you grant that capability for that module. Ungranted imports refuse with capability <x> not granted to this agent; they do not trap.

Serving a plugin over MCP

The protocol gateway (agents-and-specs.md) serves a plugin’s tools to MCP clients outside chatty at /mcp/{module} (and the HTTP+SSE transport at /mcp/{module}/sse), only when its [protocols] mcp is set — otherwise 404. There is no OpenAI route and no A2A route for a plugin: it has tools, not a loop (PL-U3, PL-U5).

Delegation by policy

When to read this: You are changing who may call whom (swarm.delegates_to, swarm.callers), the call chain, run budgets, or how approvals and questions travel from a worker back to a person (PL-S2, the DP-* issues).

An agent may call the agents its spec lists, nothing else. The decision is made by the broker, before anything is spawned, and never by the worker that asks. The field reference is agents-and-specs.md; this page is the rules in one place.

Who may call whom

  • Rights come from the spec, not the profile. swarm.delegates_to (names or * globs) is what gives an agent list_agents and invoke_agent; a tool profile neither adds nor removes it. Empty means no agent tools.
  • swarm.callers, when set, narrows the other side: only those specs may call this one. exposed = false hides a spec from listings.
  • chatty_core::services::delegation_policy implements the check (may_call(caller, callee) for a node, root_may_call(callee) for the root). A plain root (a bare chatty-tui, the desktop conversation) may call anyone; its own tools decide whether it delegates at all. A --team leader or --agent <spec> root is held to its spec.
  • A broker given no policy consults the named LocalPermissive; the check is never skipped.

The call chain

  • A CallChain (chatty-fabric/src/delegation.rs) is the list of spec names a call went through, root first, plus a root_task_id minted when the root starts a call. Every run under that root call shares the root_task_id, on every local hop (pinned by root_task_id_is_stable_across_hops).
  • The broker stamps the chain. It reads the caller’s chain from its own task table (the run the agent.invoke names) and extends it with the callee. Nothing in the request can set or widen it: the chain is not a wire field, and a frame that smuggles one in (metadata.chatty.call) does not decode. No bearer or token travels with a local call; a worker never holds a credential.
  • CallChain::extend refuses a cycle (the callee is already on the chain) and a call deeper than MAX_DEPTH = 4 (the root’s callee is depth 1). The cycle is checked first.
  • A refusal is CallError::Delegation(Refusal), written to the edge log and shown to the calling model as Error: invoke_agent: cycle: …, too_deep: depth 5 > max 4 or not_listed: a may not call c.

Budgets

  • Each spec may declare [budget] (turns, time, cap_usd). A callee runs under the tighter of its own budget and what its caller has left (CallChain::budget): a caller can only lower what it passes down.
  • invoke_agent sends what is left with every call (InvokeAgentParams.remaining). A used-up limit is refused before spawn as budget_spent: turns|seconds|usd.
  • Dollars are enforced in-process by LocalSpendGate; see token-tracking.md.
  • The broker stops a callee still running past its deadline plus deadline_grace and returns a failed result.

Approvals and questions

  • human.approve (a shell or write approval) is answered only by the root. The broker stamps the asker from the node’s admitted name and chain, never from the frame, and never sends the request to another worker.
  • human.ask (a question) climbs the caller chain: each caller may answer or escalate, and the first answer found goes back down. The wire shapes are in agents-and-specs.md.

Calls that leave the machine

Cross-host authority is a broker-held ledger hold (ADR-0022), not a token on the A2A wire. invoke_agent refuses remote delegations that fail the same budget check. That work is tracked separately (CX-*) and is not described here.

Team smoke test

When to read this: You are about to touch the broker, --team, the worker tree, or the coder-reviewer test team and want one end-to-end run that says whether a headless leader can still delegate, review, merge and pass a verifier. This is the run that found five product bugs in one evening (2026-09-13, AGE-400 to AGE-404) before it became a script (ADR-0011 C14, AGE-408).

Scope is deliberately minimal: one task, one team, one reward. Task sets, arms, statistics and cloud runs belong to harbor-chatty (AGE-34).

What it runs

bash scripts/team-smoke.sh

One command, no arguments. It:

  1. Builds target/release/chatty-tui when it is missing or older than the sources (or uses CHATTY_TUI=<path>).
  2. Builds the chatty-team-smoke image (scripts/team-smoke/Dockerfile: ubuntu:24.04 plus python3, git, ca-certificates).
  3. Writes a throwaway HOME under target/team-smoke/run-<stamp>/home/: providers.json pointing at Ollama, models.json with the leader model (extra_params.think=false) and the coder model, and the coder-reviewer team (a test fixture since AGE-752, in crates/chatty-tui/tests/fixtures/team-workspace/.chatty/, no longer a shipped preset) copied into ~/.local/share/chatty/teams/ and ~/.local/share/chatty/agents/ with the two models and the verification command (python3 -m unittest discover -s tests -t . -v) filled in, so the run exercises AGE-407’s search order and AGE-406’s evidence hook without editing the task text.
  4. Creates the fixture repo from scripts/team-smoke/fixture/: a bank account module whose withdraw allows overdrafts and negative amounts, one passing test_deposit, and tests/__init__.py so unittest discovery works.
  5. Runs the leader as your uid in the container, under a 15-minute timeout: chatty-tui --headless --team coder-reviewer --auto-approve --workspace /work -m "<task>". The task names the fix and the tests to add and says “Never edit files yourself”; --team injects read_skill coder-reviewer and follow it and the verification command on the first turn.
  6. Runs scripts/team-smoke/verify.sh in a second container on the tree the leader left: the tests on the merged working tree, hidden checks (deposit still returns the balance; withdraw raises ValueError on 0, negative and excess; a successful withdrawal returns the new balance), at least four tests defined, no unmerged sub-agent/* branches. It prints REWARD=0|1 and the script exits accordingly.
  7. Prints a one-screen summary (also saved as summary.txt in the run directory): worker processes spawned per role, invoke_agent delegations per agent, the reviewer’s first line(s), the merge commit, wall time, and the verifier’s output.

Everything the run produced stays in target/team-smoke/run-<stamp>/: leader.out (the leader’s answer), leader.err (the trace), ps.log, verify.log, home/ and work/.

Knobs, all optional: CHATTY_TUI, OLLAMA_URL (default http://172.17.0.1:11434, the Docker bridge address of the host), LEADER_MODEL (default qwen3:14b, also the reviewer’s), CODER_MODEL (default qwen3:4b), TEAM_SMOKE_TIMEOUT (seconds, default 900).

Prerequisites

  • Docker, usable by your user.
  • Ollama on the host, reachable from a container: Ollama’s default bind is 127.0.0.1:11434, which the Docker bridge cannot reach, so run it with OLLAMA_HOST=0.0.0.0:11434 (or point OLLAMA_URL at wherever it listens). curl http://172.17.0.1:11434/api/version from the host is the quick check.
  • The two models pulled: ollama pull qwen3:14b and ollama pull qwen3:4b.
  • OLLAMA_NUM_PARALLEL ≥ 2, so a worker’s request is not queued behind the leader’s open turn.
  • A GPU with room for both models at once (about 12 GB for the defaults).

Reading the result

A passing run (2026-09-13, 188 s) looks like: one local-coder process and one local-reviewer process, two or three delegations (coder, reviewer, and the reviewer again to verify the merged tree), APPROVE as the reviewer’s first line, a Merge branch 'sub-agent/local-coder-0' commit, and REWARD=1.

Small local models flake. Two passing runs out of three consecutive is acceptable for this smoke test; a run that fails on a 500 from Ollama or on a timeout is a host problem, not a product one, so check the traps below before reading a red run as a regression.

Measured 2026-09-14 on this script (chatty2 f3cbc434 plus the script): three consecutive runs gave rewards 0, 1, 1 in 9 s, 191 s and 181 s. The failure modes seen while the script was being built, all in the leader or the coder rather than in the broker:

  • the leader restates the criteria and ends its turn without delegating (the 9 s run);
  • the leader hands the reviewer a prompt without the coder’s branch name, so the reviewer diffs its own empty sub-agent/local-reviewer-N branch and answers BLOCKED, repeatedly, until the 50-turn budget ends the run;
  • the leader drops the “add tests” criterion when it restates a prose task, so the coder fixes withdraw without tests and the reviewer approves (the task text is a numbered list for that reason);
  • the coder’s report ends up committed as answer.txt on its branch, and a venv/ it created was committed with it by the commit-on-exit hook; the verifier does not score stray files, but a merged tree can carry them.

Traps already known

  • Ollama’s OpenAI-compatible URL. Pointing --openai-compat-url at Ollama’s /v1 needed AGE-403’s normalization so discovery and chat agree; the script sidesteps the question by writing an ollama provider into providers.json, which the worker processes read from the same mounted HOME.
  • A GPU full from another model. If something else already holds the GPU, the worker’s model fails to load and the delegation ends with 500 Internal Server Error: model failed to load; the leader retries, blocks, and the run times out. Check ollama ps and nvidia-smi before a run and unload what you do not need.
  • A binary not named chatty-tui. The broker finds the worker executable as chatty-tui next to the running binary (chatty_core::tools::worker_executable). The script mounts whatever CHATTY_TUI names at /opt/chatty/chatty-tui, so a renamed build works here, but a hand-run leader with another name spawns no workers.
  • Think mode on the coder. extra_params.think=false is set on the leader model only. qwen3:4b with thinking off narrates instead of working; leave the coder model’s extra_params empty.
  • The container keeps running after the timeout. timeout kills the Docker client, not the container; the script force-removes the container by name on exit, so do not run two copies with the same name in parallel (the name carries the script’s PID).

Not in CI

The run needs a GPU and a local Ollama. The self-hosted runner has neither in its runner environment, so this is a local gate: run it before opening a PR that touches crates/chatty-protocol-gateway, chatty-tui/src/participant/, chatty-core/src/services/{team,virtual_agents,worker_tree}.rs or the fixture team, and paste the summary in the PR.

Swarm test kit

When to read this: You are writing a test for anything that spans processes — a leader, its broker, real chatty-tui workers, a sub-leader’s own broker — and it must run in CI with no network and no real model (AGE-632, the fabric epics’ BI-0).

Two pieces:

  • chatty_core::testing::fake_model (behind chatty-core/test-support): FakeDaemon, a localhost model server. FakeDaemon::scripted(Script) answers from a script; FakeDaemon::ollama/openai_compatible replay canned bodies in order (what session::append_only_prefix uses).
  • crates/chatty-tui/src/participant/swarm_kit.rs (test-only): SwarmKit, a real root Broker whose roster’s models point at two fake servers, spawning real chatty-tui workers.

Scripting the model

A Script maps a routing key to a queue of Replys. A key matches a request whose model field equals it or whose system prompt contains it; the first matching route wins.

ReplyEffect
Text(s)Ends the provider turn with s
ToolCalls(vec![(name, args)]) / Reply::tool_call(name, args)Answers with those tool calls (ids call_0, call_1, … per server)
Usage { input, output, cache_read }The usage the next answer reports (default 10/5/0)
Delay(ms)Sleep, then serve the next reply
Error(status)Answer with that HTTP status

Replies are OpenAI-compatible SSE on …/chat/completions and Ollama NDJSON on /api/chat. A request no route matches, or whose queue ran dry, gets HTTP 500 naming its model — a missing script fails loudly rather than hanging.

Every request is recorded (requests(), requests_for(key)) with its body, arrival and finish time; max_concurrency() is the peak number in flight. One server is one endpoint.

Mind the headless recovery policy when scripting errors: a worker retries any provider status other than 401/403/429 after 10 s, 20 s, …, and a 401 once immediately. Two Error(401)s end a worker’s turn fast.

Writing a swarm test

#![allow(unused)]
fn main() {
use super::swarm_kit::{AgentDef, Endpoint, SwarmKit};
use chatty_core::testing::fake_model::{Reply, Script};

#[tokio::test]
async fn my_swarm_feature() {
    let kit = SwarmKit::start(
        vec![AgentDef::new("kit-worker", "kit/worker", Endpoint::Sse)],
        Script::new().route("kit/worker", [
            Reply::tool_call("read_file", serde_json::json!({ "path": "README.md" })),
            Reply::text("done"),
        ]),
        Script::new(), // the NDJSON endpoint's script
    )
    .await;

    let run = kit.run_leader("read the readme").await;
    // run.output: Result<InvokeAgentOutput, String>, what the leader's model sees
    // run.progress: every InvokeAgentProgress, in order
    // run.requests / kit.sse.requests_for("kit/worker"): the model's view
}
}
  • Each agent’s model identifier is its routing key. Endpoint::Sse is an OpenRouter-type provider, Endpoint::Ndjson an Ollama one: a provider is looked up by type, so these are the two separate endpoints a roster can have.
  • AgentDef::sub_leader() starts that worker with --broker; it serves the same roster (the kit’s module_settings.json), so it can delegate to its siblings.
  • Workers run auto-approved, with filesystem tools on, fetch and memory off, in a non-git workspace holding README.md (# Chatty).
  • A worker is worker_executable()’s chatty-tui behind a two-line sh wrapper that only sets HOME and the XDG dirs to the kit’s temp dir, so it never reads your real settings. cargo test -p chatty-tui builds the binary (tests/worker_binary.rs is what makes it); CHATTY_WORKER_EXE overrides which binary worker_executable() picks.
  • normalize / parent_trace strip the temp dir, ports and uuids; request bodies are otherwise byte-stable run to run (swarm_kit_is_deterministic).

Pre-change goldens

src/participant/goldens/pre_fabric/ holds the parent-side traces of equivalence.rs’s scenarios and nested delegation on separate endpoints, recorded through the kit on main before BI-3 (invariant 10 of the broker-identity spec). pre_fabric_goldens_replay replays them. UPDATE_GOLDENS=1 does not touch them, and assert_pre_fabric never overwrites an existing file; a later PR may only delete one, with a reason in its PR body. CI’s fabric-goldens job runs the replay on its own and fails a pull request that modifies an existing golden (ADR-0020’s first kill criterion, AGE-640).

The hop-latency baseline (measure_hop_latency, run by the ignored fabric_hop_latency_baseline) is in research/fabric-hop-latency-baseline.md; the new path, measured the same way, is in harbor-chatty (private), experiments/reports/fabric-hop-latency-2026-09-29.md.

Research notes

When to read this: You want to know what the research crates in this workspace are for, how they relate to the product, and where the decisions behind them are recorded.

Chatty ships a working agent: a ReAct-shaped tool loop, memory and skills, a token budget with compaction, ATIF trace export. The research track takes a handful of agentic-self-improvement papers and asks, per mechanism, whether it earns a place in that product — as a default, as an opt-in setting, or not at all. Papers inform the design; experiments decide what ships. The full frame is Paper → experiment → product.

Work is tracked in the Linear project Self-improving chatty2. A small set of symbols in the research crates is reserved for the human to write; see RESERVED.md before touching those crates.

The modules

ModulePaperLands inWhat it adds
M0 Trace— (contract layer)chatty-traceThe Trajectory every optimizer reflects on; per-step attribution; ATIF round-trip; FeedbackFn
M1 ReActYao et al., ICLR 2023chatty-core (loop already ships) + chatty-traceFidelity to the paper’s action space; strategy variants for eval
M2 AFlowZhang et al., ICLR 2025chatty-flow (IR + interpreter), chatty-optimize (MCTS)Workflow topology search offline; saved workflows run in-app
M3 GEPAAgrawal et al., ICLR 2026chatty-optimizeReflective prompt optimization of ModelConfig.preamble
M4 ACEZhang et al., ICLR 2026chatty-playbookEvolving playbook over the memory/skills store with deterministic merge
M6 RetrievalRetrieval literature (BM25, RRF, rerankers, context packing)chatty-core (search_web, search_memory), chatty-optimize (eval)One retrieval pipeline over two corpora, open web and agent memory, measured on SimpleQA and FRAMES

Per-module pages, dependency graph and status: modules/index.md. The fifth paper (DGM) self-modifies its target repo and therefore lives in a separate repo, never against chatty2.

How research meets the product

  • Stage A (fidelity) runs inside this workspace’s research crates. Stage B (task value) runs in the sibling repo harbor-chatty — see Harbor pivot for why. Neither stage promotes anything by itself; the bar is in Experiment protocol.
  • Shipping crates (chatty-trace, chatty-playbook, chatty-flow) sit on the request path and are held to the same production bar as chatty-core; chatty-optimize is build-time tooling and never blocks a chat.
  • Where mechanisms land: App ↔ research bridge maps production components (agent loop, memory, context window, traces, sub-agents) to the modules that touch them; Settings integration map lists the settings each promoted mechanism would surface through.

Experiment reports

Run reports, pre-registrations, spikes and their harnesses live in the private harbor-chatty repo under experiments/, not here. This folder keeps what documents chatty2’s own code: the module notes, the process docs above, and the benchmark baselines that chatty2’s benches check against (fabric-codec-baseline.md, fabric-hop-latency-baseline.md). A result a shipped feature relies on is summarised in that feature’s user guide.

Decision records (ADRs)

DocDecision
harbor-pivot.mdStage B evaluation lives in Harbor, not in chatty2
appworld-decision.mdWhich AppWorld sandbox to evaluate against
cost-model.mdHow optimizer runs are costed from live ModelConfig prices

Each research crate’s scope is stated in its own README.md (linked in the table above).

Archived: the empty promotion-log template and the pre-Stage-A crate-promise pages were kept under docs/archive/research/ for their reasoning only; they are not maintained.

App ↔ research bridge

When to read this: You know either the production Chatty app or a research module, and need to see how they connect — memory, context window, agent loop, traces, sub-agents, and where SOTA work lands.

Parent frame: Paper → experiment → product.

Two views of the same system

flowchart TB
  subgraph app [Production Chatty — ships today]
    Loop[Agent loop<br/>llm_service + AgentFactory]
    Mem[Memory + skills<br/>memory_service]
    Ctx[Context window<br/>token_budget]
    Stream[StreamManager + ATIF export]
    Tools[~60 tools + MCP]
    SubAg[Sub-agents<br/>invoke_agent_tool]
    Settings[Settings models<br/>ModelConfig, Execution, Training]
  end

  subgraph research [Research domains — experiment → promote]
    M0[M0 Trace contract]
    M1[M1 ReAct fidelity]
    M2[M2 AFlow topology]
    M3[M3 GEPA prompts]
    M4[M4 ACE playbook]
  end

  Loop --> M1
  Loop --> M0
  Stream --> M0
  Mem --> M4
  Ctx --> M3
  Ctx --> M4
  Settings --> M3
  SubAg --> M2
  Tools --> M1
  Tools --> M2
  M0 --> M2
  M0 --> M3
  M0 --> M4

Read left → right to find which research module touches a production component. Read right → left to find where a promoted mechanism lands in the app.

Master mapping

App componentCode locationUser-facingResearch module(s)Promotion path
Agent loopservices/llm_service.rs, factories/agent_factory/Every chat turnM1 ReActAlready default; strategy variants TBD
LLM tools (~60)tools/Tool calls in chatM1 (action space), M2 (workflow nodes)Tools ship; topology search offline
Sub-agentsinvoke_agent_tool, list_agents_toolAgent delegationM2 AFlowWinning IR → FlowSettingsModel (planned)
Memory storeservices/memory_service.rs, memory.mv2remember / search_memoryM4 ACE, M6 Retrieval (Part B)Playbook on same store; scope TBD (AGE-47)
Web searchtools/search_web_tool.rs, tools/fetch_tool.rssearch_web / fetchM6 Retrieval (Part A)Provider chain + keyless tier as default if the eval shows it
Skillssave_skill_tool, SKILL.md files, [SKILL] prefixProcedures in contextM4 ACE (facts vs procedures split)Self-improving playbook vs manual skills
Context windowtoken_budget/ + GPUI footer barToken fill indicatorM3 cost, M4 prefix-cacheEstimation and compaction ship
Model preambleModelConfig.preambleSettings → model system promptM1 few-shot block, M3 GEPAUser-editable; GEPA optimizes offline
Trace / ATIFexporters/types.rs, StreamManagerTraining export toggleM0 TraceExport setting; full recorder TBD
Token pricingModelConfig.cost_per_million_*Cost display (sidebar)M3 GEPA, cost modelShips; optimizer accounting uses it
Code sandboxsandbox/, Monty bridgeCode execution settingM2 Monty code mode (future PythonRepr)Subprocess today; pause/resume deferred
Conversation historyConversationsStore, SQLiteChat threadsAll modules (trace input)Ships; compaction/summary future
Training exportTrainingSettingsModel, exportersOpt-in ATIF/JSONLM0 round-trip, M3/M4 offlineatif_auto_export setting

Full settings field map: settings integration map.


Agent loop & streaming

Production today

The ReAct-shaped loop runs in chatty-core: AgentFactory builds a rig-agent client, llm_service streams multi-turn tool calls, and GPUI’s StreamManager owns lifecycle (cancel, token usage, trace JSON) without duplicating response text.

ConcernDocKey files
Stream lifecyclestream-manager.mdchatty-gpui/.../stream_manager.rs
Entity wiringentity-communication.mdapp_controller/
Provider agentssystem-overview.mdfactories/agent_factory/

Research touchpoints

ModuleWhat changesStatus
M1Per-step ModuleId in trace; CoT/Act/SC variants for evalFidelity WIP
M0Hook + rig-tap capture → Trajectory; no hand-rolled loopNot started
M2Interpreter replaces flat loop with saved workflow IRNot started

Reserved: Strategy backoff rule (RESERVED.md) — affects loop termination only; loop body is not rewritten.


Memory, skills & playbook (M4)

Production today

Persistent memory uses memvid (memory.mv2). The agent gets remember and search_memory; skills are stored with a [SKILL] title prefix or as filesystem SKILL.md files. There is no automatic recall: the preamble tells the model to call search_memory itself. How that retrieval works, stage by stage, is in M6 Retrieval Part B.

ConcernDocKey files
Memory architectureagent-memory.mdmemory_service.rs, remember_tool.rs
Skill tools—save_skill_tool.rs, search_memory_tool.rs
Toggle—ExecutionSettingsModel.memory_enabled

Research touchpoints (ACE)

Paper conceptProduction analogueGap
Sectioned playbookFacts vs [SKILL] proceduresNo Reflector/Curator yet
Delta ops + deterministic applyManual remember / save_skillNo execution-feedback merge
Grow-and-refine / de-dupUnbounded memvid growthProduction eviction policy needed
Prompt-cache prefix stabilityStable skill ordering in search resultsByte-stable serialization TBD

Promotion: playbook runtime likely a setting (AGE-47 scope: global / per-model / per-conversation).


Context window & token budget

Production today

Before each LLM call, Chatty BPE-estimates preamble + tools + history against ModelConfig.max_context_window. The footer bar shows fill level; actual token counts patch in after the stream completes.

ConcernDocKey files
Token trackingtoken-tracking.mdtoken_budget/, GlobalTokenBudget
Response reserve—TokenTrackingSettings.response_reserve (default 4096)
Summarizationcontext-compaction.mdsummarizer.rs — summarize_oldest_half ships (/compact, desktop auto-summarise); only the secondary-model variant summarize_with_model is still a stub

Research touchpoints

ModuleConnection
M3 GEPAShorter optimized preambles affect context headroom; cost formula uses same token counts
M4 ACEPlaybook growth competes for context; paper assumes prompt-cache prefix reuse
M0RolloutBudget extends token accounting for train vs validation rollouts

Future product link: conversation compaction (the per-call guard in services/context_shaper.rs and the persisted summarize_oldest_half) is the natural place to apply ACE grow-and-refine eviction when playbook + history exceed pressure thresholds — the compaction ships; the playbook hook into it does not exist yet.


Model config & prompts (M1, M3)

Production today

Each model has a persisted ModelConfig: preamble (system prompt), temperature, context limit, per-million token pricing, multimodal flags.

#![allow(unused)]
fn main() {
// crates/chatty-core/src/settings/models/models_store.rs
pub struct ModelConfig {
    pub preamble: String,
    pub temperature: f32,
    pub max_context_window: Option<i32>,
    pub cost_per_million_input_tokens: Option<f64>,
    pub cost_per_million_output_tokens: Option<f64>,
    // ...
}
}

Research touchpoints

FieldM1M3
preambleFew-shot exemplar stable prefixGEPA optimization target
temperaturePaper regimesFixed during GEPA search
cost_per_million_*—Optimizer cost sheet (cost-model.md)

Promotion: GEPA output → preamble per model (setting); apply via AGE-45.


Traces, ATIF & training export (M0)

Production today

Conversations export to ATIF v1.6 (exporters/types.rs). StreamManager holds trace_json per stream. TrainingSettingsModel toggles auto-export after each assistant response.

SettingDefaultPurpose
atif_auto_exportoffWrite ATIF JSON after completion
jsonl_auto_exportoffSFT/DPO JSONL export

Research touchpoints

GapModuleWhy it matters
No Deserialize on ATIF typesM0Reflection must read traces back
No per-step ModuleIdM0GEPA credit assignment
No FeedbackFnM0GEPA µ_f, ACE reflector input
Full vs no retentionM0Recorder trait; no-op in release builds

Deep dive: M0 Trace module page, chatty-trace README.


Tools, sub-agents & workflow (M1, M2)

Production today

~60 native tools plus MCP integrations. Sub-agent tools compose other agents from the registry. Execution settings control workspace path, approval mode, and sandbox.

Tool clusterExamplesResearch use
Search / fetchsearch_web, fetchM1 WikiEnv adapter (paper fidelity); M6 Retrieval Part A (SimpleQA/FRAMES eval)
Coderun_command, sandboxM2 Programmer operator
Agentsinvoke_agentM2 Ensemble / Review-and-Revise nodes
Memoryremember, search_memoryM4 playbook backing store

Research touchpoints (AFlow)

Production substrateM2 uses it as
tool_registry / tool_collectorWorkflow node targets
invoke_agent_toolEnsemble branches
IrRepr interpreter (planned)Executes saved topology

Monty code mode (sandbox/monty_bridge.rs) could become PythonRepr for AFlow. Today MontySandbox (sandbox/monty.rs) runs stdlib-only Python through a python3 subprocess under time/memory limits, and SandboxManager tries it before Docker; the bridge’s ToolBridge interface exists but the pause/resume loop that would let scripts call Chatty tools is not wired. Source: crates/chatty-core/src/sandbox/.


Optimizer & offline runs (M2, M3)

Not in the request path. Search runs via chatty-optimize (planned CLI: chatty-tui --optimize, AGE-44).

OptimizerReads from appWrites to app
GEPA (M3)Traces, ModelConfig, cost fieldspreamble candidates
AFlow (M2)Traces, sub-agent registryWorkflow IR JSON
ACE offline (M4)Traces, memory storePlaybook delta ops

Stage B evals run in harbor-chatty, not inside the app binary.


Quick lookup: research module → app landing

ModuleLands in app at…Architecture doc
M0 Traceexporters/, StreamManager, future Recorderstream-manager.md
M1 ReActllm_service.rs, agent_factory/system-overview.md
M2 AFlowSub-agent tools + future FlowSettingsModelcomponent-map.md
M3 GEPAModelConfig.preamblesettings-integration-map
M4 ACEmemory_service, skill toolsagent-memory.md
M6 Retrievalsearch_web_tool.rs, fetch_tool.rs, search_memory_tool.rs, doc_retriever_tool.rsagent-memory.md, evals/search/README.md (archived)

Paper → experiment → product pipeline

When to read this: You need the end-to-end story of how SOTA agentic papers land in Chatty — what gets experimented on, what the human reviewer decides, and what becomes production code vs a user setting vs nothing.

This is the organizing frame for research documentation. Individual papers, crate promises, and ADRs hang off it.

The flow

flowchart LR
  Papers[SOTA papers] --> StageA[Stage A<br/>in-repo fidelity]
  StageA --> StageB[Stage B<br/>Harbor benchmarks]
  StageB --> Human[Human evaluation<br/>paired stats + ablations]
  Human -->|reject| Archive[Document why not]
  Human -->|setting| Setting[User-configurable<br/>opt-in feature]
  Human -->|default| Default[Ship as default<br/>when dominating]

  StageA --> Crates[Research crates<br/>trace / playbook / flow / optimize]
  Setting --> Core[chatty-core settings<br/>+ shipping crates]
  Default --> Core

Rule: papers inform the design; experiments decide what ships. Reproducing a paper is necessary but not sufficient — cross-module results (AGE-21) and product constraints (AGE-26) filter what becomes Chatty.

Two experiment stages

StageWherePurposeWho interprets results
Stage Achatty2 research cratesFidelity to each paper’s mechanism; trace contracts; in-process optimizersThe human reviewer (owner:human / owner:pair on reserved symbols)
Stage Bharbor-chatty (Harbor)Containerized coding/env benchmarks (HumanEval, Polyglot, AppWorld, …)The human reviewer — agents build harness plumbing only

Stage A answers “did we implement the idea correctly?” Stage B answers “does it help on tasks users care about?” Neither stage auto-promotes to production.

See Harbor pivot for why Stage B lives outside this repo.

Three promotion outcomes

Every paper mechanism ends in exactly one bucket after experimentation:

OutcomeMeaningExample (illustrative — decided by experiment)
RejectedMechanism stays in research/optimizer tooling only; not exposed in the appCoT-SC hybrid backoff as a user-facing strategy if ReAct + tools already wins for Chatty’s workloads
SettingAvailable but off by default; user picks per task/model/conversationworkflow_enabled + active_workflow_id (AFlow IR); ACE playbook scope; optimizer-launched preamble variants
DefaultBecomes the normal path when evidence shows clear dominanceReAct loop itself (already ships in chatty-core); ATIF export when trace retention is explicitly enabled

Dominance bar for defaults: sustained gain on Stage B (or representative in-app tasks), acceptable cost/latency, and no regression on the production bar (AGE-26). A paper win on HotpotQA alone does not automatically become a Chatty default.

Settings surface: persisted models in chatty-core settings (ModelConfig, future FlowSettingsModel, playbook storage — see product gate issues below). Optimizer output (preamble, playbook bullets, workflow IR) lands through an apply policy the human reviewer must choose (AGE-45).

Paper → crate → product landing

ModulePaperPrimary crate(s)Ships in app?Production landing siteTypical promotion
M1ReAct (ICLR 2023)chatty-core loop + chatty-traceLoop: yes; strategy variants: eval-onlyExisting agent loop; per-step attribution in traceLoop = default; Act/CoT/CoT-SC hybrids = setting or eval-only
M2AFlow (ICLR 2025)chatty-flow (runtime IR) + chatty-optimize (MCTS)IR interpreter: yes; search: build-time onlySub-agent composition via saved workflowSearch = offline; winning topology = setting (FlowSettingsModel)
M3GEPA (ICLR 2026)chatty-optimizeNo (on-demand / CI)ModelConfig.preambleOptimized preamble = setting per model; apply via review/export (AGE-45)
M4ACE (ICLR 2026)chatty-playbookYesMemory / [SKILL] store backingPlaybook merge/refine in runtime; grow bounds = production concern; scope = setting (AGE-47)
—Trace / ATIF contractchatty-traceYes (recorder opt-in)ATIF export + reflection inputFull retention = setting; no-op recorder = default in release
M5DGMagenticloop (separate repo)NoNever against chatty2 itselfStays research — see RESERVED.md

Linear project: Self-improving chatty2.

Inference path vs offline tooling

flowchart TB
  subgraph request [Request path — must stay fast]
    UI[GPUI / TUI] --> Core[chatty-core]
    Core --> Trace[chatty-trace Recorder]
    Core --> Playbook[chatty-playbook]
    Core --> Flow[chatty-flow interpreter]
  end

  subgraph offline [Offline — never blocks chat]
    Opt[chatty-optimize<br/>GEPA / AFlow search]
    Harbor[harbor-chatty Stage B]
    Opt --> Artifacts[preamble / playbook / workflow IR]
    Artifacts -.->|apply policy| Core
  end

  Trace --> Opt
  Playbook --> Opt
  Flow --> Opt
  Core --> Harbor

Shipping crates (chatty-core, chatty-trace, chatty-playbook, chatty-flow) are held to the production bar (AGE-26). Build-time crates (chatty-optimize) are held to correct, not latency-safe.

What the human reviewer owns vs what agents build

Human reviewerAgents
Reserved type definitions in RESERVED.md (~200 lines containing the idea)Everything around them (~2000 lines): wiring, tests, loaders, CI
Running and interpreting cross-module experiments (AGE-21)Harness plumbing for those experiments
Answering gate:product-decision and gate:reflection issuesImplementing decided policy
Choosing reject / setting / default per mechanismDocumenting the decision in ADRs
Posting benchmark numbers before agents may cite themBuilding cost sheets and paired-stats tooling

Agents must not implement reserved symbols, close gate issues, or predict benchmark outcomes.

Open product gates (block promotion UX)

These human product decisions define how optimized artifacts reach users:

IssueQuestion
AGE-44Where users start optimizer runs (GPUI vs chatty-tui --optimize vs CI)
AGE-45Auto-apply vs review-and-approve vs export-only for optimized artifacts
AGE-46Dataset bundling in the product binary
AGE-47Playbook scope: global vs per-model vs per-conversation

Project: Chatty agentic product integration.

Documentation layers for this pipeline

Rigorous docs mirror the pipeline — each layer answers a different question:

LayerQuestionLocationStatus
PipelineWhy does research exist and how does it become product?This pageActive
App bridgeWhich app components map to which research modules?app-research-bridge.mdBuilt
ArchitectureWhat runs where at runtime?system-overview.md, component-map.mdBuilt
Paper fidelityWhat did the paper actually claim?research/modules/Built (M0–M4)
Crate promisesWhat does each shipping crate guarantee?Each crate’s README.md (chatty-trace, chatty-playbook, chatty-flow)Built
ADRsWhy this fork (Harbor, AppWorld, cost model)?docs/research/*.mdPartial
Promotion recordWhat shipped, as what, with what evidence?Recorded by the human reviewer per verdict (archived template)No verdicts yet
Settings mapWhich settings map to which mechanisms?settings-integration-map.mdBuilt (gates open)
Experiment protocolHow to run Stage A/B rigorously?experiment-protocol.mdBuilt
ReferenceWhat can I configure today?Generated tools/CLI/env pagesPartial

Completed doc layers

  1. Per-paper module pages — modules/
  2. Settings integration map — settings-integration-map.md
  3. Experiment protocol — experiment-protocol.md

Still open

  1. Link checker CI (DOC-50)
  2. README → /user/ migration (DOC-15, human review)

Experiment protocol

When to read this: You are running or reviewing Stage A/B experiments and need the shared bar for when results count toward a promotion decision.

Human interpretation: AGE-21 (the human reviewer only). Agents: build harnesses and cost sheets; do not write up cross-module conclusions.

Two stages

StageLocationPass criterion
A — Fidelitychatty2 research cratesMechanism matches paper; acceptance criteria in module page checked
B — Task valueharbor-chattyGain on representative tasks under paired stats; cost within budget

Neither stage auto-promotes; see “Promotion decision inputs” below.

Stage A checklist (per module)

  1. Read the module page acceptance criteria.
  2. Run with fixed seed + warm response cache where determinism is claimed.
  3. Separate training vs validation rollouts (critical for M3 GEPA).
  4. Log artifact token counts when comparing ACE vs GEPA (M4 vs M3).
  5. File failures as Linear issues — do not patch reserved symbols.

Stage B checklist

  1. Use Harbor adapter (AGE-34) — not in-repo sandboxes.
  2. Report paired differences with confidence, not single-run deltas.
  3. Use cost model with live ModelConfig prices — never hand-typed dollars.
  4. The human reviewer posts authoritative numbers before they appear in docs or a promotion record.

Cost accounting

cost ≈ rollouts × calls_per_rollout × (in_tok×in_price + out_tok×out_price) / 1e6
       × (1 − cache_hit_rate) × seeds

Implementation: chatty_optimize::cost. Token means blocked on M0 rig-tap wiring (AGE-5).

Until pilot means exist, do not treat Stage B dollar figures as authoritative.

Determinism requirements

ClaimRequirement
Byte-identical tracesSame seed + warm cache
Reproducible workflow scoreSerialize → reload → re-execute
Stable playbook serializationNo-op update must not reorder sections (M4)

Cross-module experiments (AGE-21)

Reserved whole for the human reviewer. Examples:

  • ACE vs GEPA rollout counts on same task
  • Combined workflow + playbook + preamble
  • Ordering across modules no single paper reports

Agents may build plumbing; the human reviewer runs and interprets.

Promotion decision inputs

After Stage A + B, the human reviewer records (fields per the archived promotion-log template):

  1. Effect size — paired stats, MDE met?
  2. Cost / latency — acceptable on desktop agent?
  3. Production bar — AGE-26 satisfied for shipping crates?
  4. Product gates — AGE-44–47 answered?
  5. Verdict — rejected / setting / default

Settings integration map

When to read this: You need to know which user-facing settings correspond to which research mechanisms — and which are still blocked on product decisions.

Parent: Paper → product pipeline.

Today (shipping)

Settings modelField(s)Research linkPromotion today
ModelConfigpreambleM1 few-shot block; M3 GEPA targetUser-editable default
ModelConfigtemperature, cost_per_million_*M3 cost accountingShips
ModelConfigsupports_images, supports_pdf, …—Ships (unrelated)
TrainingSettingsModelatif_auto_exportM0 trace export toggleSetting
ModuleSettingsModelvirtual_agentsNamed worker roles for the small-teams experiment (ADR-0011 C10, dev/design/fleet.md §2)Ships (JSON only, no GPUI page)
ModuleSettingsModelteam.verificationThe command behind the evidence envelope every delegation reply carries (ADR-0011 C12)Ships (JSON only, no GPUI page)

Location: crates/chatty-core/src/settings/models/.

virtual_agents is a list of { name, model?, disable_tools?, extra_args? } in module_settings.json; empty means the single local-agent. The team a leader delegates to is a roster plus a skill file, e.g. local-coder on qwen3:4b and local-reviewer on gemma4:26b with disable_tools: ["fs-write", "shell", "git"]. team.verification sits beside it and belongs to the whole roster: the runner runs it in each worker’s worktree when the worker’s task ends and puts its exit code into the evidence envelope the leader reads. Schema and the resolution rules: Agents and specs, “Named virtual agents” and “The evidence envelope”.

Planned (product integration project)

Settings modelField(s)Research linkGate issueStatus
FlowSettingsModelworkflow_enabled, active_workflow_idM2 AFlow IRAGE-54Not implemented
Playbook storagescope TBDM4 ACEAGE-47Blocked — human product decision
Optimizer launcherCLI vs GPUI vs CIM2, M3 offline runsAGE-44Blocked
Artifact apply policyauto / review / exportM2 IR, M3 preamble, M4 deltasAGE-45Blocked
Dataset pathsbundled vs user-suppliedStage A loadersAGE-46Blocked

Optimizer artifacts → settings (when gates close)

flowchart LR
  GEPA[GEPA run] --> Preamble[ModelConfig.preamble]
  AFlow[AFlow search] --> IR[Saved WorkflowRepr JSON]
  ACE[ACE offline] --> Bullets[Playbook sections]
  IR --> FlowSettings[FlowSettingsModel]
  Bullets --> PlaybookStore[Playbook scope TBD]
  Preamble --> Apply[Apply policy AGE-45]
  FlowSettings --> Apply
  PlaybookStore --> Apply

Ablation flags (research / CLI, not GPUI v1)

In chatty-optimize — for reproduction, not end-user settings until promoted:

Flag areaPurposeModule
SelectBestCandidate vs ParetoGEPA ablationM3
Merge on/offGEPA+MergeM3
Strategy enumReAct / CoT / CoT-SC / hybridsM1
DGM archive disableM5 in agenticloop—

See crates/chatty-optimize/src/ablation.rs.

Persistence paths

DataTypical path
Models + preamblesJSON via models_repository
General settingsJSON via general_settings_repository
ConversationsSQLite
Agent memory / skillsmemvid via memory_service
Playbook (future)TBD — AGE-47

Harbor pivot — Stage B eval

Date: 2026-08-26

Stage B coding / environment benchmarks do not live in chatty2. There is no chatty-eval crate.

ConcernLocation
Containerized Stage B (HumanEval, Polyglot, AppWorld, ALFWorld)harbor-chatty — Linear AGE-34
Paired stats, MDE, ablation flags, QA loaders for optimizerschatty-optimize
FeedbackFn / ATIF round-tripchatty-trace (AGE-5, after walking skeleton)

Harbor: https://github.com/harbor-framework/harbor

AGE-25 Cost model sheet

Rule: unit prices come from ModelConfig.cost_per_million_input_tokens / cost_per_million_output_tokens in settings/models/models_store.rs — never hand-typed into a Stage B issue.

Formula:

cost ≈ rollouts × calls_per_rollout × (in_tok×in_price + out_tok×out_price) / 1e6
       × (1 − cache_hit_rate) × seeds

Status

FieldStatus
Sheet template + estimatorchatty_optimize::cost
Prices from ModelConfigAPI ready (row_from_model_prices) — fill per run from live settings
Measured token means via rig-tapBlocked on AGE-5 / AGE-22 (rig-tap not wired yet)
Per-module caps + subset sizingAfter pilots; drop claims that cannot meet MDE under budget

Placeholder rows (replace means after pilot)

Use chatty_optimize::cost::format_cost_sheet once prices and pilot means are known. Until then, do not treat any Stage B dollar figure as authoritative.

Module 5

Budgeted and capped separately. Self-modification step uses the documented frontier-model exception; M5 calibration (AGE-23) must use that same model.

AppWorld / Stage B environments (AGE-24 pivot)

Decision (updated 2026-08-26): Stage B sandboxes — including any future AppWorld slice — run via Harbor in the sibling repo harbor-chatty (~/Documents/chattyapp/harbor-chatty). There is no in-repo chatty-eval harness.

M4 Stage B for now: FiNER (entity exact-match) plus synthetic tool-use that exercises ACE’s Generator / Reflector / Curator loop. AppWorld remains cited, not reproduced, until FiNER + calibration are green; then add an AppWorld Harbor task adapter (AGE-34).

See Linear AGE-24 (Harbor pivot) and AGE-34.

Fabric hop latency: baseline before BI-3

When to read this: You are doing BI-8 (ADR-0020’s kill-criteria check) and need the number the new path’s hop latency is compared against.

Recorded by AGE-632 (BI-0) on main at e432477, before BI-3, with the swarm test kit (docs/swarm-test-kit.md).

What a hop is

From the leader’s invoke_agent call to the broker handing the child its task frame, both read off tracing timestamps in the leader’s process: the kit logs swarm kit: invoke_agent call just before the call, and LocalRunner::run_task logs Delegated a task to a local worker right after submit_task writes the frame. On this path a hop spawns a chatty-tui worker process and waits for it to register, so process start-up dominates.

200 scripted delegations, one at a time, each answered by one Text reply from the fake model.

cargo test -p chatty-tui fabric_hop_latency_baseline -- --ignored --nocapture

Result

Debug build, Linux, 4 vCPUs (the cloud container this issue ran in).

Runp50p95meanminmax
1463.7 ms523.8 ms469.8 ms423.5 ms566.1 ms
2474.1 ms531.9 ms479.4 ms423.2 ms561.7 ms

Baseline: p50 463.7 ms, p95 523.8 ms (run 1). BI-8’s metric is new p95 − baseline p95, measured with measure_hop_latency unchanged on comparable hardware; run 2 shows the run-to-run spread (≈ 10 ms at p50 and p95).

Fabric codec baseline: the v3 envelope against the v2 frames

When to read this: You are checking ADR-0021’s third kill criterion (a v3/v2 median ratio above 2 fails a step), or changing the worker socket’s FrameCodec and want the numbers it has to stay near.

Measured by AGE-769 (EN-1) on 2026-09-29, one session on one machine: an Intel i9-10940X (28 threads), Linux 6.8, shared with other agents’ builds; every run under nice -n 19. The two sides ran interleaved (v2, v3, v2, v3), release builds of the same scenario, in the same target directory.

  • v2: crates/chatty-protocol-gateway/benches/frame_codec.rs on a local branch from 1ea4bb0b, driving the free encode_frame / decode_frame. The branch was never pushed; its bench differs from the v3 one only in the two calls it makes.
  • v3: the same file on EN-1’s branch, driving a BrokerCodec and a WorkerCodec per iteration, as a connection does.
nice -n 19 cargo bench -p chatty-protocol-gateway --bench frame_codec

The scenario

One task’s traffic. encode: the broker encodes 1 task (v3: task.run) and the worker 1,000 lines, alternating working statuses naming a tool and answer chunks (v3: task.events of kind status and artifact). decode: the worker decodes the task and the broker the 1,000 lines. The v3 encode also makes both codecs and has the worker decode the task.run, since a worker cannot name the run before it has read it; the v3 decode starts from a broker codec that already sent the task.run (set up outside the timing), and makes the worker’s codec inside it.

Results

Criterion medians, in µs per iteration (1 task and 1,000 lines), with the median’s 95 % interval.

RunLoad average (1/5/15 min)encodedecode
v2, 1st3.01 / 7.41 / 8.57546.18 [544.78, 548.29]708.31 [703.02, 722.14]
v3, 1st2.68 / 6.97 / 8.39217.88 [216.86, 220.51]584.67 [583.32, 586.09]
v2, 2nd4.60 / 7.15 / 8.42558.41 [557.66, 559.27]707.94 [707.21, 709.00]
v3, 2nd3.73 / 6.73 / 8.25216.46 [216.29, 216.74]576.98 [576.37, 577.67]
v2 medianv3 medianv3/v2
encode (mean of the two runs)552.3 µs217.2 µs0.39
decode (mean of the two runs)708.1 µs580.8 µs0.82

Both ratios are well under 2, so kill criterion 3 holds for step 1.

Re-run for step 3 (EN-3a, AGE-772)

Measured on 2026-10-02 on the same machine, the same way (interleaved, nice -n 19, release builds in one target directory, the v2 side again a local branch from 1ea4bb0b), after the payloads became typed: task.event statuses carry no metadata, the terminal one a TaskMetadata, and every struct denies unknown fields.

RunLoad average (1/5/15 min)encodedecode
v2, 1st5.76 / 9.31 / 9.62524.19 [522.29, 526.83]659.09 [657.93, 660.86]
v3, 1st4.76 / 8.86 / 9.46207.21 [206.68, 208.07]566.95 [565.27, 568.44]
v2, 2nd3.84 / 8.38 / 9.29523.31 [520.79, 524.72]657.42 [655.31, 659.39]
v3, 2nd3.11 / 7.92 / 9.11208.12 [206.90, 209.02]571.54 [569.19, 574.00]
v2 medianv3 medianv3/v2
encode (mean of the two runs)523.8 µs207.7 µs0.40
decode (mean of the two runs)658.3 µs569.2 µs0.86

Both ratios are within a few hundredths of step 1’s, well under 2: kill criterion 3 holds for step 3.

Why v3 is not slower

v2’s encode_frame serialised each frame to a serde_json::Value, inserted v and serialised the map again; the codec serialises one borrowed struct straight to a string. v2’s decode_frame parsed each line into a Value, removed v and deserialised the rest; the codec reads the envelope with its payload left as a RawValue and deserialises the params once. The id state (a lock and a hash lookup per line) costs less than either intermediate Value did.

Research modules (M0–M4, M6)

When to read this: You need a per-paper view of what is being built, where it lands in Chatty, and how far along the pipeline each module is.

Parent frame: Paper → experiment → product.

App bridge: App ↔ research bridge — maps production components (memory, context window, agent loop, …) to these modules.

flowchart TB
  M0[M0 Trace contract] --> M1[M1 ReAct]
  M1 --> M2[M2 AFlow]
  M1 --> M3[M3 GEPA]
  M1 --> M4[M4 ACE]
  M2 --> M3
  M0 --> M2
  M0 --> M3
  M0 --> M4
  M1 --> M6[M6 Retrieval]
  M4 -.memory store.- M6
ModulePaperCrate(s)StagePromotion
M0 Trace— (contract layer)chatty-traceNot startedPending
M1 ReActYao et al. ICLR 2023chatty-core, chatty-traceLoop ships; fidelity WIPLoop = default
M2 AFlowZhang et al. ICLR 2025 Oralchatty-flow, chatty-optimizeNot startedPending
M3 GEPAAgrawal et al. ICLR 2026 Oralchatty-optimizeNot startedPending
M4 ACEZhang et al. ICLR 2026chatty-playbookNot startedPending
M6 RetrievalRetrieval literature (BM25, RRF, rerankers, packing)chatty-core tools, chatty-optimize evalEval built; improvements gatedPending

M5 (DGM) lives in the separate agenticloop repo — not documented here. See RESERVED.md.

Status legend

FieldValues
Stagenot started · stage-a · stage-b · promotion-pending
Promotionpending · rejected · setting · default — recorded by the human reviewer only (the archived promotion-log template shows the fields)

Cross-module work

Head-to-head comparisons (ACE vs GEPA rollout counts, combined workflows) belong in AGE-21 — human interpretation only. Agents build harness plumbing; the human reviewer runs and writes up results.

M0 — Trace contract (chatty-trace)

Linear: AGE-5 · Crate: chatty-trace · Promotion: pending

No single paper — this is the contract layer every optimizer reflects on.

Why it exists

AFlow, GEPA, ACE, and DGM all optimize against traces, not final answers alone. Chatty already ships a ReAct loop, ATIF v1.6 export, and token budgeting. M0 adds:

  1. Per-module attribution — which prompt produced each step (GEPA credit assignment)
  2. ATIF round-trip — Deserialize path so reflection reads trajectories back in
  3. FeedbackFn — scalar + natural-language feedback per module (GEPA’s µ_f)

Chatty mapping

Paper conceptChatty locationStatus
Trajectory (thought, action, observation)*chatty-trace::TrajectoryReserved — human writes
Action::Language (ReAct’s  = A ∪ L)chatty-trace::ActionReserved
Per-step ModuleIdExtension over AtifStepTo build
ATIF exportchatty-core exporters (Serialize-only today)Round-trip in M0
Trace retentionRecorder trait, no-op in releaseTo build
Train vs validation rolloutsRolloutBudget over token_budget/To build

Capture attaches to the existing loop via rig-agent hooks + rig-tap — no hand-rolled agent loop.

Eval protocol

Stage A: HotpotQA run produces a Trajectory that round-trips; two seeded runs on a warm cache are byte-identical.

Stage B: Harbor runs consume exported ATIF; attribution must survive export/import.

Production landing

MechanismLikely promotion
No-op Recorder in releaseDefault
Full step retentionSetting (TrainingSettingsModel.atif_auto_export already exists)
FeedbackFn implementationsOffline / optimizer only

Reserved symbols (human only)

Trajectory, Step, Action, Outcome, FeedbackFn — see RESERVED.md.

Depends on

Further reading

M1 — ReAct substrate loop

Paper: Yao, Zhao, Yu, Du, Shafran, Narasimhan, Cao — ReAct: Synergizing Reasoning and Acting in Language Models (ICLR 2023)

Linear: AGE-6 · Crates: chatty-core (loop), chatty-trace (attribution) · Promotion: loop = default; variants pending

Mechanism (what the paper actually claims)

Augment action space to  = A ∪ L — language thoughts condition the next action without changing environment state. Trajectory = (thought, action, observation) triples from a frozen LLM with few-shot exemplars.

Two regimes:

  • Knowledge-intensive (HotpotQA, FEVER): dense thoughts; search / lookup / finish
  • Decision-making (ALFWorld, WebShop): sparse thoughts; model decides when to think

Important: ReAct alone does not beat CoT-SC on HotpotQA in the paper — it wins on FEVER and interaction tasks. Reproducing “ReAct beats CoT on HotpotQA” would be wrong.

Chatty mapping

Paper conceptChatty locationStatus
ReAct loopchatty-core llm_service.rs + agent_factory/Ships today
~60 toolschatty-core/src/tools/Ships
Wikipedia envAdapter over search_tool / fetch_toolStage A
Strategy variants (Act, CoT, CoT-SC, hybrids)chatty-optimize::StrategyReserved backoff rule
Per-step traceM0 TraceWIP
Few-shot exemplar prefixModelConfig.preambleShips (user-editable)

Do not rebuild the loop. Extend fidelity and capture only.

Eval protocol

Stage A:

  • Real HotpotQA run → Trajectory round-trips with per-step ModuleId
  • Four prompt regimes: dense/sparse × QA/interaction
  • Wikipedia lookup returns k-th matching sentence (paper semantics)
  • Reproduce ordering of methods, not absolute PaLM-540B numbers

Stage B: Harbor coding/env tasks using the same tool registry.

Production landing

MechanismLikely promotion
ReAct loop with toolsDefault (already)
CoT / CoT-SC / Act / hybrid strategiesSetting or eval-only
WikiEnv adapterEval harness only

Reserved symbols

Strategy backoff rule and loop termination semantics — see RESERVED.md.

Depends on

Downstream

Everything. M2–M4 all reflect on traces from this loop.

M2 — AFlow workflow topology search

Paper: Zhang, Xiang, Yu, Teng, Chen, Chen, Zhuge et al. — AFlow: Automating Agentic Workflow Generation (ICLR 2025 Oral)

Linear: AGE-7 · Crates: chatty-flow (runtime IR + interpreter), chatty-optimize (MCTS search) · Promotion: pending

Mechanism

Search over workflow topology before optimizing prompt text:

  • Nodes = LLM calls (model, prompt, temperature, format); edges = control flow
  • Operators: Generate, Format, Review & Revise, Ensemble, Test, Programmer, Custom
  • MCTS: each tree node is a complete workflow; soft-mixed selection (λ=0.2, α=0.4); LLM expansion with experience log; 5× validation evaluation; backpropagate diffs

Kill criterion: if IrRepr cannot express the search space by week 6, wait for Monty code mode or narrow the search — no third scripting language.

Chatty mapping

Paper conceptChatty locationStatus
Workflow representationWorkflowRepr traitReserved (WfNode)
IR interpreterchatty-flow → sub-agent / tool registryTo build
MCTS searchchatty-optimize (offline only)To build
Composition substratesub_agent_tool, invoke_agent_tool, list_agents_toolShips
Monty code mode (future)sandbox/monty.rs, sandbox/monty_bridge.rsMontySandbox runs stdlib Python via a python3 subprocess under rlimits (tried before Docker); ToolBridge is interface only — the pause/resume tool-call loop is not wired
Saved workflowFuture FlowSettingsModelGate: product integration
flowchart LR
  Search[chatty-optimize MCTS] --> IR[WorkflowRepr JSON]
  IR --> Interpreter[chatty-flow interpreter]
  Interpreter --> Tools[Existing tool registry]
  Tools --> Core[chatty-core agents]

Split is intentional: search never ships in the app binary; interpreter does.

Eval protocol

Stage A:

  • All 7 operators as composable Ops
  • Selection matches closed form for λ=0.2, α=0.4; blank template retains mass every round
  • Experience log stores parent diff, not just score
  • Discovered workflow serializes, reloads, re-executes to same score under warm cache

Stage B: HotpotQA / DROP / HumanEval / MBPP / GSM8K / MATH — target ordering (+5.7% over best manual design reported in paper; do not treat as binding until the human reviewer posts numbers).

Production landing

MechanismLikely promotion
MCTS searchOffline / CI only — reject from request path
IrRepr interpreterSetting (workflow_enabled, active_workflow_id)
Winning workflow JSONUser-selected artifact via apply policy (AGE-45)

Reserved symbols

WorkflowRepr, WfNode, soft_mixed_select, SelectionStrategy — see RESERVED.md.

Depends on

Further reading

M3 — GEPA reflective prompt evolution

Paper: Agrawal, Tan, Soylu, Ziems, Khare, Opsahl-Ong, Singhvi, … Zaharia, Khattab — GEPA: Reflective Prompt Evolution Can Outperform Reinforcement Learning (ICLR 2026 Oral)

Linear: AGE-8 · Crate: chatty-optimize (offline) · Promotion: pending

Mechanism

Optimize prompts Π in a compound system Φ = (modules, control flow) under rollout budget B — weights frozen, no training stack required.

Three load-bearing details:

  1. Pareto candidate selection — keep every candidate best on ≥1 instance; prune dominated; sample proportional to win count. Ablating to greedy costs 6.4 aggregate points in the paper.
  2. FeedbackFn (µ_f) — scalar plus natural-language traces (compiler output, missing docs, failed constraints), optionally per module. Scalar-only = prompt shuffling.
  3. Reflective mutation — round-robin module updates; meta-prompt asks for domain-specific declarative instructions (not quasi-exemplars).

Optional Merge (GEPA+Merge): system-aware crossover, capped at 5 invocations, off by default (hurts some models).

Chatty mapping

Paper conceptChatty locationStatus
Module promptsModelConfig.preambleShips (user-editable)
Compound systemReAct loop (M1) or IrRepr (M2)M1 first; M2 later
Pareto archivechatty-optimizeTo build
select_candidatechatty-optimize/src/gepa/select.rsReserved
mergechatty-optimize/src/gepa/merge.rsReserved
REFLECTION_META_PROMPTchatty-optimize/src/gepa/prompts.rsReserved
Train vs validation rolloutschatty-trace budget viewTo build
Per-model costModelConfig.cost_per_million_*Ships

Product feature: “optimize this agent’s preamble against a task set you supply” — never in the chat request path.

Eval protocol

Stage 0 (AGE-23): synthetic task with known-optimal instruction recovered by one reflective mutation — separates broken code from broken reflection.

Stage A:

  • Pareto selection unit-tested against paper’s worked example
  • Module-level µ_f for multi-hop system
  • Rollout accounting splits train vs validation
  • Same optimizer runs against M1 system and M2 IrRepr (required before module done)

Stage B: HotpotQA, IFBench, HoVer, PUPA — the human reviewer posts numbers before agents cite them.

Production landing

MechanismLikely promotion
Full GEPA searchOffline / chatty-tui --optimize / CI (AGE-44)
Optimized preambleSetting per model
Apply optimized preambleReview UI / export / auto (AGE-45)
Merge operatorAblation flag, default off

Reserved symbols

select_candidate, merge, REFLECTION_META_PROMPT — see RESERVED.md.

Depends on

  • M0 Trace — FeedbackFn, budget split
  • M1 ReAct — first CompoundSystem (not blocked on M2)

Antagonist

M4 ACE argues against brevity-bias optimizers like GEPA — head-to-head in AGE-21 only.

Further reading

M4 — ACE evolving playbooks

Paper: Zhang, Hu, Upasani, … Thakker, Zou, Olukotun — Agentic Context Engineering (ICLR 2026)

Linear: AGE-9 · Crate: chatty-playbook · Promotion: pending

Mechanism

Three roles over one playbook artifact:

  • Generator — runs task, reports which bullets were used (bullet_ids)
  • Reflector — tags bullets helpful/harmful/neutral from execution feedback (≤5 rounds)
  • Curator — emits JSON delta ops (ADD, UPDATE); never rewrites whole playbook

Merge is deterministic code — no LLM in apply(). Bullet format: [ctx-00263] helpful=1 harmful=0 :: <content> in named sections.

Grow-and-refine bounds size via de-duplication; paper uses loose thresholds (10K–100K token prune trigger) — in Chatty this becomes a production eviction policy, not a knob.

Two failure modes to reproduce as negative controls:

  • Brevity bias — optimizers drop domain heuristics (ACE cites GEPA’s shorter prompts)
  • Context collapse — monolithic rewrite shrinks artifact (AppWorld: 18K tokens → 122)

Chatty mapping

Paper conceptChatty locationStatus
Facts / procedures storememory_service, remember_tool, save_skill_toolShips
Sectioned playbookchatty-playbook::PlaybookTo build
Delta mergechatty-playbook::applyReserved
Grow-and-refinechatty-playbook::grow_and_refineReserved
GeneratorExisting running agentShips
Reflector / CuratorNew rig_agent::AgentsTo build
Stable section orderBTreeMap serializationRequired for prompt-cache cost story

Product prize: playbook grows from execution feedback, not only when the agent calls remember.

Eval protocol

Stage A:

  • apply pure: ADD, UPDATE counters, no-op on unknown id, parallel deltas commute
  • Byte-stable serialization across no-op update (prompt-cache invariant)
  • Context-collapse control reproduces collapse curve
  • Label-free modes including degradation case (FiNER 70.7 → 67.3 without labels)

Stage B: AppWorld (primary), Finance (FiNER, Formula) — the human reviewer posts numbers.

ACE vs GEPA rollout comparison → AGE-21, not this module alone.

Production landing

MechanismLikely promotion
Deterministic apply + runtime playbookSetting (scope TBD)
Playbook scopeGlobal / per-model / per-conversation (AGE-47)
Eviction policyDefault bounds required for desktop app (not paper’s 10K–100K knob)
Offline multi-epoch warmupOptimizer feature, not request path

Reserved symbols

apply, grow_and_refine — see RESERVED.md.

Depends on

Further reading

M6 — Retrieval: one pipeline, two corpora

Papers: no single paper. This module collects the retrieval literature behind two systems Chatty already ships: open-web search (search_web, fetch) and the agent’s own memory (search_memory, doc_retriever). Every citation below was checked against the paper itself (arXiv, ACL Anthology, OpenReview or the publisher). The Literature by stage section lists them.

Linear: AGE-515 (SimpleQA eval), AGE-516 (FRAMES eval), AGE-517 (keyless tier) · Crates: chatty-core (tools), chatty-optimize (eval scoring) · Promotion: keyless tier changes shipped as the default, cross-encoder rerank opt-in (AGE-521)

M5 is DGM, which lives outside this repo, so this module is M6.

The pipeline

Both systems do the same five things; only the corpus differs.

flowchart LR
  Q[Query formulation] --> C[Candidate retrieval]
  C --> F[Fusion]
  F --> R[Reranking]
  R --> P[Context packing]

State after this module (keyless = no search API key):

StageQuestion it answersPart A: open webPart B: memory
Query formulationWhat string do we search for?The model writes a search_web query; the Wikipedia backend reduces it to content words joined with ORThe model writes a search_memory query
Candidate retrievalWhich items could be relevant?Keyed: Tavily / Brave API. Keyless: Wikipedia search API in parallel with a Bing → DuckDuckGo scrapememvid BM25 (always), memvid vector search (opt-in)
FusionHow do several ranked lists become one?Keyless: Wikipedia and web lists interleaved (RRF tried, reverted)Raw max-score merge of the BM25 and vector lists
RerankingWhich few candidates are best?Keyless: BM25 over passages of the fetched pages; with a reranker configured, a cross-encoder orders a 10-candidate poolNone
Context packingWhat goes into the prompt, in what order?Up to max_results results; keyless snippets lead with the page’s best passage (≤ 1,000 chars total)Up to top_k (default 5) hits; facts first, then skills

The rest of this page explains why each stage exists (the papers), what Chatty does at each stage today (the code), and what we measured.

Literature by stage

Each entry gives the claim, the mechanism, and where it applies: A (open web), B (memory), or both. “Preprint” means no peer-reviewed venue could be found.

1. Query formulation

A search engine can only return what the query asks for. For multi-hop questions, a single query written from the whole question is usually the wrong query. It names the final entity only indirectly (“the 15th first lady’s mother”), so no page matches it.

  • Self-Ask: Press, Zhang, Min, Schmidt, Smith, Lewis. Measuring and Narrowing the Compositionality Gap in Language Models. Findings of EMNLP 2023. arXiv:2210.03350
    • Claim: as models grow, single-hop accuracy improves faster than multi-hop composition, and explicit decomposition narrows that gap.
    • Mechanism: the model asks itself follow-up questions and answers each before the final answer. Because the follow-ups have a fixed shape, each can be sent to a search engine instead of being answered from the model’s weights.
    • Applies to: A. FRAMES-style questions need sub-queries, and the fan-out mode of the eval simulates exactly this.
  • IRCoT: Trivedi, Balasubramanian, Khot, Sabharwal. Interleaving Retrieval with Chain-of-Thought Reasoning for Knowledge-Intensive Multi-Step Questions. ACL 2023. arXiv:2212.10509
    • Claim: one retrieve-then-read step is not enough for multi-step questions. Retrieving after every reasoning step improves both retrieval and answers: up to +21 points retrieval and +15 QA with GPT-3, with similar gains for Flan-T5.
    • Mechanism: each new chain-of-thought sentence becomes the next retrieval query, so “what to retrieve depends on what has already been derived.”
    • Applies to: A, through the agent loop. The model already calls search_web repeatedly; IRCoT is the argument for letting it do so, not for one big query.
  • Query rewriting (Rewrite-Retrieve-Read): Ma, Gong, He, Zhao, Duan. EMNLP 2023. arXiv:2305.14283. The ACL Anthology title reads “Query Rewriting in…”, the arXiv title “…for…”.
    • Claim: rewriting the input into a search query before retrieval beats plain retrieve-then-read.
    • Mechanism: an LLM rewrites, a web search engine retrieves, and a frozen LLM reads. A small rewriter can also be trained with RL from the reader’s feedback.
    • Applies to: A. It is the published version of what the agent does when it turns a chat message into a search_web string.
  • query2doc: Wang, Yang, Wei. Query2doc: Query Expansion with Large Language Models. EMNLP 2023. arXiv:2303.07678
    • Claim: appending an LLM-written pseudo-document to the query improves BM25 by 3–15% on MS MARCO and TREC DL, and also helps dense retrievers.
    • Mechanism: the pseudo-document adds the vocabulary a relevant document would use (expansion terms), which is the thing lexical matching lacks.
    • Applies to: both, especially the BM25 stages (memvid lex, doc_retriever, and a future web passage ranker).
  • HyDE: Gao, Ma, Lin, Callan. Precise Zero-Shot Dense Retrieval without Relevance Labels. ACL 2023. arXiv:2212.10496
    • Claim: embedding an LLM-written hypothetical answer, rather than the question, gives strong zero-shot dense retrieval with no relevance labels.
    • Mechanism: the LLM writes N hypothetical documents, an unsupervised encoder (Contriever) embeds and averages them, and the nearest real documents are returned. The encoder’s bottleneck is meant to wash out the invented details.
    • Applies to: B (memvid vector search). It has no effect on keyword web engines.
  • RAG-Fusion: Rackauckas. IJNLC 13(1), 2024. arXiv:2402.03367
    • Claim: generating several queries and merging their result lists with RRF gives more complete answers.
    • Mechanism: multi-query generation, then RRF (below).
    • Applies to: both.
    • Weak evidence: a single-author industry case study with manual evaluation. Cite it for the practice, not as proof.
  • FLARE: Jiang, Xu, Gao, et al. Active Retrieval Augmented Generation. EMNLP 2023. arXiv:2305.06983
    • Claim: deciding when to retrieve during generation beats retrieving once up front.
    • Mechanism: draft the next sentence; if it has low-confidence tokens, use the draft as the query and regenerate.
    • Applies to: A, as an argument for conditional search. It needs token probabilities, which the agent loop does not expose.
  • Search-R1: Jin, Zeng, Yue, et al. COLM 2025. arXiv:2503.09516
    • Claim: RL with an outcome-only reward teaches a model when and what to search across several turns: +41% (Qwen2.5-7B) and +20% (3B) over RAG baselines.
    • Mechanism: the model emits <search>…</search>, results come back inside <information>, and those retrieved tokens are masked out of the loss.
    • Applies to: A. Evidence that query policy is learnable, including by small models. Chatty prompts instead of training.

2. Candidate retrieval

This stage trades recall against cost. It has to be cheap enough to run over the whole corpus, so it uses either an inverted index (lexical) or a vector index (dense).

  • BM25: Robertson, Zaragoza. The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in IR 3(4):333–389, 2009. doi:10.1561/1500000019. Crossref now lists the same DOI as vol. 4(1–2); the author PDF and ACM DL say 3(4).
    • Claim: BM25 follows from the probabilistic relevance model. A term’s weight is an idf-like weight times a saturating, length-normalised term frequency.
    • Mechanism: tf / (k1·((1−b) + b·dl/avdl) + tf) · idf, summed over query terms.
      • k1 sets how fast repeated occurrences stop adding score. The tenth “Paris” in a page is worth far less than the first.
      • b sets how much long documents are penalised for simply containing more words.
      • The authors suggest 0.5 < b < 0.8 and 1.2 < k1 < 2.
    • Applies to: both. memvid’s lexical search (Tantivy BM25) and doc_retriever (k1 = 1.2, b = 0.75) are BM25 today. A web passage ranker would be too, but there avdl and idf come from a handful of fetched pages, so the statistics are noisy.
  • DPR: Karpukhin, Oğuz, Min, et al. Dense Passage Retrieval for Open-Domain Question Answering. EMNLP 2020. arXiv:2004.04906
    • Claim: a dual encoder trained on a modest set of question–passage pairs beats BM25 by 9–19 points top-20 accuracy on open-domain QA.
    • Mechanism:
      • Two BERT encoders; relevance is the dot product.
      • Training uses the other questions’ gold passages as in-batch negatives, plus one BM25 hard negative.
      • The paper also tests a linear hybrid BM25 + 1.1·sim, a score-level fusion to contrast with RRF.
    • Applies to: B. memvid vector search is this paradigm. There is no web-scale vector index in Part A.
  • Contriever: Izacard, Caron, Hosseini, et al. Unsupervised Dense Information Retrieval with Contrastive Learning. TMLR 2022. arXiv:2112.09118
    • Claim: contrastive training without labels gives a dense retriever that beats BM25 on 11 of 15 BEIR datasets for Recall@100.
    • Mechanism: two spans from the same document are positives; everything else is a negative.
    • Applies to: B. Personal memory has no relevance labels, so this is the relevant regime.
  • E5: Wang, Yang, Huang, et al. Text Embeddings by Weakly-Supervised Contrastive Pre-training. Preprint. arXiv:2212.03533
    • Claim: contrastive pre-training on web-mined pairs gives embeddings that beat BM25 zero-shot on BEIR.
    • Mechanism: one vector per text.
    • Applies to: B. It is the class of embedder memvid would use.
  • SPLADE: Formal, Piwowarski, Clinchant. SIGIR 2021. arXiv:2107.05720
    • Claim: a learned sparse retriever can match dense retrievers while still running on an inverted index.
    • Mechanism: a BERT MLM head predicts weights for every vocabulary term, including terms the text does not contain (expansion). A sparsity penalty keeps the vectors small.
    • Applies to: B, as an upgrade path that keeps memvid’s lexical index.
    • SPLADE v2 (preprint, arXiv:2109.10086) can expand documents only, which moves all neural cost to ingest time.
  • ColBERT: Khattab, Zaharia. SIGIR 2020. arXiv:2004.12832. ColBERTv2: Santhanam et al. NAACL 2022. arXiv:2112.01488
    • Claim: late interaction keeps most of a cross-encoder’s quality at a fraction of the cost. v2 cuts the index size 6–10×.
    • Mechanism: one embedding per token. The score sums, over query tokens, the best match among the document’s tokens (MaxSim), and document vectors are precomputed.
    • Applies to: B only. In Part A the candidates are freshly fetched pages, so nothing can be precomputed.
  • M3-Embedding (BGE-M3): Chen, Xiao, Zhang, et al. Findings of ACL 2024. arXiv:2402.03216
    • Claim: one model produces dense, sparse and multi-vector scores, in 100+ languages and on inputs up to 8,192 tokens.
    • Mechanism: three heads, trained with self-distillation in which the three modes’ combined score is the teacher.
    • Applies to: B, if a single multilingual (Dutch and English) model is wanted.
  • BEIR: Thakur, Reimers, Rücklé, Srivastava, Gurevych. NeurIPS 2021 Datasets & Benchmarks. arXiv:2104.08663
    • Claim: across 18 zero-shot datasets, BM25 is a robust baseline. Rerankers and late interaction are best but cost the most, and trained dense retrievers often generalise worse out of domain.
    • Applies to: both. It is the main evidence for keeping BM25 as the backbone. Personal memory is out of domain for every trained retriever.

3. Fusion

Several retrievers or queries each return a ranked list, and fusion turns them into one. The core difficulty is that scores from different systems are not on the same scale. A BM25 score is unbounded and depends on the query length and the collection. A cosine similarity lives in [−1, 1]. memvid’s vector score is 1/(1+L2), which is in (0, 1].

  • Reciprocal Rank Fusion (RRF): Cormack, Clarke, Büttcher. Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods. SIGIR 2009, pp. 758–759. doi:10.1145/1571941.1572114
    • Claim: a trivially simple, unsupervised rank combination beats every input system, Condorcet fuse, and CombMNZ.
    • Mechanism: RRF(d) = Σ_r 1/(k + rank_r(d)) with k = 60, which was fixed in a pilot run and never tuned afterwards.
      • Only ranks enter the formula, so nothing needs normalising.
      • A document that several systems rank moderately high beats one that a single system ranks first.
      • k stops any one system’s top rank from dominating.
    • Applies to: both. In Part A it can merge several engines (Tavily, Wikipedia, Bing) or several queries. In Part B it can replace today’s raw max-score merge.
  • Score-level alternatives covered above: DPR’s linear hybrid, M3’s weighted mode sum, and Generative Agents’ normalised weighted sum (below).

4. Reranking

A reranker is too expensive to run over the whole corpus, but it reads the query and a candidate together, so it can judge relevance far better than the first stage. It runs on the top few dozen candidates only.

  • monoBERT: Nogueira, Cho. Passage Re-ranking with BERT. Preprint. arXiv:1901.04085
    • Claim: a BERT cross-encoder over BM25’s top 1,000 raises MS MARCO MRR@10 from 16.7 (BM25) to 36.5 on dev.
    • Mechanism: [CLS] query [SEP] passage goes into a binary relevance classifier, and candidates are sorted by P(relevant).
    • Applies to: A mainly, to rerank extracted web passages. It costs one forward pass per passage.
  • monoT5: Nogueira, Jiang, Lin. Document Ranking with a Pretrained Sequence-to-Sequence Model. Findings of EMNLP 2020. arXiv:2003.06713
    • Claim: a seq2seq model trained to emit “true”/“false” ranks at least as well as encoder classifiers, and better when data is scarce.
    • Mechanism: the input is Query: q Document: d Relevant:. A softmax over the “true” and “false” logits gives the relevance probability.
    • Applies to: A. It is also the template for using an LLM as a pointwise relevance judge.
  • RankGPT: Sun, Yan, Ma, et al. Is ChatGPT Good at Search? EMNLP 2023. arXiv:2304.09542
    • Claim: an instructed LLM reranks competitively with supervised rerankers, and a distilled 440M model beats a 3B supervised one on BEIR.
    • Mechanism: the LLM outputs a permutation of a numbered passage window, and the window slides back to front (window 20, step 10).
    • Applies to: A. The agent’s own LLM could rerank, at the cost of extra calls.
  • ColBERT (above) can also rerank BM25 candidates.

5. Context packing

Retrieval ends at the model’s context window, not at a ranked list. How many items go in, how long each is, and in what order all change the answer.

  • Lost in the Middle: Liu, Lin, Hewitt, Paranjape, Bevilacqua, Petroni, Liang. TACL 12:157–173, 2024. arXiv:2307.03172
    • Claim: models use long contexts unevenly. Accuracy is highest when the relevant passage is first or last, and drops in the middle, for GPT-3.5 below its closed-book 56.1%.
    • Mechanism: a controlled sweep of the gold passage’s position among distractors gives a U-shaped curve (primacy and recency).
    • Applies to: both. Put the best items at the edges, and cap k instead of filling the window.
  • FreshLLMs: Vu, Iyyer, Wang, et al. Findings of ACL 2024. arXiv:2310.03214
    • Claim: injecting up-to-date search evidence (FreshPrompt) fixes much of LLMs’ failure on fast-changing facts, and “both the number of retrieved evidences and their order play a key role.”
    • Mechanism: evidence is sorted by date and the most relevant is kept closest to the end of the prompt.
    • Applies to: A. It speaks directly to how search_web output should be laid out.
  • RECOMP: Xu, Shi, Choi. ICLR 2024. arXiv:2310.04408
    • Claim: compressing retrieved documents to about 6% of their length loses little, and returning nothing when they do not help (“selective augmentation”) is part of the method.
    • Mechanism: trained extractive and abstractive compressors, either of which may output an empty string.
    • Applies to: both. Fetched pages are long, and memory recall should be allowed to return nothing.
  • Self-RAG: Asai, Wu, Wang, Sil, Hajishirzi. ICLR 2024. arXiv:2310.11511
    • Claim: retrieving a fixed k whether or not it is needed hurts. A model that decides when to retrieve and critiques passages does better.
    • Mechanism: trained reflection tokens gate retrieval and grade passages.
    • Applies to: both. It is the main argument against unconditional recall.
  • Generative Agents: Park, O’Brien, Cai, Morris, Liang, Bernstein. UIST 2023. arXiv:2304.03442
    • Claim: an agent that stores all its experiences as text, retrieves them by recency, importance and relevance, and reflects on them behaves believably over long runs.
    • Mechanism:
      • Each score is min-max normalised to [0, 1]:
        • recency: exponential decay, factor 0.995 per game hour;
        • importance: the LLM rates each memory 1–10 at write time;
        • relevance: cosine similarity to the query.
      • The final score is their sum, with all weights = 1.
      • The top memories that fit are packed into the prompt.
    • Applies to: B. Recency and importance are signals memvid does not have.
  • MemGPT: Packer, Wooders, Lin, Fang, Patil, Stoica, Gonzalez. Preprint. arXiv:2310.08560
    • Claim: OS-style virtual context (memory tiers with explicit paging) gives effectively unbounded context.
    • Mechanism:
      • The main context holds the system prompt, a small working context, and a FIFO message queue that starts with a recursive summary.
      • Recall and archival storage live outside the prompt, and the LLM pages them in with function calls.
      • The system warns the model at 70% of the window and flushes at 100%.
    • Applies to: B. Chatty’s memory is MemGPT-shaped today: the model decides when to call search_memory.

Frame and evaluation

  • RAG: Lewis, Perez, Piktus, et al. NeurIPS 2020. arXiv:2005.11401
    • Origin of the term: parametric generator + non-parametric retriever. Only the framing applies here, since Chatty’s models are frozen.
  • ReAct: Yao, Zhao, Yu, et al. ICLR 2023. arXiv:2210.03629
    • Thought → search action → observation. This is the loop that calls search_web (M1).
  • WebGPT: Nakano, Hilton, Balaji, et al. Preprint. arXiv:2112.09332
    • A model browses a text browser with search, click, scroll and quote actions. It is trained by behaviour cloning, and answers are then chosen against a reward model. It has to collect quotes that support its answer: the Part A loop in trained form.
  • SimpleQA: Wei, Karina, Chung, et al. Measuring short-form factuality in large language models. OpenAI, preprint. arXiv:2411.04368
    • 4,326 short fact-seeking questions with one indisputable answer, adversarially collected against GPT-4.
  • FRAMES: Krishna, Krishna, Mohananey, et al. Fact, Fetch, and Reason. NAACL 2025. arXiv:2409.12941
    • 824 multi-hop questions that each need several Wikipedia articles, labelled by reasoning type. The paper reports accuracy of 0.40 with no retrieval and 0.66 with its multi-step retrieval pipeline.

Part A — open-web retrieval

How it worked before this module

crates/chatty-core/src/tools/search_web_tool.rs, as of main on 2026-09-23 (for the state after this module see the pipeline table above and Where it ended):

  • Keyed:
    • A Tavily key calls api.tavily.com/search with search_depth: basic.
    • A Brave key calls the Brave web search API.
    • There is no retry and no failover. An HTTP error becomes a tool error.
  • Keyless:
    • The tool scrapes Bing (b_algo blocks, with tracking redirects decoded, AGE-506). If Bing fails, it tries DuckDuckGo lite.
    • A decoy detector (results_match_query) rejects Bing pages whose results share no content term with the query. A challenge detector rejects DDG’s 202 “anomaly” page (AGE-495). Both fail loudly rather than hand the model unrelated links.
  • Every result carries a source tag (tavily, brave, bing, duckduckgo), added for this eval.
  • fetch (fetch_tool.rs) is separate: it turns HTML into text with its own scanner and pages through long documents with start_index. Nothing selects passages yet.

Measured on 2026-09-23, before any Phase 2 change (full numbers below):

  • Bing serves our scraper decoy pages (casino and Google-help results) for ordinary questions, and the decoy detector correctly rejects them.
  • DuckDuckGo lite now answers 200 with real results, but in a new markup: single-quoted class='result-link' and //duckduckgo.com/l/?uddg= redirect hrefs. The parser expects double quotes and http hrefs, so it finds zero results. The challenge detector does not fire either, so the tool reports an empty result, not an error. This is a silent failure: exactly the “no working search” of AGE-498.

The eval

The harness has since been removed: the eval answered its question and nothing ran it automatically. It is preserved at commit ce57cc906c; see evals/search/README.md at that commit. It called the real tool, with no agent loop:

  • SimpleQA (AGE-515):
    • 300 questions, stratified by topic, split 200 DEV / 100 HOLDOUT.
    • Metric: answer hit@k, meaning the normalised gold answer appears as a whole-token run in the title+snippet of a result ranked ≤ k.
  • FRAMES (AGE-516):
    • 150 questions, stratified by primary reasoning type, split 100 DEV / 50 HOLDOUT.
    • Metrics: source recall@k (share of gold Wikipedia pages among the top k URLs) and all-sources@k.
    • Two modes: single (the prompt as the query) and fan-out (3 committed sub-queries, merged round-robin, scored at the same k).
  • Leak blocklist: domains that republish the datasets (Hugging Face, Kaggle, the simple-evals repo, …) keep their rank but never count as hits.
  • Record/replay: raw responses are recorded per backend and query, so reruns replay them through the current parsers and cost nothing.
  • Keyless runs are reported with and without the wikipedia source. The version without Wikipedia is the honest general-web number, because FRAMES is all-Wikipedia and much of SimpleQA is too.

Baselines (2026-09-23)

Keyed means Tavily basic. Brave was not run: there is no key, and it was skipped by decision. Tavily was measured live once, on SimpleQA DEV only. There were then no more live calls, to protect the credit budget (179 credits used); later Tavily numbers are replays of those recordings.

ConfigSeterroremptyhit@1 / @5FRAMES recall@10p50 / p95 ms
Keyed (Tavily)SimpleQA DEV10.5%¹0%59.5% / 70.0%—1200 / 3517
Keyless (Bing → DDG)SimpleQA DEV47%46%0% / 0%—683 / 1271
KeylessSimpleQA HOLDOUT74%15%0% / 0%—688 / 788
KeylessFRAMES single DEV57%15%0% / 0%0%221 / 846
KeylessFRAMES fan-out DEV26%29%0% / 0%0.5%1523 / 2194

¹ All 21 are replay misses (items never recorded). On the 179 recorded items, Tavily had 0 errors and hit@5 ≈ 78%.

Two findings from the baseline itself:

  • A Tavily development key rate-limits hard. At concurrency 4, 65 of 200 calls got 429, and the block then held for minutes. At concurrency 1 there were none. An agent firing parallel searches on a dev key will see this.
  • Keyless was worse than “no working search”: it returned wrong results as if they were right.
    • Bing answers our scraper with decoy pages.
    • The one-term decoy guard let 14 of them through as “ok”: “architect” matched architecture firms in Cyprus for a question about a palace in Buenos Aires.
    • DuckDuckGo’s real result pages parsed to nothing.

Iteration log

One entry per Phase 2 change, kept or reverted, in the form “paper says X; in Chatty we measured Y; because Z”. Predictions and results were posted on AGE-517 before and after each run.

1. DuckDuckGo lite parser. Kept.

  • Says: nothing to cite; this is a recall bug at the candidate stage.
  • Measured: SimpleQA DEV hit@5 0% → 22% (McNemar 44/0, p < 0.001). FRAMES did not move, and the prediction was 3–8%.
  • Because: DDG serves roughly the first 25 requests of a session and then blocks. The SimpleQA DEV run went first and got the working pages. The gain is real but depends on DDG’s block state. HOLDOUT, recorded while blocked, shows 0% from it.

2. Wikipedia API in parallel with the scrape. Kept.

  • Says:
    • ReAct’s action space was a keyless Wikipedia search API.
    • Rewrite-Retrieve-Read and query2doc: shape the query for the retriever.
  • Measured:
    • SimpleQA DEV hit@5 22% → 33% (29/7, p = 0.0005). Error+empty 57% → 0%.
    • FRAMES recall@10: single 0% → 34%, fan-out 0.5% → 44%. All-sources@10 fan-out 0% → 13%.
    • HOLDOUT confirms: error+empty 0%; SimpleQA hit@5 24%; FRAMES recall@10 38% / 43%.
  • Because:
    • Wikipedia’s search ANDs every term, so a whole question finds nothing. The content words joined with OR let rare entity names drive the ranking.
    • A search snippet is only ~150 characters, so the right article at rank 1 often still misses the answer string. That is the job of the passage stage.
    • The first attempt failed on Wikimedia’s 10 requests/minute cap for unidentified User-Agents. Wikipedia calls now send a User-Agent with the project URL (200/minute).
  • Without Wikipedia, SimpleQA DEV stays at 21.5%.

3. Browser escalation (headless Chrome when the scrape is blocked). Reverted.

  • Says: AGE-517’s premise was that a real browser on a home IP passes checks plain HTTP fails. One manual probe agreed: Chrome got the real Jerlov Award results where reqwest got decoys.
  • Measured: 342 escalations, 0 hits gained. 188 were Bing “There are no results for …” pages and 154 were connection refusals (chrome-error://). Latency got worse.
  • Because: Bing blocks by IP, not by client. After a day of evaluation traffic this workstation’s IP was flagged, and a real Chrome, even at human pace, got the same empty or decoy pages. Some of those decoys were adult-site forum threads.
  • Caveat: this says little about a user doing a few searches a day on a clean IP. The patch is kept outside the repo for a re-test from another network.

4. Fetch the top pages and put the best BM25 passage in the snippet. Kept, keyless only.

  • Says:
    • BM25 (term saturation, length normalisation) is the robust zero-shot scorer (BEIR).
    • RECOMP: pass the useful part of a document, not the engine’s excerpt.
  • Measured:
    • Keyless SimpleQA hit@5 33% → 48% (30/0, p < 0.001); hit@1 13% → 27%; p95 1.3 s → 2.3 s.
    • Keyed (Tavily) hit@5 70% → 72.5% (5/0, p = 0.07), with p95 up to about 7.8 s.
  • Because:
    • A Wikipedia search snippet is about 150 characters around the query terms, while the answer usually sits a sentence or two away in the article. The passage window catches it.
    • Tavily’s content is already a query-focused extract, so a second lexical selection adds little there and costs a slow page fetch. The stage therefore runs on the keyless path only.
  • Statistics caveat: idf here comes from the passages of 5 pages, a collection far smaller than BM25 assumes. It still works, because the rare entity names in a question are rare in almost any collection.

5. Widen the pool to 10 and rerank it by best BM25 passage. Reverted.

  • Says: two-stage retrieval (monoBERT): a cheap stage for recall, then a precise stage over a small pool.
  • Measured:
    • SimpleQA hit@5 48% → 46.5% (11 gained, 14 lost, p = 0.69); hit@1 27% → 30.5%.
    • FRAMES fan-out recall@10 44% → 40%.
    • p95 rose by about 1.5 s.
  • Because: here the second stage was still lexical. A page that repeats the question’s rare words, such as a list or an overview page, outscores the page that answers it. A bigger pool adds as many near-misses as answers.
  • Confound: 10 page fetches per search made each search cost 11 Wikipedia calls, and 763 of them hit the rate limit.

6. The same pool, ordered by a cross-encoder. Kept, optional.

  • Setup: bge-reranker-v2-m3 on the local GPU behind a /rerank endpoint.
  • Says:
    • monoBERT/monoT5: reading question and passage together judges relevance, not just overlap.
    • BEIR: rerankers are the strongest zero-shot systems.
  • Measured:
    • SimpleQA hit@5 48% → 54% (17/5, p = 0.019); hit@1 27% → 43%.
    • FRAMES fan-out all-sources@10 13% → 20% (8/1, p = 0.046).
    • p95 2.3 s → 3.5 s.
  • Because: the pool from iteration 5 was fine; its ranking was not. With a cross-encoder the same candidates give 17 wins against 5 losses, where BM25 gave 11 against 14.
  • Without a reranker configured, the pool is not widened. A BM25 rerank of a larger pool was measured to be worse.

7. The cross-encoder chooses the passage, not only the page. Kept (small).

  • Change: each candidate’s top 3 BM25 passages go to the cross-encoder. The best one ranks the page and leads its snippet.
  • Says: monoBERT’s rerank-a-shortlist applied inside the page; RECOMP on selecting what text reaches the model.
  • Measured:
    • SimpleQA hit@5 54% → 57% (11/5, p = 0.21, not significant); hit@1 43% → 49%.
    • FRAMES answer hit@5 +3 points on both modes.
    • Latency unchanged.
  • Because: BM25 picks the passage that repeats the question’s words, while the cross-encoder picks the one that answers it. Recall is now the limit: the answer has to be in one of 10 pages’ top 3 passages.

8. Multi-query queries argument with RRF. Reverted.

  • Says: RRF (Cormack et al.) fuses rankings by rank alone. RAG-Fusion and Self-Ask support searching sub-questions together.
  • Measured:
    • FRAMES, one call with sub-queries vs. iteration 7’s three-call fan-out: all-sources@10 21% → 14% (1/8, p = 0.046). The only win was p95, 6.1 s → 2.7 s.
    • The single-query RRF swap cost SimpleQA 1.5 points.
  • Because: RRF rewards agreement, but multi-hop recall needs coverage. Each hop has its own gold page, which only its own sub-query retrieves. Fused into one 10-slot pool and judged against the whole question, those pages rank out.
    • Fan-out keeps coverage by construction: each sub-query’s results are ranked against that sub-query, and each gets its own slots.
    • This is the clearest case in this module of a paper’s mechanism being right, but for a different objective.

9. Widen the cross-encoder pool from 10 to 20. Reverted. The loop stops here.

  • Says: rerankers in the literature work over the BM25 top 100 to 1,000 (monoBERT, RankGPT), so a 10-deep first stage is shallow.
  • Measured:
    • SimpleQA hit@5 57% → 59% (6/2, p = 0.29), but p95 3.2 s → 7.2 s.
    • FRAMES fan-out all-sources@10 21% → 18%.
  • Because: 20 page fetches mean the slowest of 20 sets the latency. The extra depth adds near-duplicate distractors for multi-hop.
  • Iterations 8 and 9 were reverted back to back, which is the stop condition.

Where it ended

The final configuration is iteration 7: DDG parser fix, Wikipedia API, BM25 passages and, with a reranker endpoint configured, a cross-encoder over a 10-candidate pool. Keyless numbers are with the reranker (bge-reranker-v2-m3); without one the keyless tool is the iteration 4 pipeline. Keyed (Tavily) code is unchanged by this module.

DEV, baseline → final

ConfigSeterror+emptyhit@1hit@5hit@5 without Wikipediarecall@10all-sources@10p50 / p95
KeylessSimpleQA (200)93% → 0%0% → 49%0% → 57%0% → 27%——0.7/1.3 s → 1.4/3.2 s
KeylessFRAMES single (100)72% → 0%0% → 7%0% → 18%0% → 0%0% → 33.7%0% → 8%0.2/0.8 s → 1.4/2.4 s
KeylessFRAMES fan-out (100)55% → 0%0% → 4%0% → 19%0% → 2%0.5% → 48.8%0% → 21%1.5/2.2 s → 3.8/6.1 s
Keyed (Tavily)SimpleQA (200)10.5%¹59.5%70.0%———1.2/3.5 s (live)

HOLDOUT, baseline → final (run once per two kept changes, and once at the end)

ConfigSeterror+emptyhit@1hit@5hit@5 without Wikipediarecall@10all-sources@10
KeylessSimpleQA (100)89% → 0%0% → 40%0% → 43%0% → 0%——
KeylessFRAMES single (50)76% → 0%0% → 4%0% → 12%0% → 2%0% → 38.3%0% → 12%
KeylessFRAMES fan-out (50)52% → 0%0% → 2%0% → 18%0% → 4%0% → 43.3%0% → 14%

¹ Replay misses, not errors: Tavily had 0 errors on the 179 recorded items (hit@5 ≈ 78% there). The keyed HOLDOUT was not run, to protect the credit budget.

FreshQA check (100 valid-premise questions, 2026-04-21 release, hit@5; one-off, not tuned on)

TavilyKeyless (final)Keyless without Wikipedia
All78%48%14%
Never-changing (33)91%55%21%
Slow-changing (32)91%62.5%16%
Fast-changing (35)54%29%6%
error+empty0%0%69%

Targets

TargetResult
Keyless error+empty < 5%Met (0% on DEV and HOLDOUT)
Keyless SimpleQA hit@5 within 10 points of TavilyNot met: DEV 57% vs 70% (−13). HOLDOUT 43%. FreshQA 48% vs 78% (−30).
Keyed error+empty < 3%Met on DEV at concurrency 1. A Tavily dev key gets 429-blocked at concurrency 4. Retry/failover was not built, because the decision rule did not select it at 0% errors.

What the numbers say. The keyless tier went from worse than nothing (93% error or empty, plus decoys returned as results) to reliable, and to good on encyclopedic questions. Almost all of its accuracy comes from Wikipedia:

  • Without Wikipedia, SimpleQA HOLDOUT is 0% and FreshQA 14%.
  • On fast-changing facts it finds the answer only 29% of the time.

From this workstation the general-web side is blocked (Bing decoys, DDG blocks), and a real browser did not get past that. Keyless search should be presented as “encyclopedic, not current”, and a keyed provider remains the recommendation for current events.

What did not work, and why

ChangePaper’s promiseWhat happenedWhy
Browser escalationa real browser passes bot checks0 gains, slowerblocking is per IP, not per client
BM25 rerank of a wider pooltwo-stage retrieval11 gained / 14 lostlexical overlap cannot separate the answer page from pages that repeat the question
Multi-query + RRFfusion by agreementall-sources@10 21% → 14%multi-hop needs coverage, and RRF rewards agreement
Pool of 20deeper first stage+2 n.s., p95 ×2.3the slowest page fetch decides; distractors grow with depth
Passages on the keyed pathselect what the model reads+2.5 n.s., p95 ×2Tavily’s snippets are already query-focused extracts

Part B — internal memory retrieval

How it works today, from the code (crates/chatty-core/src) and agent-memory.md. Nothing here was changed by this module.

Storage.

  • One memvid file (memory.mv2). Every remember call is one frame with no chunking, plus an optional title and tags.
  • The text is indexed by Tantivy (lex). If embeddings are enabled (off by default), the content is also embedded by the configured provider (OpenRouter, Ollama or Azure) and stored with the frame.
  • There are no timestamps or importance scores in Chatty’s metadata.

Query formulation.

  • The model’s search_memory query is used verbatim; there is no rewrite.
  • There is no automatic recall. The per-turn injection (auto_context.rs) was removed in 6cef438f (“improved ttft”, 2026-04-29). The preamble now tells the model to call search_memory proactively.
  • simplify_memory_query and build_memory_context_block survive with test-only callers.
  • Chatty’s memory is therefore MemGPT-shaped (the model pages memory in), not Generative-Agents-shaped (the system pushes memories in).

Candidate retrieval.

  • BM25 via memvid/Tantivy always runs, with Tantivy’s default k1/b and 500-char snippets.
  • Vector search runs when embeddings are on. It is an exact brute-force L2 scan: the memvid vec feature (HNSW) is not compiled in, although code comments say “HNSW”. The score is 1/(1+L2).

Fusion.

  • merge_search_results (tools/search_memory_tool.rs) deduplicates on the first 200 characters, keeps the higher raw score, then sorts by raw score.
  • This compares an unbounded BM25 score with a score ≤ 1, so a lexical hit almost always outranks a vector hit. The vector list barely matters once both lists are non-empty.
  • This is the textbook case for rank fusion (RRF) instead of score fusion.

Reranking. None, and no score threshold.

Context packing.

  • select_context_hits keeps up to 3 facts, then up to 2 skills, then fills from the rest.
  • Output is JSON {text, title, relevance_score, source}. Lexical hits are 500-char snippets; vector hits are the full frame text.

doc_retriever (local Markdown/text files):

  • One chunk per Markdown heading section, with no size limit and no overlap. The title is indexed twice as a boost.
  • Tokens are lowercase alphanumeric runs, with no stemming.
  • Hand-written BM25 with k1 = 1.2, b = 0.75 and the non-negative ln((N−df+0.5)/(df+0.5)+1) idf.
  • Top 3 results (max 5), chunks cut to 900 chars.
  • The index is rebuilt on every call.

Skills (SkillService::load_hits):

  • Cosine similarity to a cached skill embedding when embeddings are on.
  • Otherwise keyword overlap, with 0.5 for an empty query.

Trust. Nothing marks recalled memory, skills or fetched web text as untrusted when it enters the context.

Part C — what transfers

Proposals only, filed as AGE-520; none is built here. Two measured lessons from Part A change the proposals:

  • RRF fits memory, not multi-hop. Memory’s lexical and vector lists are two views of the same need, so agreement is the right signal. That is unlike web sub-queries, where each hop needs its own page.
  • The shared reranker is the bigger win. A cross-encoder puts both lists on one scale, which fixes the max-score merge bug as a side effect. The concrete proposal is on AGE-520: one /rerank endpoint for web and memory, and BGE-M3 as the matching embedder, which needs a re-embedding migration.
StageShare between A and B?Why / why not
Query formulationPartly. A shared “sub-query” prompt pattern (Self-Ask / RAG-Fusion)Memory queries are short and personal; web queries need entity names. The decomposition idea transfers, the prompt does not.
Candidate retrievalNoDifferent indexes: a remote engine vs. a local Tantivy + vector store. Different failure modes: rate limits and bot detection vs. empty stores.
FusionOnly for B. RRF for memory’s lexical + vector listsBoth merge incomparable scores. In A, RRF across sub-queries was measured to hurt multi-hop (iteration 8), so A keeps interleaving.
RerankingYes: one cross-encoder endpoint, plus the BM25 passage scorer (tools/passages.rs)The cross-encoder was the largest precision gain in A (hit@1 27% → 49%). doc_retriever::bm25_rank and passages::score_passages are the same formula over different corpus statistics.
Context packingYes: one packer (budget, edge ordering, allow empty)Lost in the Middle and RECOMP apply to both. Trust differs, so web and memory need separate delimiters and labels.

What differs, and why it matters:

PropertyOpen web (A)Memory (B)Consequence
Corpus sizeEffectively infinite, remoteHundreds to thousands of frames, localA has to delegate candidate retrieval to an engine; B can afford exact search
FreshnessChanges dailyChanges only when the user or agent writesA needs no cache TTL beyond a session; B can cache embeddings indefinitely
TrustAdversarial (SEO, injection, decoys)Mostly the user’s own words, but contains past web textA needs decoy/challenge detection and untrusted-content labels; B needs provenance
Latency0.5–3 s per call, 1–3 s more for a browserMillisecondsA should run backends in parallel; B can run several retrievers sequentially
Relevance labelsPublic benchmarks (SimpleQA, FRAMES)NoneA can be tuned on data; B needs zero-shot methods (BM25, Contriever-style)

Eval protocol

  • Iterate on DEV; run HOLDOUT once after every two kept changes. A DEV gain that HOLDOUT does not show is overfitting.
  • Report for both configs:
    • error rate and empty rate;
    • SimpleQA hit@1/3/5;
    • FRAMES source recall@5 and all-sources hit;
    • p50/p95 latency (keyless: split into fast path and browser-escalated calls);
    • Tavily credits used;
    • McNemar against the previous best (search_eval paired + paired_report).
  • Keep a change if its target config improves on the primary metric and the other config does not regress on error rate, hit@5 or p95 latency.
  • Targets (DEV, confirmed on HOLDOUT):
    • keyed: error+empty < 3%;
    • keyless: error+empty < 5%, and SimpleQA hit@5 within 10 points of Tavily.
  • Scoring, normalisation, the leak blocklist and the ID lists are frozen.

Production landing

MechanismOutcome
DDG parser fix, Wikipedia API in parallel, loud failuresDefault for keyless (shipped with this module)
Fetch + BM25 best passage in snippetsDefault for keyless; not used with Tavily/Brave (no significant gain, twice the p95)
Cross-encoder rerank of a 10-candidate poolSetting: needs a local /rerank server (AGE-521)
Browser escalation, multi-query + RRF, pool of 20Rejected on measurement; patches kept outside the repo for a re-test
Keyed retry + keyless failoverNot built: 0% keyed errors at a human pace. The dev-key 429 risk is real for parallel agents.

Reserved-function candidates

These functions hold the core ideas of this module. On 2026-09-23 the human chose to reserve none of them (and lifted the M6 reflection gate, AGE-518, for this loop). Nothing was added to RESERVED.md.

  1. rrf_fuse: built in iteration 8 and reverted with it. The patch is kept outside the repo. It is the right function for memory (Part C) but not for multi-hop web.
  2. BM25 passage scoring: tools/passages.rs::score_passages, shipped (iteration 4). Its statistics come from a tiny, per-query collection.
  3. pack_context: not built as a function. Packing is currently “the best passage leads the snippet”; a budgeted, order-aware packer remains a Part C proposal.

Depends on

  • M1 ReAct: the loop that issues search calls.
  • M4 ACE: shares the memory store that Part B reads.

Further reading

Workspace crates

When to read this: You need a one-glance map of the 13 workspace crates and where to dive deeper. This page opens the Reference section.

The Reference section is lookup material, most of it generated or synced from the repository: the crate index below (one README per crate), the tools catalog, the provider matrix, the slash commands, the CLI flags, the environment variables, the settings schema, the GPUI event catalog, the singleton inventory, the WIT interface, the curated MCP catalog, build disk usage, make targets & CI workflows, the doc frontmatter schema and the glossary. The reference/* pages are regenerated by make docs-gen from scripts/gen-docs-reference.sh; edit the script, not the page.

Each crate has a README synced into this site on make docs-sync. Edit the source at crates/<name>/README.md, not the built copy under dev/crates/.

None of the workspace crates are published to crates.io / docs.rs today. Research crates set publish = false. Until a crate is published, use the README on this site and cargo doc -p <crate> --open locally — do not add docs.rs badge URLs (they 404 and fail the link checker).

Cratecrates.io / docs.rsLocal rustdoc
Application + WASM + Hive cratesUnpublishedcargo doc -p <crate> --open
chatty-trace, chatty-playbook, chatty-flow, chatty-optimizepublish = falsecargo doc -p <crate> --open

When a crate is published, add a docs.rs badge here and on its README, and keep the crate-level //! docs complete enough for docs.rs.

Application crates

CratePurposeREADME
chatty-coreUI-agnostic agent core: models, services, tools, settings, sandboxcrates/chatty-core/
chatty-gpuiGPUI desktop app — ships the chatty binarycrates/chatty-gpui/
chatty-tuiRatatui terminal app — interactive, headless, and pipe modescrates/chatty-tui/

WASM modules & protocols

CratePurposeREADME
chatty-wasm-runtimeWasmtime embedding and host-side WIT interfacescrates/chatty-wasm-runtime/
chatty-module-registryModule discovery, manifest, and lifecyclecrates/chatty-module-registry/
chatty-protocol-gatewayHTTP gateway: OpenAI / MCP / A2A protocol surfacescrates/chatty-protocol-gateway/
chatty-module-sdkSDK for authoring wasm32-wasip2 agent modulescrates/chatty-module-sdk/

Research crates (Self-improving chatty2)

Reserved symbols and human-only entry points are listed in RESERVED.md. Linear project: Self-improving chatty2.

CratePurposeREADME
chatty-traceTrace capture, ATIF export, feedback hooks (M0)crates/chatty-trace/
chatty-playbookACE-style playbook memory for evolving instructions (M4)crates/chatty-playbook/
chatty-flowAFlow workflow representation and IR (M2)crates/chatty-flow/
chatty-optimizeGEPA/AFlow optimizers, paired stats, QA loaders (M3)crates/chatty-optimize/

Hive integration

CratePurposeREADME
hive-clientOpen-source client for the Hive module registrycrates/hive-client/
hive-billing-sdkPublisher SDK for Hive billing in WASM modules (separate Cargo.lock)crates/hive-billing-sdk/

Dependency direction

flowchart TB
  gpui[chatty-gpui] --> core[chatty-core]
  tui[chatty-tui] --> core
  gateway[chatty-protocol-gateway] --> core
  registry[chatty-module-registry] --> runtime[chatty-wasm-runtime]
  runtime --> core
  trace[chatty-trace] -.-> core
  playbook[chatty-playbook] -.-> core
  flow[chatty-flow] -.-> core
  optimize[chatty-optimize] -.-> trace
  optimize -.-> playbook
  optimize -.-> flow

chatty-core never depends on a UI crate. Research crates integrate with production code but optimizer runs must not block the interactive chat path.

chatty-core

UI-agnostic core of the Chatty agent. Contains every piece of logic that both the GPUI desktop app and the Ratatui terminal app share: models, services, tools, settings, repositories, factories, exporters, and the sandbox.

This crate has no GPUI dependency. Optional GPUI Global trait impls are gated behind the gpui-globals feature; chatty-gpui enables it, chatty-tui does not.

Module layout

PathResponsibility
auth/Azure / OAuth credential acquisition
curated_mcp.rsBuilt-in MCP catalog seeded on first launch
exporters/Conversation export (markdown, PDF, ATIF, …)
factories/agent_factory/Build AgentClient per provider; tool registration
gpui_globals.rsimpl Global for core types (feature-gated)
install.rsHive module install/uninstall (validated names, capped download, .chatty-install.json record) and first-launch seeding
models/Pure data types: Conversation, Message, stores
repositories/Persistence abstractions (SQLite for conversations)
sandbox/Bollard/Docker sandbox backend + manager
services/Math/Mermaid renderers, shell, MCP, git, memory, skills, … and the delegation seams: worker_tree (one git worktree per worker), virtual_agents / worker_endpoint (roster → runner specs and per-endpoint budgets), team (team directories), spend_gate (hosted cap hook)
session/AgentSession: the frontend-agnostic turn loop, usage pricing and delegated-usage rollup
settings_snapshot.rsSettingsSnapshot / SettingsDelta: canonical serialization, delta and apply across every settings family
settings/Settings models + JSON repositories
token_budget/Tokenization, context-window accounting, summarizer
tools/LLM tool implementations (filesystem, shell, MCP, …)

Process-global singletons

A single comment block at the top of src/lib.rs lists every process-global singleton in the workspace, where it lives, and the rationale for whether it’s centralized or domain-local. Update that inventory whenever you add or move a singleton.

Build / test

From the workspace root:

cargo test -p chatty-core --lib      # fast inner loop (no integration tests)
cargo test -p chatty-core            # includes integration tests
make test                            # full CI-equivalent run

See AGENTS.md for repo-wide conventions and CLAUDE.md for coding patterns.

chatty-gpui

Desktop frontend for Chatty, built on GPUI. Ships the chatty binary.

This crate is the GPUI-specific layer on top of chatty-core. All UI-agnostic state (settings models, tools, services, repositories) lives in chatty-core and is consumed here.

Module layout

PathResponsibility
main.rsApplication entry point; Tokio runtime + GPUI app setup
auto_updater/In-app update download / install (macOS .dmg, Linux AppImage, Windows .exe)
assets.rsEmbedded asset loading for the GPUI app
cli_installer.rsInstalls the chatty-cli companion symlink
global_entity.rsHelper for storing WeakEntity<T> in globals
chatty/controllers/Top-level app controller (ChattyApp), message ops, conversation ops, slash commands
chatty/views/All GPUI views: chat, sidebar, chat input, trace components, …
chatty/models/GPUI-specific entity wrappers (e.g. StreamManager)
chatty/services/GPUI-specific services (theme, window, notifications)
chatty/token_budget/GPUI token-budget UI manager
settings/views/Settings window views (providers, models, MCP, …)

crates/chatty-gpui/src/chatty/mod.rs also re-exports several modules from chatty-core for ergonomic use crate::chatty::… paths: auth, exporters, factories, repositories, tools. If grep doesn’t find a definition under this crate, look in chatty-core/.

Patterns

Read CLAUDE.md before changing UI code. Key rules:

  • Event-driven communication between entities — EventEmitter + cx.subscribe(), never Arc<dyn Fn> callbacks.
  • Weak entity refs in globals — never strong references.
  • Optimistic updates — mutate the in-memory global immediately, then persist asynchronously with a logged error path.
  • Stream lifecycle flows through StreamManager — the stream loop never updates the UI directly, it emits events.

Run / build / test

make run-gpui                   # cargo run -p chatty-gpui
cargo test -p chatty-gpui       # unit + light integration
make test                       # full CI-equivalent run

On Linux you need a handful of system packages — run make setup once.

See AGENTS.md for the repo-wide cheat sheet.

chatty-tui

A lightweight terminal chat interface for Chatty. Single-session, no persistence — launch it, chat with an LLM, exit. Think of it as a terminal-native companion to the chatty-gpui desktop app.

Installation

Pre-built binaries

Download the latest release for your platform from the GitHub Releases page:

  • macOS (Apple Silicon): .dmg
  • Linux (x86_64): .AppImage
  • Windows (x86_64): .exe installer

From source

Requires Rust (stable toolchain).

git clone https://github.com/boersmamarcel/chatty2
cd chatty2
cargo install --path crates/chatty-tui

This installs chatty-tui to ~/.cargo/bin/, which should already be on your PATH if you installed Rust via rustup.

Building

# Debug build
cargo build -p chatty-tui

# Release build
cargo build -p chatty-tui --release

The binary is output as target/{debug,release}/chatty-tui.

Running

Interactive mode

# Uses the model marked default in the desktop app (else the first configured)
chatty-tui

# Specify a model by name, ID, or partial identifier
chatty-tui --model claude-3.5-sonnet
chatty-tui --model "Claude 3.5 Sonnet"

Zero-config with Ollama

Connect directly to a running Ollama instance — no desktop app setup needed:

# Auto-discover models from local Ollama (localhost:11434)
chatty-tui --ollama

# Pick a specific Ollama model
chatty-tui --ollama --model llama3.2

# Connect to a remote Ollama instance
chatty-tui --ollama http://remote-host:11434

Zero-config with vllm / llama.cpp / LM Studio

Connect to any OpenAI-compatible server:

# vllm
chatty-tui --openai-compat-url http://localhost:8000

# llama.cpp
chatty-tui --openai-compat-url http://localhost:8080

# Pick a specific model
chatty-tui --openai-compat-url http://localhost:8000 --model my-model

# With API key (if required)
chatty-tui --openai-compat-url https://api.example.com --api-key sk-...

Headless mode

Send a single message and print the response to stdout:

chatty-tui --headless -m "Explain Rust ownership in one paragraph"

Usage file

--usage-file <PATH> (with --headless or --pipe) writes what the run spent (tokens, model and tool calls, passes, how it ended) to PATH as one JSON object, rewritten after every model call and written last when the run ends. Fields and semantics: the user guide, Terminal interface → Usage file; the schema lives in src/headless/usage_file.rs.

Saved conversations

--save-conversation <PATH> writes the run’s whole conversation to PATH when it ends, as the JSON array of messages a worker’s captured conversation carries (RC-0). --restore <PATH> starts the run from such a file instead of an empty conversation, so --message is the next turn on that history. Both are --headless only; the resume spike (experiments/resume-spike/ in harbor-chatty (private)) is what uses them.

Pipe mode

Read from stdin, send as a message, print the response:

echo "Summarize this code" | chatty-tui --pipe
cat src/main.rs | chatty-tui --pipe

Delegation: --broker and --team

A leader delegates through its invoke_agent tool to local-agent or to a named virtual agent; each delegation is a fresh chatty-tui worker process. The desktop app’s module settings start that broker; a terminal leader starts its own with --broker (Unix only, valid with --headless, --pipe and the interactive TUI):

chatty-tui --headless --broker -m "Refactor the auth module and write tests"

--team <id> runs a fixed roster from a team directory (teams/<id>/team.json + SKILL.md) and implies --broker. Searched in <workspace>/.chatty/teams/, then the platform data directory’s chatty/teams/, then the compiled-in presets (crosscheck, crosscheck-data; both experimental):

chatty-tui --team crosscheck-data --headless --ollama --model qwen3:14b \
  -m "What was the average transaction value in March 2023, in EUR?"

Flags a worker is started with (a leader rarely passes these by hand): --workspace <DIR> (its own git worktree), --participant-fd <N> (the connection the broker made for it; say hello and wait for one task — set by the broker, never by hand), --tools <profile> (coordinator / coder / reviewer allowlist), --preamble <text> (role instructions) and --max-agent-turns <n> (that worker’s own turn budget, AGE-440; 0 is no cap). A run without a human (--headless, --pipe, a worker) never takes the persisted turn cap: with no --max-agent-turns it runs uncapped under --max-duration (default 30m, also for an explicit 0). --tools / --preamble / --model on a --team leader override the team file’s leader settings. Design and file format: docs/agents-and-specs.md.

Prerequisites

chatty-tui shares configuration with the desktop app. You need:

  1. At least one provider configured — API keys and provider settings are read from the same JSON config files as chatty-gpui (stored in ~/.config/chatty/ or platform equivalent).
  2. At least one model configured — run the desktop app once to set up providers and models, or edit the config files directly.

Or use --ollama / --openai-compat-url to skip all configuration and connect directly to a running model server.

MCP servers configured in the desktop app are also available in chatty-tui.

Keybindings

KeyAction
EnterSend message
/Slash-command picker; @ opens the file picker
PageUp / PageDown, Shift+↑/↓, mouse wheelScroll the transcript
EndJump to the bottom and resume auto-scroll
Ctrl+CStop streaming response / quit if idle
Ctrl+QQuit immediately
Ctrl+RToggle folded tool calls vs full payloads (same as /verbose)
y / nApprove / deny tool execution (during approval prompt)
1-9, t, EscPick an option, type a custom answer, or go back when the agent asks a clarifying question (ask_user)

Slash commands

Typing / in the input opens an inline slash-command menu. Use ↑/↓ to select and Tab or Enter to apply.

CommandAction
/model [query]Switch model (/model opens picker)
/tools [name]Toggle tool groups (/tools opens picker)
/add-dir <directory>Expand workspace access to include a directory
/modules …Module runtime settings (enable, directory)
/agent [name] <prompt>Launch a headless chatty-tui sub-agent, or send the prompt to a named A2A agent
/clear, /newClear conversation history and start fresh
/compactSummarize older messages to reduce context usage
/contextShow token/context usage and current working directory
/copyCopy the latest assistant response to system clipboard
/updateTrigger CLI auto-update when an installed CLI target exists
/cwd, /cd [directory]Show or change the working directory
/online [url|off]Show where this conversation runs; move it to a chatty-server or back. Developer-only until hosted_conversations_enabled is on (AGE-298/308)
/verboseToggle folded tool-call summaries vs full payloads
/paste [n]Print the full text of an elided long paste
/quit, /exitQuit

Architecture

main.rs        CLI args (clap), Tokio runtime, settings loading, --broker/--team wiring
app.rs         Ratatui render loop + crossterm input + event mux
engine/        ChatEngine — single-conversation logic, stream processing,
               slash commands (commands.rs), typed transcript blocks (transcript.rs)
events.rs      AppEvent enum (channel-based, replaces GPUI EventEmitter)
headless/      Headless/pipe/worker turn loop, tool-result formatting,
               answer-file early stop, recovery
participant/   The broker half: Broker::start (gateway + participant socket),
               team presets, the input-required chain, delegation equivalence tests
ui/            Ratatui widgets (chat view, input, status bar, approval, plan card,
               clarification, pickers)

ChatEngine is UI-agnostic — it powers the interactive TUI, headless mode and every delegated worker (--participant-fd), which is what lets a leader and its workers be the same binary.

Event flow

User input ──► ChatEngine.send_message()
                    │
                    ├── Spawns tokio task: stream_prompt() loop
                    │   └── StreamChunk ──► AppEvent (via mpsc channel)
                    │
                    ▼
               Main loop (tokio::select!)
                    │
                    ├── crossterm events ──► key handling
                    ├── AppEvent ──► ChatEngine.handle_event() ──► update state
                    └── tick ──► redraw

chatty-terminal

The model half of Chatty’s embedded terminal: a PTY running a shell and an alacritty_terminal Term fed from it. No UI toolkit and no async runtime, so it runs headless (the agent shell, Harbor runs) as well as under a view.

Public surface

  • TerminalHandle::spawn(TerminalConfig) starts the shell ($SHELL login + interactive on Unix, pwsh/powershell over ConPTY on Windows) and returns the handle plus the event channel. Drop the receiver to run with no subscriber.
  • spawn_with_tap(config, tap) adds a byte-stream tap: a callback that sees every byte read from the PTY before the parser does.
  • TerminalConfig::scrollback sets the lines of history kept (default 10,000).
  • MarkScanner (fed from the tap): OSC 133 A/B/C/D marks, chatty’s private OSC 6973, and the CleanText between them (no escapes, \r\n as \n, redrawn lines as they ended, no wrapping at the terminal width).
  • commands() -> Vec<CommandRecord>: the last 200 commands run at a shell with OSC 133 integration (text after line editing, output line range, exit code, start/finish time, truncated once the output left the scrollback), and snapshot(Region::LastCommand): the last command’s output (end kept, capped at 16 KiB) with last_command saying what ran and how it exited, or that integration is not active. Line numbers come from a shadow Term in the byte tap, stopped at the exact byte of each mark (see src/commands.rs).
  • Shell integration (TerminalConfig::shell_integration, default on): bash and zsh started with default arguments load chatty’s OSC 133 snippets (--rcfile / a ZDOTDIR wrapper, src/integration.rs) after the user’s own startup files, which are never edited. fish and PowerShell are unsupported and run without integration. With injection, bash is not a login shell to itself (shopt login_shell is off; our rcfile sources the login startup files instead), so a .bash_profile that branches on login_shell sees “not login”.
  • write, resize, kill; dropping the handle kills the child’s process group and joins the PTY thread.
  • has_foreground_job(): whether a program other than the shell holds the terminal’s foreground (the PTY’s foreground process group on Unix; None on Windows, where it cannot be told).
  • foreground_process_name() and current_dir(): the foreground program’s name and the shell’s working directory, for tab titles (Linux, via /proc; None elsewhere).
  • with_term reads the grid under its lock (for the renderer); generation() changes whenever content or size does.
  • snapshot(Region) -> TerminalText: the screen, or the last N rows of scrollback, as plain text.

PtyWrite and TextAreaSizeRequest replies are written back to the PTY inside the crate, so programs that query the terminal work headless.

Tests

cargo test -p chatty-terminal spawns real shells under a PTY (Unix only). The zsh test runs when zsh is on PATH (or CHATTY_TEST_ZSH names one) and is skipped otherwise. The Windows ConPTY path compiles with cargo check --target x86_64-pc-windows-msvc -p chatty-terminal (verified locally). CI’s Windows check covers it once chatty-gpui or chatty-tui depend on this crate; the Windows runtime is untested.

chatty-wasm-runtime

Wasmtime embedding and host-side WIT interface for chatty WASM plugins.

This crate wraps wasmtime 30 with the component model enabled, loads WASM components compiled to wasm32-wasip2 against chatty:plugin@0.3.0, applies resource limits, and provides the host side of the plugin imports (llm, config, logging, file, billing, one interface per capability).

Public surface

  • WasmModule — a loaded plugin: metadata(), list_tools(), invoke_tool()
  • ResourceLimits — fuel / memory / wall-clock / output-size caps applied per call (PL-D3/PL-D3b: 10¹² fuel, 256 MiB, 60 s including host time, 1 MiB output; a manifest may only lower these)
  • [Engine] — re-exported wasmtime::Engine so callers can share one engine
  • Host traits: LlmProvider, BillingProvider
  • Errors: CallError (a limit or a trap), ToolFailure (the guest’s own tool-error)
  • WIT types: PluginMetadata, Capability, ConfigKey, ToolCallRequest, ToolResult, ToolError, ToolErrorKind, ToolDefinition, Message, Role, ToolCall, TokenUsage, CompletionResponse, SessionInfo

Most callers go through chatty-module-registry instead of this crate directly.

WIT versioning

A single bindgen! invocation lives in lib.rs: bindings, generated from the repo-root wit/ directory, which is chatty:plugin@0.3.0 (WIT_PACKAGE).

Only that package is loaded. A component that does not export chatty:plugin/plugin@0.3.0 is refused at load with module targets chatty:module@0.2.0; this chatty supports chatty:plugin@0.3.0 — rebuild it with the current SDK (naming whatever world it does target): there is no adapter for an older world (PL-D1). The build fails if the WIT file declares another package until WIT_PACKAGE/PLUGIN_EXPORT (and the SDK’s WIT_PACKAGE) are updated with it.

See docs/wit-reference.md for the WIT schema and docs/plugins.md for the broader module architecture.

Build / test

cargo test -p chatty-wasm-runtime

The unit tests need no WASM; the fixtures and sandbox suites load real fixture components, built once per checkout by scripts/build-wasm-fixtures.sh (--features test-support).

chatty-module-registry

Module discovery, loading, and lifecycle management for chatty WASM agent modules.

This crate sits on top of chatty-wasm-runtime and adds:

  • Discovery — scan a directory for .wasm files paired with module.toml manifests
  • Manifest parsing — typed ModuleManifest with capabilities, protocols, and resource limits
  • Lifecycle — load, unload, reload, enumerate currently loaded modules
  • Install record — a module chatty installed carries .chatty-install.json (InstallRecord: sha256, trust level, publisher key); every load re-hashes the .wasm against it and refuses a mismatch. A module without one loads as TrustLevel::Local

There is no filesystem watcher: an agent that runs a module as a plugin loads its own instance when it is built (PL-U2), so a changed module is picked up by the next agent, and by reload or a new scan here.

Public surface

See the rustdoc on ModuleRegistry for the canonical usage example.

Where modules live by default

Platform-native app-data directories, not a single fixed path: ~/Library/Application Support/chatty/modules/ on macOS, ~/.local/share/chatty/modules/ (or $XDG_DATA_HOME/chatty/modules/) on Linux, %APPDATA%\chatty\modules\ on Windows — overridable in module settings. .chatty/modules (relative to the working directory) is only a last-resort fallback when the platform data directory can’t be determined. Each subdirectory is one module: a .wasm plus a module.toml.

See docs/plugins.md for the end-to-end module flow.

Build / test

cargo test -p chatty-module-registry

chatty-protocol-gateway

HTTP server with two protocol surfaces, both plain HTTP + JSON over TCP — there is no gRPC, no WebSocket (except MCP SSE), and no binary framing:

  • MCP serves the tools of each loaded WASM plugin (chatty:plugin@0.3.0) to external MCP clients. A plugin has tools and no loop of its own, so it is never an agent and has no other route (PL-U3).
  • A2A serves agents: local participants (worker processes the broker spawned on a connection it made for them) and virtual agents (ADR-0011, ADR-0020). That connection is the one thing here that is not HTTP; see Local participants.

Transport

No gRPC. All endpoints are plain HTTP requests with JSON bodies.

ProtocolMethodContent-Type
MCP JSON-RPCPOST /mcp/{module}application/json
MCP SSE streamGET /mcp/{module}/ssetext/event-stream
MCP SSE messagePOST /mcp/{module}/sse?sessionId=…application/json
A2A JSON-RPCPOST /a2a/{module}application/json
A2A streamingPOST /a2a/{module} (method: message/stream)text/event-stream
Agent card (per module)GET /a2a/{module}/.well-known/agent.jsonapplication/json
Agent card (aggregated)GET /.well-known/agent.jsonapplication/json
Participant connectionbroker-made socketpair per worker (open_connection)newline-delimited JSON, v2

All of it is served on a Unix socket in an owner-only directory, never on a TCP port (see Running).

What every route enforces

  • The launch token. Every route, and the fallback, answers 401 without Authorization: Bearer <token>, where the token is the gateway’s per-launch one (ProtocolGateway::token(), written to gateway.token beside the socket, 0600; ADR-0021 § 4).
  • Loopback callers only. A request whose Host is not a loopback name (localhost, 127.0.0.0/8, [::1]), or whose Origin is present and not loopback (null included), gets 403, a guard against DNS rebinding for any listener an embedder puts in front of the socket. A request without Host (not a browser’s) is served.
  • The /a2a/{name} surface is modules and the remote-runner forward only (BI-7, ADR-0020). Once a worker calls over its own connection (BI-4), a {name} that is a role — a registered participant or a virtual agent — or the aggregated card when this gateway has any role at all, is refused with 403 {"error":"fabric: roles are reached over the worker connection"}, logged as a refusal row on the edge log. There is no loopback_roles setting to bring the old path back: see Local participants.
  • Request bodies up to 10 MiB (MAX_REQUEST_BYTES); a larger one is a 413 before any handler runs.
  • [protocols] mcp. A plugin is served over MCP only when its manifest sets mcp = true; otherwise it answers 404, as if it were not loaded. It is never on the aggregated agent card.
  • One lock per module. The registry is locked only to look a module up; the guest call runs under that module’s own lock (ModuleHandle), on the blocking pool. Calls to one module queue; calls to different modules do not wait on each other. Credit checks happen before the module lock.
  • Guest output is capped at 1 MiB per call by the runtime (PL-D3); a reply over the cap is a 502 with a short error body, never relayed.

Protocol summary

1 · MCP (Model Context Protocol)

Speaks JSON-RPC 2.0 (tools/list, tools/call). Each call is a direct pass-through to the plugin’s list-tools or invoke-tool WIT exports; the caller (an orchestrator or another LLM) decides when to call each tool and how to interpret its output. tools/call hands the guest its arguments object JSON-encoded once, as tool-call-request.arguments-json — what the tool’s inputSchema describes.

Two transports, one dispatcher:

  • Streamable HTTP — POST /mcp/{module}, each message answered in its response (what chatty’s own rmcp client uses).
  • HTTP+SSE (MCP 2024-11-05) — GET /mcp/{module}/sse opens a stream whose first event is endpoint (/mcp/{module}/sse?sessionId=…). The client POSTs each message there, gets 202 Accepted, and the answer arrives on the stream as a message event. The stream stays open (keep-alives) until the client disconnects, which ends the session.

2 · A2A (Agent-to-Agent)

Speaks the A2A JSON-RPC 2.0 schema (message/send, message/stream, tasks/get), but only for the remote-runner forward now (BI-7, ADR-0020): a module whose Hive metadata says execution_mode = "remote" is forwarded to the Hive runner over this shape until PL-H8b removes that path. A local plugin has no A2A route (PL-U3). A role — a registered participant or a virtual agent — is refused here with 403; it answers message/send and message/stream over the connection the broker made for its caller instead (see Local participants), which is where the frame shapes below actually apply.

message/send returns a complete JSON-RPC response:

{ "result": { "id": "task-…", "contextId": "…", "status": { "state": "completed" }, "artifacts": [{ "parts": [{ "type": "text", "text": "…" }] }] } }

message/stream returns an SSE stream (text/event-stream) with incremental updates per the A2A streaming spec:

data: {"jsonrpc":"2.0","id":1,"result":{"id":"task-…","contextId":"…","status":{"state":"working"},"final":false}}

data: {"jsonrpc":"2.0","id":1,"result":{"id":"task-…","contextId":"…","artifact":{"parts":[{"type":"text","text":"…"}],"index":0,"lastChunk":true}}}

data: {"jsonrpc":"2.0","id":1,"result":{"id":"task-…","contextId":"…","status":{"state":"completed"},"final":true}}

The agent card advertises "capabilities": { "streaming": true } so clients can discover streaming support.

Architecture

                          ┌──────────────────────────────┐
HTTP client               │   chatty-protocol-gateway     │
                          │   (Axum HTTP server)          │
                          │                              │
POST /mcp/{m}        ────►│ mcp.rs handler               ├──► ModuleRegistry
                          │                              │    (wasmtime instances)
POST /a2a/{module}   ────►│ a2a.rs handler  ─────────────┼──► remote runner (PL-H8b)
GET  /.well-known/…  ────►│ a2a.rs handler  (403 once     │
                          │  this gateway has a role)     │
                          └──────────────────────────────┘

                          ┌──────────────────────────────┐
worker (child process,    │  socketpair per worker        │
 own connection)     ◄───►│  (open_connection, BrokerCalls)├──► ParticipantRegistry
                          └──────────────────────────────┘

A role — a registered participant or a virtual agent — is never reached from the HTTP side any more (BI-7): a2a.rs refuses {name} when it names one, before routing anything. a2a_participant.rs’s submit/spawn are shared building blocks BrokerCalls (the connection’s call path, participant/ calls.rs) runs a worker’s task on, not an HTTP handler.

The gateway holds a single ModuleRegistry (behind an Arc<RwLock<…>>, read only to look a module up) whose modules each sit behind their own lock, and a single ParticipantRegistry. Only the MCP handler calls a plugin, through its plugin::list-tools and plugin::invoke-tool exports.

Local participants

A worker registers over a connection the broker made for it and is reached over that same connection — invoke_agent, list_agents and the frames below — never over loopback HTTP (BI-7, ADR-0020): /a2a/{name} refuses a role with 403.

The connection is the identity (ADR-0020). The broker admits a node — Directory::admit names it <spec>-<n>, never reusing a name — creates a socketpair, keeps one end and hands the other to the child it spawns at descriptor 3 (chatty-tui --participant-fd 3; open_connection, LocalRunner). The worker’s session.hello names nothing: a card’s name is ignored, and the hello’s result says who the worker is. There is no way to register otherwise. The shared socket (with_participant_socket, bind, serve) stays bound and refuses every connection — a hello with an error for its id, anything else by closing it — so no local process can take a name the broker is about to route a task to.

The connection carries newline-delimited JSON, not A2A: A2A is the gateway’s public wire format, and a child process is not a public endpoint. This is version 3, ADR-0021’s one envelope (participant::FrameCodec, one per connection): every line is a request {"v":3,"id":…,"method":…,"params":…}, a result {"v":3,"id":…,"result":…}, an error {"v":3,"id":…,"error":{"kind":…,…,"message":…}} or a notification {"v":3,"method":…,"params":…}. There is no fallback to an older version. What rides in params, result and error is typed in chatty_fabric::wire, split by direction (ADR-0021 § 1, EN-3a): every type denies unknown fields, nothing is untagged or flattened, and a payload decodes straight into its struct, so an unknown field, a duplicate key or an error kind outside WireError is a decode error that closes the connection. A task’s terminal metadata is a TaskMetadata (usage, trace, conversation, handoff keys, evidence); the captured conversation, the handoff answer and schema and the runner’s evidence cross as capped opaque JSON the broker never reads. task.run carries whose task it is as identity: {tenant, user} (absent for a desktop root’s task), never a credential. (hive’s worker moves to v3 with HS-4a, after ADR-0021 step 3.)

participant → {"v":3,"id":1,"method":"session.hello","params":{"card":{"name":"",…}}}
broker      → {"v":3,"id":1,"result":{"name":"local-coder-0","scope":"root","owner":null}}
broker      → {"v":3,"id":1,"method":"task.run","params":{"taskId":"task-…","text":"summarise foo.rs"}}
participant → {"v":3,"method":"task.event","params":{"kind":"status","id":1,"state":"working","message":"read_file"}}
participant → {"v":3,"method":"task.event","params":{"kind":"artifact","id":1,"text":"foo.rs defines…","lastChunk":false}}
participant → {"v":3,"id":1,"result":{"state":"completed"}}

Each side numbers its own requests and never reuses an id; reusing one still in flight closes the connection. Results, errors, req.progress and task.event name the receiver’s request (a task’s events name its task.run); req.cancel names the sender’s — the broker stops a task with req.cancel of its task.run. A response or cancel naming nothing in flight is dropped and logged. Each direction decodes into its own set of methods, so a method the peer may not send — a worker’s task.run, say — closes the connection, as does a line that does not decode at all; an error is only ever sent back for a refused hello.

SenderRequestsNotifications
workersession.hello, agent.invoke, agent.list, mailbox.post, human.ask, human.approvetask.event, req.cancel
brokertask.run, human.askreq.progress, req.cancel

Only the root answers an approval (ADR-0021 § 2, EN-2a). A worker whose command or write needs a human sends human.approve (kind: exec | write, command_or_path, diff_stat) and waits for its result, "approved" or "denied". The broker overwrites any asker with the connection’s admitted name and chain, and delivers the request straight to the root’s call under an id of its own; no caller in between sees it, and the broker never sends human.approve to a worker. The root’s verdict is the request’s result. A worker withdraws its own with req.cancel; a closed connection withdraws all of its own. At most four wait per connection; one more, or one with no root to ask, is denied.

participant → {"v":3,"id":4,"method":"human.approve","params":{"kind":"exec","command_or_path":"[shell] echo hi"}}
broker      → {"v":3,"id":4,"result":"approved"}

scope is the conversation the node works for and owner the node that asked for it; for now every node is the root’s, in scope root. task.run also carries the node’s spawnContext (BI-5, below).

Task states are A2A’s (submitted, working, input-required, completed, failed, canceled); a terminal one is the task.run’s result and ends the task.

A question is a request that climbs the caller chain (ADR-0021 § 2, EN-2b). A worker whose ask_user is waiting parks its task on a human.ask request carrying every question with its options, and gets the answers as its result. The broker overwrites any asker with the connection’s admitted name and chain and relays the request, under an id of its own (question), to the caller of the asking worker: a worker gets it as a broker→worker human.ask, the root on its call’s stream. A worker answers escalate, and the broker forwards the original request, first stamp intact, to the next caller up, so the root sees the agent that asked. A question a worker relays from a third-party A2A peer carries the peer’s origin, set by the worker’s client code. A worker withdraws its own with req.cancel; when the asker’s call ends or is cancelled, the broker withdraws the relayed copy with req.cancel.

leaf        → {"v":3,"id":2,"method":"human.ask","params":{"questions":[{"id":"q1","question":"Which database?","options":["Postgres","SQLite"]}]}}
broker      → {"v":3,"id":3,"method":"human.ask","params":{"question":"question-1","request":{"questions":[…],"asker":{"agent":"leaf-0","chain":["root","mid","leaf"]}}}}
mid         → {"v":3,"id":3,"result":"escalate"}
broker      → {"v":3,"id":2,"result":[{"id":"q1","answer":"Postgres","custom":false}]}

Calls over the connection (ADR-0020, BI-4). A worker’s invoke_agent, list_agents and send_message reach local roles and the directory over the same connection, not over loopback HTTP, as agent.invoke, agent.list and mailbox.post requests. The broker runs each as the node the connection names and answers under its id, so several can be in flight and finish in any order. Closing the connection cancels every call still in flight on it, which reaps the workers those calls started; a worker may also withdraw one with req.cancel.

participant → {"v":3,"id":2,"method":"agent.invoke","params":{"agent":"local-reviewer","prompt":"review it","handle":null,"include_trace":false}}
participant → {"v":3,"id":3,"method":"agent.list"}
broker      → {"v":3,"id":3,"result":[{"name":"local-reviewer","origin":"local",…}]}
broker      → {"v":3,"method":"req.progress","params":{"id":2,"event":{"Step":"read_file"}}}
broker      → {"v":3,"id":2,"result":{"success":true,"response":"Looks good.","metadata":{…}}}

A call that cannot run ends with an error ({"kind":"unknown_agent","agent":…,"message":…}: the variant’s fields beside kind, message display text only); a callee whose task failed ends with a result whose success is false. The in-process root uses ProtocolGateway::transport(), the same calls with no socket.

The spawn context rides the request (ADR-0020 invariants 5–6, BI-5). Only a root process runs a broker; a sub-leader delegates over its connection. A worker a call starts is spawned with a SpawnContext {workspace_root, base_branch, roster, verification, endpoint} the broker derives from the calling node’s own (its tree, its branch, its roster; the root’s verification command and endpoint for the agent), so a sub-leader’s worker branches from the sub-leader’s branch. A call may bring one as params.spawn_context; it is clamped to the caller’s own and refused otherwise with an error {"kind":"spawn_context_refused","field":…,"reason":…,"message":…} (participant::spawn_context).

At the root, invoke_agent asks a question the broker delivers on its own agent’s ask_user store, headed by who asked, so the human sees the ordinary popover. Escalate-to-human is the only policy; whether a leader may answer on a worker’s behalf is an open question on ADR-0011. A root with nobody to ask ends the delegation rather than guessing.

A non-streaming caller gets the question as a failure (AGE-321). A plain message/send reply is a single object with no room for a non-terminal update, so a worker that parks under one is asking someone who will never hear it. The broker ends the task at that point and quotes the question in status.message, leaving the structured request on status.metadata:

{ "id": "task-…",
  "status": { "state": "failed",
              "message": { "parts": [{ "type": "text",
                "text": "the worker asked: Which database? — a `message/send` task cannot carry a question back to its caller…" }] },
              "metadata": { "clarification": { "id": "req-…", "questions": [ … ] } } } }

Decision, 2026-09-09 (Marcel). Fail fast rather than hold the task open for tasks/get polling. Polling is the A2A-shaped answer and would make non-streaming callers first-class, but it makes the broker stateful for open tasks — a task table, its cleanup, and lifetimes that interact with leases and the ledger — and every delegation path in this repository streams, so the callers it would serve are third parties. Failing immediately with the question in hand removes the multi-minute silent hang that was the actual complaint, and costs nothing that polling would later have to undo.

The connection is the liveness signal. There is no heartbeat: when the connection closes, for any reason, the participant is deregistered and every task it still owed is failed with a failed status naming the disconnect. A process that has died cannot fail to send a heartbeat, so the connection is the only signal that cannot lie.

Every connection is bounded (EN-0b, participant/limits.rs), and only a peer’s own frame closes its connection. Each end reads lines of at most MAX_FRAME_BYTES (33 MiB, interim until ADR-0021 Q4); a longer line, or one that does not decode, closes the connection it arrived on. A reply that would put a frame over the receiver’s cap — a callee’s result too large for its caller — fails only that call with an error. The outbound queue holds OUTBOUND_QUEUE_FRAMES and makes the producer wait when full, never closing the slow reader. Calls past MAX_IN_FLIGHT_CALLS or CALLS_PER_SECOND per connection are refused. fuzz/ fuzzes the FrameCodec (see its README).

The hosted transport is Firecracker vsock, which reaches this crate as a plain stream — serve_connection(stream, registry, admitted_node) takes any of them, and ParticipantConnection::hello_over is the worker’s side of the same generalization (AGE-307; the hosted broker adopts it with HS-4).

Virtual agents and the local runner

ProtocolGateway::with_virtual_agent publishes one agent that is not a connected process but a factory. A task addressed to it starts a worker on a connection the broker made for it, waits for that worker’s session.hello, routes the task to it, and reaps it. To its caller — over the caller’s own connection, or the root’s direct handle — it is reached exactly like a registered participant, which is the point: invoke_agent replaces sub_agent without the parent learning a second fan-out path.

LocalRunner is the implementation that spawns a chatty-tui child. It is not the only one: hive’s VmRunner leases a Firecracker microVM per task (AGE-307) and fills the same slot, which is why the trait exists rather than the gateway naming a concrete runner. Everything past “the worker said hello” is the same code for both.

One child per task. The child can serve tasks until its socket closes, but the runner’s policy is one-shot, keeping the process lifecycle identical to the sub_agent it replaces — that equivalence is what makes ADR-0011’s second kill criterion a comparison of the hop rather than of process-reuse strategies. Cancellation of a running task is enforced by reaping the child; a persistent worker is a change to runner.rs and nothing else.

Where a worker runs is the embedder’s decision, not the gateway’s. ADR-0012 gives each worker a git worktree, and git lives in chatty-core, so the runner takes a WorkspaceFactory and only spawns in whatever directory it is handed. Without a factory the child inherits the broker’s own directory. A factory that is configured and then fails fails the task rather than silently running the worker unisolated.

Running

This crate is a library, not a binary — it has no [[bin]] target and no --modules-dir CLI. An embedder constructs a ModuleRegistry, calls scan_directory, and passes it to ProtocolGateway::new(registry). The desktop (chatty-gpui) and chatty-tui --broker both do this; see their module-settings / broker wiring for a worked example, or crates/chatty-protocol-gateway/tests/ for a minimal one.

start() serves on gateway.sock in an owner-only directory (access::default_runtime_dir(): $XDG_RUNTIME_DIR/chatty-run, else <cache dir>/chatty-run; with_runtime_dir overrides it), and writes the per-launch token to gateway.token (0600) beside it. Every route of every listener serving build_router() answers 401 without Authorization: Bearer <token>. It refuses to start when the directory is a symlink, someone else’s, or open to group or others, and it never unlinks a socket this user does not own. There is no TCP listener. On Windows start() fails with access::WINDOWS_UNSUPPORTED (an owner-only DACL is not implemented yet, tracked by AGE-778): external MCP access is macOS/Linux-only for now.

Being a worker (worker feature)

The worker feature adds the other end of the participant connection: TaskMapper, the SessionEvent → A2A table, and serve_one_task, the loop a process runs when it is the worker — say hello, take one task, run it, send one terminal status, exit.

It lives here rather than in chatty-tui because two crates run it: chatty-tui on the desktop and hive’s chatty-server inside a microVM. The frame sequence a parent renders is what ADR-0011’s first kill criterion is measured on, so a second copy of the mapping would be a second answer to the question the ADR asks. The feature is off by default — it is the only thing in this crate that needs chatty-core, and a broker that never runs an agent itself builds without it.

chatty-module-sdk

SDK for authoring chatty WASM plugins against chatty:plugin@0.3.0. Compile target: wasm32-wasip2.

A plugin contributes tools to a chatty agent and never runs a loop of its own: the agent’s model decides when to call a tool, and the host calls the plugin’s invoke-tool (PL-D1 option B).

This crate is intentionally outside the main workspace (see the [workspace] table at the bottom of Cargo.toml). Plugin authors depend on it as a path = "../../crates/chatty-module-sdk" from their own single-crate project rooted at the plugin directory.

What it provides

  • WIT types, generated from wit/chatty-plugin.wit: PluginMetadata, Capability, ToolDefinition, ToolCallRequest, ToolResult, ToolError, Message, Role, …
  • Host imports, one module per capability: llm::complete, config::get, log::info/warn/…, file::read_bytes, billing::*. A plugin lists the capabilities it uses in metadata().requested_capabilities; logging is always granted.
  • Plugin, the trait a plugin implements (metadata, list_tools, invoke_tool), and export!, which wires it to the component’s exports. Both are wit-bindgen’s own generated code, so the export names always match the WIT.

hive-billing-sdk uses this crate’s billing imports, so a paid plugin can depend on both (the billing test fixture links them together).

Quick start

See the rustdoc example at the top of src/lib.rs, the reference plugins under ../../modules/ (echo, benford), and the Build a WASM plugin guide.

Build

rustup target add wasm32-wasip2
cd modules/your-plugin
cargo build --target wasm32-wasip2 --release

The output .wasm lives under target/wasm32-wasip2/release/your_plugin.wasm. Copy it next to your module.toml so the registry can discover it.

WIT version

WIT_PACKAGE is chatty:plugin@0.3.0. The build fails if wit/chatty-plugin.wit declares any other package: a WIT version bump has to update WIT_PACKAGE here and in chatty-wasm-runtime deliberately, since the host refuses every other world with “rebuild it with the current SDK”.

chatty-trace

Research crate: trace capture, ATIF export, and feedback hooks for the Self-improving chatty2 project.

Role in the system

flowchart LR
  Agent[chatty-core agent loop] --> Trace[chatty-trace]
  Trace --> ATIF[ATIF v1.6 export]
  Trace --> Feedback[FeedbackFn hooks]
  • Consumes agent run data from the production ReAct loop (via rig hooks / rig-tap)
  • Does not replace chatty-core exporters — extends research workflows

Boundaries

Some symbols are reserved for human implementation — see RESERVED.md. Do not implement todo!("human: …") markers.

chatty-playbook

Research crate: ACE-style playbook memory for evolving agent instructions.

Role in the system

flowchart LR
  Runs[Agent runs] --> Playbook[chatty-playbook]
  Playbook --> Memory[Playbook store]
  Memory --> Preamble[Updated system preamble]
  Preamble --> Agent[chatty-core AgentFactory]

Part of Self-improving chatty2 — optimizes real preambles inside Chatty, not a parallel lab.

Boundaries

Reserved functions listed in RESERVED.md. Agents scaffold tests only around todo!("human: …") markers.

chatty-flow

Research crate: AFlow workflow representation and IR for searchable agent topologies.

Role in the system

flowchart LR
  Flow[chatty-flow IR] --> Search[Topology search]
  Search --> Optimize[chatty-optimize]
  Optimize --> Agent[chatty-core runtime]

Defines WorkflowRepr and intermediate representation used by offline optimizers.

Boundaries

See RESERVED.md for human-only entry points.

chatty-optimize

Research crate: GEPA/AFlow optimization drivers, paired statistics, and QA loaders.

Role in the system

flowchart LR
  Traces[chatty-trace] --> Opt[chatty-optimize]
  Playbook[chatty-playbook] --> Opt
  Flow[chatty-flow] --> Opt
  Opt --> Offline[Offline optimizer runs]
  Offline -.->|never blocks| Chat[User chat path]

Test-time / offline only — optimizer runs must not block the interactive chat path.

Boundaries

Reserved symbols in RESERVED.md.

hive-client

Open-source client library for the Hive module registry.

HiveRegistryClient lets you browse, search, download, and verify Chatty modules from a Hive registry server, with transparent offline caching. Every download is verified against a registry root key the client holds, never one the registry sends; a download that does not verify, or comes from a registry with no trusted root key, is refused.

Public surface

  • HiveRegistryClient — main entry point (new, with_token, search, download, login, …)
  • models — wire types (ListParams, SearchResults, DownloadResult, TrustLevel, …)
  • cache — on-disk cache used transparently by the client
  • verify — the signing chain: root key → publisher certificate → signed manifest → the .wasm’s SHA-256 (mirrors hive’s hive-verify)
  • trust — which root key a client trusts: the compiled production key (empty until pinned), or CHATTY_HIVE_ROOT_KEY for a local (loopback) registry such as the compose stack

See src/lib.rs for the canonical usage example.

Tests

Unit tests cover cache eviction and chain verification; tests/vectors.rs checks the verifier against hive’s byte-for-byte vectors (tests/vectors/, copied from hive’s services/hive-verify/tests/vectors/); tests/registry_contract.rs replays every registry route the client calls from responses recorded off a real hive-registry (tests/recorded/):

cargo test -p hive-client

The recordings are checked against a live registry every night by the hive-e2e suite (.github/workflows/plugin-e2e.yml); re-record them with HIVE_E2E_RECORD=1 against a running stack (see scripts/hive-e2e.sh), never by hand.

hive-billing-sdk

Publisher-facing Rust SDK for integrating Hive billing into WASM modules.

Overview

This SDK wraps the WIT billing interface with ergonomic helpers for paid modules on the Hive marketplace. It provides:

  • Session acquisition with cryptographic verification
  • Session token verification (EdDSA, certified by the registry root key)
  • Usage reporting for post-execution settlement
  • Helper functions like require_session() and report_usage()

Installation

Add to your module’s Cargo.toml:

[dependencies]
chatty-module-sdk = { path = "../../crates/chatty-module-sdk" }
hive-billing-sdk = { path = "../../crates/hive-billing-sdk" }

Quick Start

The SDK is used inside a plugin’s invoke_tool (a chatty:plugin@0.3.0 plugin built on chatty-module-sdk). It uses chatty-module-sdk’s own billing imports, so the two crates link into one component.

#![allow(unused)]
fn main() {
use chatty_module_sdk::*;
use hive_billing_sdk::{require_session, report_usage};

struct MyPaidPlugin;

impl Plugin for MyPaidPlugin {
    fn metadata() -> PluginMetadata {
        PluginMetadata {
            name: "my-paid-plugin".into(),
            version: "0.1.0".into(),
            description: "Summarises text, billed per token".into(),
            requested_capabilities: vec![Capability::Llm, Capability::Billing],
            config_keys: vec![],
        }
    }

    fn list_tools() -> Vec<ToolDefinition> {
        vec![ToolDefinition {
            name: "summarise".into(),
            description: "Summarise the input text".into(),
            parameters_schema: r#"{"type":"object","properties":{"input":{"type":"string"}}}"#.into(),
        }]
    }

    fn invoke_tool(call: ToolCallRequest) -> Result<ToolResult, ToolError> {
        // 1. Acquire and verify a billing session (reserve 5000 tokens)
        let session = require_session(5000).map_err(ToolError::denied)?;

        // 2. Do the work
        let prompt = Message::new(Role::User, call.arguments_json);
        let response = llm::complete("", &[prompt], None).map_err(ToolError::failed)?;

        // 3. Report the actual usage
        let usage = response.usage.unwrap_or(TokenUsage { input_tokens: 0, output_tokens: 0 });
        report_usage(&session, usage.input_tokens.into(), usage.output_tokens.into())
            .map_err(ToolError::failed)?;

        Ok(ToolResult { content: response.content, usage: Some(usage) })
    }
}

export!(MyPaidPlugin);
}

Trust Model

The session token is an EdDSA (Ed25519) JWT signed by hive-registry’s session key. Every token carries:

  • skey: the session public key that signed it (hex)
  • skey_sig: the registry root’s base64 signature certifying that key, over hive-registry/session-key-cert/v1\n followed by the key’s hex

require_session() verifies, in order:

  1. skey_sig under one of the trusted registry root public keys
  2. the token’s EdDSA signature under skey
  3. that the token has not expired, and that it reserves what was asked

Any other algorithm is refused. Only public keys are embedded in the module, so there is nothing secret in the binary. Tokens are short-lived (5 minutes).

verify_session_token(token, roots, now) is public if you need to check a token you obtained some other way.

Configuration

A module built for the production registry needs no configuration. It trusts PRODUCTION_ROOT_PUBLIC_KEYS, which is compiled in and mirrors hive-client’s SEC-3 list. That list is empty until the real root key is pinned, so until then every token is refused unless you name a root key yourself.

To test against a local registry (for example the hive docker compose stack), pass its root public key (hex Ed25519) once, at module initialisation and before require_session():

#![allow(unused)]
fn main() {
hive_billing_sdk::configure_root_keys(&[env!("HIVE_DEV_ROOT_PUBLIC_KEY")]);
}

configure_root_keys replaces the production list and panics if called twice.

API Reference

configure_root_keys(keys: &[&str])

Trust these hex Ed25519 registry root public keys instead of PRODUCTION_ROOT_PUBLIC_KEYS. For a local registry only. Call once before require_session(); panics if called twice.

verify_session_token(token: &str, roots: &[String], now: i64) -> Result<SessionClaims, String>

Verify a token against roots at now (Unix seconds): root certifies skey, skey signed the token (EdDSA), and the token has not expired.

require_session(estimated_tokens: i64) -> Result<BillingSession, String>

Acquire and verify a billing session:

  1. Calls the host’s billing::acquire-session import
  2. Verifies the token with verify_session_token under the trusted root keys
  3. Checks that the reserved tokens match the request
  4. Returns a verified BillingSession

Returns:

  • Ok(BillingSession) on success
  • Err(String) if verification fails, insufficient credits, or no root key is trusted

report_usage(session: &BillingSession, input_tokens: i64, output_tokens: i64) -> Result<(), String>

Report actual token usage after work completes. Settles the session:

  • Deducts actual usage from user’s balance
  • Releases over-reserved tokens

report_usage_simple(input_tokens: i64, output_tokens: i64) -> Result<(), String>

Simplified version that doesn’t require passing the session object.

Types

BillingSession

#![allow(unused)]
fn main() {
pub struct BillingSession {
    /// The raw session token (JWT)
    pub token: String,
    /// Verified claims from the token
    pub claims: SessionClaims,
    /// User's balance at session creation
    pub balance_tokens: i64,
    /// Tokens reserved for this session
    pub reserved_tokens: i64,
    /// Pricing model ("free" or "paid")
    pub pricing_model: String,
}
}

SessionClaims

#![allow(unused)]
fn main() {
pub struct SessionClaims {
    pub sid: String,           // Session ID (UUID)
    pub uid: String,           // User ID (UUID)
    pub module_name: String,   // Module name
    pub ver: String,           // Module version
    pub res: i64,              // Reserved tokens
    pub bal: i64,              // User balance
    pub iat: i64,              // Issued at (Unix timestamp)
    pub exp: i64,              // Expires at (Unix timestamp)
    pub skey: String,          // Session public key that signed the token (hex)
    pub skey_sig: String,      // Root's base64 signature certifying skey
}
}

Error Handling

All functions return Result<T, String> for easy integration with WIT exports.

Common errors:

  • "no registry root key is trusted: ..." — No root key is pinned yet; call configure_root_keys() for a local registry
  • "Session token verification failed" — Uncertified session key, bad signature, or expired token
  • "Insufficient credits" — User balance too low
  • "Session token mismatch" — Reserved tokens don’t match request

Testing

Since this targets wasm32-wasip2, tests must run in a WASM runtime with the billing host imports available.

For development, test in the context of a full chatty module build and runtime.

Security Best Practices

  1. Fail closed: Return errors if billing verification fails; never skip checks
  2. Over-estimate: Reserve more tokens than expected to avoid mid-execution failures
  3. Report accurately: Under-reporting costs users more (full reservation deducted after timeout)
  4. Use remote execution for high-value modules: Phase 4 Firecracker hosting eliminates local trust issues

License

Same as the parent chatty2 project.

Tools catalog

When to read this: Look up an LLM tool name, its category, or which source file implements it.

Hand-maintained in scripts/gen-docs-reference.sh; CI diffs it against every const NAME under crates/chatty-core/src/tools/ (make docs-check-reference), so a tool cannot be added without a row here. Re-run make docs-gen.

Tool nameCategorySource moduleNotes
list_toolsmetalist_tools_tool.rsAlways available
write_todosagentagent_todo_tool.rsAlways available
update_todoagentagent_todo_tool.rsAlways available
verify_completionagentagent_todo_tool.rsAlways available
read_skillmemoryread_skill_tool.rsAlways available
list_agentsagentslist_agents_tool.rsAlways available
invoke_agentagentsinvoke_agent_tool.rsAlways available
best_ofagentsbest_of_tool.rsA spec with swarm.best_of (the Crosscheck leaders)
send_messageagentssend_message_tool.rsDelegated workers only (a broker-made connection); messages the owner
list_mcp_servicesmcplist_mcp_tool.rsWhen MCP enabled
fetchwebfetch_tool.rsHTTP fetch
read_filefilesystemfilesystem_tool.rsfs_read
read_binaryfilesystemfilesystem_tool.rsfs_read
list_directoryfilesystemfilesystem_tool.rsfs_read
glob_searchfilesystemfilesystem_tool.rsfs_read
doc_retrieversearchdoc_retriever_tool.rsCHATTY_ENABLE_DOC_RETRIEVER
write_filefilesystemfilesystem_write_tool.rsfs_write + approval
final_answerfilesystemfilesystem_write_tool.rsfs_write
create_directoryfilesystemfilesystem_write_tool.rsfs_write
delete_filefilesystemfilesystem_write_tool.rsfs_write + approval
move_filefilesystemfilesystem_write_tool.rsfs_write
apply_difffilesystemfilesystem_write_tool.rsfs_write
shell_executeshellshell_tool.rsapproval
shell_set_envshellshell_tool.rs
shell_cdshellshell_tool.rs
shell_statusshellshell_tool.rs
git_statusgitgit_tool.rs
git_diffgitgit_tool.rs
git_loggitgit_tool.rs
git_addgitgit_tool.rs
git_create_branchgitgit_tool.rs
git_switch_branchgitgit_tool.rs
git_commitgitgit_tool.rs
git_mergegitgit_tool.rsapproval
search_codesearchsearch_tool.rs
find_filessearchsearch_tool.rs
find_definitionsearchsearch_tool.rs
add_attachmentchatadd_attachment_tool.rs
read_exceldocumentsexcel_tool/feature: excel
write_exceldocumentsexcel_tool/feature: excel
edit_exceldocumentsexcel_tool/feature: excel
read_docxdocumentsdocx_tool/feature: docx
write_docxdocumentsdocx_tool/feature: docx
read_pptxdocumentspptx_tool/feature: pptx
write_pptxdocumentspptx_tool/feature: pptx
pdf_to_imagepdfpdf_to_image_tool.rsfeature: pdf
pdf_infopdfpdf_info_tool.rsfeature: pdf
pdf_extract_textpdfpdf_extract_text_tool.rsfeature: pdf
query_datadatadata_query_tool/feature: duckdb
describe_datadatadata_query_tool/feature: duckdb
profile_datadatadata_query_tool/feature: duckdb
file_structure_detectordatadata_query_tool/feature: duckdb
compile_typstmathtypst_tool.rsfeature: math-render
execute_codesandboxexecute_code_tool.rsMonty + Docker fallback
remembermemoryremember_tool.rsmemory enabled
save_skillmemorysave_skill_tool.rsmemory enabled
search_memorymemorysearch_memory_tool.rsmemory enabled
search_webwebsearch_web_tool.rs
browser_usewebbrowser_use_tool.rs
daytona_runsandboxdaytona_tool/Daytona cloud sandbox
publish_wasm_modulemodulespublish_module_tool.rs
create_chartvizchart_tool.rsRegistered via tool_collector
ask_useragentask_user_tool.rsClarifying questions; answered in the UI
terminal_readterminalterminal_read_tool.rsterminal_access; read-only view of the user’s tmux panes
terminal_runterminalterminal_run_tool.rsDesktop only; a dock tab shared as Read + run; every command approved by the human, auto-approve never applies
browser_navigatewebbrowser_tools.rsfeature: browser; localhost/file:// by default
browser_snapshotwebbrowser_tools.rsfeature: browser
browser_screenshotwebbrowser_tools.rsfeature: browser; PNG via add_attachment path
browser_consolewebbrowser_tools.rsfeature: browser
browser_networkwebbrowser_tools.rsfeature: browser
browser_resizewebbrowser_tools.rsfeature: browser
browser_clickwebbrowser_tools.rsfeature: browser; by snapshot ref; approval off localhost/workspace
browser_typewebbrowser_tools.rsfeature: browser; by snapshot ref; refuses password/card fields; never submits

Provider matrix

When to read this: Configure LLM backends, auth, or default model capabilities.

Source: crates/chatty-core/src/settings/models/providers_store.rs, crates/chatty-core/src/factories/agent_factory/provider_builder.rs.

Provider types

ProviderDisplay nameAuthRequired configDefault imagesDefault PDFTemperature default
openrouterOpenRouterAPI keyapi_key; optional base_urlyesyesper ModelConfig
ollamaOllamanoneoptional base_url (default http://localhost:11434)nonoalways applied
azure_openaiAzure OpenAIAPI key or Entra IDbase_url (resource endpoint) + credentialsyesnoper ModelConfig

OpenRouter

  • Gateway to 200+ upstream models (Anthropic, OpenAI, Google, Mistral, Meta, …).
  • Persisted ProviderType JSON is open_router (serde snake_case). Legacy values open_ai, open_a_i, anthropic, gemini, mistral deserialize as OpenRouter. The string openrouter is not accepted.
  • configured_providers() requires a non-empty api_key.
  • Rig client: rig_core::providers::openrouter::Client.
  • MCP tools are sanitized for OpenAI-compatible schemas before attachment.

Ollama

  • Local or remote Ollama instance; no API key required.
  • Included in configured_providers() even without api_key.
  • Default capabilities are (false, false); vision/PDF support is detected per model via /api/show (sync_service.rs) and stored in ModelConfig.
  • Rig client: rig_core::providers::ollama::Client.

Azure OpenAI

  • Auth via extra_config.auth_method: api_key (default) or entra_id.
  • configured_providers() requires non-empty base_url and (api_key or Entra ID).
  • Endpoint URLs are normalized (trailing slashes stripped, /openai/deployments/... paths truncated).
  • Per-model api_version in ModelConfig.extra_params; default from AZURE_DEFAULT_API_VERSION.
  • Entra ID tokens are cached in AzureTokenCache when available.
  • Rig client: rig_core::providers::azure::Client.

Model capabilities (ModelConfig)

FieldPurpose
supports_imagesAllow image attachments in chat
supports_pdfAllow PDF attachments
supports_temperatureShow temperature slider; skipped for reasoning models

New models inherit ProviderType::default_capabilities(). Ollama overrides per model after sync.

TUI direct-connect flags (bypass Settings UI)

FlagMaps toNotes
--ollama [URL]Ollama providerAuto-discovers models from /api/tags
--openai-compat-url URLOpenRouter provider typevLLM, llama.cpp, LM Studio, etc.; optional --api-key

See CLI flags for full chatty-tui options.

Slash commands

When to read this: Look up chat input / commands. GPUI and TUI do not share a command table — see Add a slash command.

Sources: crates/chatty-gpui/src/chatty/views/chat_input/slash.rs, crates/chatty-gpui/src/chatty/controllers/app_controller/slash_commands.rs, crates/chatty-tui/src/engine/commands.rs.

CommandActionGPUITUI
/clearStart new conversationYesYes
/newStart new conversationYesYes
/compactSummarize oldest half of historyYesYes
/contextShow token/context usageYesYes
/copyCopy last assistant responseYesYes
/cwdShow working directoryYesYes
/cd [dir]Change per-chat working directoryYesYes
/add-dir <dir>Add workspace directoryYesYes
/agent [name] <prompt>Run an agent spec on the local roster or a named A2A agent, else a local sub-agentYesYes
/agentsList the agents /agent and invoke_agent reach, and the spec files left out—Yes
/swarmPrint this conversation’s latest swarm tree: agent · model · status · spend (TB-5, AGE-667)—Yes
/agents runningEvery live agent of the conversation’s swarm: agent · conversation · chain · running or waiting on you · elapsed · spend (TB-6, AGE-748)—Yes
/stop <agent>Stop one agent of the swarm and every agent it started; the rest keeps running (TB-7, AGE-749)—Yes
/msg <agent> <text>Message a running agent; it reads the message at its next tool call (TM-5, AGE-750)—Yes
/online [url|off]Where this conversation runs; move it to a chatty-server or back. Refuses unless hosted_conversations_enabled (developer setting, AGE-308)—Yes
/verboseToggle folded tool-call summaries vs full payloads (Ctrl+R)—Yes
/paste [n]Print the full text of an elided long paste—Yes
/now <text>Cancel the running turn and send <text> next—Yes
/unqueueTake back the message queued last—Yes
/export [path]Export the conversation as ATIF; default path is <config dir>/chatty/exports/<conversation-id>.atif.json—Yes
/model [query]Switch / list models—Yes
/tools [name]Open tool picker or toggle by name—Yes
/modules …Module runtime settings—Yes
/updateCLI auto-update—Yes
/quit /exitQuit the application—Yes

Skills from .claude/skills/ appear in both pickers with a skill badge.

CLI reference (chatty-tui)

When to read this: Scripting, headless mode, or direct provider flags.

Build the binary to refresh this page from --help:

cargo build -p chatty-tui
make docs-gen

Common flags

FlagPurpose
--headlessNon-interactive single-shot mode
--pipeRead stdin, write stdout (pipeline / sub-agent)
--ollama URLUse local Ollama base URL
--model IDModel id for direct provider modes
-m, --messageUser message (headless)
--helpFull clap help text

Environment variables

When to read this: Debug path/config issues or run Chatty in non-standard environments.

VariablePurposeDefault
CHATTY_DATA_DIROverride user data directory (AppImage)Platform default
CHATTY_DEBUG_UIEnable chat view debug overlayunset
CHATTY_PDFIUM_LIB_DIRPath to pdfium native libraryauto-detect + cache
CHATTY_ENABLE_DOC_RETRIEVEREnable doc_retriever toolunset = disabled
CHATTY_SECRET_STOREWhere provider keys, MCP/A2A tokens and Hive tokens are kept (AGE-741): auto, keychain (refuse the file fallback), file (owner-only secrets.json in the config dir)auto = OS keychain, else secrets.json with a warning
CHATTY_INFER_MISSING_ANSWERHeadless: infer missing final answerunset
CHATTY_PROGRESS prefixSub-agent stderr progress linesprotocol constant
XDG_RUNTIME_DIRRequired for GPUI on Linux/X11/tmp/...
XDG_DATA_HOMELinux data dir override~/.local/share
GITHUB_TOKENAuto-updater GitHub API; REST fallback for the branch’s PR status when gh is unavailableunset
GH_TOKENSecond choice after GITHUB_TOKEN for the PR-status REST fallbackunset
OLLAMA_NUM_PARALLELRead for a loopback Ollama provider to size its delegation endpoint budget (AGE-305)unset = default_endpoint_budget
ECHO_PLUGIN_WASMTests: path to the echo plugin when it is not the staged fixture (AGE-176)target/wasm-fixtures/echo/echo.wasm
UPDATE_GOLDENSTests: rewrite the stream-fixture goldens instead of diffing themunset
APPIMAGEAppImage self-update pathset by AppImage
DISPLAYX11 display for GPUI:0 / :1
HOMEUser home for pathsrequired
PATHShell tool environmentinherited
SHELLShell for subprocesses/bin/bash
PDFIUM_LIB_DIRCompile-time pdfium pathbuild script

audience: [contributor, agent] source_files:

  • crates/chatty-core/src/settings/repositories/mod.rs
  • crates/chatty-core/src/settings/repositories/generic_json_repository.rs
  • crates/chatty-core/src/settings/repositories/oauth_credential_json_repository.rs
  • crates/chatty-core/src/settings/repositories/module_settings_json_repository.rs
  • crates/chatty-core/src/services/mcp_token_store.rs
  • crates/chatty-core/src/settings/models/ related:
  • ./env-vars.md
  • ./provider-matrix.md
  • ./singleton-inventory.md
  • ../adrs/settings-integration-map.md

Settings schema reference

When to read this: Find the JSON file, Rust model, key fields, and defaults for a persisted setting. Source of truth: crates/chatty-core/src/settings/repositories/ + settings/models/.

Field tables are hand-maintained in scripts/gen-docs-reference.sh; CI checks the repository file names and the ExecutionSettingsModel / ModelConfig field lists against the source (make docs-check-reference).

Config vs data directories

JSON settings live under dirs::config_dir()/chatty (used by GenericJsonRepository). WASM module binaries and agent memory live under dirs::data_dir()/chatty.

KindResolverLinuxmacOSWindows
Config (this page)dirs::config_dir()/chatty~/.config/chatty/ ($XDG_CONFIG_HOME/chatty)~/Library/Application Support/chatty/%APPDATA%\chatty\
Datadirs::data_dir()/chatty~/.local/share/chatty/ ($XDG_DATA_HOME/chatty)~/Library/Application Support/chatty/%APPDATA%\chatty\

On macOS and Windows, config and data resolve to the same Application Support / AppData folder. On Linux they differ (~/.config vs ~/.local/share).

Persistence behaviour

  • Single-object files (load / save): missing file → T::default().
  • List files (load_all / save_all): missing file → [].
  • Settings JSON writes are atomic: pretty-printed JSON to a sibling *.json.<pid>.<seq>.tmp, then rename (GenericJsonRepository). Saves of one file are serialized and the newest call wins; an older save that lands late is dropped rather than written over a newer one.
  • Serde field names are snake_case unless a table notes otherwise.
  • #[serde(skip)] fields are runtime-only and never written to disk.

File inventory

FileShapeModelRepository
general_settings.jsonobjectGeneralSettingsModelGeneralSettingsJsonRepository
execution_settings.jsonobjectExecutionSettingsModelExecutionSettingsJsonRepository
search_settings.jsonobjectSearchSettingsModelSearchSettingsJsonRepository
training_settings.jsonobjectTrainingSettingsModelTrainingSettingsJsonRepository
user_secrets.jsonobjectUserSecretsModelUserSecretsJsonRepository
hive_settings.jsonobjectHiveSettingsModelHiveSettingsJsonRepository
extensions.jsonobjectExtensionsModelExtensionsJsonRepository
token_tracking.jsonobjectTokenTrackingSettingsTokenTrackingJsonRepository
module_settings.jsonobjectModuleSettingsModelModuleSettingsJsonRepository
providers.jsonarrayProviderConfigJsonFileRepository
models.jsonarrayModelConfigJsonModelsRepository
mcp_servers.jsonarrayMcpServerConfigJsonMcpRepository
a2a_agents.jsonarrayA2aAgentConfigA2aJsonRepository
mcp_oauth_<sanitized>.jsonobjectopaque StoredCredentials JSONlive: FileCredentialStore; also JsonOAuthCredentialRepository

Secrets in bold must never appear in logs, traces, or LLM-facing tool output.

Secrets kept outside the JSON (AGE-741). In providers.json, mcp_servers.json, a2a_agents.json and hive_settings.json the secret fields marked (store) below are not written to the file. The model keeps the plain field; on disk it becomes <field>_ref ("api_key_ref": "provider/<name>", mcp/<name>, a2a/<name>, hive/token, hive/refresh_token) and the value lives in the SecretStore (settings/secret_store.rs): the OS keychain under the service chatty:<config dir>, or, when no keychain answers, the owner-only secrets.json in the config dir (one warning line; CHATTY_SECRET_STORE=keychain refuses the fallback, =file forces it). Docs examples use "****" or omit the field.


general_settings.json — GeneralSettingsModel

Source: settings/models/general_model.rs.

FieldTypeDefaultNotes
font_sizef3214.0UI font size (px)
theme_nameOption<String>nullTheme folder name; null = built-in default
dark_modeOption<bool>nullnull = follow OS / theme default
{ "font_size": 14.0, "theme_name": null, "dark_mode": null }

execution_settings.json — ExecutionSettingsModel

Source: settings/models/execution_settings.rs. Master toggle enabled is opt-in (false) for security.

FieldTypeDefaultNotes
enabledboolfalseMaster code-execution toggle
approval_modeApprovalMode"AutoApproveSandboxed"JSON enum: AlwaysAsk, AutoApproveSandboxed, AutoApproveAll
workspace_dirOption<String>nullAbsolute path required for filesystem / git tools
filesystem_read_enabledbooltrueRequires workspace_dir
filesystem_write_enabledbooltrueRequires workspace_dir + approval
fetch_enabledbooltrueBuilt-in read-only HTTP GET
git_enabledboolfalseOpt-in; workspace must be a git repo
browser_enabledboolfalseOpt-in built-in browser tools; may download a pinned Chrome on first use
allow_private_network_accessboolfalseWith internet access on, let the browser reach private/internal IPs (RFC-1918) on your own network; the SSRF guard refuses them otherwise. Cloud-metadata 169.254.0.0/16 stays refused regardless (AGE-459)
execute_code_enabledboolfalseExposes execute_code to the model
docker_code_execution_enabledboolfalseDocker fallback for non-Monty code
docker_hostOption<String>nullSocket / URI; null = auto-detect
timeout_secondsu3230Execution timeout
max_output_bytesusize5120050 KiB cap
network_isolationboolfalseSandbox network isolation when available
sandbox_requiredboolfalseRefuse to start a shell outside the sandbox instead of falling back to the host (AGE-918)
max_agent_turnsu320Tool-call rounds per response; 0 = no cap (headless runs get a wall-clock budget instead, chatty-tui --max-duration)
memory_enabledbooltrueremember / search_memory
warn_on_external_agentboolfalsesay so before invoke_agent sends a prompt outside the fleet (ADR-0011 C5)
embedding_enabledboolfalseSemantic memory search
embedding_providerOption<ProviderType>nullIndependent of chat provider
embedding_modelOption<String>nulle.g. text-embedding-3-small
hosted_conversations_enabledboolfalseDeveloper-only: offers the per-conversation move between local and hosted (AGE-308)
tool_loadingToolLoading"all"How tools are offered to the model. JSON: all (every allowed tool on every request) or dynamic (a small core, plus groups the model loads with load_tools). Omitted from the file while all. CLI override: chatty-tui --tool-loading
ask_user_enabledbooltrueOffer the ask_user tool (ADR-0011 C7). Omitted from the file while true. CLI override: chatty-tui --disable ask-user (unattended runs, so a stray call fails fast instead of blocking)
terminal_accessboolfalseOffer the read-only terminal_read tool over the user’s tmux panes (AGE-577); registered only while a tmux server has panes. Omitted from the file while false. CLI: chatty-tui --enable terminal

ApprovalMode has no rename_all — JSON uses the Rust variant names above.


search_settings.json — SearchSettingsModel

Source: settings/models/search_settings.rs. Also stores Browser Use and Daytona keys (not only web search).

FieldTypeDefaultNotes
enabledboolfalseMaster web-search toggle
active_providerSearchProvider"Tavily"JSON: Tavily or Brave
tavily_api_keyOption<String>nullSecret
brave_api_keyOption<String>nullSecret
max_resultsusize5Search hit cap
browser_use_enabledbooltrueKey alone is enough to activate; set false to disable without deleting the key
browser_use_api_keyOption<String>nullSecret
daytona_enabledbooltrueSame pattern as browser-use
daytona_api_keyOption<String>nullSecret
rerank_urlOption<String>nullCohere/Jina-style /rerank endpoint; keyless search only, needs rerank_model too
rerank_modelOption<String>nullModel name sent to rerank_url
{
  "enabled": false,
  "active_provider": "Tavily",
  "max_results": 5,
  "browser_use_enabled": true,
  "daytona_enabled": true
}

training_settings.json — TrainingSettingsModel

Source: settings/models/training_settings.rs. Both flags opt-in.

FieldTypeDefaultNotes
atif_auto_exportboolfalseATIF JSON after each completed assistant turn
jsonl_auto_exportboolfalseJSONL (SFT + DPO) after each completed turn

user_secrets.json — UserSecretsModel

Source: settings/models/user_secrets_store.rs. Injected into shell sessions as env vars; never sent to the LLM.

FieldTypeDefaultPersisted?
secrets[{key, value}][]yes — values are secrets
revealed_keysHashSet<String>emptyno (#[serde(skip)]; UI-only)
{ "secrets": [ { "key": "EXAMPLE_TOKEN", "value": "****" } ] }

hive_settings.json — HiveSettingsModel

Source: settings/models/hive_settings.rs.

FieldTypeDefaultNotes
registry_urlStringhttp://localhost:8080DEFAULT_REGISTRY_URL
runner_urlStringhttp://localhost:8081DEFAULT_RUNNER_URL
tokenOption<String>nullOne-hour access JWT. Secret (store: token_ref → hive/token). is_logged_in() = token present
refresh_tokenOption<String>null30-day refresh token, rotated on every refresh. Secret (store: refresh_token_ref → hive/refresh_token)
expires_atOption<DateTime>nullWhen token expires
usernameOption<String>nullCached for UI
emailOption<String>nullCached for re-login

extensions.json — ExtensionsModel

Source: settings/models/extensions_store.rs. Unified install list for MCP, WASM, and A2A.

FieldTypeDefaultPersisted?
extensions[InstalledExtension][]yes
mcp_auth_statusesmapemptyno (#[serde(skip)])
a2a_statusesmapemptyno (#[serde(skip)])

InstalledExtension:

FieldTypeDefaultNotes
idStringrequiredUnique slug
display_nameStringrequiredUI name
descriptionStringrequired
kindExtensionKindrequiredInternally tagged kind: mcp (flattened McpServerConfig), wasm, a2a (flattened A2aAgentConfig)
sourceExtensionSourcerequiredInternally tagged type: hive (module_name, version) or custom
pricing_modelOption<String>nullHive marketplace classification
enabledbooltrue

token_tracking.json — TokenTrackingSettings

Source: settings/models/token_tracking_settings.rs. The compaction share — ADR-0007’s utilisation key. Ratio fields (high_threshold, critical_threshold) are fractions of the model’s context window, never a character count.

FieldTypeDefaultNotes
enabledbooltrueShow the token context bar
response_reserveusize4096Tokens reserved for model output
high_thresholdf640.70Ratio 0.0–1.0; bar turns amber
critical_thresholdf640.90Ratio 0.0–1.0; bar turns red
auto_summarizeboolfalseAuto-summarize on crossing critical_threshold
summarization_model_idOption<String>nullnull = use the conversation’s own model
cap_usdOption<f64>nullMonthly spend cap (USD); hive-only (AGE-416 / ADR-0010)

module_settings.json — ModuleSettingsModel

Source: settings/models/module_settings.rs. Load path (ModuleSettingsJsonRepository) runs normalize_module_dir(): empty values and the legacy .chatty/modules fallback become the platform data-dir default.

FieldTypeDefaultNotes
enabledboolfalseWASM module runtime
module_dirStringplatform data dir + /chatty/modulesLinux ~/.local/share/chatty/modules; macOS ~/Library/Application Support/chatty/modules; Windows %APPDATA%\chatty\modules. Last-resort fallback: .chatty/modules
gateway_portu168420Local protocol gateway
default_endpoint_budgetusize1Delegated workers the broker runs at once against one model endpoint when nothing more specific is known (ADR-0011 C6, AGE-305). Ollama’s OLLAMA_NUM_PARALLEL / a provider’s num_parallel beat it
endpoint_budgetsHashMap<String, usize>{}Per-endpoint overrides keyed by the server’s base URL (http://localhost:11434); beats the default and what the provider reports. Omitted when empty
virtual_agents[string][]The broker’s named workers (ADR-0011 C10, AGE-377), by agent spec name (AGE-614): each is <name>.toml in .chatty/agents/, the data directory’s chatty/agents/, or the presets. Agent objects (the old VirtualAgentConfig shape) fail to load. Empty = local-agent and every exposed spec (PL-U5). A --team run replaces this roster for that run only. Field table: Agents and specs
teamTeamConfig{}What the team shares: verification — a shell command run in each worker’s worktree at task end, whose exit code and tail go into the evidence envelope (ADR-0011 C12, AGE-406); isolate — each worker of a roster no team claims gets a git worktree of its own (default false, AGE-822). Omitted when empty

providers.json — [ProviderConfig]

Source: settings/models/providers_store.rs. See also Provider matrix.

FieldTypeDefaultNotes
nameStringrequiredDisplay name
provider_typeProviderTyperequiredJSON: open_router, ollama, azure_openai. Serde rename_all = "snake_case" on the variant OpenRouter yields open_router — the string openrouter does not deserialize. Legacy aliases open_ai, open_a_i, anthropic, gemini, mistral deserialize as OpenRouter
api_keyOption<String>omitted if nullSecret. Never expose to the LLM (store: api_key_ref → provider/<name>)
base_urlOption<String>omitted if nullOllama / Azure / OpenAI-compat
extra_configMap<String, String>{} (omitted if empty)Azure: auth_method = api_key (default) or entra_id

configured_providers(): Ollama always; Azure needs non-empty base_url plus API key or Entra ID; others need a non-empty API key.


models.json — [ModelConfig]

Source: settings/models/models_store.rs.

FieldTypeDefaultNotes
idStringrequiredInternal UUID (not the API model name)
nameStringrequiredUI label
provider_typeProviderTyperequiredSame enum as providers (open_router / ollama / azure_openai)
model_identifierStringrequiredAPI / Ollama model id
temperaturef321.0
preambleString""System prompt / GEPA target
max_tokensOption<i32>omitted if null
top_pOption<f32>omitted if null
extra_paramsMap<String, String>{} (omitted if empty)Azure: api_version (default 2025-03-01-preview)
cost_per_million_input_tokensOption<f64>omitted if nullUSD
cost_per_million_output_tokensOption<f64>omitted if nullUSD
cost_per_million_cache_read_tokensOption<f64>omitted if nullUSD; falls back to the input rate when unset
cost_per_million_cache_write_tokensOption<f64>omitted if nullUSD; falls back to the input rate when unset
supports_imagesboolfalseNew models inherit ProviderType::default_capabilities()
supports_pdfboolfalse
supports_temperaturebooltrueOff for some reasoning models
max_context_windowOption<i32>omitted if nullToken-bar budget
sourceModelSource"User"User (added by hand or from a catalogue pick) or Sync (owned by a provider sync)
is_favoriteboolfalseStarred rows sort to the top of the roster
is_defaultboolfalseModel new conversations start with

mcp_servers.json — [McpServerConfig]

Source: settings/models/mcp_store.rs. HTTP MCP endpoints only (the app does not spawn stdio servers). Writes are serialized with MCP_WRITE_LOCK.

FieldTypeDefaultNotes
nameStringrequiredUnique id
urlStringrequirede.g. http://localhost:3000/mcp
api_keyOption<String>omitted if nullBearer token. Secret. LLM-facing copies must not include the real value (store: api_key_ref → mcp/<name>)
enabledbooltrue
is_moduleboolfalse (omitted when false)Auto-registered WASM gateway entry

Runtime McpAuthStatus is not persisted.

MASKED_API_KEY_SENTINEL is "****": on edit, that string means “keep the stored key”. There is no env map on McpServerConfig in current source.


a2a_agents.json — [A2aAgentConfig]

Source: settings/models/a2a_store.rs.

FieldTypeDefaultNotes
nameStringrequiredAlso the /agent <name> token
urlStringrequiredA2A base URL
api_keyOption<String>omitted if nullBearer token. Secret (store: api_key_ref → a2a/<name>)
enabledbooltrue
skills[String][] (omitted if empty)Cached from the remote agent card

Runtime A2aAgentStatus is not persisted.


mcp_oauth_<sanitized>.json — OAuth credentials

One file per MCP server name in the config dir. Non-alphanumeric characters in the server name (except - _) become _. Example: server my server/with:special.chars → mcp_oauth_my_server_with_special_chars.json.

The live writer is FileCredentialStore (mcp_token_store.rs), used by McpService. JsonOAuthCredentialRepository uses the same filenames but is not registered in init_repositories(). FileCredentialStore writes the JSON directly (not the temp-file rename used by settings repos).

Shape is opaque rmcp::StoredCredentials JSON (client_id, token_response, granted_scopes, …). Do not paste real token files into issues, PRs, or docs. Corrupt files are deleted on load.


Not persisted as JSON (yet)

StoreTypical pathNotes
TokenTrackingSettingsin-memory GPUI globalDefaults: enabled true, response_reserve 4096, high_threshold 0.70, critical_threshold 0.90, auto_summarize false, summarization_model_id omitted. Source comment: JSON persistence is a follow-up.
Conversationsconfig dir chatty/conversations.dbSQLite, not settings JSON
Provider keys, MCP/A2A tokens, Hive tokensOS keychain, service chatty:<config dir>; fallback config dir chatty/secrets.jsonReferenced from the JSON by <field>_ref (AGE-741)
Agent memory / skillsdata dir chatty/memvid + skill files
WASM module binariesdata dir chatty/modules/See module_dir
Pdfium native libdata dir chatty/lib/See env-vars
Math SVG cachedata dir chatty/math_cache/

Planned research settings (FlowSettingsModel, playbook store) are not implemented — see settings integration map.

openrouter_curated.json also lives in the config dir (GPUI OpenRouter catalog override: [{id, name}, …]). It is not a settings repository.

GPUI event catalog

When to read this: Wire a new entity subscriber, debug event flow, or find which component emits a variant.

All entity-to-entity communication uses EventEmitter + cx.subscribe() (see entity-communication). This table lists the typed events in chatty-gpui and chatty-core.

Event enumVariantKey fieldsEmitterTypical subscriber
StreamManagerEventStreamStartedconversation_idStreamManagerChattyApp
TextChunkconversation_id, textStreamManagerChattyApp → ChatView
ToolCallStartedconversation_id, id, nameStreamManagerChattyApp → ChatView
ToolCallInputconversation_id, id, argumentsStreamManagerChattyApp → ChatView
ToolCallResultconversation_id, id, resultStreamManagerChattyApp → ChatView
ToolCallErrorconversation_id, id, errorStreamManagerChattyApp → ChatView
ApprovalRequestedconversation_id, id, command, is_sandboxedStreamManagerChattyApp → ChatView
ApprovalResolvedconversation_id, id, approvedStreamManagerChattyApp → ChatView
ClarificationRequestedconversation_id, id, questionsStreamManagerChattyApp → ChatView (ask-user card)
TokenUsageconversation_id, input_tokens, output_tokens, cache_read_tokens, cache_write_tokensStreamManagerChattyApp
TurnProgressconversation_id, turn, tokensStreamManagerChattyApp → ChatInputState (live turn/token/elapsed indicator)
SwarmTreeChangedconversation_id, traceStreamManagerChattyApp → ChatView (swarm tree under each delegation row, TB-4); RunningAgentsModel (every conversation, TB-6)
StreamEndedconversation_id, status, token_usage, trace_json, …StreamManagerChattyApp (finalization); RunningAgentsModel (drops the conversation’s live agents)
RunningAgentsEventCountsChanged—RunningAgentsModelChattyApp (redraws the footer’s running-agents chip, TB-6)
SidebarEventNewChat—SidebarViewChattyApp
OpenSettings—SidebarViewChattyApp
SelectConversationString (id)SidebarViewChattyApp
DeleteConversationString (id)SidebarViewChattyApp
ExportConversationString (id)SidebarViewChattyApp
ToggleCollapsedboolSidebarViewChattyApp
LoadMore—SidebarViewChattyApp
ChatInputEventSendmessage, attachmentsChatInputStateChattyApp
ModelChangedString (model id)ChatInputStateChattyApp
Stop—ChatInputStateChattyApp
SlashCommandSelectedString (command)ChatInputStateChattyApp
WorkingDirChangedOption<PathBuf>ChatInputStateChattyApp
ChatViewEventFeedbackChangedhistory_index, feedbackChatViewChattyApp
RegenerateMessagehistory_indexChatViewChattyApp
ArtifactViewEventClosed—ArtifactViewChatView
PresentationChanged—ArtifactViewChatView
BrowserControlChangedtaken, urlArtifactViewChatView (activity trail)
TerminalDockEventHidden—TerminalDockChatView (refocuses the composer)
TerminalViewEventTitleChanged / Exited—TerminalViewTerminalDock (tab strip)
TraceEventToolCallStateChangedtool_id, old_state, new_stateSystemTraceViewChatView
ToolCallInputReceivedtool_idSystemTraceViewChatView
ToolCallOutputReceivedtool_id, has_outputSystemTraceViewChatView
ThinkingStateChangedold_state, new_stateSystemTraceViewChatView
ModelsNotifierEventModelsReady—ModelsNotifierChattyApp (startup)
AgentConfigEventRebuildRequired—AgentConfigNotifierSettings controllers, ChattyApp
ErrorNotifierEventNewError—ErrorNotifierChattyApp (toast/banner)

Source files: stream_manager.rs, sidebar_view.rs, chat_input/mod.rs, chat_view/mod.rs, transcript/artifact_view.rs, terminal/dock.rs, terminal/mod.rs, message_types.rs (TraceEvent), models_notifier.rs, agent_config_notifier.rs, error_notifier.rs. This table is hand-maintained; CI checks that every EventEmitter enum and every StreamManagerEvent variant has a row (make docs-check-reference).

Adding a new event: define an enum on the emitter entity, impl EventEmitter<YourEvent>, subscribe in the parent (usually ChattyApp or ChatView). Never use Arc<dyn Fn> callbacks between entities. Step-by-step: Add a desktop GPUI view.

Process-global singleton inventory

When to read this: Find where shared state lives before adding a new global, repository, or OnceLock.

Canonical comment block: crates/chatty-core/src/lib.rs (top of file). This page expands it with accessors and rationale. Hand-maintained; CI checks that every OnceLock/LazyLock static in chatty-core (regex and font caches excepted) has a row (make docs-check-reference).

Service singletons (chatty-core/src/lib.rs)

NameTypeInitRationale
MCP_UPDATE_SENDEROnceLock<mpsc::Sender<Vec<McpServerConfig>>>StartupCross-cutting MCP config change notifications
MCP_SERVICEOnceLock<McpService>StartupTool context has no UI handle; needs shared MCP client

Repository registry (chatty-core/src/lib.rs)

AccessorJSON / storagePurpose
provider_repository()providersLLM provider configs + API keys
general_settings_repository()general settingsTheme, font, UI prefs
models_repository()modelsPer-model capabilities
mcp_repository()MCP serversMCP service definitions
a2a_repository()A2A agentsWASM / remote agent registry
execution_settings_repository()executionWorkspace, approval mode, sandbox
search_settings_repository()searchWeb search provider keys
training_settings_repository()trainingFine-tuning prefs
user_secrets_repository()secretsUser-provided secret values
module_settings_repository()modulesWASM module settings
hive_settings_repository()hiveHive registry / billing
extensions_repository()extensionsBrowser extension config
token_tracking_repository()token trackingCompaction share / context-window thresholds (ADR-0007)

All repositories initialize via init_repositories() once at startup. Use accessor functions — never read REPOSITORY_REGISTRY directly.

Domain-local singletons (near usage)

NameLocationTypePurpose
GLOBAL_WRITE_APPROVAL_MODEtools/filesystem_write_tool.rsOnceLock<Mutex<ApprovalMode>>Write-tool approval without coupling to UI
REGISTRYservices/browser/registry.rsLazyLock<Mutex<HashMap<String, Arc<BrowserManager>>>>conversation_id → BrowserManager so the transcript can dock a live browser panel
AZURE_TOKEN_CACHEfactories/agent_factory/provider_builder.rsOnceLock<Option<AzureTokenCache>>Azure OAuth token reuse
PLUGIN_ENGINEtools/plugin_tool.rsOnceLock<wasmtime::Engine>One Wasmtime engine for every spec plugin instance (PL-U2); limits are per store
LLM_CLIENTservices/http_client.rsLazyLock<reqwest::Client>Shared connection pool behind llm_client(), handed to every provider agent builder
MCP_WRITE_LOCKsettings/models/mcp_store.rsLazyLock<Mutex<()>>Serialize MCP JSON writes
WRITE_SLOTSsettings/repositories/generic_json_repository.rsLazyLock<Mutex<HashMap<PathBuf, WriteSlot>>>Per settings file, the last save written behind an async lock, so overlapping saves of one file never interleave and an older save never lands over a newer one (AGE-562)
PATH_AUGMENTEDauth/azure_auth.rsOnceLock<()>One-time PATH fix for Azure CLI
LIVE_SANDBOXESsandbox/manager.rsLazyLock<Mutex<HashMap<u64, SandboxMap>>>Holds every live SandboxManager’s container map so shutdown_all() can destroy them at process exit — a manager’s Drop can only spawn a detached task, which dies with the runtime
FONTSservices/math_renderer_service.rsLazyLock<(Vec<Font>, FontBook)>Typst’s embedded fonts parsed once per process; every math compile used to re-parse all of them in MathWorld::new (AGE-394)

Design rule: service and repository singletons stay centralized in lib.rs; domain-local OnceLocks stay in the module that owns the behavior to avoid coupling unrelated code.

GPUI globals (chatty-core + gpui-globals feature)

UI frontends store WeakEntity<T> handles in types implementing gpui::Global (see chatty-core/src/gpui_globals.rs). Examples: ConversationsStore, ModelsModel, GlobalStreamManager, GlobalChattyApp. Prefer weak refs in globals to prevent circular ownership.

Not singletons (avoid confusing with globals)

Module-level LazyLock<Regex> and tokenizer caches (token_budget/counter.rs, renderer services) are immutable process caches, not application state. They do not require init_*() and are safe to lazy-init on first use.

WIT Interface Reference

Package: chatty:plugin@0.3.0
Source: wit/chatty-plugin.wit

This document describes the WIT (WebAssembly Interface Types) contract between chatty (the host) and WASM plugins (guests). Every chatty plugin targets the plugin-world world defined here.

A plugin contributes tools to a chatty agent. It never runs a loop of its own: the agent that loads it (an agent spec lists it under [[plugins]]) offers its tools to its model as <plugin>__<tool>, and calls invoke-tool when the model asks for one (PL-D1 option B). The same tools are served to external MCP clients at /mcp/{plugin} when the plugin’s module.toml sets [protocols] mcp = true.


Architecture Overview

┌──────────────────────────────────────────────────────────────────────┐
│                  chatty (host): an agent's harness                    │
│                                                                        │
│  ┌─────────┐  ┌──────────┐  ┌────────────┐  ┌────────┐  ┌──────────┐ │
│  │   llm   │  │  config  │  │  logging   │  │  file  │  │ billing  │ │
│  │ import  │  │  import  │  │  import    │  │ import │  │ import   │ │
│  └────┬────┘  └────┬─────┘  └─────┬──────┘  └───┬────┘  └────┬─────┘ │
│       │ one interface per capability (requested in metadata)  │       │
├───────┼────────────┼──────────────┼──────────────┼───────────┼───────┤
│       ▼            ▼              ▼              ▼           ▼       │
│  ┌──────────────────────────────────────────────────────────────┐    │
│  │                     WASM plugin (guest)                       │   │
│  │                                                                │   │
│  │  exports: plugin                                               │   │
│  │    • metadata() → name, version, requested capabilities, …     │   │
│  │    • list-tools() → definitions                                │   │
│  │    • invoke-tool(call) → result | typed error                  │   │
│  └────────────────────────────────────────────────────────────────┘  │
└────────────────────────────────────────────────────────────────────┘

Each host import is its own interface, one per capability, so the host links only the capabilities an agent granted (PL-U4). A plugin declares the ones it needs in metadata().requested-capabilities; an agent spec’s [[plugins]].grants names a subset (llm, config, logging, file, file:<root>, billing), and logging is always granted. An import the plugin was not granted is still satisfied, by a stub that refuses: llm, file and billing return Err("capability <x> not granted to this agent") to the guest, and config::get, which has no error channel, ends the call with that text as its reason. Either way the module instantiates and the refusal reaches the model in the tool result. A module served on its own by the gateway (no spec) is granted config (if it requests it) by default, and llm, file and billing only when the user granted them in Settings → Plugins (SEC-11). A component that never calls an import does not import it at all, so a plugin that only computes (like benford) imports nothing but logging.

Plugins are written against chatty-module-sdk: implement its Plugin trait and call export!(MyPlugin). Both are wit-bindgen’s own generated code, so the export names always match this file.


Shared Types (types interface)

role (enum)

enum role { system, user, assistant, tool }

The role of a message in an llm::complete conversation. tool is the result of a tool call, answering the assistant message that requested it.

tool-call (record)

record tool-call {
    id: string,         // unique id of this call
    name: string,       // tool name
    arguments: string,  // JSON-encoded arguments
}

A tool call the model asked for in an llm::complete reply.

message (record)

record message {
    role: role,
    content: string,
    tool-calls: list<tool-call>,  // the calls an assistant message made
    tool-call-id: option<string>, // for a tool message: the call it answers
}

A plugin that runs a short tool exchange of its own inside one tool call feeds the model’s calls back as an assistant message with tool-calls, then each result as a tool message whose tool-call-id names the call. The host sends them to the provider as a real assistant tool call and tool result, not as user text.

token-usage (record)

record token-usage {
    input-tokens: u32,
    output-tokens: u32,
}

completion-response (record)

record completion-response {
    content: string,
    tool-calls: list<tool-call>,
    usage: option<token-usage>,
}

What llm::complete returns. usage.input-tokens is the whole prompt (cached tokens included).

tool-definition (record)

record tool-definition {
    name: string,               // unique within the plugin, e.g. "reverse"
    description: string,        // shown to the model
    parameters-schema: string,  // JSON Schema of the arguments
}

The model sees the tool as <plugin>__<tool> (e.g. echo__reverse); the name must keep that under 64 characters of [a-zA-Z0-9_-], or the plugin fails to load into an agent.


Host Imports

These interfaces are provided by chatty. They are the only host capabilities a plugin has: no WASI environment, arguments, preopened directories or sockets.

llm — LLM Completion

interface llm {
    use types.{message, completion-response};
    complete: func(model: string, messages: list<message>, tools: option<string>) -> result<completion-response, string>;
}

Capability llm. A completion that goes through the same provider client the calling agent uses (chatty_core::services::plugin_llm::PluginLlmProvider), so OpenRouter, Ollama and Azure OpenAI (API key or Entra ID) all work, and every call’s usage is recorded as the plugin’s own line in the calling turn’s totals.

Parameters:

  • model — Empty ("") for the calling agent’s model, which is what most plugins want. Otherwise a model identifier or id that must match a model configured in the host; any other name is refused with an error and no request is sent.
  • messages — The conversation; the last message is the prompt.
  • tools — Optional JSON-encoded array of tool definitions for the model: the flat form (name, description, parameters) or the OpenAI wrapped form ({"type": "function", "function": {...}}).

Returns: the completion or an error message. The call is bounded by the plugin’s per-call deadline: past it the host stops waiting and returns deadline exceeded.

Example (pseudocode):

let messages = [
    { role: system, content: "Classify the sentiment.", tool-calls: [], tool-call-id: none },
    { role: user, content: "I love it", tool-calls: [], tool-call-id: none },
];
let response = llm::complete("", messages, none);
// response.content = "positive"

config — Configuration

interface config {
    get: func(key: string) -> option<string>;
}

Capability config. Read the plugin’s configuration: the string → string [config] table of its module.toml. A non-string value there is a manifest error.

[config]
api-key = "sk-..."
threshold = "0.8"

Parameters:

  • key — The configuration key to look up.

Returns: option<string> — The value, or none if the key is not set.

Example (pseudocode):

let api_key = config::get("api-key");       // some("sk-...")
let threshold = config::get("threshold");    // some("0.8")
let missing = config::get("nonexistent");    // none

file — Sandboxed File Reads

interface file {
    read-bytes: func(path: string) -> result<list<u8>, string>;
}

Capability file. Read a file under the plugin’s file root: the directory its module.toml grants with

[files]
root = "weights"   # relative to the module directory

The root is host-set (the runtime’s ModuleManifest::with_weights_root), never a [config] key: a config value named weights_root grants nothing. The plugin reads it only when its agent grants file; file:<root> grants an absolute <root> instead. A plugin without [files] and without a file:<root> grant can read no files. Most plugins never need this; ML inference plugins use it to load weights once at startup.

Parameters:

  • path — Relative to the file root. / and \ both separate components.

Returns: the file’s bytes, or an error when:

  • the plugin has no file root;
  • path is empty, absolute, has a drive letter (:), or a .. component;
  • the path’s resolved location (both sides canonicalized, symlinks followed) is outside the root — a symlink that stays inside the root is fine;
  • it is not a regular file, or is over 256 MiB (MAX_FILE_READ_BYTES, checked before reading);
  • the file is missing or unreadable.

Error text never contains host paths. The read counts against the call’s wall-clock limit.

Example (pseudocode):

let weights = file::read-bytes("model/weights.bin");   // ok([..])
let escape = file::read-bytes("../module.toml");       // err("... `..` component rejected ...")

logging — Structured Logging

interface logging {
    log: func(level: string, message: string);
}

Capability logging, always granted. Emit log messages that appear in the host’s log output (its tracing subscriber; nothing is queued for a caller).

Parameters:

  • level — Log level: "trace", "debug", "info", "warn", or "error".
  • message — The log message.

Example (pseudocode):

logging::log("info", "Starting code review...");
logging::log("debug", "Analyzing 42 files");
logging::log("error", "Failed to parse input: unexpected token");

billing — Paid Plugin Sessions

interface billing {
    record session-info {
        token: string,
        balance-tokens: s64,
        reserved-tokens: s64,
        pricing-model: string,
    }

    acquire-session: func(estimated-tokens: s64) -> result<session-info, string>;
    report-usage: func(input-tokens: s64, output-tokens: s64) -> result<_, string>;
}

Capability billing. Only paid plugins use it; a component that never calls an import does not import it at all. A paid plugin calls acquire-session before doing work — the host asks Hive to reserve credits and returns a signed session token — then report-usage once the work is done, so Hive can settle the reservation against actual usage.

hive-billing-sdk calls these through chatty-module-sdk’s billing module (it generates no bindings of its own), so a plugin can depend on both crates.

session-info.token is a Hive-signed EdDSA (Ed25519) JWT. It carries the public key that signed it (skey) and the registry root’s signature over that key (skey_sig). hive-billing-sdk checks skey_sig against the registry root public keys the plugin trusts (the compiled production list, or configure_root_keys for a local registry), then the token’s signature under skey, then its expiry and reservation. Only public keys are embedded, so there is nothing in the plugin worth extracting, and a host cannot forge a token: it holds no key the roots certify. HS256 tokens are refused (CX-0b, AGE-823).

Parameters (acquire-session):

  • estimated-tokens — estimated token usage for this invocation.

Parameters (report-usage):

  • input-tokens / output-tokens — actual tokens consumed.

Guest Export

plugin — Plugin Interface

interface plugin {
    use types.{tool-definition, token-usage};

    enum capability { llm, config, logging, file, billing }

    record config-key { name: string, description: string, required: bool }

    record plugin-metadata {
        name: string,
        version: string,
        description: string,
        requested-capabilities: list<capability>,
        config-keys: list<config-key>,
    }

    record tool-call-request {
        name: string,
        arguments-json: string,
        call-id: string,
        caller: option<string>,
    }

    record tool-result {
        content: string,
        usage: option<token-usage>,
    }

    enum tool-error-kind { unknown-tool, invalid-arguments, denied, failed }

    record tool-error { kind: tool-error-kind, message: string }

    metadata: func() -> plugin-metadata;
    list-tools: func() -> list<tool-definition>;
    invoke-tool: func(call: tool-call-request) -> result<tool-result, tool-error>;
}

metadata

Who the plugin is and what it asks the host for. name and version match [module] in its module.toml. requested-capabilities is what an agent spec will be able to grant it: a grant it did not request fails the agent’s build as a spec error (plugin is granted, which it does not request), and an import it was not granted answers capability <x> not granted to this agent (PL-U4); the host reads it at load from an instance granted nothing; config-keys names the config::get keys it reads. Hive validates this at publish (PL-H7). Budget: 1 s.

list-tools

Every tool the plugin provides. Read once, when an agent loads the plugin. Budget: 1 s.

invoke-tool

Run one tool call.

  • name — a name from list-tools.
  • arguments-json — the model’s arguments object, JSON-encoded once, exactly what the tool’s parameters-schema describes. A tool reads its own fields out of it.
  • call-id — an id for this call.
  • caller — the calling agent’s name, when the host knows it.

Returns tool-result { content, usage } — content is what the model sees (JSON or plain text); usage is the plugin’s own account of model use, informational (the host already counts every llm::complete call) — or a tool-error:

kindMeaning
unknown-toolNo tool of that name
invalid-argumentsThe arguments did not parse or fit the schema
deniedA host capability refused (not granted, no credits, a path outside the file root)
failedThe tool ran and failed

The model reads the error as <kind>: <message>, and the turn goes on. A limit (fuel, deadline, memory, output) or a trap is not a tool-error: the host reports it the same way, and the next call runs on a fresh instance after a trap.

Example (Rust, with chatty-module-sdk):

#![allow(unused)]
fn main() {
fn invoke_tool(call: ToolCallRequest) -> Result<ToolResult, ToolError> {
    match call.name.as_str() {
        "reverse" => {
            let args: serde_json::Value = serde_json::from_str(&call.arguments_json)
                .map_err(|e| ToolError::invalid_arguments(e.to_string()))?;
            let input = args["input"].as_str().unwrap_or_default();
            Ok(ToolResult::text(input.chars().rev().collect::<String>()))
        }
        other => Err(ToolError::unknown_tool(other)),
    }
}
}

World

world plugin-world {
    import llm;
    import config;
    import logging;
    import file;
    import billing;

    export plugin;
}

Resource Limits

Every call into a guest export (metadata, list-tools, invoke-tool) runs inside a sandboxed Wasmtime instance under per-call limits (crates/chatty-wasm-runtime/src/limits.rs, ResourceLimits). Fuel and the wall-clock deadline are reset before each call, so a long-lived plugin never runs out of a lifetime budget. The defaults below are also the host ceilings: a plugin manifest’s [resources] section may only lower a limit, never raise it — a larger value is clamped down to the ceiling.

LimitDefault = ceilingEnforcementError
Fuel10¹² units per callWasmtime fuel (≈1 unit per Wasm instruction)fuel exhausted
Wall clock60 s per call, host time includedEpoch interruption (10 ms ticks); host imports (llm, file, billing) and WASI clock waits (sleep) stop waiting at the deadlinedeadline exceeded
Memory256 MiBStore memory limitermemory limit
Output1 MiB per callSize of each export’s return valueoutput too large

metadata and list-tools get a fixed 1 s wall-clock budget regardless of the call’s own max_execution_ms.

A guest that sleeps through WASI (std::thread::sleep: a wasi:clocks subscription polled with wasi:io/poll) blocks inside the host, where no epoch check runs. The host’s wasi:clocks/monotonic-clock therefore caps every subscription at the call’s deadline (AGE-706): a wait that would end after the deadline fires at the deadline instead, and the call fails with deadline exceeded. A wait that ends in time is untouched. Clock subscriptions are the only waits a plugin can start (it gets no sockets, files or stdin), so every WASI wait is bounded this way.

The fuel ceiling is sized so a CPU-bound guest is bounded by the 60 s wall clock, not by fuel (AGE-708/PL-D3b). An earlier ceiling of 10⁹ fuel — set before this was measured — let a tight arithmetic loop exhaust its fuel in roughly 130 ms, so the wall clock never got a chance to apply to legitimately CPU-heavy tools (parsing, a Benford’s-law audit over a large input). Measured on the reference host, Wasmtime fuel drains at roughly 1.5 x 10^10 units/s for a pure arithmetic loop; 10¹² keeps that same loop running for about a minute of fuel (measured: ~68 s) — past the 60 s wall clock, so the deadline fires first. Fuel remains the deterministic bound underneath the wall clock: it is refilled per call, and a manifest may still only lower it.


Versioning

The package is chatty:plugin@MAJOR.MINOR.PATCH, and the host loads exactly one version: chatty:plugin@0.3.0 today. A component that does not export chatty:plugin/plugin@0.3.0 is refused at load with

module targets chatty:module@0.2.0; this chatty supports chatty:plugin@0.3.0 — rebuild it with the current SDK

naming whatever world it does target. There is no adapter for an older world and no deprecation window (PL-D1): a version change means rebuilding every plugin against the new SDK.

A version bump is deliberate. chatty-module-sdk and chatty-wasm-runtime each hold the package as a constant (WIT_PACKAGE) and assert at compile time that wit/chatty-plugin.wit declares it, so editing the file’s package line fails both builds loudly until the constants are updated with it.

History

VersionChange
0.3.0Plugin world (PL-U3): plugin { metadata, list-tools, invoke-tool } replaces agent { chat, invoke-tool, list-tools, get-agent-card }; typed tool calls, results and errors; plugin metadata with requested capabilities; message.role gains tool. No longer an agent: plugins contribute tools to agent specs.
0.2.0Agent world chatty:module, with the file and billing imports. Refused.
0.1.0First agent world. Refused.

Curated MCP server catalog

When to read this: You want to know which external MCP servers Chatty ships pre-configured, how they are seeded and persisted, or how to add one.

Chatty ships a small, hand-picked catalog of well-known hosted MCP servers so common integrations are one click away. The catalog is compiled in as CURATED_CATALOG in crates/chatty-core/src/curated_mcp.rs and seeded into the Extensions store at launch.

What’s in the catalog

ProviderEndpointTransportDocs
Hugging Facehttps://hf.co/mcpStreamable HTTPhttps://huggingface.co/docs/hub/agents-mcp
Notionhttps://mcp.notion.com/sseSSEhttps://developers.notion.com/docs/mcp
Atlassian (Jira + Confluence)https://mcp.atlassian.com/v1/sseSSEhttps://www.atlassian.com/platform/remote-mcp-server
Google Calendarhttps://calendarmcp.googleapis.com/mcp/v1Streamable HTTPhttps://developers.google.com/calendar
Gmailhttps://gmailmcp.googleapis.com/mcp/v1Streamable HTTPhttps://developers.google.com/gmail
Google Drivehttps://drivemcp.googleapis.com/mcp/v1Streamable HTTPhttps://developers.google.com/drive

Every entry has default_enabled: false — users opt in explicitly from Settings → Extensions → Installed.

How users manage it

  1. Open Settings → Extensions.
  2. The curated entries appear under Installed with an MCP badge and an ↗ External badge (they are hosted by the provider, not run locally).
  3. Click Enable on the entries you want. Chatty connects in the background and shows each entry’s connection status inline.
  4. Click Disable to disconnect; the entry stays in the list so you can re-enable it later.
  5. The enabled / disabled state is persisted to extensions.json and mcp_servers.json, so it survives restarts.

Authentication

ProviderHow to authenticate
Hugging FaceOptional. Paste a Hugging Face access token into the API key field for private repos / higher rate limits.
NotionOAuth — sign in with your Notion workspace when prompted by the MCP server.
AtlassianOAuth — Atlassian Cloud sign-in is performed in the browser on first connect.
Google Calendar / Gmail / DriveOAuth — sign in with your Google account when prompted; grant the matching API scope.

Transport caveat

The built-in MCP client (crates/chatty-core/src/services/mcp_service.rs) speaks streamable HTTP only, via rmcp’s StreamableHttpClientTransport. Notion and Atlassian advertise Server-Sent Events, so connecting to those two endpoints requires an SSE-capable transport bridge or proxy. The transport field on each CuratedMcpEntry records what the upstream serves, and the SSE caveat is repeated in those two entries’ auth_notes; the UI shows only the generic connection error when a direct connect fails, so this page is where the explanation lives.

Community servers that pair well

These are not in the catalog, but are common companions. The reference servers below are stdio processes launched with npx, and McpServerConfig has only a url (plus optional API key) — Chatty connects to an already-running streamable-HTTP endpoint and never spawns a server itself. To use one, run it behind a local stdio-to-HTTP bridge (for example mcp-proxy or supergateway) and add the bridge’s URL under Settings → Extensions. Required environment variables are set on the bridged process, not in Chatty.

ServerCommandEnv
GitHubnpx -y @modelcontextprotocol/server-githubGITHUB_TOKEN
Filesystemnpx -y @modelcontextprotocol/server-filesystem /path/to/dir—
PostgreSQLnpx -y @modelcontextprotocol/server-postgresPOSTGRES_CONNECTION_STRING
Brave Searchnpx -y @modelcontextprotocol/server-brave-searchBRAVE_API_KEY
Memorynpx -y @modelcontextprotocol/server-memory—
Puppeteernpx -y @modelcontextprotocol/server-puppeteer—
Fetchnpx -y @modelcontextprotocol/server-fetch—
Hugging Face (hosted)URL https://huggingface.co/mcpAccess token as the API key

The hosted Hugging Face server is already the catalog’s mcp-huggingface entry and needs no bridge; add it by URL only if you want a second configuration (for example a different token).

Adding a provider to the catalog

  1. Append a CuratedMcpEntry { … } to CURATED_CATALOG in crates/chatty-core/src/curated_mcp.rs. Every field is 'static: id, slug, display_name, url, transport, description, docs_url, auth_notes, default_enabled.
  2. Make the id unique and prefixed with mcp- so it cannot collide with WASM module ids or A2A agent ids; the slug becomes the MCP server’s name.
  3. Add a row to the table above and document any auth quirks.
  4. Extend the unit tests in curated_mcp.rs (catalog_contains_initial_providers asserts every id).

The catalog is seeded by ensure_curated_mcp_servers(extensions, mcp_servers), called from main.rs after the extension and MCP stores have loaded. It is idempotent — entries already installed (matched by id) are left alone, so a user’s enabled flag or API key is never overwritten — and returns true when it added something, which is the caller’s cue to persist both stores. Adding a new entry therefore reaches existing installs on their next launch without disturbing already-toggled entries.

Build disk usage

Chatty is a large workspace: Cargo.lock pins ~1,400 crates, several of them enormous (wasmtime, typst, gpui + tree-sitter grammars, chromiumoxide’s generated CDP types, and the bundled DuckDB C++ build). A full cargo test --all-features at Cargo’s defaults needs more than 28 GiB of target/ and will not finish on a machine with 30 GB free.

This page explains where the space goes, what the workspace does about it, and how to keep a local checkout small.

Where the space goes

Measured on Linux x86_64 with cargo test --all-features --no-run (the same build CI runs), before and after the profile settings in the root Cargo.toml. The baseline run died with No space left on device while linking the desktop binary and its tests, so its real total is higher than shown; the five binaries it never linked (the desktop binary, its unit tests, and three integration-test binaries) are the largest ones, so a complete baseline would land well past 30 GiB. The middle column is the profile without split-debuginfo, measured separately to show what each setting buys.

target/debug/ componentCargo defaultsDep debuginfo trimmed+ split debuginfo (current)
Total> 27.5 GiB (died while linking; 12 of 17 binaries)18.5 GiB, complete15.8 GiB, complete (17.1 GiB after cargo clippy --all-features)
deps/*.rlib (compiled dependencies)9.3 GiB5.2 GiB5.0 GiB (+ 0.9 GiB of .dwo)
deps/ test + bin executables6.8 GiB for 12 of 175.4 GiB for all 173.3 GiB for all 17
build/libduckdb-sys-* (DuckDB C++ objects)4.6 GiB1.0 GiB1.0 GiB
incremental/ (workspace crates only)2.8 GiB (gpui crate unfinished)3.1 GiB2.7 GiB
Largest single fileliblibduckdb_sys.rlib, 2.3 GiBchatty desktop binary, 1.3 GiBchatty desktop binary, 0.9 GiB
Wall clock28 min to the failure17 min15.5 min

Two things dominate at the defaults:

  1. Full DWARF for every dependency. The dev profile defaults to debug = 2, so each of the 1,400 rlibs carries complete debuginfo, and every executable that links them gets its own copy. The chatty-core unit-test binary alone was 2.2 GiB.
  2. DuckDB compiled with -g. libduckdb-sys builds DuckDB from source through the cc crate, which passes -g whenever the package profile has any debuginfo. Objects plus rlib came to ~7 GiB.

What the workspace does

The root Cargo.toml sets, for the dev profile (and therefore test, which inherits it):

[profile.dev]
split-debuginfo = "unpacked"

[profile.dev.package."*"]
debug = "line-tables-only"

[profile.dev.package.libduckdb-sys]
debug = false

Workspace crates keep full debuginfo, so stepping through Chatty code in a debugger is unchanged. Dependencies keep line tables, so panics and RUST_BACKTRACE=1 still print file:line inside them; what you lose is variable inspection inside third-party code. DuckDB gets no debuginfo at all.

split-debuginfo = "unpacked" keeps whatever debuginfo remains in per-crate .dwo files under target/debug/deps/ instead of copying it into every linked executable; the 17 test and bin binaries then share one set. This is already Cargo’s default on macOS. Backtraces (RUST_BACKTRACE=1), gdb and lldb all read the .dwo files in place; only the files under target/ are needed, nothing else to install. If you copy a test binary elsewhere to debug it, copy target/debug/deps/*.dwo along with it.

Release builds are untouched.

Keeping a local checkout small

  • Share one target/ across worktrees and clones. Every checkout gets its own target/ by default, so three worktrees cost three full builds. Point them at one directory with CARGO_TARGET_DIR (for example in ~/.cargo/config.toml under [build] target-dir = "/path/to/shared-target").
  • Don’t mix profiles you don’t need. cargo build --release writes a second, separate tree under target/release/. Only build release locally when packaging.
  • Prune stale artifacts. Every dependency bump leaves the old version’s artifacts behind until you clean. cargo clean drops everything; cargo-sweep (cargo sweep --time 14) removes only artifacts unused for N days and keeps the warm cache.
  • Keep CARGO_INCREMENTAL on locally, off in CI. Incremental state is only kept for workspace crates and is what makes edit-compile loops fast. CI already sets CARGO_INCREMENTAL=0 because a fresh runner never reuses it.
  • The registry cache lives outside target/. ~/.cargo/registry holds ~2 GiB of crate sources for this lockfile. cargo cache --autoclean (from cargo-cache) trims old versions.

If you need full dependency debuginfo for one session

Override on the command line without editing Cargo.toml:

# one dependency
cargo build --config 'profile.dev.package.wasmtime.debug=2' -p chatty-wasm-runtime
# everything (expect the "Cargo defaults" column above)
cargo build --config 'profile.dev.package."*".debug=2'

Make targets & CI workflows

When to read this: You want the exact command a make target runs, or which GitHub workflow does what and when it fires.

The Makefile mirrors .github/workflows/ci.yml; the workflow is the ground truth, and the two are kept in step by hand. Full context for the release pipeline is in the Release process.

Make targets

TargetRuns
make / make helpLists the targets below
make setupLinux: scripts/setup-linux.sh (apt packages for GPUI + rustup target add wasm32-wasip2). Other OSes: prints instructions and adds the wasm target
make buildcargo build
make build-releasecargo build --release
make testcargo test --all-features (matches CI)
make test-fastcargo test -p chatty-core --lib
make test-tuicargo test -p chatty-tui
make test-gpuicargo test -p chatty-gpui
make test-gatewaycargo test -p chatty-protocol-gateway
make lintcargo clippy --all-features -- -D warnings
make fmtcargo fmt
make fmt-checkcargo fmt --check
make typecheckcargo check --all-features
make wasm-modulesRuns scripts/build-wasm-fixtures.sh: builds the echo and benford plugins and every modules/fixtures/ crate for wasm32-wasip2 (release) and stages each at target/wasm-fixtures/<name>/<name>.wasm next to its module.toml
make run-gpuicargo run -p chatty-gpui
make run-tuicargo run -p chatty-tui
make ciwasm-modules, test, fmt-check, lint, scripts/check-reserved.sh, scripts/check-rig-pins.sh
make cleancargo clean
make docs-genscripts/gen-docs-reference.sh → docs/generated/*.md
make docs-syncscripts/docs-sync.sh — copies repo markdown (and docs-sized GIFs) into docs-site/src
make docsdocs-gen + docs-sync + mdbook build docs-site, then installs llms.txt / llms-full.txt into the book
make docs-servedocs-gen + docs-sync + mdbook serve docs-site --open (port 3000)
make docs-check-linksdocs, then scripts/check-docs-links.sh (lychee over the sources) and scripts/check-docs-site-links.sh (relative links on the built site)
make docs-check-navscripts/check-docs-nav-drift.sh — docs/INDEX.md and SUMMARY.md completeness
make docs-check-frontmatterscripts/check-docs-frontmatter.sh — optional YAML frontmatter schema
make docs-check-leakagescripts/check-docs-user-leakage.sh — user guides carry no contributor material
make docs-check-referencescripts/check-docs-reference-drift.sh — reference tables match tool_registry.rs, StreamManagerEvent, event enums and settings structs
make docs-checkAll five docs-check-* targets
make animationsscripts/animations/record.sh --all — re-records the README/docs GIFs

Note

Two things the CI workflow runs that make ci does not: clippy is invoked with --all-targets as well (cargo clippy --all-features --all-targets -- -D warnings, so lints in tests/ are reported), and scripts/check-no-core-reexports.sh runs after the rig-pin check. Run both before pushing a change to test code or to the chatty-gpui/chatty-tui module trees.

GitHub workflows

WorkflowTriggerPurpose
CI (ci.yml)PR / push to mainTests, formatting, clippy, reserved-symbol, rig-pin and re-export checks. Docs-only diffs skip compile (the required test job still passes). Stale PR runs are cancelled. Swatinem/rust-cache is warmed on main (never with cache-on-failure: a cancelled run would save a partial target/ that is then never re-saved). On pushes to main, warm-release-cache also builds --release on Linux, macOS and Windows once per Cargo.lock + rustc and saves each under the key from scripts/release-cache-key.sh.
Docs (docs.yml)Push to main / PR touching documentation pathsgen-docs-reference.sh, docs-sync.sh, then every check behind make docs-check (links on sources and built site, nav, frontmatter, user-guide leakage, reference drift); a deploy job publishes the site.
cargo-deny (deny.yml)PR / push to main / merge group / daily 04:17 UTCcargo deny check for advisories, sources, bans and licenses against deny.toml. Every advisory exception names its tracking issue (AGE-829). Job name cargo-deny (AGE-734).
Prepare Release (prepare-release.yml)PR merged with release:patch/release:minor/release:major label, or manual workflow_dispatchBumps version via a cut-release PR (main is protected), generates changelog, tags, creates the GitHub Release, then calls Release via workflow_call.
Release (release.yml)Called by Prepare Release via workflow_call, or manual GitHub Release publishBuilds cross-platform artifacts (Linux AppImage, macOS DMG, Windows EXE), generates checksums, uploads to release. Restore-only cache: each platform restores the release cache that CI’s warm-release-cache matrix built on main.
Claude Code Review (claude-code-review.yml)PR opened/updated (Rust/CI paths only)Automated AI code review via Claude. Skipped for docs-only PRs.
Rig canary (rig-canary.yml)Weekly, or PR that touches Cargo manifestsInformational cargo update + cargo check against latest rig.
Plugin e2e (plugin-e2e.yml)Nightly, or manualThe chatty × Hive contract and trust suite (PL-E6): builds private hive main (read with HIVE_READ_TOKEN) against this chatty2’s chatty-wasm-runtime, brings up hive’s compose e2e stack, seeds it with the WASM fixtures, runs cargo test -p hive-e2e -- --ignored and posts the per-row table as the job summary. Local equivalent: scripts/hive-e2e.sh all.
Claude (claude.yml)@claude mention on issues/PRsInteractive AI assistance.
Update user docs (update-readme.yml)PR merged to mainClaude analyzes the diff; if user-facing features changed, opens a follow-up PR updating docs-site/src/user/*.md (README stays a short landing page, edited only for download/install/positioning/links). Add skip-readme label to opt out.
Update Agent Docs (update-agent-docs.yml)PR merged to mainClaude analyzes the merged PR for guidance drift and opens a follow-up PR to sync CLAUDE.md / AGENTS.md when needed. Add skip-agent-docs label to opt out.
UI Sync Check (ui-sync-check.yml)PR merged to mainClaude checks if one UI crate (chatty-gpui/chatty-tui) changed without the other; creates a ui-sync labeled issue if sync needed. Add skip-sync-check label to opt out.
Reserved symbols (reserved.yml)PR / push to main / merge groupRequired check: fails if a symbol listed in RESERVED.md is implemented without its todo!("human: …") marker or a // HUMAN-WRITTEN: attestation (scripts/check-reserved.sh).
Auto-arm (auto-arm.yml)PR opened / reopened / marked ready for reviewArms GitHub auto-merge (squash) on same-repo PRs from the repository owner, github-actions[bot] or a docs bot’s docs/* branch, so every ordinary PR lands by itself once required checks are green and the branch is current. Hold a PR back by opening it as a draft. Uses AUTO_UPDATE_TOKEN so the merge still triggers the push-to-main workflows.
Update next queued PR (update-next-queued-pr.yml)Push to main, auto-merge enabled on a PR, every 30 min, or manualThe one-lane queue: presses “update branch” on the oldest auto-merge-armed PR so PRs land one at a time under the branch-must-be-current ruleset.
PR nurse (pr-nurse.yml)Hourly, after any CI / Reserved / Ship-auto guard run completes, or manualPicks one auto-merge-armed PR that is conflicted or has a red required check and has a Sonnet session fix it (scripts/pr-nurse-triage.sh), pushing a nurse: commit; after two attempts it disarms auto-merge and labels blocked:human. Never merges.
Ship-auto guard (ship-auto-guard.yml)PR opened / synchronized / (un)labeled, merge groupRequired check that no-ops on ordinary PRs; on a release:*-labeled PR enforces the path deny-list, and for ship:auto additionally patch-only and an auto/* branch.
Ship-auto merge (ship-auto-merge.yml)PR opened / reopened / labeled / synchronized / ready for reviewEnables squash auto-merge for ship:auto PRs from same-repo heads when the sender is the owner or github-actions[bot] — the zero-human patch-release path.
Privileged labels (privileged-labels.yml)PR labeledOnly the repository owner and github-actions[bot] may apply ship:auto, `release:patch
Dependency Check (dependency-check.yml)Weekly (Monday 9:00 UTC) or manualChecks crates.io for dependency updates, files grouped work in Linear only (auto-ship, agent tech debt, or human tech debt track) — no GitHub issues; requires LINEAR_API_KEY.

A merge only releases if the PR carried a release label before the merge; the label is read from the merged pull request. Prepare Release still shows a run for an unlabeled merge, with conclusion skipped. Details, including the docs-branch exclusion and the changelog rules, are in the Release process.


audience: [contributor, agent] source_files:

  • scripts/check-docs-frontmatter.sh related:
  • ./dev/guides/contribute-docs.md
  • ./dev/agents.md

Doc frontmatter schema

When to read this: Add or edit YAML frontmatter on a markdown doc page.

Frontmatter is optional. Pages without it pass the lint. When a page starts with a --- fence, the block must match this schema:

---
audience: [contributor, agent]
source_files:
  - crates/chatty-core/src/tools/mod.rs
related:
  - ./dev/reference/tools-catalog.md
---
FieldRequired if fence presentValue
audienceyesList; each item one of contributor, agent, user
source_filesnoList of repo-relative paths the page describes
relatednoList of related doc links or issue ids

Unknown keys fail the lint. Inline lists (audience: [agent]) and block lists (- agent) are both accepted. Paths in related are written relative to docs-site/src/ (for example ./dev/guides/test.md), whatever directory the page itself lives in.

Lint

make docs-check-frontmatter
# or
bash scripts/check-docs-frontmatter.sh

CI runs the same script in .github/workflows/docs.yml. The checker walks docs/, docs-site/src/, AGENTS.md, and CLAUDE.md. Synced copies under docs-site/src/dev/architecture/ inherit whatever the source file in docs/ has. How the frontmatter fits into the rest of the docs workflow is in Contribute to the docs.

Glossary

When to read this: A word in a page, a commit message or a log line means something specific in Chatty and you want the one-paragraph definition plus the page that owns it.

Conversations and streaming

Exchange · turn · API call. An exchange is one user text message answered by one assistant text message, whatever tool round-trips sit between them (services::exchange_count counts them; the title generator triggers on the first one). A turn is everything the agent does to produce that answer, including every tool call it makes. An API call is one provider request; a turn with tool calls makes several. Token usage is recorded per API call (ApiCallUsage) and aggregated into the turn’s TokenUsage, which is why a cache hit rate is a per-call property. Owning page: Token budget.

__pending__ stream. The key StreamManager registers a stream under when the user sends a message before the conversation has an id; once the id exists the stream is promoted to it with promote_pending. Owning page: Stream lifecycle.

Preamble. rig’s word for the system prompt. build_preamble in factories/agent_factory/preamble_builder.rs extends the configured prompt with the tool summary, formatting guide, memory instructions and the names of user secrets; on OpenRouter it is also the first prompt-cache breakpoint. Owning page: Contributing patterns (token usage section).

System trace. The SystemTrace JSON persisted next to an assistant message: a sequence of thinking blocks, tool calls, approval prompts and clarification prompts. The transcript renders it; the exporters read it. Owning page: Debug (reading a system_trace).

Artifact. A file the agent produced that the desktop opens in the artifact panel beside the transcript: a chart, a PDF from compile_typst, a browser screenshot, a query table. Tools hand the path over through PendingArtifacts (the add_attachment path), so the model sees it on the next turn rather than inline. Owning pages: Chatting for users, Rendering pipeline for the transcript.

Desktop (GPUI)

Entity. A GPUI component with state and lifecycle, held as Entity<T>: it implements Render and usually EventEmitter, mutates itself inside update, and calls cx.notify() to re-render. Owning page: Entity communication.

Global. Process-wide state reached with cx.global::<T>() / cx.set_global. Entities go into globals through GlobalWeakEntity<T> (default) or GlobalStrongEntity<T> (when the global must keep the entity alive). chatty-core types get their impl Global behind the gpui-globals feature. Owning page: Contributing patterns.

Notifier. An entity that exists only to emit events other entities subscribe to (ModelsNotifier, AgentConfigNotifier), so a controller can announce “models loaded” without knowing who listens. Owning page: GPUI event catalog.

Agent tools and security

Approval mode. ApprovalMode in the execution settings: AlwaysAsk (default), AutoApproveSandboxed, AutoApproveAll. It decides whether shell commands and file writes stop for a y/n prompt (ExecutionApprovalStore, WriteApprovalStore); chatty-tui --auto-approve forces AutoApproveAll. Owning page: Security & approvals; fields in Settings schema.

Workspace. The absolute directory in ExecutionSettingsModel.workspace_dir that roots the filesystem, shell, git and browser tools; paths outside it are refused. The TUI defaults it to the current directory. Project skills live in <workspace>/.agents/skills/ or <workspace>/.claude/skills/, browser dumps in <workspace>/.chatty/browser/. Owning page: Agents & tools.

Lane A / Lane B (browser). Two navigation policies for the built-in headless Chrome. Lane A is what ships: an ephemeral profile that reaches localhost and workspace-local file:// URLs, widened to the public web when the internet-access setting is on, behind the same SSRF denylist as fetch unless the workspace’s allow_private_network_access toggle opts into reaching its own LAN too (AGE-459) — the link-local/cloud-metadata range stays refused regardless. Lane B — a per-task origin allowlist with a persistent, credentialed profile — is designed into NavigationPolicy but not implemented. Owning page: Contributing patterns (Built-in Browser); user view in Agents & tools.

Sub-agent. A delegated worker: a separate chatty-tui process with the parent’s tool set that works on one task and reports back. The parent asks for one through invoke_agent against the broker’s local-agent, and the worker reports over the broker’s participant socket. Owning page: Sub-agents.

Broker. The half of the protocol gateway that serves local participants — chatty-tui processes the broker spawns on a connection it made for each, and which answer delegated tasks at /a2a/{name} under the name the broker gave them (ADR-0011, ADR-0020). The desktop starts it from the module settings; a terminal leader starts its own with --broker. Unix only. Owning page: Agents and specs.

Virtual agent. A named worker the broker publishes — local-agent by default, or each agent spec module_settings.virtual_agents / a team’s agents names: a name, an optional model, a tool profile or disabled groups, a preamble and its own budget. Roles live in specs, never on the invoke_agent call.

Agent spec. The one declarative definition of an agent (AGE-614): [agent], [tools], [[plugins]], [swarm], [budget] in <name>.toml under .chatty/agents/, the data directory or the presets; AgentBuildContext::from_spec builds it. Owning page: Agents and specs.

Tool profile. A named allowlist of tool names (coordinator, coder, reviewer in tool_profile.rs) that is a worker’s whole tool set, MCP included; it only ever removes tools. Passed as chatty-tui --tools. Contrast tool groups, which --enable / --disable switch.

Team. A directory teams/<id>/team.json + SKILL.md declaring a leader (model, profile, preamble), a roster of virtual agents, a verification command, the skill the leader follows and a turn budget; run with chatty-tui --team <id>. Two presets ship, both experimental: crosscheck and crosscheck-data. Owning page: Sub-agents › Teams.

Evidence envelope. The runner’s — not the model’s — account of a worker’s output, appended to every delegation reply as a fenced evidence block and carried on the terminal status’s metadata.evidence: branch, base, commit count, diff stat and, when the team declares one, the verification command’s exit code and tail. Empty branch, no envelope.

Skill. A SKILL.md in .agents/skills/<name>/ or .claude/skills/<name>/ — in the workspace and its parents up to the git root (project), or under ~ (global) — loaded by SkillService, read with read_skill, written with save_skill, and offered in the slash-command picker. Owning page: Memory & skills; internals in Agent memory.

MCP. Model Context Protocol: external tool servers configured under Settings → Extensions, started by McpService, and attached to the agent as tools. Their environment variables are shown to the model only through masked_env(). Owning page: Extensions & MCP; the shipped list is the curated MCP catalog.

A2A. Agent-to-Agent protocol. Remote agents configured in settings and the local agent specs the broker serves are both reached with invoke_agent over A2A, the latter through the protocol gateway on localhost:8420. Owning page: Agents and specs.

WASM module (plugin). A sandboxed wasm32-wasip2 component targeting chatty:plugin@0.3.0 (the SDK’s Plugin trait), with a module.toml, run by chatty-wasm-runtime and discovered by chatty-module-registry. It contributes tools to the agent specs that list it and is never an agent itself. It can call the host LLM but never sees API keys. Owning pages: Build a WASM plugin, WIT interface.

Research

ATIF. Agent Trace Interchange Format, the trace export produced by exporters::atif_exporter and consumed by chatty-trace. Owning pages: chatty-trace, M0 Trace contract.

M0–M4. The research modules, one per paper, and the crates they land in: M0 trace contract (chatty-trace), M1 ReAct (chatty-core), M2 AFlow (chatty-flow), M3 GEPA (chatty-optimize), M4 ACE (chatty-playbook). Owning page: Research modules.