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
| Platform | Format |
|---|---|
| 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 chatting | Getting started |
| Understand what the agent can do | Agents & tools · Security & approvals |
| Use the terminal app or sub-agents | Terminal interface · Sub-agents |
| Build a team of named workers | Tutorial: your first named worker · Tutorial: a small agentic team · Tutorial: an autonomous dev team |
| Write a plugin or an MCP integration | Build a WASM plugin · Extensions & MCP |
| Contribute to the code | Build and run · Contributing patterns |
| Look something up | Reference · 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:
| Platform | File |
|---|---|
| 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
- Click the gear icon in the title bar to open Settings, then Models & Providers.
- Manage keys — paste an OpenRouter key, point at a local Ollama, or connect Azure OpenAI. Press Test to check it.
- 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?
| App | Use when |
|---|---|
| Chatty (desktop) | Daily work, settings, attachments, artifacts |
chatty-tui | Terminal 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 usehttp://only forlocalhost/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 usehttps://; 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 listin 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:
- API key, or switch on Use Entra ID instead of a key to sign in with your Azure account.
- 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. - 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:
| Capability | What it controls |
|---|---|
| Images | Image attachments in chat |
| PDF attachments | |
| Temperature | The 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
mermaidblocks — 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, cost and search
- 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
| Keys | What 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:
| Setting | Default | Applies |
|---|---|---|
| Font Family | the theme’s code font | at once |
| Font Size | the theme’s code font size | at once |
| Shell | your login shell ($SHELL on macOS and Linux, PowerShell on Windows) | to new terminals |
| Scrollback Lines | 10,000 | to new terminals |
| Dock Height | 300 pixels | when the dock is next drawn |
| When sharing a terminal | Ask | the next click on a tab’s eye icon |
| Attach terminal context automatically | on | the 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:
- Set a Workspace Directory (an absolute path). File, shell and git tools can only touch files inside it.
- Turn on Enable Code Execution.
- 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:
| Badge | Meaning |
|---|---|
| MCP / Plugin / A2A | What kind of extension it is — a tool server, a WASM plugin, or a remote agent |
| • Local | A module that runs on this machine |
| ☁ Cloud / ☁ Cloud Only | Runs on the Hive runner; cloud-only modules cannot be switched local |
| ↗ External | An MCP server or agent at an external URL |
| Paid | Not free — check the pricing before enabling |
| Trust: signed / Trust: local | Where 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:
| Integration | What the agent gets | Sign-in |
|---|---|---|
| Hugging Face | Hub models, datasets and Spaces | Optional — add an access token as the API key for private repositories |
| Notion | Pages, databases and comments | Sign in to your Notion workspace when prompted |
| Atlassian (Jira + Confluence) | Issues, comments and Confluence pages | Atlassian Cloud sign-in in the browser on first connect |
| Google Calendar, Gmail, Google Drive | Events, mail, files | Sign 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.
- Start the server yourself and note its URL.
- Add Custom Extension → Add MCP Server.
- Give it a name (for example
github-mcp), the URL (for examplehttp://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.sockin$XDG_RUNTIME_DIR/chatty-run. WithoutXDG_RUNTIME_DIR(macOS, some Linux setups) the folder ischatty-runin your cache folder:~/Library/Cachesor~/.cache. - Token:
gateway.token, next to the socket, readable only by you. Send it asAuthorization: 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
- Settings → Extensions, and sign in to Hive under Hive Account (installing needs your account; browsing does not).
- Under Browse Marketplace, switch to Teams and search by name, publisher or task (an empty search lists every team).
Each team shows:
| Field | What it is |
|---|---|
| Name, description | The leader spec’s |
| by · v · installs | The 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:
- fetches the leader’s spec and each member’s;
- 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;
- 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;
- writes the specs to your global agents folder (
~/.local/share/chatty/agents/on Linux,~/Library/Application Support/chatty/agents/on macOS); - 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.mdor.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.mdor~/.claude/skills/<name>/SKILL.mdin 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_userand 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_budgetin 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.
| Field | What it does |
|---|---|
agent.name | What the parent addresses; also the branch name, sub-agent/<name>. Must match the file name. |
agent.model | This 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.profile | A 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.preamble | Standing 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_turns | How 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.disable | Tool 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:
| Role | Can | Cannot |
|---|---|---|
coordinator | Read 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. |
coder | Everything 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. |
reviewer | Read 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.
| Team | Start it with | What 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
- Tutorial: from one agent to a team: the desktop walkthrough with a data team, approvals, the bill and mixed models
- Tutorial: your first named worker — twenty minutes, one reviewer, real hand-off
- Tutorial: a small agentic team — a coding team directory of your own, with its own playbook
- Tutorial: an autonomous dev team — five agents run unattended in a loop, story by story
- Terminal interface
- Agents & tools
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
- 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).
- 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.
- 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.
- 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:
| Team | Id | Use it for |
|---|---|---|
| Crosscheck | crosscheck | Any task with one right answer you can describe: a figure, a name, a short fix. |
| Crosscheck: Data | crosscheck-data | Questions 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 set | Single attempt | Crosscheck | Gain | Cost |
|---|---|---|---|---|
| DABstep Hard, all 378 tasks | 77.2 % | 84.9 % (2 attempts + judge) | +7.7 points | about 2× |
| DABstep, 80-task subset | 79.6 % | 88.75 % (3 attempts + judge) | +9.2 points | about 3× |
| DABstep, 18 tasks where attempts disagree | 4 of 18 | 7 of 18 | +16.7 points | 2.1× |
| Same set, Crosscheck: Data, first 9 trials | 5 of 9 | 7 of 9 | +22 points | about 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:
- set up the example;
- look at the agents Chatty ships;
- start the team with one command;
- watch the team work, live;
- approve the file a worker wants to write;
- read the result, and who spent what, per model;
- move one agent to a second, smaller model;
- 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-INT4served 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:4bin 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:
- The folder chip reads sales.
orders.csvis in that folder:ls ~/saleslists it.- Type
/agent(with the space) in the message box. The list that opens showsdata-lead,data-analystandreviewer. 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:
| Agent | Job | Can use |
|---|---|---|
data-lead | Breaks 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-analyst | Answers 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 |
reviewer | Checks 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
Proorders (132 down to 91) at an unchanged price, most of them in the EU. EUProalone went from 47 orders to 14, about 6,600 or 70% of the drop. - The second driver: the
SUMMER30code, 30% offStarterbought online from 1 August. It gave away about 1,760 in discounts, andStartervolume 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_tois the only thing that letsmemo-leadcall another agent. It can callmemo-writerand no one else.- The
coordinatorprofile can read and delegate, but it cannot write or run commands. Thecoderprofile 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
Proas the biggest driver (about 70%) and theSUMMER30discount on onlineStarteras the second (about 27%). - The swarm tree shows
data-leadwithdata-analystandreviewerbelow it. After step 7,reviewerhas a different model from the rest. ~/sales/report.mdexists after step 5, and~/sales/memo.mdafter step 8; you approved both writes.~/saleshas no.chatty/worktreesfolder: this team works in your folder directly.
Common issues
| You see | Why | Fix |
|---|---|---|
/agent data-lead … goes to the default agent | The 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 folder | The chat has no folder | Pick ~/sales with the folder chip under the message box and send the message again |
| A red Could not start card: code execution is off | Code execution is turned off | Turn on Enable Code Execution in Settings → Code Execution and send the message again |
The lead says it cannot find orders.csv | The chat’s folder is not the one with the file | Check the folder chip and the workspace line under your message, then pick ~/sales |
| A red Could not start card that mentions a git worktree | A team with "isolate": true (a coding team) gives each agent its own git worktree, and the folder is a git repository without a first commit | Commit 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 all | The approval mode is Auto-approve sandboxed (the default), which lets writes inside the workspace through | Switch to Always ask in Settings → Code Execution to see each write and command first |
| The reviewer appears twice in the tree | It asked for changes, and the lead fixed the report and sent it back | This is the review loop working. The lead sends the report back only once. |
| A run takes a long time on a local model | Every agent’s turns share one model server, one request at a time | Expect five to ten minutes on a single local GPU. A hosted lead (step 7) makes it faster. |
| The small model’s answer is wrong | Some roles need a stronger model | Keep small models on narrow roles like reviewer, and the lead and analyst on your strongest model |
Next
- More teams, all experimental:
research-briefships with this release (a folder of documents to a sourced brief, see Sub-agents), andanalyst-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:14bfor the leader andqwen2.5-coder:14bfor 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:
nameis how the leader will address it, and the file’s name. Keep thelocal-prefix by convention: the leader’s tools list where each agent runs, andlocalmeans this machine.modelis the worker’s own model. It does not have to be the leader’s.profileis the role.reviewercan read, search, look at diffs and run commands — enough to run a test suite — and cannot write, commit or delegate. The other two roles arecoderandcoordinator; Sub-agents has the full table.preambleis 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_agentsshowedlocal-reviewerwith 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 statusis clean andgit branchshowssub-agent/local-reviewer-0.
Common issues
list_agentsshows nothing, or onlylocal-agent. The leader was started without--broker, or the settings file is not where Chatty reads it.chatty-tuiprints the config directory in--helpunder 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 asqwen3:4b. - The leader thinks for a very long time before delegating.
qwen3reasons 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 inmodels.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 — a coder and a reviewer, a verification command, and a leader that merges only on APPROVE.
- Sub-agents › Named workers and roles — every field and every role.
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:14bandqwen2.5-coder:14b,chatty-tui, and the~/bankproject with its one passing test and unfixedwithdraw. - A clean tree in
~/bank(git statusshows nothing to commit).
How a team run works
Four things happen that a single worker never does:
- 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. - 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 anevidenceblock to its report: branch, commit count, diff summary. - 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.
- 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:
| Field | Here | Why |
|---|---|---|
agent.model in the worker specs | qwen2.5-coder:14b on both workers | A 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.toml | 30 | A 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. |
verification | in the file | So 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. |
isolate | true | Each 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) | 50 | The 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. |
skill | bank-fix | The 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.tomlwithprofile = "reviewer", listed inteam.json’sagents, 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_turnsto8and 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 ofprofile = "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-0present, and anevidenceblock ending inexit code 0in the coder’s report, with the coder runningqwen2.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
skillinteam.jsonmatches theSKILL.mdname and that the file is besideteam.json; run without--headlessto 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_mergereports 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
- Sub-agents › Teams — where teams are searched for and how
--model/--tools/--preambleoverride the leader. - Security & approvals — what
--auto-approvelets a worker do and how to run a team attended instead. - Tutorial: from one agent to a team: the desktop walkthrough with the shipped
data-analysisteam, approvals, the per-model bill and mixed models.
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--enablereaching 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
| Agent | Tool profile | Job |
|---|---|---|
dev-lead | coordinator | The leader. Delegates, merges each worker’s branch, writes no code and runs no commands |
product-owner | coder, shell off | Writes user stories, Gherkin feature files and the ordered BACKLOG.md. Never writes code |
developer | coder | Writes a short plan, then the code |
tester | coder | Writes pytest unit tests and pytest-bdd steps for the feature file and runs the whole suite |
code-reviewer | reviewer | Reads 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
bubblewrapsandbox 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": trueinexecution_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-approveis 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,AndorBut. pytest-bdd rejects a lowercaseand, 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, inAGENTS.mdand in the tester’s, and let the tester fix a bad feature file. bdd_features_base_dir = featuresinpytest.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
mainpoisons 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 mainbefore the gate, andAGENTS.mdmakes 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 1 | Run 2 | |
|---|---|---|
| Date | 26 Oct 2026 | 28-29 Oct 2026 |
| Product | envcheck | logslice |
| Setup | First attempt | With the fixes from run 1 |
| Duration | About 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 fixed | 8 h, 7 iterations |
| Stories | 6 of 7 ticked | 2 of 8 done |
| Tests passing at the end | 131 | 118 |
| Commits | 51 | 19 |
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.mdlists six to eight stories,docs/stories/S001-*.mdandfeatures/S001-*.featureexist, andgit loghas commits namingS001. - After a successful iteration:
PYTHONPATH=src .venv/bin/python -m pytest -qpasses onmain, and S001 is ticked inBACKLOG.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,gitis enough; otherwise enable the groups in the execution settings. - Every test fails at collection. A
.featurefile does not parse. RunPYTHONPATH=src .venv/bin/python -m pytest -qby hand and read the first error; the usual cause is a lowercaseand. 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
- Tutorial: a small agentic team: the mechanics of leaders, workers, branches and verification in a one-hour setup.
- Sub-agents: where teams are searched for, and how the leader’s flags apply.
- Security & approvals: approval modes and the shell sandbox.
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:
| Mode | What 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-approvein 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
| Method | How |
|---|---|
| From the desktop app | macOS: Chatty menu → Install CLI…. Linux and Windows: Settings → General → Install CLI…. The same spot offers Reinstall CLI later |
| From the release | The desktop package already contains chatty-tui |
| From source | Build 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/binis refreshed the next time the desktop app launches./updateinsidechatty-tuidoes the same on demand.
Modes
| Mode | Command | Use |
|---|---|---|
| Interactive | chatty-tui | Full-screen chat with a model picker, tool picker and approval prompts |
| Headless | chatty-tui --headless -m "question" | One message in, the answer on stdout — for scripts and sub-agents |
| Pipe | cat notes.md | chatty-tui --pipe | stdin is the message; the answer goes to stdout |
| ACP | chatty-tui acp | Serves 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"}
| Field | Meaning |
|---|---|
schema | 1; bumped only when a field changes meaning or goes away |
input_tokens | Prompt tokens over every model call, cached ones included |
output_tokens | Generated tokens over every model call, reasoning included |
cache_read_tokens | Prompt tokens served from the provider’s cache (part of input_tokens); null when no call reported any |
cache_write_tokens | Prompt tokens written to the provider’s cache (part of input_tokens); null when no call reported any |
reasoning_tokens | Output tokens spent reasoning (part of output_tokens); null when no call reported any |
model_calls | Model requests that completed and reported usage |
tool_calls | Tool calls the model started |
tool_calls_failed | Those that ended in an error |
turns | Passes: the task, then every prompt the run sent itself |
follow_up_passes | The passes after the task’s (stall resumes, retries, nudges, finalizations) |
duration_ms | Wall-clock time since the run started |
exit | running, completed, deadline (the --max-duration budget was spent), error (non-zero exit) or cancelled (a signal) |
model | The model identifier the run used |
delegated_by_agent | Agent 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.jsonand 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
| Key | Action |
|---|---|
Enter | Send |
/ | Command picker (↑/↓, Tab or Enter) |
@ | File picker (type to filter) |
PageUp / PageDown, Shift+↑/↓, mouse wheel | Scroll |
End | Jump to the bottom and resume auto-scroll |
y / n | Approve or deny a tool prompt |
1-9 | Pick an option when the agent asks a clarifying question (ask_user) |
t | Type a custom answer instead of picking an option |
Ctrl+C | Stop streaming, or quit when idle |
Ctrl+Q | Quit immediately |
Ctrl+R / /verbose | Toggle 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-tuibinary inside the AppImage, or one you built - From source:
cargo build --release -p chatty-tui, thentarget/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-useris 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).
| What | macOS | Linux | Windows |
|---|---|---|---|
| Settings, extensions | ~/Library/Application Support/chatty/ | ~/.config/chatty/ | %APPDATA%\chatty\ |
| Provider API keys, MCP and A2A tokens, Hive sign-in | Keychain (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 tokens | same 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/ | same | same |
| Project skills | <workspace>/.agents/skills/, <workspace>/.claude/skills/ (and in parent folders up to the git root) | same | same |
Sub-agent worktrees (one per delegated worker of a team with "isolate": true, left in place; delete when done) | <workspace>/.chatty/worktrees/<name>/ | same | same |
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-tui | the 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 writesecrets.json.file— always usesecrets.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-tuiinstalled 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 rootCargo.toml;rustup default stablesatisfies it. - Git and a checkout of https://github.com/boersmamarcel/chatty2.
- Linux: Debian/Ubuntu packages for GPUI (
make setupinstalls them). macOS: Xcode Command Line Tools. Windows: see the repoREADME.md. - Optional: Ollama for a zero-configuration local model.
Warning
A full
cargo test --all-featuresneeds about 16 GiB oftarget/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 oneCARGO_TARGET_DIRacross 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 setupran (or the equivalent packages and thewasm32-wasip2target are installed) -
make wasm-modulesstaged the modules undertarget/wasm-fixtures/ -
chatty-tui --headlessanswered a prompt -
cargo run -p chatty-gpuiopened the desktop app -
make test-fastandmake cipass
Common mistakes
| Symptom | Fix |
|---|---|
| A test fails with “run scripts/build-wasm-fixtures.sh” | make wasm-modules |
feature edition2024 is required | Toolchain too old; rustup default stable |
No models configured from chatty-tui | Add --ollama (or --openai-compat-url), or configure a provider in the desktop app first |
Could not connect to Ollama | ollama serve is not running, or the URL is wrong |
BadMatch when the desktop window opens on Linux | Export XDG_RUNTIME_DIR as in step 4 |
No space left on device during make ci | Build 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 test | A pdfium call bypassing create_pdfium(); see Test |
Next
- Your first change: add a tool
- Where do I…? — the how-to index
- Contributing patterns — the conventions the code follows
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-fastpasses 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) andexecute_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.
-
tool_collector.rs— add a field toNativeTools, a.tool(t)call inapply_to_builder, and the field to thenative_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); } } -
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 singlenative_tools!(…)call. -
tool_registry.rs— add aword_count: boolflag toToolAvailabilityand insert"word_count"inactive_native_tool_nameswhen it is set (or add the name to the always-on set at the top of that function). Then set the flag in bothToolAvailability { … }literals inmod.rs. These names are whatlist_toolsreports, what the preamble’s tool summary lists, and whatfilter_mcp_tool_infouses 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_toolscounts them.
Note
A delegated worker started with
--tools <profile>sees only the tool names that profile lists intool_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,coderfor 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_compatpass. /toolsin the TUI (orlist_toolsin a conversation) showsword_count.- Asking for a count produces a tool call in the transcript; an empty string produces
Error: word_count: text is emptyin the tool row, not “the tool failed”. make ciandmake docs-check-referencepass.
Checklist
-
impl Toolwithconst NAME,description,parameters,call -
map_errordelegates tomap_tool_error(Self::NAME, error) - Exported from
tools/mod.rs -
NativeToolsfield,apply_to_buildercall,native_tools!macro entry - Constructed in
from_model_config_with_tools, flag set in bothToolAvailabilityliterals - Name in
active_native_tool_names -
gemini_compattest added - Row in
scripts/gen-docs-reference.sh,make docs-genrun - 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
| Mistake | Consequence | Fix |
|---|---|---|
No map_error override | Model and transcript see “the tool failed” | Delegate to map_tool_error |
Rewording the Error: prefix | Failure filed as success, error prose in output | Keep map_tool_error’s format |
Registered in tool_collector.rs but not named in tool_registry.rs | Missing from list_tools; an MCP tool with the same name is not filtered | Add the name |
Schema object without type | Gemini rejects the request | Run the gemini_compat test |
Forgot scripts/gen-docs-reference.sh | docs-check-reference fails in CI | Add the row, make docs-gen |
Routing a provider’s raw error through map_tool_error | Leaks text the model should not act on | Only 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).
| Export | echo’s answer |
|---|---|
metadata | name echo, version 0.2.0, no requested capabilities |
list-tools | echo, reverse, count_words, each taking {"input": string} |
invoke-tool | runs 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
- Scaffold your own plugin:
templates/module/ - Give an agent a plugin: Tutorial: benford
- Quick start, project layout and testing: Build a WASM plugin
- The contract: WIT reference
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_distributionandchi_square_test. - The agent (
auditor) is chatty’s own harness: a spec with a forensic-auditor preamble andplugins = [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)
}
}
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"
preambleis 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;versionis 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/benfordon 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
- Build a WASM plugin — quick start, project layout, testing your plugin
- WIT reference — the
chatty:plugin@0.3.0contract
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
| Guide | Use it when |
|---|---|
| Add an LLM provider | A new ProviderType, or deciding whether an OpenAI-compatible endpoint even needs one |
| Add a slash command | A new /command in the desktop and terminal pickers |
| Add a desktop GPUI view | A panel, dialog or widget in chatty-gpui |
| Build a WASM plugin | A sandboxed agent module served by the protocol gateway |
| Test | Which tests to run, goldens, mocks, the WASM prebuild |
| Debug | Logs, the render overlay, stalled streams, tool failures, cache hits |
| Build & package | Local CI and platform packages |
| Release process | Cutting a version |
| Contribute to the docs | Editing this site |
| For AI agents | The 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 manual | Getting started and the other user pages |
| Build the workspace for the first time | Build and run |
| Understand the big picture | System overview |
| See component diagrams | Component map |
| Find a workspace crate | Reference: crates |
| Build a WASM plugin | Build a WASM plugin · echo tutorial · benford tutorial |
| Add an LLM tool | Your first change: add a tool |
| Add a provider | Add an LLM provider |
| Add a slash command | Add a slash command |
| Add a GPUI view | Add a desktop GPUI view |
| Look up a persisted setting | Settings schema |
| Fix stream/cancel bugs | Stream lifecycle · Debug |
| Fix rendering/layout | Debug · Rendering pipeline |
| Look up a tool name | Tools catalog |
| Change delegation, the broker, virtual agents or teams | Agents and specs · Team smoke test · Sub-agents · tutorials: named worker, team |
| Run tests like CI | Test · Make targets & CI workflows |
| Look up a term | Glossary |
| Research / reserved code | RESERVED.md |
| Report or fix a stale page | Contribute 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
- Build and run done;
make test-fastgreen. - Know the three current providers and how they differ: the provider matrix.
- Files to read first:
providers_store.rs(ProviderType,ProviderConfig,configured_providers),provider_builder.rs(one match arm per provider),llm_service.rs(streaming and usage normalisation).
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::OpenRouterwith a custombase_url; that is exactly whatchatty-tui --openai-compat-urlinjects. 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:
- Add the variant. The enum is
#[serde(rename_all = "snake_case")], so the variant name is the persisted JSON string (AzureOpenAIuses an explicitrename = "azure_openai"). Do not rename an existing variant without a#[serde(alias)]; users’ saved settings would stop loading. - Add its arm to
display_name(). - Add its arm to
default_capabilities()—(supports_images, supports_pdf)for models created under it:(true, true),(true, false)or(false, false). - Decide what counts as configured in
configured_providers(). The default arm requires a non-emptyapi_key; Ollama is always included; Azure needsbase_urlplus 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 withbase_url. Check the rig pins inCargo.tomlbefore relying on a new provider module. - Auth.
ProviderConfigcarriesapi_key,base_urland a free-formextra_configmap (Azure keeps itsauth_methodthere). If a credential must be refreshed per request, wrap the HTTP client the wayazure_auth_http.rsdoes rather than awaiting a token inside the stream handler;on_chunkis 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 aPromptCachingHttpClient; copy that only if the upstream honourscache_controlbreakpoints.
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)
settings/views/models_page/provider_keys_sheet.rs— one section per provider: key and URL inputs, status line, test-connection label. Add yours.settings/controllers/providers_controller.rs— creates or updates theProviderConfigand persists it through the existing JSON repository; no new repository is needed.add_model_sheet.rs— the model catalog shown when adding a model (OpenRouter has one; Ollama and Azure showCatalog::Unavailable).
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
ProviderTyperound-trips through serde with the string you intended (settings/models/mod.rshas the round-trip tests for the existing variants; add one).- A conversation streams text and completes a tool call.
- The
LLM completion call usageline showsinput + cache_read + cache_writeequal to the prompt size the provider reports. - Attachments are offered only for the capabilities you declared.
Checklist
-
ProviderTypevariant,display_name,default_capabilities,configured_providersrule - Every
matchupdated (cargo check --all-featuresclean) -
build_provider_agentarm: 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-featuresand clippy pass; manual chat, tool call and attachment checks done
Common mistakes
| Mistake | Do this instead |
|---|---|
| Adding a variant for an OpenAI-compatible endpoint | OpenRouter + base_url |
| Renaming a persisted variant | Keep a #[serde(alias)] for the old string |
| Setting temperature unconditionally | Gate on supports_temperature |
Skipping sanitize_mcp_tools_for_openai on an OpenAI-family API | Sanitise; the request fails on schema format fields otherwise |
Leaving InputIncludesCache for an Anthropic-style API | Choose semantics by agent.provider |
| Refreshing a token inside the stream | Per-request HTTP wrapper, like Azure |
Checking capabilities from ProviderType at runtime | Read 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
| Shape | Example | GPUI wiring | TUI wiring |
|---|---|---|---|
| Immediate (no args) | /compact | Picker execute_immediately: true → ChatInputEvent::SlashCommandSelected → handle_slash_command | Command variant, no Option<String> |
| Arg-based | /agent <prompt> | Picker inserts /agent (does not execute) → user sends → try_handle_arg_slash_command | Command::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)
- Add a
SlashCommandtoSLASH_COMMANDSincrates/chatty-gpui/src/chatty/views/chat_input/slash.rs(command,description,insert_text,execute_immediately). - Immediate commands: add a match arm in
ChattyApp::handle_slash_command(crates/chatty-gpui/src/chatty/controllers/app_controller/slash_commands.rs). - Arg-based commands: handle the prefix in
ChattyApp::try_handle_arg_slash_commandin the same file. Returntrueso the message is not forwarded to the LLM. ChattyAppalready routesChatInputEvent::SlashCommandSelectedand interceptsSendfor arg-based commands — no extra subscribe.
TUI (terminal)
- Add a
Commandvariant and aparse_commandmatch arm incrates/chatty-tui/src/engine/commands.rs. - Handle the variant in the engine (same file /
ChatEngine). - The TUI slash menu builds from the same
Commandparse list plus skills (crates/chatty-tui/src/ui/input.rs). If the new command should appear in the/picker, add it there too. - 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
/foois ignored (TUI) or logged (GPUI) — never sent as a user message if you claimed it as a command -
make docs-genafter 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
| Kind | When | Canonical example | Where it lives |
|---|---|---|---|
| Owned entity view | Persistent panel with state; talks to ChattyApp | SidebarView, ChatView | crates/chatty-gpui/src/chatty/views/, stored as Entity<T> on ChattyApp |
| Nested entity | State owned by a parent view, not the app | ChatInputState (on ChatView), SystemTraceView | Same views/ tree; parent or ChattyApp subscribes |
| Dialog | Modal overlay, opened on demand | SearchConversationsDialog, ErrorLogDialog | views/, window.open_dialog |
IntoElement widget | Render-once row/chrome, no EventEmitter | ConversationItem, AppTitleBar, StatusFooterView | views/; callbacks must cx.emit on a parent entity |
| Settings page | Settings window, not the main chat chrome | settings/views/models_page/ | crates/chatty-gpui/src/settings/views/ |
| TUI view | Terminal UI | Ratatui widgets | crates/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:
| Shape | Example | Use when |
|---|---|---|
| Stateless helper | ErrorLogDialog::open | Contents come from a global (ErrorStore) and do not need live input |
| Entity inside the dialog | SearchConversationsDialog::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(). NoArc<dyn Fn>between entities. IntoElement/RenderOncewidgets may take callbacks, but those closures mustentity.update(cx, |_, cx| cx.emit(…))on a parent entity.- Store
WeakEntity<T>in globals, never a strongEntity. cx.notify()after mutating view state;cx.emitwhen a parent must act.cx.defer()if you need to update an entity that is already being updated.- Always
.detach()a subscription (or keep theSubscriptionhandle). - 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) ownsEntity<T>and subscribes insetup_callbacks() - Composed in the parent
Rendertree -
IntoElementchildren route clicks throughcx.emiton 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
| Mistake | Do this instead |
|---|---|
set_on_select(Arc<dyn Fn>) between entities | Event variant + cx.subscribe |
Strong Entity<T> in a Global | WeakEntity<T> (GlobalChattyApp pattern) |
Mutating a view from inside its own update / subscribe stack | cx.defer |
Putting a settings form in chatty/views/ | settings/views/ |
| Building a TUI panel with GPUI widgets | chatty-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/gpuiand.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-wasip2target:make setup, orrustup target add wasm32-wasip2. cargo-generateif 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 "…"callsmy-plugin__<tool>.- With the desktop’s module gateway on,
tools/liston/mcp/my-pluginover 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-wasip2target installed -
metadata().name/versionmatch[module]inmodule.toml - Every host import the plugin calls is in
requested_capabilities(loggingneeds no request) -
.wasmcopied next tomodule.toml - An agent spec lists the plugin under
[[plugins]], withgrantsnaming the capabilities it may use - Host-target unit tests for your tool logic
Common mistakes
| Mistake | Fix |
|---|---|
The agent has no my-plugin__* tools | The spec must list the plugin; the plugin must be in the module directory (Settings → Modules) |
| “rebuild it with the current SDK” at load | The plugin was built against an older world; rebuild it against this repo’s SDK |
| Built for the host target | Use the .cargo/config.toml from the template, or pass --target wasm32-wasip2 |
.wasm name does not match [module].wasm | The manifest path is relative to module.toml |
Calling llm::complete with a model id chatty does not have | Pass "" 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
| Topic | Doc |
|---|---|
| WIT types, host imports, the guest export, versioning | WIT reference |
| Gateway routes, manifest fields, resource limits, module directory per platform | Plugins |
| Crate stack diagram | Component map |
| Template source | templates/module/ |
| Reference plugins | modules/echo/, modules/benford/ |
| SDK rustdoc | chatty-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 setupdone. make wasm-moduleshas been run (scripts/build-wasm-fixtures.sh). It stages every fixture and theechoandbenfordplugins undertarget/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
| Where | What it covers | Run with |
|---|---|---|
#[cfg(test)] mod tests beside the code, in every crate | Unit tests. Most logic is in chatty-core, so most tests are too | make test-fast (cargo test -p chatty-core --lib), or cargo test -p <crate> |
crates/chatty-core/tests/integration.rs | The 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 server | cargo test -p chatty-core --test integration |
crates/chatty-core/tests/browser_live.rs | End to end against a real Chrome, #[ignore]d because the first run may download the pinned build | cargo test -p chatty-core --features browser --test browser_live -- --ignored --test-threads=1 |
crates/chatty-gpui/tests/core_integration.rs | chatty-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 display | make 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.rs | Every tool schema converts to a Gemini Schema without empty type strings | cargo test -p chatty-core --lib gemini_compat |
crates/chatty-core/src/repositories/store_conformance.rs | Every 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.sh | The 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 CI | bash scripts/team-smoke.sh; see Team smoke test |
| Characterization goldens (three directories, below) | The event sequence each frontend produces for a scripted stream | see 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 target | cd crates/hive-billing-sdk && cargo test --target x86_64-unknown-linux-gnu |
modules/benford/src/lib.rs unit tests | Standalone plugin crate, also outside the workspace and defaulted to wasm32-wasip2 for the same reason | cd 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 instead | cd crates/chatty-module-sdk && cargo clippy --target wasm32-wasip2 -- -D warnings |
crates/chatty-wasm-runtime/tests/sandbox.rs | Plugin 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=1becausechatty-coretests 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 aPdfiumHandlethat serialises every use (AGE-176). If a SIGTRAP, SIGSEGV or SIGABRT comes back undercargo test, look for a new pdfium call that bypassescreate_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 directory | Recorded by | Event type |
|---|---|---|
crates/chatty-core/src/services/goldens/stream_loop/ | loop_callback_sequence_matches_goldens in stream_processor.rs | StreamChunkHandler callbacks |
crates/chatty-core/src/session/goldens/ | session/tests.rs | SessionEvent |
crates/chatty-tui/src/engine/goldens/ | engine/characterization.rs | AppEvent |
crates/chatty-gpui/src/chatty/controllers/app_controller/goldens/ | session_characterization.rs | StreamManagerEvent |
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—MockMcpRepositoryandMockA2aRepository, 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
ChatViewin a headless test window, a liveStreamManager), not stubs, so what it records is what the UI receives. browser_live.rsbuilds a throwaway workspace withtempfilerather than fixtures on disk.
Verify
make test-fastis 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-moduleshas been run on this checkout - New logic in
chatty-corehas a unit test beside it - A new tool has a
gemini_compatguard (see Your first change) - Turn-lifecycle changes re-recorded all three golden directories, not one
-
make testpasses
Common mistakes
| Mistake | Do this instead |
|---|---|
| Deleting a golden file to make a test pass | UPDATE_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 pushing | make test |
A signal (SIGTRAP/SIGSEGV/SIGABRT) under cargo test | Find the pdfium call that bypasses create_pdfium(); do not add --test-threads=1 |
Running browser_live in CI | It is #[ignore]d on purpose; run it locally |
Enabling test-support in a normal [dependencies] block | Dev-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.
| Binary | Where logs go | Default level |
|---|---|---|
chatty (desktop) | stderr of the process. An ErrorCollectorLayer also copies every warn and error event into the in-app error log dialog | info |
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 start | warn |
chatty-tui --headless / --pipe | Nowhere: 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
| Field | Meaning |
|---|---|
visible / total / filtered | Messages the list renders, all messages, and how many the empty-streaming-message filter hides |
awaiting, skeleton | is_awaiting_response() — waiting for the first token, thinking indicator shown |
s | is_streaming |
m | is_markdown |
ti | Number of items in live_trace (streaming) or the SystemTraceView (finalized) |
c | content.len() |
trace | live 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:
| Event | Fired from | Useful fields |
|---|---|---|
start_assistant_message | chat_view/mod.rs | total_messages |
append_assistant_text | chat_view/mod.rs | delta_len, content_len_before, new_content_len, message_idx; a warn variant append_assistant_text dropped means no streaming parent existed |
tool_call_started | chat_view/handlers.rs | tool_id, tool_name, text_before_len, had_trace_view, live_trace_items |
render_message_list | chat_view/mod.rs | total, visible, is_awaiting, thinking_visible |
render_message | message_component.rs | index, role, is_streaming, is_markdown, should_interleave, content_len, trace_items, attachments |
interleave_segment | message_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_message | chat_view/mod.rs | had_live_trace, cleared_streaming_cache, content_len |
What to look for:
- Whitespace during streaming. A
render_message_listtransition fromvisible=1 … is_awaiting=true thinking_visible=truetovisible=2 … is_awaiting=false; the frame between them is the jump. With tools involved,render_messageshowingcontent_len=0 trace_items>0followed by a largecontent_lenis the reflow from “trace only” to “text + trace”. - Overlapping or duplicated text. Across the
interleave_segmentevents of one message,text_before_lenmust be non-decreasing and eachlast_text_endmust equal the previoustext_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 staleStreamEndedfrom a superseded stream (each registration carries an epoch; older epochs are ignored). - The stall watchdog wakes every
STALL_TICK(5 s). AfterSTALL_TIMEOUT(180 s) with no chunk and no sub-agent progress it logsStream produced nothing for too long; ending the turn as stalledatwarnand ends the turn with the error textThe model stopped responding (no output for 3 minutes)…(the duration is the timeout that fired: while a tool call runs it isTOOL_STALL_TIMEOUT, 13 minutes). A long tool call is silence from the stream’s point of view, which is why the timeout is generous.--headlessresumes a stalled turn itself — a short “continue” prompt on the same history, at mostHEADLESS_STALL_RESUME_ATTEMPTS(2) times per task, logged asAuto-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_LOGnarrowed 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
| Symptom | Check |
|---|---|
| Stream never ends | StreamManager status and cancel flag; watchdog warn after 180 s of silence |
| Text duplicated | Conversation model vs ChatView state; interleave_segment accounting |
| Tool UI stuck | The ToolCallStarted and tool-result StreamManagerEvents reached the view for the active conversation |
| Whitespace or overlap | render_message_list transitions; markdown cache cleared on finalize |
| “the tool failed” | Tool bypassed map_tool_error |
| Cache hit rate low | Prompt prefix changes between requests |
| No TUI log file | You 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
- Build and run done once (
make setup,make wasm-modules). - About 16 GiB of
target/for the full test build (Build disk usage).
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
| Platform | Script |
|---|---|
| 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
| Mistake | Fix |
|---|---|
Running cargo test without --all-features and calling it CI | Feature-gated tools never compiled; use make test |
| Adding a release label after merging | Labels 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.ymlmanually - 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)
- Open a PR to
mainwith exactly one ofrelease:patch,release:minor,release:major(owner-applied; unauthorized labelers are stripped byprivileged-labels.yml). - Merge when CI is green.
ship-auto-guardenforces the path deny-list on any release-labeled PR. prepare-release.ymlbumpsCargo.tomlon arelease/vX.Y.ZPR (cut-release, notrelease:patch), merges that PR (main is protected — a direct push gets GH006), generates the changelog, tagsvX.Y.Z, creates the GitHub Release with that changelog as its body, and callsrelease.ymlviaworkflow_callto build artifacts. Runs are serialised by aprepare-releaseconcurrency 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):
- Agent opens PR: branch
auto/*, titleauto: …, labelsship:auto+release:patch(owner/Actions only). ship-auto-guard.ymlenforces patch-only + path deny-list;ship-auto-merge.ymlenables squash auto-merge only for same-repo heads when the sender is the owner or the actor isgithub-actions[bot]. Latersynchronizeevents from other same-repo actors do not re-arm; if auto-merge is already on, the prepare-release waiter still starts.- When required checks are green, auto-merge squash-merges to
main. The ruleset requires the branch to be current withmain;update-next-queued-pr.ymlpresses “update branch” for the oldest auto-merge-armed PR whenevermainmoves (and every 30 min as a fallback), so PRs land one at a time without a human. It needs theAUTO_UPDATE_TOKENsecret: a fine-grained PAT scoped to this repo with contents + pull-requests write.GITHUB_TOKENcannot be used because its branch updates do not trigger CI. A PR in that queue is also in care ofpr-nurse.yml(hourly, after any failed CI run, or on dispatch):scripts/pr-nurse-triage.shpicks one PR that is conflicted withmainor 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 anurse: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 labelsblocked:human. Removingblocked:humanby 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. - If Actions performed the squash (
GITHUB_TOKENdoes not emitpull_request.closed),ship-auto-mergedispatchesprepare-release(bump=patch). Owner merges still use thepull_requestclosed path. - Same
prepare-release→ bump PR → tag →release.ymlpipeline 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).
-
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 -
Create release via GitHub UI:
- Go to: https://github.com/boersmamarcel/chatty2/releases/new
- Choose tag:
v0.1.21(or create new tag) - Target:
mainbranch - Release title:
v0.1.21or custom title - Description: Write custom release notes
- Important: Click “Publish release” (NOT “Save as draft”) — only the
publishedevent triggers the workflow
-
Workflow runs automatically:
- Validates the tag against
Cargo.tomlandorigin/main - Builds for all platforms
- Uploads artifacts to your release, preserving your release notes
- Validates the tag against
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 overchecksums.txt, onceRELEASE_SIGNING_ENABLEDis on — seerelease-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) matchesCargo.tomlversion - 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 certificateMACOS_CERTIFICATE_PASSWORD- P12 passwordKEYCHAIN_PASSWORD- Random keychain passwordMACOS_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:
- Build job failed (check Linux/macOS/Windows build logs)
- Artifact upload failed (check artifact names match)
fail_on_unmatched_files: true- files missing- Prepare Release created the GitHub Release but
release.ymldid not build. Reusable workflows inherit the caller’sgithub.event_name, so the reusable-call path is marked with an explicitfrom_prepare: trueinput 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
- Let Prepare Release bump the version — hand-edit
Cargo.tomlonly for a UI release - Use semantic versioning:
MAJOR.MINOR.PATCH(e.g.,0.1.21,1.0.0) - Tag format: Use
vprefix (v0.1.21) for consistency - Release notes: Use the GitHub UI path for releases that need hand-written notes
- Test locally first: Run
cargo build --releaseand./scripts/package-macos.shbefore releasing - Check Actions: Monitor the workflow to ensure all platforms build successfully
Not yet automated
- macOS Intel builds (only
aarch64is 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.ymlsignschecksums.txtwith an Ed25519 release key in minisign format and uploadschecksums.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 passescodesign --verify --deep --strict, and itsTeamIdentifierequals the Team ID compiled into the running app (CHATTY_MACOS_TEAM_ID, set byrelease.ymlfrom theNOTARIZE_TEAM_IDsecret). 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
| Thing | Where |
|---|---|
| Private key | RELEASE_SIGNING_KEY secret (base64 of the minisign secret-key file) in the release-signing GitHub environment; offline copy in your password manager |
| Public keys | crates/chatty-gpui/src/auto_updater/release_keys.txt, one RW... line per key |
| Switch | repo variable RELEASE_SIGNING_ENABLED (true = sign every release) |
| Signing job | sign-checksums in .github/workflows/release.yml |
| Key inventory | vault 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.
-
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 -
Create the environment and set the secret. Restrict the environment to the refs releases run from (
mainforprepare-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 -
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 -
Pin the public key. Add the second line of
release.pub(the one starting withRW) torelease_keys.txtin a PR. Merge it after step 3, never before: a build that pins a key must never be followed by an unsigned release. -
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.
-
Store and clean up. Put
release.keyandrelease.pubin your password manager, thenrm -P(macOS) orshred -u(Linux) both files. Ask the coordinator to add the key ID (minisign -Vprints it) to the vault key inventory.
Rotation (two-key window, SEC-3)
- Generate the new key (step 1) and add its public line to
release_keys.txtnext to the old one. Release: installs now trust both. - Replace the
RELEASE_SIGNING_KEYsecret with the new key (step 2, last command). Release: signed by the new key, accepted by every install that took step 1’s release. - 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 therelease-signingenvironment.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) formake docs/make docs-serve. - lychee (
cargo install lychee --locked) andpython3formake docs-check-linksand 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 page | Edit instead |
|---|---|
src/dev/architecture/*.md | docs/*.md |
src/dev/adrs/*.md | docs/research/*.md |
src/dev/research/modules/*.md | docs/research/modules/*.md |
src/dev/agents.md | AGENTS.md |
src/dev/reference/*.md | The tables in scripts/gen-docs-reference.sh, then make docs-gen |
src/dev/crates/*.md | crates/*/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 todev/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/srcare relative to the page’s own directory (../architecture/stream-manager.mdfrom 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,.rsfile names or GPUI internals.make docs-check-leakagefails 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
| Target | Checks |
|---|---|
make docs-check-links | Builds 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-nav | docs/INDEX.md and SUMMARY.md list every page |
make docs-check-frontmatter | Frontmatter matches the schema |
make docs-check-leakage | User guides carry no contributor material |
make docs-check-reference | Reference 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
-
# Titleand**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(anddocs/INDEX.mdfordocs/*.md) -
make docs-checkgreen - Docs change is in the PR that changed the behaviour
Common mistakes
| Mistake | Do this instead |
|---|---|
Editing docs-site/src/dev/architecture/foo.md | Edit docs/foo.md; the sync overwrites the copy |
Editing a reference/*.md table by hand | Edit scripts/gen-docs-reference.sh, run make docs-gen |
| A link that works in the repo but 404s on the site | Run 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 entry | mdBook does not render it; docs-check-nav fails |
AGENTS.md
Research crates carry reserved symbols; read
RESERVED.mdbefore 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 provider | crates/chatty-core/src/factories/agent_factory/ + ProviderType |
| Add an LLM tool | crates/chatty-core/src/tools/, register in agent_factory.rs |
| Add a service | UI-agnostic → crates/chatty-core/src/services/. UI-specific → crates/chatty-gpui/src/chatty/services/ |
| Add a setting | model in chatty-core/src/settings/models/, repo in chatty-core/src/settings/repositories/, UI in chatty-gpui/src/settings/views/ |
| Add a desktop view | crates/chatty-gpui/src/chatty/views/ — emit events to ChattyApp |
| Add a terminal view | crates/chatty-tui/src/ui/ |
| Add a workspace-global singleton | Define in chatty-core, add impl Global in chatty-core/src/gpui_globals.rs |
| Find all process-wide statics | Singleton inventory at top of crates/chatty-core/src/lib.rs |
| Persist data | JSON repo in chatty-core/src/settings/repositories/, or SQLite in chatty-core/src/repositories/ |
| Add a slash command | crates/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(). NoArc<dyn Fn>callbacks between entities. Seedocs/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); useGlobalStrongEntity<T>only when the global must keep the entity alive itself (e.g.StreamManager,ModelsNotifier). - Stream lifecycle — All LLM streams go through
StreamManagerwith cancellation tokens; the stream loop never updates UI directly, it emits events. Streams carry a monotonic epoch so a staleStreamEndedcan’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;--headlessauto-resumes a stalled turn itself, up toHEADLESS_STALL_RESUME_ATTEMPTS(2) times, before failing the run. Seedocs/stream-manager.md. - Turn budget —
TurnBudget(chatty-core/src/services/turn_budget.rs) is a rig hook everystream_promptcall 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’sMaxTurnsErrorswallow an in-progress reply.max_agent_turnsdefaults to0(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 aswarn!()for non-critical paths; propagate with?for critical I/O. - Tool errors — Every
impl Tool’s error path must go throughmap_tool_error(tool_name, error)(chatty-core/src/tools/mod.rs), not rig’s defaultTool::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 theapi_keyfield; 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 thanlazy_static/once_cell. - GPUI / gpui-component skills — When changing desktop UI, load
.claude/skills/gpuiand.claude/skills/gpui-component(vendored fromnpx skills add longbridge/gpui-component; lockfileskills-lock.json). - Desktop screenshot harness — A desktop UI change is not done until it
has been seen running.
.claude/skills/desktop-screenshotstarts 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_traceJSON) in chatty-core. Don’t leak transcript block types into chatty-core. chatty-tui has its own parallelMessageBlockenum (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’slist/ListState(measured heights), notv_virtual_list(predicted heights) — see CLAUDE.md. - Tool failure signal is text, not a flag —
map_tool_error()’sError: {tool_name}: {message}prefix is the only thing that tellsllm_service::tool_result_looks_like_errora 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.ymlonly safety-netsAGENTS.md/CLAUDE.md. See the Documentation section ofCONTRIBUTING.md. - Experiments live in harbor-chatty, not here — pre-registrations, run
reports, spikes, benchmark harnesses and their task fixtures go in the
private
harbor-chattyrepo underexperiments/. 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
-
No
chatty-gpui::chatty::*re-exports.crates/chatty-gpui/src/chatty/mod.rsused to re-exportauth,exporters,factories,repositories,toolsfromchatty_core; those re-exports were removed so call sites importchatty_core::…directly. If grep finds no definition underchatty-gpui/, look inchatty-core/.scripts/check-no-core-reexports.sh(in CI) fails the build if they come back. -
Test parallelism. See “Test-thread footgun” above: the pdfium SIGTRAP is fixed, but CI and
make testintentionally still pass--test-threads=1for an unrelated runner flake — that isn’t a regression to “fix”. -
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. -
Linux system packages. GPUI needs a long list of
lib*-devpackages. Runmake setup(orscripts/setup-linux.sh) on a fresh machine. -
Standalone crates, own lockfiles, wasm32 by default.
crates/hive-billing-sdk/,modules/benford/andcrates/chatty-module-sdk/each declare their own[workspace]andCargo.lock(intentional) — bump their deps in that lockfile, not the root one.hive-billing-sdkandbenfordalso default towasm32-wasip2via their own.cargo/config.toml, which has no libtest runner, socargo testthere needs an explicit host target:cargo test --manifest-path <crate>/Cargo.toml --target x86_64-unknown-linux-gnu(make test-billing-sdk/make test-benforddo this; CI runs the same, AGE-600). -
The
gpui-globalsfeature. chatty-core types implementgpui::Globalonly when this feature is enabled. chatty-gpui enables it; chatty-tui does not. If aGlobalimpl is missing, add it incrates/chatty-core/src/gpui_globals.rs. -
Sub-agent worktrees. Delegated workers (
invoke_agent→local-agentor a named virtual agent;sub_agentwas retired in AGE-303) each get their owngit worktreeunder<workspace>/.chatty/worktrees/<name>on asub-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 ingit statusbut 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. -
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 themod.rsand its module-level docstring to scope what you need before loading sibling files. The largest single files aremessage_ops.rs(~1260 lines) andmain.rs(~1225 lines). -
rpptxis pinned to a fork. The rootCargo.tomlpatchesrpptx,oxml-layoutandoxml-pdftoboersmamarcel/rdocxvia[patch.crates-io](AGE-343/AGE-474) — the publishedrpptx = "0.12.1"doesn’t compile alongsideusvgand mishandles real decks.cargo updatewill 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. -
The agent shell loads the login profile, but still starts
--norc --noprofile.shell_serviceruns bash on a PTY (chatty-terminal) and sources/etc/profileand the first of~/.bash_profile/~/.bash_login/~/.profilein its init, before the first prompt (output discarded,PS1unset while it runs), so a project’s conda env orPATHadditions 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
| Topic | File |
|---|---|
| System overview (diagrams) | docs/system-overview.md |
| Component map (diagrams) | docs/component-map.md |
| Crate split rationale | docs/workspace-crate-split.md |
| Stream lifecycle | docs/stream-manager.md |
| Entity communication | docs/entity-communication.md |
| Rendering pipeline | docs/rendering-system.md |
| Token budget | docs/token-tracking.md |
| Agent memory | docs/agent-memory.md |
| Agents, specs & A2A | docs/agents-and-specs.md |
| WASM plugins | docs/plugins.md |
| WIT reference | docs/wit-reference.md |
| Debugging the UI and streams | docs-site/src/dev/guides/debug.md |
| Release process | docs/RELEASE_PROCESS.md |
| Coding patterns & behavior | CLAUDE.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 rootCargo.toml(the research crates inherit it). If a build fails withfeature edition2024 is required, the VM’s default toolchain is too old: runrustup default stable. CI usesdtolnay/rust-toolchain@stable, so it always runs ahead of the declared MSRV; if a dependency raises the floor, bumprust-versionand 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, runsudo update-alternatives --set cc /usr/bin/gccandsudo update-alternatives --set c++ /usr/bin/g++. -
The WASM fixtures under
target/wasm-fixtures/are build output and required by some integration tests; runmake wasm-modulesafter a fresh checkout. -
Disk. A full
--all-featurestest build needs about 16 GiB oftarget/; seedocs/build-disk-usage.mdbefore 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). ExportXDG_RUNTIME_DIRfirst, 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 withBadMatch.chatty-tuineeds 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 warningspasses (AGE-174). CI added--all-targetsso tests/benches/examples are linted too — without it, lints insidetests/go unreported (a finding sat unnoticed inchatty-protocol-gateway’s e2e test until this was added).make lint/make cinow pass--all-targetstoo (AGE-600), so a cleanmake lintmatches 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
stableCI uses (e.g. 1.98.1) — clippy findings visible only on newer stable (such as redundant glob imports) can pass locally and fail CI. Runrustup 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
| Crate | Role in the system |
|---|---|
| chatty-core | Single source of business logic: conversations, tools, LLM agents, settings persistence, sandbox, MCP, memory |
| chatty-gpui | Desktop shell: renders UI, owns StreamManager, routes entity events through ChattyApp |
| chatty-tui | Terminal shell + headless/pipe mode for scripting; also the worker process behind every delegation and, with --broker / --team, a leader of its own |
| chatty-wasm-runtime | Wasmtime host for agent modules (wasm32-wasip2) |
| chatty-module-registry | Discovers, validates, and loads WASM module manifests |
| chatty-protocol-gateway | HTTP 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-fabric | The 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-sdk | Authoring SDK for third-party WASM agents |
| chatty-trace / playbook / flow / optimize | Research crates (self-improvement papers); see RESERVED.md |
| hive-client / hive-billing-sdk | Hive 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:
- Create the Tokio runtime and enter it for the whole process lifetime.
- Initialise the settings repositories (
RepositoryRegistry) and resolve — but do not open — the SQLite conversation database path (ConversationSqliteRepository::deferred). - Start the GPUI
Application; insiderun, 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. - Open the window and create the
ChattyAppentity;open_windowpaints the first frame synchronously. - 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
- 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). - Event-driven communication. Entities talk only through
EventEmitter/cx.subscribe(); there are noArc<dyn Fn>callbacks between entities. See entity-communication.md. StreamManagerowns the stream lifecycle. The stream loop never touches a view; it updates theConversationand forwards chunks toStreamManager, which emits typed events that handlers route to views. Cancellation is a sharedAtomicBool. See stream-manager.md.- Global state via GPUI. App-wide state implements
Globaland is reached withcx.global(); entity references inside globals default toWeakEntity<T>so globals do not keep entities alive by accident. - Provider abstraction.
ProviderType::default_capabilities()seeds a new model,ModelConfigpersists per-model capabilities, andAgentFactorybuilds the provider-specific client. - Tools are rig
Toolimplementations registered byAgentFactory; side-effecting tools (shell, file writes) go through the approval stores first. The full list is the tools catalog.
Where to go next
| Question | Document |
|---|---|
Crate boundaries and the gpui-globals feature | workspace-crate-split.md |
| Component relationships & diagrams | component-map.md |
| App components ↔ research modules | research/app-research-bridge.md |
| Entity events between GPUI components | entity-communication.md |
| LLM stream lifecycle | stream-manager.md |
| Agent memory & skills | agent-memory.md |
| Context window / token budget | token-tracking.md |
| Agent quick-start | AGENTS.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
| Module | Responsibility | Does NOT contain |
|---|---|---|
models/ | Pure data + in-memory stores | UI, disk I/O |
settings/ | Config models + JSON repositories | GPUI views |
repositories/ | SQLite + abstraction traits | Business rules |
factories/ | Build AgentClient, register tools | Stream UI updates |
services/ | Long-running / stateful logic | GPUI Render |
tools/ | LLM-callable tool definitions | Agent loop itself |
sandbox/ | Code execution isolation | Tool approval UI |
token_budget/ | Context window accounting | Chat 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:
| Crate | Purpose | GPUI dependency? |
|---|---|---|
| chatty-core | Models, services, tools, settings, repositories, factories | No |
| chatty-gpui | Desktop UI: views, controllers, stream manager, notifiers | Yes |
| chatty-tui | Terminal UI: single-session chat, headless/pipe mode | No |
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:
| Crate | Purpose |
|---|---|
| chatty-fabric | The 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 undercrates/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(¬ifier, |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(viaMcpServerConfig::has_api_key()), not the key.list_mcp_services(tools/list_mcp_tool.rs) is the existing example; itsMcpServerSummaryhas no key field at all, andtest_list_masks_api_keypins 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.
Read next
- 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
| EventEmitter | Callbacks (Arc<dyn Fn>) | |
|---|---|---|
| Handler context | &mut Self directly (subscriber closure) | &mut App only — must clone entity + entity.update() |
| Wiring location | One cx.subscribe() per entity in setup_callbacks() | Scattered set_on_*() calls, one per action |
| Type safety | Typed enum — exhaustive match catches missing arms | Option<Arc<dyn Fn>> — silent no-op if not wired |
| Adding a new action | Add enum variant + match arm | Add type alias + field + None init + setter + clone chain |
| Traceability | Events are #[derive(Debug)] — easy to log | Opaque 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
- Add a variant to the entity’s event enum (e.g.,
SidebarEvent::RenameConversation(String)) - Emit it:
cx.emit(SidebarEvent::RenameConversation(new_title)) - 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
| Event | Emitted by | Handler action |
|---|---|---|
StreamStarted | register_stream, register_pending_stream | Marks the conversation as streaming; sets ChatInputState.is_streaming = true (deferred) |
TextChunk | handle_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 / ToolCallError | handle_chunk | ChatView.handle_tool_call_*() |
ApprovalRequested / ApprovalResolved | handle_chunk | ChatView.handle_approval_*() |
ClarificationRequested | handle_chunk | Records the block on the conversation’s streaming trace (so it survives a switch), then ChatView.handle_clarification_requested() |
TokenUsage | handle_chunk | No-op (usage is processed at finalisation) |
StreamEnded | finalize_stream, stop_stream, cancel_pending, stop_all | Resets 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_pendingand bothregister_*_streamsupersede pathsdropthe turn’s task, takingDesktopSinkand anything still in layer 1 with it. Each of them callsflush_textbefore the drop, so the tail reachesConversation.streaming_message— which is exactly whatfinalize_stopped_streampersists — 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__",StreamStartedis emitted for"__pending__", and the async task first callscreate_new_conversation(), writes the real id intoresolved_id, and callspromote_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.
| Event | Action |
|---|---|
Conversation switch (load_history, clear_messages) | parsed_cache.clear(), streaming_parse_cache = None |
| Stream finalisation | streaming_parse_cache = None |
| Theme change | New 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_reservefromTokenTrackingSettings(default 4096)- Tool count from
ExecutionSettingsModel+ enabled MCP servers (build_tool_hint()) - Warms
GlobalTokenBudget::cachefor 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 everyrig_core::completion::Messageentry, with non-text parts charged a fixed per-item estimate (see “Known limitations”); counted fresh every turn - Latest user message — plain text from
UserContent::Textvariants (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 byllm_service::stream_promptfromAgentClient::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:
| Method | Formula |
|---|---|
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):
| Encoding | Models |
|---|---|
o200k_base | gpt-4o*, o1*, o3*, o4* |
cl100k_base | Everything 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 concatenatesUserContent::Textvariants. 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 fixedNON_TEXT_CONTENT_TOKENSestimate (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::HistoryTokenCachecaches 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, butcompute_snapshot_background()in the desktop manager does not yet hold one, soGlobalTokenBudgetstill 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,
TokenTrackingSettingsis thetoken_trackingsettings family:token_tracking_repository()(TokenTrackingRepository, JSON attoken_tracking.jsonin the config directory, in-memory in tests) is a field onSettingsSnapshot/SettingsDeltaand is covered by the AGE-280 conformance suite.main.rsregistersTokenTrackingSettings::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_usdis 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_reservecheck_pressure()—high_threshold,critical_thresholdfinalize_completed_stream()—auto_summarizeTokenContextBarView—should_show_bar()(hides the bar whenenabled = false)
Footer bar
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’smax_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’smax_tokens, or 4 096.base: the preamble plus the tool schemas, measured once when the agent is built (calibrate_context_shaperinfactories/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.
| # | Stage | Effect |
|---|---|---|
| 0 | Terminal context | Stub every <terminal_context> snapshot a desktop user turn carried (AGE-587) but the newest one: [older terminal snapshot of "…" removed to save context] |
| 1 | Cap | Stub every tool result over 8 KB outside the tail ([tool result truncated — N chars] plus a 200-char preview) |
| 2 | Compact | Replace every tool result outside the tail with a one-liner ([compacted] …, 120 chars) |
| 3 | Snip | Keep 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) |
| 4 | Cap the tail | Stub, 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:
/compactin 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_streamchecksTokenTrackingSettings.auto_summarize(defaultfalse) and the snapshot’sCriticalPressurestatus (default 90 % ofmax_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
/compactwhen 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.
Related
- token-tracking.md: the prompt-token estimate that drives the critical-pressure trigger.
- Architecture Notes in CLAUDE.md: prompt caching and persisted tool turns.
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:
| Tool | Purpose |
|---|---|
remember | Store a piece of information with optional title and tags |
search_memory | Retrieve relevant memories via natural-language query |
save_skill | Store 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:
| Platform | Path |
|---|---|
| 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 storetitle(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 stringtop_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
- At app startup, the
memory_enabledsetting is checked (enabled by default) - If enabled,
MemoryService::open_or_create()is called asynchronously - The service is stored as a global singleton via
cx.set_global() - 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:
| Method | Description |
|---|---|
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
MemoryServiceis 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 type | Where it runs | How it’s called | Configured in |
|---|---|---|---|
| Remote A2A | External HTTP service | Direct HTTP to the remote URL | Settings → Extensions |
| Local agent | A chatty-tui worker the broker spawns from an agent spec | Via the local Protocol Gateway / broker | Agent 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:
| Variant | When |
|---|---|
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:
| Method | Path | Protocol | Description |
|---|---|---|---|
GET | / | — | JSON index of all modules and endpoints |
GET | /.well-known/agent.json | A2A | Aggregated agent card (participants and virtual agents) |
GET | /a2a/{agent}/.well-known/agent.json | A2A | Per-agent card |
POST | /a2a/{agent} | A2A | JSON-RPC: message/send, message/stream, tasks/get |
POST | /mcp/{module} | MCP | JSON-RPC: tools/list, tools/call, initialize |
GET | /mcp/{module}/sse | MCP | HTTP+SSE transport: the event stream |
POST | /mcp/{module}/sse?sessionId=… | MCP | HTTP+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:
| Direction | Message | Fields |
|---|---|---|
| worker → broker | agent.invoke, agent.list, mailbox.post requests | id (the worker’s, never reused on the connection), params (none for agent.list) |
| broker → worker | req.progress notification | id, 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 → worker | result | id, 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 → worker | error | id, 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 → broker | req.cancel notification | id — the worker withdraws its call, its approval or its question |
| worker → broker | human.approve request | id, 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 → broker | human.ask request | id, 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 → worker | human.ask request | id (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 → worker | req.cancel notification | id — 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)
| Origin | Means | Inside the fleet? |
|---|---|---|
local | a process on this machine: a spawned worker running a spec | yes |
fleet | elsewhere in this user’s fleet — a leased microVM registering over vsock | yes |
remote_configured | a URL from Settings → A2A Agents: a third party, chosen deliberately | no |
discovered | learned from another agent’s card rather than configured | no |
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;
tomust be that owner’s name. The owner is whoever spawned the worker: the node whoseinvoke_agentstarted it, recorded when the broker admits it, orroot(ROOT_NAME) when the root asked. The hello’s result says which, and thetoparameter’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 isnot_on_tree; an owner that has ended isrecipient_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 isover_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_agentresult the recipient receives, asmessages: [...]inInvokeAgentOutput(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_messagesviaLazyBroker::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 amessagerow with outcomedelivered_mid_run. Sideways messages between siblings stay refused. -
Drops. When the recipient ends, what is still waiting for it is dropped, one edge-log
messagerow per message with outcomedropped. -
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_parallelon its provider, or anendpoint_budgetsentry, 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.
| Field | Meaning |
|---|---|
agent.name | The name invoke_agent addresses, served at /a2a/{name}; lowercase letters, digits, -, _, and the file’s own name. |
agent.model | Optional. Resolved as chatty-tui --model resolves it (id, then name, then a substring of the model identifier). Absent: the roster’s default. |
agent.preamble | Optional. The role’s standing instructions, appended to the system prompt after the base preamble, before the tool summary. |
tools.profile | Optional. A named tool profile: coordinator, coder or reviewer — see below. |
tools.disable | Optional. 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.skills | Optional. Skills the role is told to read_skill before it starts. |
swarm.delegates_to | Optional. 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.exposed | Optional, default true. false: no other agent may call it. |
swarm.callers | Optional. 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_turns | Optional. The agent’s own turn budget (AGE-440). Absent: an unattended run has no turn cap and a 30-minute time budget. |
budget.max_duration | Optional. The wall-clock budget, as --max-duration writes it. |
budget.cap_usd | Optional. 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.
| Profile | What it can call |
|---|---|
coordinator | The 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. |
coder | The 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). |
reviewer | The 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" }
}
| Field | Meaning |
|---|---|
team.isolate | Optional, default false. Whether each worker of a roster no team claims gets a git worktree of its own (AGE-822). |
team.verification | Optional. 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
}
| Field | Meaning |
|---|---|
leader | The 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). |
agents | The roster, by spec name. Replaces module_settings.virtual_agents for the run. |
verification | Optional. The team’s verification command (team.verification above) for the run. |
isolate | Optional, 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. |
skill | Optional. 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_turns | Optional. 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. |
handoffs | Optional. 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_agentresult ashandoff; - 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) | Leader | Attempts | Judge |
|---|---|---|---|
crosscheck (Crosscheck) | crosscheck-lead | crosscheck-solver-direct, -plan, -verify | crosscheck-judge |
crosscheck-data (Crosscheck: Data) | crosscheck-data-lead | crosscheck-data-direct, -plan, -verify | crosscheck-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 │
└──────────────────────────────────────────────────────────┘
| Crate | Role |
|---|---|
chatty-module-sdk | Guest-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-runtime | Wasmtime 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-registry | Discovery (scan_directory), lifecycle (load/unload/reload), manifest parsing |
chatty-core | tools::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-gateway | HTTP 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):
| Interface | Function | Purpose |
|---|---|---|
llm | complete(model, messages, tools) | A completion on the calling agent’s model, through its provider client |
config | get(key) | Read the manifest’s [config] table |
logging | log(level, message) | Structured logs to the host’s tracing (always granted) |
file | read-bytes(path) | Read under the manifest’s [files] root; a plugin without [files] reads nothing |
billing | acquire-session(estimated_tokens), report-usage(input, output) | Paid plugins; reserves and settles credits against a Hive-signed session token |
Guest export plugin:
| Function | Purpose |
|---|---|
metadata() → plugin-metadata | Name, version, description, requested capabilities, config keys |
list-tools() → definitions | The 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:
| Platform | Path |
|---|---|
| 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.
- Add
nexttoPRODUCTION_ROOT_PUBLIC_KEYSand release chatty. Bothcurrentandnextverify from this point on, so every chatty install can already accept certificates signed by either one. - Switch the registry to signing new publisher certificates with
next. - In the following chatty release, remove
currentfrom the list. Onlynextverifies after that; any certificate still outstanding undercurrentmust have been re-issued undernextbefore 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.
| Limit | Default = ceiling | Enforcement | Error |
|---|---|---|---|
| Fuel | 10¹² units per call | Wasmtime fuel (≈1 unit per Wasm instruction) | fuel exhausted |
| Wall clock | 60 s per call, host time included | Epoch interruption (10 ms ticks); host imports (llm::complete, file::read-bytes, billing) stop waiting at the deadline | deadline exceeded |
| Memory | 256 MiB | Store memory limiter | memory limit |
| Output | 1 MiB per call | Size of each export’s return value | output 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 agentlist_agentsandinvoke_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 = falsehides a spec from listings.chatty_core::services::delegation_policyimplements the check (may_call(caller, callee)for a node,root_may_call(callee)for the root). A plain root (a barechatty-tui, the desktop conversation) may call anyone; its own tools decide whether it delegates at all. A--teamleader 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 aroot_task_idminted when the root starts a call. Every run under that root call shares theroot_task_id, on every local hop (pinned byroot_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.invokenames) 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::extendrefuses a cycle (the callee is already on the chain) and a call deeper thanMAX_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 asError: invoke_agent: cycle: …,too_deep: depth 5 > max 4ornot_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_agentsends what is left with every call (InvokeAgentParams.remaining). A used-up limit is refused before spawn asbudget_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_graceand 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 orescalate, 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:
- Builds
target/release/chatty-tuiwhen it is missing or older than the sources (or usesCHATTY_TUI=<path>). - Builds the
chatty-team-smokeimage (scripts/team-smoke/Dockerfile:ubuntu:24.04pluspython3,git,ca-certificates). - Writes a throwaway
HOMEundertarget/team-smoke/run-<stamp>/home/:providers.jsonpointing at Ollama,models.jsonwith the leader model (extra_params.think=false) and the coder model, and thecoder-reviewerteam (a test fixture since AGE-752, incrates/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. - Creates the fixture repo from
scripts/team-smoke/fixture/: a bank account module whosewithdrawallows overdrafts and negative amounts, one passingtest_deposit, andtests/__init__.pysounittestdiscovery works. - 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”;--teaminjectsread_skill coder-reviewer and follow itand the verification command on the first turn. - Runs
scripts/team-smoke/verify.shin a second container on the tree the leader left: the tests on the merged working tree, hidden checks (depositstill returns the balance;withdrawraisesValueErroron 0, negative and excess; a successful withdrawal returns the new balance), at least four tests defined, no unmergedsub-agent/*branches. It printsREWARD=0|1and the script exits accordingly. - Prints a one-screen summary (also saved as
summary.txtin the run directory): worker processes spawned per role,invoke_agentdelegations 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 withOLLAMA_HOST=0.0.0.0:11434(or pointOLLAMA_URLat wherever it listens).curl http://172.17.0.1:11434/api/versionfrom the host is the quick check. - The two models pulled:
ollama pull qwen3:14bandollama 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-Nbranch and answersBLOCKED, 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
withdrawwithout tests and the reviewer approves (the task text is a numbered list for that reason); - the coder’s report ends up committed as
answer.txton its branch, and avenv/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-urlat Ollama’s/v1needed AGE-403’s normalization so discovery and chat agree; the script sidesteps the question by writing anollamaprovider intoproviders.json, which the worker processes read from the same mountedHOME. - 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. Checkollama psandnvidia-smibefore a run and unload what you do not need. - A binary not named
chatty-tui. The broker finds the worker executable aschatty-tuinext to the running binary (chatty_core::tools::worker_executable). The script mounts whateverCHATTY_TUInames 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=falseis set on the leader model only.qwen3:4bwith thinking off narrates instead of working; leave the coder model’sextra_paramsempty. - The container keeps running after the timeout.
timeoutkills 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(behindchatty-core/test-support):FakeDaemon, a localhost model server.FakeDaemon::scripted(Script)answers from a script;FakeDaemon::ollama/openai_compatiblereplay canned bodies in order (whatsession::append_only_prefixuses).crates/chatty-tui/src/participant/swarm_kit.rs(test-only):SwarmKit, a real rootBrokerwhose roster’s models point at two fake servers, spawning realchatty-tuiworkers.
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.
| Reply | Effect |
|---|---|
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::Sseis an OpenRouter-type provider,Endpoint::Ndjsonan 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’smodule_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()’schatty-tuibehind a two-lineshwrapper that only setsHOMEand the XDG dirs to the kit’s temp dir, so it never reads your real settings.cargo test -p chatty-tuibuilds the binary (tests/worker_binary.rsis what makes it);CHATTY_WORKER_EXEoverrides which binaryworker_executable()picks. normalize/parent_tracestrip 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
| Module | Paper | Lands in | What it adds |
|---|---|---|---|
| M0 Trace | — (contract layer) | chatty-trace | The Trajectory every optimizer reflects on; per-step attribution; ATIF round-trip; FeedbackFn |
| M1 ReAct | Yao et al., ICLR 2023 | chatty-core (loop already ships) + chatty-trace | Fidelity to the paper’s action space; strategy variants for eval |
| M2 AFlow | Zhang et al., ICLR 2025 | chatty-flow (IR + interpreter), chatty-optimize (MCTS) | Workflow topology search offline; saved workflows run in-app |
| M3 GEPA | Agrawal et al., ICLR 2026 | chatty-optimize | Reflective prompt optimization of ModelConfig.preamble |
| M4 ACE | Zhang et al., ICLR 2026 | chatty-playbook | Evolving playbook over the memory/skills store with deterministic merge |
| M6 Retrieval | Retrieval 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 aschatty-core;chatty-optimizeis 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)
| Doc | Decision |
|---|---|
| harbor-pivot.md | Stage B evaluation lives in Harbor, not in chatty2 |
| appworld-decision.md | Which AppWorld sandbox to evaluate against |
| cost-model.md | How 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 component | Code location | User-facing | Research module(s) | Promotion path |
|---|---|---|---|---|
| Agent loop | services/llm_service.rs, factories/agent_factory/ | Every chat turn | M1 ReAct | Already default; strategy variants TBD |
| LLM tools (~60) | tools/ | Tool calls in chat | M1 (action space), M2 (workflow nodes) | Tools ship; topology search offline |
| Sub-agents | invoke_agent_tool, list_agents_tool | Agent delegation | M2 AFlow | Winning IR → FlowSettingsModel (planned) |
| Memory store | services/memory_service.rs, memory.mv2 | remember / search_memory | M4 ACE, M6 Retrieval (Part B) | Playbook on same store; scope TBD (AGE-47) |
| Web search | tools/search_web_tool.rs, tools/fetch_tool.rs | search_web / fetch | M6 Retrieval (Part A) | Provider chain + keyless tier as default if the eval shows it |
| Skills | save_skill_tool, SKILL.md files, [SKILL] prefix | Procedures in context | M4 ACE (facts vs procedures split) | Self-improving playbook vs manual skills |
| Context window | token_budget/ + GPUI footer bar | Token fill indicator | M3 cost, M4 prefix-cache | Estimation and compaction ship |
| Model preamble | ModelConfig.preamble | Settings → model system prompt | M1 few-shot block, M3 GEPA | User-editable; GEPA optimizes offline |
| Trace / ATIF | exporters/types.rs, StreamManager | Training export toggle | M0 Trace | Export setting; full recorder TBD |
| Token pricing | ModelConfig.cost_per_million_* | Cost display (sidebar) | M3 GEPA, cost model | Ships; optimizer accounting uses it |
| Code sandbox | sandbox/, Monty bridge | Code execution setting | M2 Monty code mode (future PythonRepr) | Subprocess today; pause/resume deferred |
| Conversation history | ConversationsStore, SQLite | Chat threads | All modules (trace input) | Ships; compaction/summary future |
| Training export | TrainingSettingsModel, exporters | Opt-in ATIF/JSONL | M0 round-trip, M3/M4 offline | atif_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.
| Concern | Doc | Key files |
|---|---|---|
| Stream lifecycle | stream-manager.md | chatty-gpui/.../stream_manager.rs |
| Entity wiring | entity-communication.md | app_controller/ |
| Provider agents | system-overview.md | factories/agent_factory/ |
Research touchpoints
| Module | What changes | Status |
|---|---|---|
| M1 | Per-step ModuleId in trace; CoT/Act/SC variants for eval | Fidelity WIP |
| M0 | Hook + rig-tap capture → Trajectory; no hand-rolled loop | Not started |
| M2 | Interpreter replaces flat loop with saved workflow IR | Not 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.
| Concern | Doc | Key files |
|---|---|---|
| Memory architecture | agent-memory.md | memory_service.rs, remember_tool.rs |
| Skill tools | — | save_skill_tool.rs, search_memory_tool.rs |
| Toggle | — | ExecutionSettingsModel.memory_enabled |
Research touchpoints (ACE)
| Paper concept | Production analogue | Gap |
|---|---|---|
| Sectioned playbook | Facts vs [SKILL] procedures | No Reflector/Curator yet |
Delta ops + deterministic apply | Manual remember / save_skill | No execution-feedback merge |
| Grow-and-refine / de-dup | Unbounded memvid growth | Production eviction policy needed |
| Prompt-cache prefix stability | Stable skill ordering in search results | Byte-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.
| Concern | Doc | Key files |
|---|---|---|
| Token tracking | token-tracking.md | token_budget/, GlobalTokenBudget |
| Response reserve | — | TokenTrackingSettings.response_reserve (default 4096) |
| Summarization | context-compaction.md | summarizer.rs — summarize_oldest_half ships (/compact, desktop auto-summarise); only the secondary-model variant summarize_with_model is still a stub |
Research touchpoints
| Module | Connection |
|---|---|
| M3 GEPA | Shorter optimized preambles affect context headroom; cost formula uses same token counts |
| M4 ACE | Playbook growth competes for context; paper assumes prompt-cache prefix reuse |
| M0 | RolloutBudget 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
| Field | M1 | M3 |
|---|---|---|
preamble | Few-shot exemplar stable prefix | GEPA optimization target |
temperature | Paper regimes | Fixed 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.
| Setting | Default | Purpose |
|---|---|---|
atif_auto_export | off | Write ATIF JSON after completion |
jsonl_auto_export | off | SFT/DPO JSONL export |
Research touchpoints
| Gap | Module | Why it matters |
|---|---|---|
No Deserialize on ATIF types | M0 | Reflection must read traces back |
No per-step ModuleId | M0 | GEPA credit assignment |
No FeedbackFn | M0 | GEPA µ_f, ACE reflector input |
| Full vs no retention | M0 | Recorder 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 cluster | Examples | Research use |
|---|---|---|
| Search / fetch | search_web, fetch | M1 WikiEnv adapter (paper fidelity); M6 Retrieval Part A (SimpleQA/FRAMES eval) |
| Code | run_command, sandbox | M2 Programmer operator |
| Agents | invoke_agent | M2 Ensemble / Review-and-Revise nodes |
| Memory | remember, search_memory | M4 playbook backing store |
Research touchpoints (AFlow)
| Production substrate | M2 uses it as |
|---|---|
tool_registry / tool_collector | Workflow node targets |
invoke_agent_tool | Ensemble 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).
| Optimizer | Reads from app | Writes to app |
|---|---|---|
| GEPA (M3) | Traces, ModelConfig, cost fields | preamble candidates |
| AFlow (M2) | Traces, sub-agent registry | Workflow IR JSON |
| ACE offline (M4) | Traces, memory store | Playbook delta ops |
Stage B evals run in harbor-chatty, not inside the app binary.
Quick lookup: research module → app landing
| Module | Lands in app at… | Architecture doc |
|---|---|---|
| M0 Trace | exporters/, StreamManager, future Recorder | stream-manager.md |
| M1 ReAct | llm_service.rs, agent_factory/ | system-overview.md |
| M2 AFlow | Sub-agent tools + future FlowSettingsModel | component-map.md |
| M3 GEPA | ModelConfig.preamble | settings-integration-map |
| M4 ACE | memory_service, skill tools | agent-memory.md |
| M6 Retrieval | search_web_tool.rs, fetch_tool.rs, search_memory_tool.rs, doc_retriever_tool.rs | agent-memory.md, evals/search/README.md (archived) |
Related
- Component map — crate/module/entity diagrams
- System overview — three-layer mental model
- Module pages — per-paper fidelity and eval protocol
- Experiment protocol — Stage A/B bar and promotion inputs
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
| Stage | Where | Purpose | Who interprets results |
|---|---|---|---|
| Stage A | chatty2 research crates | Fidelity to each paper’s mechanism; trace contracts; in-process optimizers | The human reviewer (owner:human / owner:pair on reserved symbols) |
| Stage B | harbor-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:
| Outcome | Meaning | Example (illustrative — decided by experiment) |
|---|---|---|
| Rejected | Mechanism stays in research/optimizer tooling only; not exposed in the app | CoT-SC hybrid backoff as a user-facing strategy if ReAct + tools already wins for Chatty’s workloads |
| Setting | Available but off by default; user picks per task/model/conversation | workflow_enabled + active_workflow_id (AFlow IR); ACE playbook scope; optimizer-launched preamble variants |
| Default | Becomes the normal path when evidence shows clear dominance | ReAct 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
| Module | Paper | Primary crate(s) | Ships in app? | Production landing site | Typical promotion |
|---|---|---|---|---|---|
| M1 | ReAct (ICLR 2023) | chatty-core loop + chatty-trace | Loop: yes; strategy variants: eval-only | Existing agent loop; per-step attribution in trace | Loop = default; Act/CoT/CoT-SC hybrids = setting or eval-only |
| M2 | AFlow (ICLR 2025) | chatty-flow (runtime IR) + chatty-optimize (MCTS) | IR interpreter: yes; search: build-time only | Sub-agent composition via saved workflow | Search = offline; winning topology = setting (FlowSettingsModel) |
| M3 | GEPA (ICLR 2026) | chatty-optimize | No (on-demand / CI) | ModelConfig.preamble | Optimized preamble = setting per model; apply via review/export (AGE-45) |
| M4 | ACE (ICLR 2026) | chatty-playbook | Yes | Memory / [SKILL] store backing | Playbook merge/refine in runtime; grow bounds = production concern; scope = setting (AGE-47) |
| — | Trace / ATIF contract | chatty-trace | Yes (recorder opt-in) | ATIF export + reflection input | Full retention = setting; no-op recorder = default in release |
| M5 | DGM | agenticloop (separate repo) | No | Never against chatty2 itself | Stays 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 reviewer | Agents |
|---|---|
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 issues | Implementing decided policy |
| Choosing reject / setting / default per mechanism | Documenting the decision in ADRs |
| Posting benchmark numbers before agents may cite them | Building 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:
| Issue | Question |
|---|---|
| AGE-44 | Where users start optimizer runs (GPUI vs chatty-tui --optimize vs CI) |
| AGE-45 | Auto-apply vs review-and-approve vs export-only for optimized artifacts |
| AGE-46 | Dataset bundling in the product binary |
| AGE-47 | Playbook 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:
| Layer | Question | Location | Status |
|---|---|---|---|
| Pipeline | Why does research exist and how does it become product? | This page | Active |
| App bridge | Which app components map to which research modules? | app-research-bridge.md | Built |
| Architecture | What runs where at runtime? | system-overview.md, component-map.md | Built |
| Paper fidelity | What did the paper actually claim? | research/modules/ | Built (M0–M4) |
| Crate promises | What does each shipping crate guarantee? | Each crate’s README.md (chatty-trace, chatty-playbook, chatty-flow) | Built |
| ADRs | Why this fork (Harbor, AppWorld, cost model)? | docs/research/*.md | Partial |
| Promotion record | What shipped, as what, with what evidence? | Recorded by the human reviewer per verdict (archived template) | No verdicts yet |
| Settings map | Which settings map to which mechanisms? | settings-integration-map.md | Built (gates open) |
| Experiment protocol | How to run Stage A/B rigorously? | experiment-protocol.md | Built |
| Reference | What can I configure today? | Generated tools/CLI/env pages | Partial |
Completed doc layers
Per-paper module pages—modules/Settings integration map—settings-integration-map.mdExperiment protocol—experiment-protocol.md
Still open
- Link checker CI (DOC-50)
- README →
/user/migration (DOC-15, human review)
Related reading
RESERVED.md— symbols agents must not implement- Production bar (AGE-26) — shipping crate requirements
- Cross-module experiments (AGE-21) — human-only interpretation
cost-model.md— optimizer economicsAGENTS.md— agent quick-start and workspace map
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
| Stage | Location | Pass criterion |
|---|---|---|
| A — Fidelity | chatty2 research crates | Mechanism matches paper; acceptance criteria in module page checked |
| B — Task value | harbor-chatty | Gain on representative tasks under paired stats; cost within budget |
Neither stage auto-promotes; see “Promotion decision inputs” below.
Stage A checklist (per module)
- Read the module page acceptance criteria.
- Run with fixed seed + warm response cache where determinism is claimed.
- Separate training vs validation rollouts (critical for M3 GEPA).
- Log artifact token counts when comparing ACE vs GEPA (M4 vs M3).
- File failures as Linear issues — do not patch reserved symbols.
Stage B checklist
- Use Harbor adapter (AGE-34) — not in-repo sandboxes.
- Report paired differences with confidence, not single-run deltas.
- Use cost model with live
ModelConfigprices — never hand-typed dollars. - 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
| Claim | Requirement |
|---|---|
| Byte-identical traces | Same seed + warm cache |
| Reproducible workflow score | Serialize → reload → re-execute |
| Stable playbook serialization | No-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):
- Effect size — paired stats, MDE met?
- Cost / latency — acceptable on desktop agent?
- Production bar — AGE-26 satisfied for shipping crates?
- Product gates — AGE-44–47 answered?
- Verdict —
rejected/setting/default
Related
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 model | Field(s) | Research link | Promotion today |
|---|---|---|---|
ModelConfig | preamble | M1 few-shot block; M3 GEPA target | User-editable default |
ModelConfig | temperature, cost_per_million_* | M3 cost accounting | Ships |
ModelConfig | supports_images, supports_pdf, … | — | Ships (unrelated) |
TrainingSettingsModel | atif_auto_export | M0 trace export toggle | Setting |
ModuleSettingsModel | virtual_agents | Named worker roles for the small-teams experiment (ADR-0011 C10, dev/design/fleet.md §2) | Ships (JSON only, no GPUI page) |
ModuleSettingsModel | team.verification | The 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 model | Field(s) | Research link | Gate issue | Status |
|---|---|---|---|---|
FlowSettingsModel | workflow_enabled, active_workflow_id | M2 AFlow IR | AGE-54 | Not implemented |
| Playbook storage | scope TBD | M4 ACE | AGE-47 | Blocked — human product decision |
| Optimizer launcher | CLI vs GPUI vs CI | M2, M3 offline runs | AGE-44 | Blocked |
| Artifact apply policy | auto / review / export | M2 IR, M3 preamble, M4 deltas | AGE-45 | Blocked |
| Dataset paths | bundled vs user-supplied | Stage A loaders | AGE-46 | Blocked |
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 area | Purpose | Module |
|---|---|---|
SelectBestCandidate vs Pareto | GEPA ablation | M3 |
| Merge on/off | GEPA+Merge | M3 |
| Strategy enum | ReAct / CoT / CoT-SC / hybrids | M1 |
| DGM archive disable | M5 in agenticloop | — |
See crates/chatty-optimize/src/ablation.rs.
Persistence paths
| Data | Typical path |
|---|---|
| Models + preambles | JSON via models_repository |
| General settings | JSON via general_settings_repository |
| Conversations | SQLite |
| Agent memory / skills | memvid via memory_service |
| Playbook (future) | TBD — AGE-47 |
Related
- Experiment protocol — how the setting-vs-default verdict is reached
- App ↔ research bridge — component-level mapping
- Module pages
- Chatty agentic product integration (Linear)
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.
| Concern | Location |
|---|---|
| Containerized Stage B (HumanEval, Polyglot, AppWorld, ALFWorld) | harbor-chatty — Linear AGE-34 |
| Paired stats, MDE, ablation flags, QA loaders for optimizers | chatty-optimize |
FeedbackFn / ATIF round-trip | chatty-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
| Field | Status |
|---|---|
| Sheet template + estimator | chatty_optimize::cost |
| Prices from ModelConfig | API ready (row_from_model_prices) — fill per run from live settings |
Measured token means via rig-tap | Blocked on AGE-5 / AGE-22 (rig-tap not wired yet) |
| Per-module caps + subset sizing | After 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).
| Run | p50 | p95 | mean | min | max |
|---|---|---|---|---|---|
| 1 | 463.7 ms | 523.8 ms | 469.8 ms | 423.5 ms | 566.1 ms |
| 2 | 474.1 ms | 531.9 ms | 479.4 ms | 423.2 ms | 561.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.rson a local branch from1ea4bb0b, driving the freeencode_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
BrokerCodecand aWorkerCodecper 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.
| Run | Load average (1/5/15 min) | encode | decode |
|---|---|---|---|
| v2, 1st | 3.01 / 7.41 / 8.57 | 546.18 [544.78, 548.29] | 708.31 [703.02, 722.14] |
| v3, 1st | 2.68 / 6.97 / 8.39 | 217.88 [216.86, 220.51] | 584.67 [583.32, 586.09] |
| v2, 2nd | 4.60 / 7.15 / 8.42 | 558.41 [557.66, 559.27] | 707.94 [707.21, 709.00] |
| v3, 2nd | 3.73 / 6.73 / 8.25 | 216.46 [216.29, 216.74] | 576.98 [576.37, 577.67] |
| v2 median | v3 median | v3/v2 | |
|---|---|---|---|
| encode (mean of the two runs) | 552.3 µs | 217.2 µs | 0.39 |
| decode (mean of the two runs) | 708.1 µs | 580.8 µs | 0.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.
| Run | Load average (1/5/15 min) | encode | decode |
|---|---|---|---|
| v2, 1st | 5.76 / 9.31 / 9.62 | 524.19 [522.29, 526.83] | 659.09 [657.93, 660.86] |
| v3, 1st | 4.76 / 8.86 / 9.46 | 207.21 [206.68, 208.07] | 566.95 [565.27, 568.44] |
| v2, 2nd | 3.84 / 8.38 / 9.29 | 523.31 [520.79, 524.72] | 657.42 [655.31, 659.39] |
| v3, 2nd | 3.11 / 7.92 / 9.11 | 208.12 [206.90, 209.02] | 571.54 [569.19, 574.00] |
| v2 median | v3 median | v3/v2 | |
|---|---|---|---|
| encode (mean of the two runs) | 523.8 µs | 207.7 µs | 0.40 |
| decode (mean of the two runs) | 658.3 µs | 569.2 µs | 0.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
| Module | Paper | Crate(s) | Stage | Promotion |
|---|---|---|---|---|
| M0 Trace | — (contract layer) | chatty-trace | Not started | Pending |
| M1 ReAct | Yao et al. ICLR 2023 | chatty-core, chatty-trace | Loop ships; fidelity WIP | Loop = default |
| M2 AFlow | Zhang et al. ICLR 2025 Oral | chatty-flow, chatty-optimize | Not started | Pending |
| M3 GEPA | Agrawal et al. ICLR 2026 Oral | chatty-optimize | Not started | Pending |
| M4 ACE | Zhang et al. ICLR 2026 | chatty-playbook | Not started | Pending |
| M6 Retrieval | Retrieval literature (BM25, RRF, rerankers, packing) | chatty-core tools, chatty-optimize eval | Eval built; improvements gated | Pending |
M5 (DGM) lives in the separate agenticloop repo — not documented here. See
RESERVED.md.
Status legend
| Field | Values |
|---|---|
| Stage | not started · stage-a · stage-b · promotion-pending |
| Promotion | pending · 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:
- Per-module attribution — which prompt produced each step (GEPA credit assignment)
- ATIF round-trip —
Deserializepath so reflection reads trajectories back in FeedbackFn— scalar + natural-language feedback per module (GEPA’sµ_f)
Chatty mapping
| Paper concept | Chatty location | Status |
|---|---|---|
Trajectory (thought, action, observation)* | chatty-trace::Trajectory | Reserved — human writes |
Action::Language (ReAct’s  = A ∪ L) | chatty-trace::Action | Reserved |
Per-step ModuleId | Extension over AtifStep | To build |
| ATIF export | chatty-core exporters (Serialize-only today) | Round-trip in M0 |
| Trace retention | Recorder trait, no-op in release | To build |
| Train vs validation rollouts | RolloutBudget 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
| Mechanism | Likely promotion |
|---|---|
No-op Recorder in release | Default |
| Full step retention | Setting (TrainingSettingsModel.atif_auto_export already exists) |
FeedbackFn implementations | Offline / optimizer only |
Reserved symbols (human only)
Trajectory, Step, Action, Outcome, FeedbackFn — see RESERVED.md.
Depends on
Further reading
chatty-traceREADME — crate scope (the pre-Stage-A promise page is archived)- Production bar (AGE-26)
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 concept | Chatty location | Status |
|---|---|---|
| ReAct loop | chatty-core llm_service.rs + agent_factory/ | Ships today |
| ~60 tools | chatty-core/src/tools/ | Ships |
| Wikipedia env | Adapter over search_tool / fetch_tool | Stage A |
| Strategy variants (Act, CoT, CoT-SC, hybrids) | chatty-optimize::Strategy | Reserved backoff rule |
| Per-step trace | M0 Trace | WIP |
| Few-shot exemplar prefix | ModelConfig.preamble | Ships (user-editable) |
Do not rebuild the loop. Extend fidelity and capture only.
Eval protocol
Stage A:
- Real HotpotQA run →
Trajectoryround-trips with per-stepModuleId - Four prompt regimes: dense/sparse × QA/interaction
- Wikipedia
lookupreturns 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
| Mechanism | Likely promotion |
|---|---|
| ReAct loop with tools | Default (already) |
| CoT / CoT-SC / Act / hybrid strategies | Setting or eval-only |
| WikiEnv adapter | Eval 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 concept | Chatty location | Status |
|---|---|---|
| Workflow representation | WorkflowRepr trait | Reserved (WfNode) |
| IR interpreter | chatty-flow → sub-agent / tool registry | To build |
| MCTS search | chatty-optimize (offline only) | To build |
| Composition substrate | sub_agent_tool, invoke_agent_tool, list_agents_tool | Ships |
| Monty code mode (future) | sandbox/monty.rs, sandbox/monty_bridge.rs | MontySandbox 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 workflow | Future FlowSettingsModel | Gate: 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
| Mechanism | Likely promotion |
|---|---|
| MCTS search | Offline / CI only — reject from request path |
IrRepr interpreter | Setting (workflow_enabled, active_workflow_id) |
| Winning workflow JSON | User-selected artifact via apply policy (AGE-45) |
Reserved symbols
WorkflowRepr, WfNode, soft_mixed_select, SelectionStrategy — see
RESERVED.md.
Depends on
Further reading
chatty-flowREADME — crate scope (the pre-Stage-A promise page is archived)- Sandbox source —
monty.rs,monty_bridge.rs,manager.rs
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:
- 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.
FeedbackFn(µ_f) — scalar plus natural-language traces (compiler output, missing docs, failed constraints), optionally per module. Scalar-only = prompt shuffling.- 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 concept | Chatty location | Status |
|---|---|---|
| Module prompts | ModelConfig.preamble | Ships (user-editable) |
| Compound system | ReAct loop (M1) or IrRepr (M2) | M1 first; M2 later |
| Pareto archive | chatty-optimize | To build |
select_candidate | chatty-optimize/src/gepa/select.rs | Reserved |
merge | chatty-optimize/src/gepa/merge.rs | Reserved |
REFLECTION_META_PROMPT | chatty-optimize/src/gepa/prompts.rs | Reserved |
| Train vs validation rollouts | chatty-trace budget view | To build |
| Per-model cost | ModelConfig.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
µ_ffor 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
| Mechanism | Likely promotion |
|---|---|
| Full GEPA search | Offline / chatty-tui --optimize / CI (AGE-44) |
| Optimized preamble | Setting per model |
| Apply optimized preamble | Review UI / export / auto (AGE-45) |
| Merge operator | Ablation flag, default off |
Reserved symbols
select_candidate, merge, REFLECTION_META_PROMPT — see RESERVED.md.
Depends on
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 concept | Chatty location | Status |
|---|---|---|
| Facts / procedures store | memory_service, remember_tool, save_skill_tool | Ships |
| Sectioned playbook | chatty-playbook::Playbook | To build |
| Delta merge | chatty-playbook::apply | Reserved |
| Grow-and-refine | chatty-playbook::grow_and_refine | Reserved |
| Generator | Existing running agent | Ships |
| Reflector / Curator | New rig_agent::Agents | To build |
| Stable section order | BTreeMap serialization | Required for prompt-cache cost story |
Product prize: playbook grows from execution feedback, not only when the agent calls
remember.
Eval protocol
Stage A:
applypure: 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
| Mechanism | Likely promotion |
|---|---|
Deterministic apply + runtime playbook | Setting (scope TBD) |
| Playbook scope | Global / per-model / per-conversation (AGE-47) |
| Eviction policy | Default bounds required for desktop app (not paper’s 10K–100K knob) |
| Offline multi-epoch warmup | Optimizer feature, not request path |
Reserved symbols
apply, grow_and_refine — see RESERVED.md.
Depends on
Further reading
chatty-playbookREADME — crate scope (the pre-Stage-A promise page is archived)- agent-memory.md
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):
| Stage | Question it answers | Part A: open web | Part B: memory |
|---|---|---|---|
| Query formulation | What string do we search for? | The model writes a search_web query; the Wikipedia backend reduces it to content words joined with OR | The model writes a search_memory query |
| Candidate retrieval | Which items could be relevant? | Keyed: Tavily / Brave API. Keyless: Wikipedia search API in parallel with a Bing → DuckDuckGo scrape | memvid BM25 (always), memvid vector search (opt-in) |
| Fusion | How 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 |
| Reranking | Which few candidates are best? | Keyless: BM25 over passages of the fetched pages; with a reranker configured, a cross-encoder orders a 10-candidate pool | None |
| Context packing | What 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_webrepeatedly; 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_webstring.
- 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.k1sets how fast repeated occurrences stop adding score. The tenth “Paris” in a page is worth far less than the first.bsets 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 thereavdland 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.
kstops 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] passagegoes 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_weboutput 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.
- Each score is min-max normalised to [0, 1]:
- 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).
- Thought → search action → observation. This is the loop that calls
- 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/searchwithsearch_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.
- A Tavily key calls
- Keyless:
- The tool scrapes Bing (
b_algoblocks, 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.
- The tool scrapes Bing (
- Every result carries a
sourcetag (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 withstart_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
200with real results, but in a new markup: single-quotedclass='result-link'and//duckduckgo.com/l/?uddg=redirect hrefs. The parser expects double quotes andhttphrefs, 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
wikipediasource. 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.
| Config | Set | error | empty | hit@1 / @5 | FRAMES recall@10 | p50 / p95 ms |
|---|---|---|---|---|---|---|
| Keyed (Tavily) | SimpleQA DEV | 10.5%¹ | 0% | 59.5% / 70.0% | — | 1200 / 3517 |
| Keyless (Bing → DDG) | SimpleQA DEV | 47% | 46% | 0% / 0% | — | 683 / 1271 |
| Keyless | SimpleQA HOLDOUT | 74% | 15% | 0% / 0% | — | 688 / 788 |
| Keyless | FRAMES single DEV | 57% | 15% | 0% / 0% | 0% | 221 / 846 |
| Keyless | FRAMES fan-out DEV | 26% | 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
ORlet 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).
- Wikipedia’s search ANDs every term, so a whole question finds nothing. The content
words joined with
- 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
contentis 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-m3on the local GPU behind a/rerankendpoint. - 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
| Config | Set | error+empty | hit@1 | hit@5 | hit@5 without Wikipedia | recall@10 | all-sources@10 | p50 / p95 |
|---|---|---|---|---|---|---|---|---|
| Keyless | SimpleQA (200) | 93% → 0% | 0% → 49% | 0% → 57% | 0% → 27% | — | — | 0.7/1.3 s → 1.4/3.2 s |
| Keyless | FRAMES 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 |
| Keyless | FRAMES 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)
| Config | Set | error+empty | hit@1 | hit@5 | hit@5 without Wikipedia | recall@10 | all-sources@10 |
|---|---|---|---|---|---|---|---|
| Keyless | SimpleQA (100) | 89% → 0% | 0% → 40% | 0% → 43% | 0% → 0% | — | — |
| Keyless | FRAMES single (50) | 76% → 0% | 0% → 4% | 0% → 12% | 0% → 2% | 0% → 38.3% | 0% → 12% |
| Keyless | FRAMES 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)
| Tavily | Keyless (final) | Keyless without Wikipedia | |
|---|---|---|---|
| All | 78% | 48% | 14% |
| Never-changing (33) | 91% | 55% | 21% |
| Slow-changing (32) | 91% | 62.5% | 16% |
| Fast-changing (35) | 54% | 29% | 6% |
| error+empty | 0% | 0% | 69% |
Targets
| Target | Result |
|---|---|
| Keyless error+empty < 5% | Met (0% on DEV and HOLDOUT) |
| Keyless SimpleQA hit@5 within 10 points of Tavily | Not 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
| Change | Paper’s promise | What happened | Why |
|---|---|---|---|
| Browser escalation | a real browser passes bot checks | 0 gains, slower | blocking is per IP, not per client |
| BM25 rerank of a wider pool | two-stage retrieval | 11 gained / 14 lost | lexical overlap cannot separate the answer page from pages that repeat the question |
| Multi-query + RRF | fusion by agreement | all-sources@10 21% → 14% | multi-hop needs coverage, and RRF rewards agreement |
| Pool of 20 | deeper first stage | +2 n.s., p95 ×2.3 | the slowest page fetch decides; distractors grow with depth |
| Passages on the keyed path | select what the model reads | +2.5 n.s., p95 ×2 | Tavily’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). Everyremembercall 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_memoryquery is used verbatim; there is no rewrite. - There is no automatic recall. The per-turn injection (
auto_context.rs) was removed in6cef438f(“improved ttft”, 2026-04-29). The preamble now tells the model to callsearch_memoryproactively. simplify_memory_queryandbuild_memory_context_blocksurvive 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
vecfeature (HNSW) is not compiled in, although code comments say “HNSW”. The score is1/(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_hitskeeps 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
/rerankendpoint for web and memory, and BGE-M3 as the matching embedder, which needs a re-embedding migration.
| Stage | Share between A and B? | Why / why not |
|---|---|---|
| Query formulation | Partly. 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 retrieval | No | Different indexes: a remote engine vs. a local Tantivy + vector store. Different failure modes: rate limits and bot detection vs. empty stores. |
| Fusion | Only for B. RRF for memory’s lexical + vector lists | Both merge incomparable scores. In A, RRF across sub-queries was measured to hurt multi-hop (iteration 8), so A keeps interleaving. |
| Reranking | Yes: 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 packing | Yes: 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:
| Property | Open web (A) | Memory (B) | Consequence |
|---|---|---|---|
| Corpus size | Effectively infinite, remote | Hundreds to thousands of frames, local | A has to delegate candidate retrieval to an engine; B can afford exact search |
| Freshness | Changes daily | Changes only when the user or agent writes | A needs no cache TTL beyond a session; B can cache embeddings indefinitely |
| Trust | Adversarial (SEO, injection, decoys) | Mostly the user’s own words, but contains past web text | A needs decoy/challenge detection and untrusted-content labels; B needs provenance |
| Latency | 0.5–3 s per call, 1–3 s more for a browser | Milliseconds | A should run backends in parallel; B can run several retrievers sequentially |
| Relevance labels | Public benchmarks (SimpleQA, FRAMES) | None | A 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
| Mechanism | Outcome |
|---|---|
| DDG parser fix, Wikipedia API in parallel, loud failures | Default for keyless (shipped with this module) |
| Fetch + BM25 best passage in snippets | Default for keyless; not used with Tavily/Brave (no significant gain, twice the p95) |
| Cross-encoder rerank of a 10-candidate pool | Setting: needs a local /rerank server (AGE-521) |
| Browser escalation, multi-query + RRF, pool of 20 | Rejected on measurement; patches kept outside the repo for a re-test |
| Keyed retry + keyless failover | Not 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.
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.- BM25 passage scoring:
tools/passages.rs::score_passages, shipped (iteration 4). Its statistics come from a tiny, per-query collection. 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
Further reading
- agent-memory.md: memory architecture.
- App ↔ research bridge.
evals/search/README.md(archived atce57cc906c): how the eval was run.
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).
| Crate | crates.io / docs.rs | Local rustdoc |
|---|---|---|
| Application + WASM + Hive crates | Unpublished | cargo doc -p <crate> --open |
chatty-trace, chatty-playbook, chatty-flow, chatty-optimize | publish = false | cargo 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
| Crate | Purpose | README |
|---|---|---|
| chatty-core | UI-agnostic agent core: models, services, tools, settings, sandbox | crates/chatty-core/ |
| chatty-gpui | GPUI desktop app — ships the chatty binary | crates/chatty-gpui/ |
| chatty-tui | Ratatui terminal app — interactive, headless, and pipe modes | crates/chatty-tui/ |
WASM modules & protocols
| Crate | Purpose | README |
|---|---|---|
| chatty-wasm-runtime | Wasmtime embedding and host-side WIT interfaces | crates/chatty-wasm-runtime/ |
| chatty-module-registry | Module discovery, manifest, and lifecycle | crates/chatty-module-registry/ |
| chatty-protocol-gateway | HTTP gateway: OpenAI / MCP / A2A protocol surfaces | crates/chatty-protocol-gateway/ |
| chatty-module-sdk | SDK for authoring wasm32-wasip2 agent modules | crates/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.
| Crate | Purpose | README |
|---|---|---|
| chatty-trace | Trace capture, ATIF export, feedback hooks (M0) | crates/chatty-trace/ |
| chatty-playbook | ACE-style playbook memory for evolving instructions (M4) | crates/chatty-playbook/ |
| chatty-flow | AFlow workflow representation and IR (M2) | crates/chatty-flow/ |
| chatty-optimize | GEPA/AFlow optimizers, paired stats, QA loaders (M3) | crates/chatty-optimize/ |
Hive integration
| Crate | Purpose | README |
|---|---|---|
| hive-client | Open-source client for the Hive module registry | crates/hive-client/ |
| hive-billing-sdk | Publisher 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.
Related docs
- Workspace crate split — why the split exists
- Component map — diagrams of how crates connect
- Research pipeline — paper → experiment → product flow
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
| Path | Responsibility |
|---|---|
auth/ | Azure / OAuth credential acquisition |
curated_mcp.rs | Built-in MCP catalog seeded on first launch |
exporters/ | Conversation export (markdown, PDF, ATIF, …) |
factories/agent_factory/ | Build AgentClient per provider; tool registration |
gpui_globals.rs | impl Global for core types (feature-gated) |
install.rs | Hive 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.rs | SettingsSnapshot / 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
| Path | Responsibility |
|---|---|
main.rs | Application entry point; Tokio runtime + GPUI app setup |
auto_updater/ | In-app update download / install (macOS .dmg, Linux AppImage, Windows .exe) |
assets.rs | Embedded asset loading for the GPUI app |
cli_installer.rs | Installs the chatty-cli companion symlink |
global_entity.rs | Helper 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(), neverArc<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):
.exeinstaller
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:
- 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). - 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
| Key | Action |
|---|---|
Enter | Send message |
/ | Slash-command picker; @ opens the file picker |
PageUp / PageDown, Shift+↑/↓, mouse wheel | Scroll the transcript |
End | Jump to the bottom and resume auto-scroll |
Ctrl+C | Stop streaming response / quit if idle |
Ctrl+Q | Quit immediately |
Ctrl+R | Toggle folded tool calls vs full payloads (same as /verbose) |
y / n | Approve / deny tool execution (during approval prompt) |
1-9, t, Esc | Pick 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.
| Command | Action |
|---|---|
/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, /new | Clear conversation history and start fresh |
/compact | Summarize older messages to reduce context usage |
/context | Show token/context usage and current working directory |
/copy | Copy the latest assistant response to system clipboard |
/update | Trigger 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) |
/verbose | Toggle folded tool-call summaries vs full payloads |
/paste [n] | Print the full text of an elided long paste |
/quit, /exit | Quit |
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 ($SHELLlogin + interactive on Unix,pwsh/powershellover 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::scrollbacksets the lines of history kept (default 10,000).MarkScanner(fed from the tap): OSC 133A/B/C/Dmarks, chatty’s private OSC 6973, and theCleanTextbetween them (no escapes,\r\nas\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,truncatedonce the output left the scrollback), andsnapshot(Region::LastCommand): the last command’s output (end kept, capped at 16 KiB) withlast_commandsaying what ran and how it exited, or that integration is not active. Line numbers come from a shadowTermin the byte tap, stopped at the exact byte of each mark (seesrc/commands.rs).- Shell integration (
TerminalConfig::shell_integration, default on): bash and zsh started with default arguments load chatty’s OSC 133 snippets (--rcfile/ aZDOTDIRwrapper,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_shellis off; our rcfile sources the login startup files instead), so a.bash_profilethat branches onlogin_shellsees “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;Noneon Windows, where it cannot be told).foreground_process_name()andcurrent_dir(): the foreground program’s name and the shell’s working directory, for tab titles (Linux, via/proc;Noneelsewhere).with_termreads 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-exportedwasmtime::Engineso callers can share one engine - Host traits:
LlmProvider,BillingProvider - Errors:
CallError(a limit or a trap),ToolFailure(the guest’s owntool-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
.wasmfiles paired withmodule.tomlmanifests - Manifest parsing — typed
ModuleManifestwith 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.wasmagainst it and refuses a mismatch. A module without one loads asTrustLevel::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
ModuleRegistry— owns all loaded modules, exposesscan_directory,load,unload,reload,get,manifest,module_names,trust_level,lenandis_empty- [
InstallRecord], [INSTALL_RECORD_FILE], [TrustLevel] (hive-client’s) ModuleManifest+ModuleCapabilities,ModuleProtocols,ModuleResourceLimits
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.
| Protocol | Method | Content-Type |
|---|---|---|
| MCP JSON-RPC | POST /mcp/{module} | application/json |
| MCP SSE stream | GET /mcp/{module}/sse | text/event-stream |
| MCP SSE message | POST /mcp/{module}/sse?sessionId=… | application/json |
| A2A JSON-RPC | POST /a2a/{module} | application/json |
| A2A streaming | POST /a2a/{module} (method: message/stream) | text/event-stream |
| Agent card (per module) | GET /a2a/{module}/.well-known/agent.json | application/json |
| Agent card (aggregated) | GET /.well-known/agent.json | application/json |
| Participant connection | broker-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 togateway.tokenbeside the socket,0600; ADR-0021 § 4). - Loopback callers only. A request whose
Hostis not a loopback name (localhost,127.0.0.0/8,[::1]), or whoseOriginis present and not loopback (nullincluded), gets 403, a guard against DNS rebinding for any listener an embedder puts in front of the socket. A request withoutHost(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 noloopback_rolessetting 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 setsmcp = 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}/sseopens a stream whose first event isendpoint(/mcp/{module}/sse?sessionId=…). The client POSTs each message there, gets202 Accepted, and the answer arrives on the stream as amessageevent. 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.
| Sender | Requests | Notifications |
|---|---|---|
| worker | session.hello, agent.invoke, agent.list, mailbox.post, human.ask, human.approve | task.event, req.cancel |
| broker | task.run, human.ask | req.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/getpolling. 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 inmetadata().requested_capabilities;loggingis always granted. Plugin, the trait a plugin implements (metadata,list_tools,invoke_tool), andexport!, 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-coreexporters — extends research workflows
Boundaries
Some symbols are reserved for human implementation — see RESERVED.md.
Do not implement todo!("human: …") markers.
Related docs
- crate-promises-chatty-trace
- Linear: Self-improving chatty2
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.
Related docs
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.
Related docs
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.
Related docs
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 clientverify— the signing chain: root key → publisher certificate → signed manifest → the.wasm’s SHA-256 (mirrors hive’shive-verify)trust— which root key a client trusts: the compiled production key (empty until pinned), orCHATTY_HIVE_ROOT_KEYfor 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()andreport_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, overhive-registry/session-key-cert/v1\nfollowed by the key’s hex
require_session() verifies, in order:
skey_sigunder one of the trusted registry root public keys- the token’s EdDSA signature under
skey - 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:
- Calls the host’s
billing::acquire-sessionimport - Verifies the token with
verify_session_tokenunder the trusted root keys - Checks that the reserved tokens match the request
- Returns a verified
BillingSession
Returns:
Ok(BillingSession)on successErr(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; callconfigure_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
- Fail closed: Return errors if billing verification fails; never skip checks
- Over-estimate: Reserve more tokens than expected to avoid mid-execution failures
- Report accurately: Under-reporting costs users more (full reservation deducted after timeout)
- Use remote execution for high-value modules: Phase 4 Firecracker hosting eliminates local trust issues
Related Documentation
- Billing Trust Model Design Doc
- Hive Issue #71 — Phase 3b Billing Implementation
- chatty-wasm-runtime — Host-side billing support
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 name | Category | Source module | Notes |
|---|---|---|---|
list_tools | meta | list_tools_tool.rs | Always available |
write_todos | agent | agent_todo_tool.rs | Always available |
update_todo | agent | agent_todo_tool.rs | Always available |
verify_completion | agent | agent_todo_tool.rs | Always available |
read_skill | memory | read_skill_tool.rs | Always available |
list_agents | agents | list_agents_tool.rs | Always available |
invoke_agent | agents | invoke_agent_tool.rs | Always available |
best_of | agents | best_of_tool.rs | A spec with swarm.best_of (the Crosscheck leaders) |
send_message | agents | send_message_tool.rs | Delegated workers only (a broker-made connection); messages the owner |
list_mcp_services | mcp | list_mcp_tool.rs | When MCP enabled |
fetch | web | fetch_tool.rs | HTTP fetch |
read_file | filesystem | filesystem_tool.rs | fs_read |
read_binary | filesystem | filesystem_tool.rs | fs_read |
list_directory | filesystem | filesystem_tool.rs | fs_read |
glob_search | filesystem | filesystem_tool.rs | fs_read |
doc_retriever | search | doc_retriever_tool.rs | CHATTY_ENABLE_DOC_RETRIEVER |
write_file | filesystem | filesystem_write_tool.rs | fs_write + approval |
final_answer | filesystem | filesystem_write_tool.rs | fs_write |
create_directory | filesystem | filesystem_write_tool.rs | fs_write |
delete_file | filesystem | filesystem_write_tool.rs | fs_write + approval |
move_file | filesystem | filesystem_write_tool.rs | fs_write |
apply_diff | filesystem | filesystem_write_tool.rs | fs_write |
shell_execute | shell | shell_tool.rs | approval |
shell_set_env | shell | shell_tool.rs | |
shell_cd | shell | shell_tool.rs | |
shell_status | shell | shell_tool.rs | |
git_status | git | git_tool.rs | |
git_diff | git | git_tool.rs | |
git_log | git | git_tool.rs | |
git_add | git | git_tool.rs | |
git_create_branch | git | git_tool.rs | |
git_switch_branch | git | git_tool.rs | |
git_commit | git | git_tool.rs | |
git_merge | git | git_tool.rs | approval |
search_code | search | search_tool.rs | |
find_files | search | search_tool.rs | |
find_definition | search | search_tool.rs | |
add_attachment | chat | add_attachment_tool.rs | |
read_excel | documents | excel_tool/ | feature: excel |
write_excel | documents | excel_tool/ | feature: excel |
edit_excel | documents | excel_tool/ | feature: excel |
read_docx | documents | docx_tool/ | feature: docx |
write_docx | documents | docx_tool/ | feature: docx |
read_pptx | documents | pptx_tool/ | feature: pptx |
write_pptx | documents | pptx_tool/ | feature: pptx |
pdf_to_image | pdf_to_image_tool.rs | feature: pdf | |
pdf_info | pdf_info_tool.rs | feature: pdf | |
pdf_extract_text | pdf_extract_text_tool.rs | feature: pdf | |
query_data | data | data_query_tool/ | feature: duckdb |
describe_data | data | data_query_tool/ | feature: duckdb |
profile_data | data | data_query_tool/ | feature: duckdb |
file_structure_detector | data | data_query_tool/ | feature: duckdb |
compile_typst | math | typst_tool.rs | feature: math-render |
execute_code | sandbox | execute_code_tool.rs | Monty + Docker fallback |
remember | memory | remember_tool.rs | memory enabled |
save_skill | memory | save_skill_tool.rs | memory enabled |
search_memory | memory | search_memory_tool.rs | memory enabled |
search_web | web | search_web_tool.rs | |
browser_use | web | browser_use_tool.rs | |
daytona_run | sandbox | daytona_tool/ | Daytona cloud sandbox |
publish_wasm_module | modules | publish_module_tool.rs | |
create_chart | viz | chart_tool.rs | Registered via tool_collector |
ask_user | agent | ask_user_tool.rs | Clarifying questions; answered in the UI |
terminal_read | terminal | terminal_read_tool.rs | terminal_access; read-only view of the user’s tmux panes |
terminal_run | terminal | terminal_run_tool.rs | Desktop only; a dock tab shared as Read + run; every command approved by the human, auto-approve never applies |
browser_navigate | web | browser_tools.rs | feature: browser; localhost/file:// by default |
browser_snapshot | web | browser_tools.rs | feature: browser |
browser_screenshot | web | browser_tools.rs | feature: browser; PNG via add_attachment path |
browser_console | web | browser_tools.rs | feature: browser |
browser_network | web | browser_tools.rs | feature: browser |
browser_resize | web | browser_tools.rs | feature: browser |
browser_click | web | browser_tools.rs | feature: browser; by snapshot ref; approval off localhost/workspace |
browser_type | web | browser_tools.rs | feature: 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
| Provider | Display name | Auth | Required config | Default images | Default PDF | Temperature default |
|---|---|---|---|---|---|---|
openrouter | OpenRouter | API key | api_key; optional base_url | yes | yes | per ModelConfig |
ollama | Ollama | none | optional base_url (default http://localhost:11434) | no | no | always applied |
azure_openai | Azure OpenAI | API key or Entra ID | base_url (resource endpoint) + credentials | yes | no | per ModelConfig |
OpenRouter
- Gateway to 200+ upstream models (Anthropic, OpenAI, Google, Mistral, Meta, …).
- Persisted
ProviderTypeJSON isopen_router(serde snake_case). Legacy valuesopen_ai,open_a_i,anthropic,gemini,mistraldeserialize as OpenRouter. The stringopenrouteris not accepted. configured_providers()requires a non-emptyapi_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 withoutapi_key. - Default capabilities are
(false, false); vision/PDF support is detected per model via/api/show(sync_service.rs) and stored inModelConfig. - Rig client:
rig_core::providers::ollama::Client.
Azure OpenAI
- Auth via
extra_config.auth_method:api_key(default) orentra_id. configured_providers()requires non-emptybase_urland (api_keyor Entra ID).- Endpoint URLs are normalized (trailing slashes stripped,
/openai/deployments/...paths truncated). - Per-model
api_versioninModelConfig.extra_params; default fromAZURE_DEFAULT_API_VERSION. - Entra ID tokens are cached in
AzureTokenCachewhen available. - Rig client:
rig_core::providers::azure::Client.
Model capabilities (ModelConfig)
| Field | Purpose |
|---|---|
supports_images | Allow image attachments in chat |
supports_pdf | Allow PDF attachments |
supports_temperature | Show 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)
| Flag | Maps to | Notes |
|---|---|---|
--ollama [URL] | Ollama provider | Auto-discovers models from /api/tags |
--openai-compat-url URL | OpenRouter provider type | vLLM, 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.
| Command | Action | GPUI | TUI |
|---|---|---|---|
/clear | Start new conversation | Yes | Yes |
/new | Start new conversation | Yes | Yes |
/compact | Summarize oldest half of history | Yes | Yes |
/context | Show token/context usage | Yes | Yes |
/copy | Copy last assistant response | Yes | Yes |
/cwd | Show working directory | Yes | Yes |
/cd [dir] | Change per-chat working directory | Yes | Yes |
/add-dir <dir> | Add workspace directory | Yes | Yes |
/agent [name] <prompt> | Run an agent spec on the local roster or a named A2A agent, else a local sub-agent | Yes | Yes |
/agents | List the agents /agent and invoke_agent reach, and the spec files left out | — | Yes |
/swarm | Print this conversation’s latest swarm tree: agent · model · status · spend (TB-5, AGE-667) | — | Yes |
/agents running | Every 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 |
/verbose | Toggle 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 |
/unqueue | Take 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 |
/update | CLI auto-update | — | Yes |
/quit /exit | Quit 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
| Flag | Purpose |
|---|---|
--headless | Non-interactive single-shot mode |
--pipe | Read stdin, write stdout (pipeline / sub-agent) |
--ollama URL | Use local Ollama base URL |
--model ID | Model id for direct provider modes |
-m, --message | User message (headless) |
--help | Full clap help text |
Environment variables
When to read this: Debug path/config issues or run Chatty in non-standard environments.
| Variable | Purpose | Default |
|---|---|---|
CHATTY_DATA_DIR | Override user data directory (AppImage) | Platform default |
CHATTY_DEBUG_UI | Enable chat view debug overlay | unset |
CHATTY_PDFIUM_LIB_DIR | Path to pdfium native library | auto-detect + cache |
CHATTY_ENABLE_DOC_RETRIEVER | Enable doc_retriever tool | unset = disabled |
CHATTY_SECRET_STORE | Where 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_ANSWER | Headless: infer missing final answer | unset |
CHATTY_PROGRESS prefix | Sub-agent stderr progress lines | protocol constant |
XDG_RUNTIME_DIR | Required for GPUI on Linux/X11 | /tmp/... |
XDG_DATA_HOME | Linux data dir override | ~/.local/share |
GITHUB_TOKEN | Auto-updater GitHub API; REST fallback for the branch’s PR status when gh is unavailable | unset |
GH_TOKEN | Second choice after GITHUB_TOKEN for the PR-status REST fallback | unset |
OLLAMA_NUM_PARALLEL | Read for a loopback Ollama provider to size its delegation endpoint budget (AGE-305) | unset = default_endpoint_budget |
ECHO_PLUGIN_WASM | Tests: path to the echo plugin when it is not the staged fixture (AGE-176) | target/wasm-fixtures/echo/echo.wasm |
UPDATE_GOLDENS | Tests: rewrite the stream-fixture goldens instead of diffing them | unset |
APPIMAGE | AppImage self-update path | set by AppImage |
DISPLAY | X11 display for GPUI | :0 / :1 |
HOME | User home for paths | required |
PATH | Shell tool environment | inherited |
SHELL | Shell for subprocesses | /bin/bash |
PDFIUM_LIB_DIR | Compile-time pdfium path | build 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.
| Kind | Resolver | Linux | macOS | Windows |
|---|---|---|---|---|
| Config (this page) | dirs::config_dir()/chatty | ~/.config/chatty/ ($XDG_CONFIG_HOME/chatty) | ~/Library/Application Support/chatty/ | %APPDATA%\chatty\ |
| Data | dirs::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
| File | Shape | Model | Repository |
|---|---|---|---|
general_settings.json | object | GeneralSettingsModel | GeneralSettingsJsonRepository |
execution_settings.json | object | ExecutionSettingsModel | ExecutionSettingsJsonRepository |
search_settings.json | object | SearchSettingsModel | SearchSettingsJsonRepository |
training_settings.json | object | TrainingSettingsModel | TrainingSettingsJsonRepository |
user_secrets.json | object | UserSecretsModel | UserSecretsJsonRepository |
hive_settings.json | object | HiveSettingsModel | HiveSettingsJsonRepository |
extensions.json | object | ExtensionsModel | ExtensionsJsonRepository |
token_tracking.json | object | TokenTrackingSettings | TokenTrackingJsonRepository |
module_settings.json | object | ModuleSettingsModel | ModuleSettingsJsonRepository |
providers.json | array | ProviderConfig | JsonFileRepository |
models.json | array | ModelConfig | JsonModelsRepository |
mcp_servers.json | array | McpServerConfig | JsonMcpRepository |
a2a_agents.json | array | A2aAgentConfig | A2aJsonRepository |
mcp_oauth_<sanitized>.json | object | opaque StoredCredentials JSON | live: 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.
| Field | Type | Default | Notes |
|---|---|---|---|
font_size | f32 | 14.0 | UI font size (px) |
theme_name | Option<String> | null | Theme folder name; null = built-in default |
dark_mode | Option<bool> | null | null = 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.
| Field | Type | Default | Notes |
|---|---|---|---|
enabled | bool | false | Master code-execution toggle |
approval_mode | ApprovalMode | "AutoApproveSandboxed" | JSON enum: AlwaysAsk, AutoApproveSandboxed, AutoApproveAll |
workspace_dir | Option<String> | null | Absolute path required for filesystem / git tools |
filesystem_read_enabled | bool | true | Requires workspace_dir |
filesystem_write_enabled | bool | true | Requires workspace_dir + approval |
fetch_enabled | bool | true | Built-in read-only HTTP GET |
git_enabled | bool | false | Opt-in; workspace must be a git repo |
browser_enabled | bool | false | Opt-in built-in browser tools; may download a pinned Chrome on first use |
allow_private_network_access | bool | false | With 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_enabled | bool | false | Exposes execute_code to the model |
docker_code_execution_enabled | bool | false | Docker fallback for non-Monty code |
docker_host | Option<String> | null | Socket / URI; null = auto-detect |
timeout_seconds | u32 | 30 | Execution timeout |
max_output_bytes | usize | 51200 | 50 KiB cap |
network_isolation | bool | false | Sandbox network isolation when available |
sandbox_required | bool | false | Refuse to start a shell outside the sandbox instead of falling back to the host (AGE-918) |
max_agent_turns | u32 | 0 | Tool-call rounds per response; 0 = no cap (headless runs get a wall-clock budget instead, chatty-tui --max-duration) |
memory_enabled | bool | true | remember / search_memory |
warn_on_external_agent | bool | false | say so before invoke_agent sends a prompt outside the fleet (ADR-0011 C5) |
embedding_enabled | bool | false | Semantic memory search |
embedding_provider | Option<ProviderType> | null | Independent of chat provider |
embedding_model | Option<String> | null | e.g. text-embedding-3-small |
hosted_conversations_enabled | bool | false | Developer-only: offers the per-conversation move between local and hosted (AGE-308) |
tool_loading | ToolLoading | "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_enabled | bool | true | Offer 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_access | bool | false | Offer 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).
| Field | Type | Default | Notes |
|---|---|---|---|
enabled | bool | false | Master web-search toggle |
active_provider | SearchProvider | "Tavily" | JSON: Tavily or Brave |
tavily_api_key | Option<String> | null | Secret |
brave_api_key | Option<String> | null | Secret |
max_results | usize | 5 | Search hit cap |
browser_use_enabled | bool | true | Key alone is enough to activate; set false to disable without deleting the key |
browser_use_api_key | Option<String> | null | Secret |
daytona_enabled | bool | true | Same pattern as browser-use |
daytona_api_key | Option<String> | null | Secret |
rerank_url | Option<String> | null | Cohere/Jina-style /rerank endpoint; keyless search only, needs rerank_model too |
rerank_model | Option<String> | null | Model 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.
| Field | Type | Default | Notes |
|---|---|---|---|
atif_auto_export | bool | false | ATIF JSON after each completed assistant turn |
jsonl_auto_export | bool | false | JSONL (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.
| Field | Type | Default | Persisted? |
|---|---|---|---|
secrets | [{key, value}] | [] | yes — values are secrets |
revealed_keys | HashSet<String> | empty | no (#[serde(skip)]; UI-only) |
{ "secrets": [ { "key": "EXAMPLE_TOKEN", "value": "****" } ] }
hive_settings.json — HiveSettingsModel
Source: settings/models/hive_settings.rs.
| Field | Type | Default | Notes |
|---|---|---|---|
registry_url | String | http://localhost:8080 | DEFAULT_REGISTRY_URL |
runner_url | String | http://localhost:8081 | DEFAULT_RUNNER_URL |
token | Option<String> | null | One-hour access JWT. Secret (store: token_ref → hive/token). is_logged_in() = token present |
refresh_token | Option<String> | null | 30-day refresh token, rotated on every refresh. Secret (store: refresh_token_ref → hive/refresh_token) |
expires_at | Option<DateTime> | null | When token expires |
username | Option<String> | null | Cached for UI |
email | Option<String> | null | Cached for re-login |
extensions.json — ExtensionsModel
Source: settings/models/extensions_store.rs. Unified install list for MCP,
WASM, and A2A.
| Field | Type | Default | Persisted? |
|---|---|---|---|
extensions | [InstalledExtension] | [] | yes |
mcp_auth_statuses | map | empty | no (#[serde(skip)]) |
a2a_statuses | map | empty | no (#[serde(skip)]) |
InstalledExtension:
| Field | Type | Default | Notes |
|---|---|---|---|
id | String | required | Unique slug |
display_name | String | required | UI name |
description | String | required | |
kind | ExtensionKind | required | Internally tagged kind: mcp (flattened McpServerConfig), wasm, a2a (flattened A2aAgentConfig) |
source | ExtensionSource | required | Internally tagged type: hive (module_name, version) or custom |
pricing_model | Option<String> | null | Hive marketplace classification |
enabled | bool | true |
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.
| Field | Type | Default | Notes |
|---|---|---|---|
enabled | bool | true | Show the token context bar |
response_reserve | usize | 4096 | Tokens reserved for model output |
high_threshold | f64 | 0.70 | Ratio 0.0–1.0; bar turns amber |
critical_threshold | f64 | 0.90 | Ratio 0.0–1.0; bar turns red |
auto_summarize | bool | false | Auto-summarize on crossing critical_threshold |
summarization_model_id | Option<String> | null | null = use the conversation’s own model |
cap_usd | Option<f64> | null | Monthly 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.
| Field | Type | Default | Notes |
|---|---|---|---|
enabled | bool | false | WASM module runtime |
module_dir | String | platform data dir + /chatty/modules | Linux ~/.local/share/chatty/modules; macOS ~/Library/Application Support/chatty/modules; Windows %APPDATA%\chatty\modules. Last-resort fallback: .chatty/modules |
gateway_port | u16 | 8420 | Local protocol gateway |
default_endpoint_budget | usize | 1 | Delegated 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_budgets | HashMap<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 |
team | TeamConfig | {} | 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.
| Field | Type | Default | Notes |
|---|---|---|---|
name | String | required | Display name |
provider_type | ProviderType | required | JSON: 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_key | Option<String> | omitted if null | Secret. Never expose to the LLM (store: api_key_ref → provider/<name>) |
base_url | Option<String> | omitted if null | Ollama / Azure / OpenAI-compat |
extra_config | Map<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.
| Field | Type | Default | Notes |
|---|---|---|---|
id | String | required | Internal UUID (not the API model name) |
name | String | required | UI label |
provider_type | ProviderType | required | Same enum as providers (open_router / ollama / azure_openai) |
model_identifier | String | required | API / Ollama model id |
temperature | f32 | 1.0 | |
preamble | String | "" | System prompt / GEPA target |
max_tokens | Option<i32> | omitted if null | |
top_p | Option<f32> | omitted if null | |
extra_params | Map<String, String> | {} (omitted if empty) | Azure: api_version (default 2025-03-01-preview) |
cost_per_million_input_tokens | Option<f64> | omitted if null | USD |
cost_per_million_output_tokens | Option<f64> | omitted if null | USD |
cost_per_million_cache_read_tokens | Option<f64> | omitted if null | USD; falls back to the input rate when unset |
cost_per_million_cache_write_tokens | Option<f64> | omitted if null | USD; falls back to the input rate when unset |
supports_images | bool | false | New models inherit ProviderType::default_capabilities() |
supports_pdf | bool | false | |
supports_temperature | bool | true | Off for some reasoning models |
max_context_window | Option<i32> | omitted if null | Token-bar budget |
source | ModelSource | "User" | User (added by hand or from a catalogue pick) or Sync (owned by a provider sync) |
is_favorite | bool | false | Starred rows sort to the top of the roster |
is_default | bool | false | Model 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.
| Field | Type | Default | Notes |
|---|---|---|---|
name | String | required | Unique id |
url | String | required | e.g. http://localhost:3000/mcp |
api_key | Option<String> | omitted if null | Bearer token. Secret. LLM-facing copies must not include the real value (store: api_key_ref → mcp/<name>) |
enabled | bool | true | |
is_module | bool | false (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.
| Field | Type | Default | Notes |
|---|---|---|---|
name | String | required | Also the /agent <name> token |
url | String | required | A2A base URL |
api_key | Option<String> | omitted if null | Bearer token. Secret (store: api_key_ref → a2a/<name>) |
enabled | bool | true | |
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)
| Store | Typical path | Notes |
|---|---|---|
TokenTrackingSettings | in-memory GPUI global | Defaults: 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. |
| Conversations | config dir chatty/conversations.db | SQLite, not settings JSON |
| Provider keys, MCP/A2A tokens, Hive tokens | OS keychain, service chatty:<config dir>; fallback config dir chatty/secrets.json | Referenced from the JSON by <field>_ref (AGE-741) |
| Agent memory / skills | data dir chatty/ | memvid + skill files |
| WASM module binaries | data dir chatty/modules/ | See module_dir |
| Pdfium native lib | data dir chatty/lib/ | See env-vars |
| Math SVG cache | data 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.
Related
- Settings integration map — research ↔ settings
- Environment variables —
CHATTY_*/XDG_* - Provider matrix — auth and default capabilities
- Singleton inventory — repository accessors
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 enum | Variant | Key fields | Emitter | Typical subscriber |
|---|---|---|---|---|
StreamManagerEvent | StreamStarted | conversation_id | StreamManager | ChattyApp |
TextChunk | conversation_id, text | StreamManager | ChattyApp → ChatView | |
ToolCallStarted | conversation_id, id, name | StreamManager | ChattyApp → ChatView | |
ToolCallInput | conversation_id, id, arguments | StreamManager | ChattyApp → ChatView | |
ToolCallResult | conversation_id, id, result | StreamManager | ChattyApp → ChatView | |
ToolCallError | conversation_id, id, error | StreamManager | ChattyApp → ChatView | |
ApprovalRequested | conversation_id, id, command, is_sandboxed | StreamManager | ChattyApp → ChatView | |
ApprovalResolved | conversation_id, id, approved | StreamManager | ChattyApp → ChatView | |
ClarificationRequested | conversation_id, id, questions | StreamManager | ChattyApp → ChatView (ask-user card) | |
TokenUsage | conversation_id, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens | StreamManager | ChattyApp | |
TurnProgress | conversation_id, turn, tokens | StreamManager | ChattyApp → ChatInputState (live turn/token/elapsed indicator) | |
SwarmTreeChanged | conversation_id, trace | StreamManager | ChattyApp → ChatView (swarm tree under each delegation row, TB-4); RunningAgentsModel (every conversation, TB-6) | |
StreamEnded | conversation_id, status, token_usage, trace_json, … | StreamManager | ChattyApp (finalization); RunningAgentsModel (drops the conversation’s live agents) | |
RunningAgentsEvent | CountsChanged | — | RunningAgentsModel | ChattyApp (redraws the footer’s running-agents chip, TB-6) |
SidebarEvent | NewChat | — | SidebarView | ChattyApp |
OpenSettings | — | SidebarView | ChattyApp | |
SelectConversation | String (id) | SidebarView | ChattyApp | |
DeleteConversation | String (id) | SidebarView | ChattyApp | |
ExportConversation | String (id) | SidebarView | ChattyApp | |
ToggleCollapsed | bool | SidebarView | ChattyApp | |
LoadMore | — | SidebarView | ChattyApp | |
ChatInputEvent | Send | message, attachments | ChatInputState | ChattyApp |
ModelChanged | String (model id) | ChatInputState | ChattyApp | |
Stop | — | ChatInputState | ChattyApp | |
SlashCommandSelected | String (command) | ChatInputState | ChattyApp | |
WorkingDirChanged | Option<PathBuf> | ChatInputState | ChattyApp | |
ChatViewEvent | FeedbackChanged | history_index, feedback | ChatView | ChattyApp |
RegenerateMessage | history_index | ChatView | ChattyApp | |
ArtifactViewEvent | Closed | — | ArtifactView | ChatView |
PresentationChanged | — | ArtifactView | ChatView | |
BrowserControlChanged | taken, url | ArtifactView | ChatView (activity trail) | |
TerminalDockEvent | Hidden | — | TerminalDock | ChatView (refocuses the composer) |
TerminalViewEvent | TitleChanged / Exited | — | TerminalView | TerminalDock (tab strip) |
TraceEvent | ToolCallStateChanged | tool_id, old_state, new_state | SystemTraceView | ChatView |
ToolCallInputReceived | tool_id | SystemTraceView | ChatView | |
ToolCallOutputReceived | tool_id, has_output | SystemTraceView | ChatView | |
ThinkingStateChanged | old_state, new_state | SystemTraceView | ChatView | |
ModelsNotifierEvent | ModelsReady | — | ModelsNotifier | ChattyApp (startup) |
AgentConfigEvent | RebuildRequired | — | AgentConfigNotifier | Settings controllers, ChattyApp |
ErrorNotifierEvent | NewError | — | ErrorNotifier | ChattyApp (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)
| Name | Type | Init | Rationale |
|---|---|---|---|
MCP_UPDATE_SENDER | OnceLock<mpsc::Sender<Vec<McpServerConfig>>> | Startup | Cross-cutting MCP config change notifications |
MCP_SERVICE | OnceLock<McpService> | Startup | Tool context has no UI handle; needs shared MCP client |
Repository registry (chatty-core/src/lib.rs)
| Accessor | JSON / storage | Purpose |
|---|---|---|
provider_repository() | providers | LLM provider configs + API keys |
general_settings_repository() | general settings | Theme, font, UI prefs |
models_repository() | models | Per-model capabilities |
mcp_repository() | MCP servers | MCP service definitions |
a2a_repository() | A2A agents | WASM / remote agent registry |
execution_settings_repository() | execution | Workspace, approval mode, sandbox |
search_settings_repository() | search | Web search provider keys |
training_settings_repository() | training | Fine-tuning prefs |
user_secrets_repository() | secrets | User-provided secret values |
module_settings_repository() | modules | WASM module settings |
hive_settings_repository() | hive | Hive registry / billing |
extensions_repository() | extensions | Browser extension config |
token_tracking_repository() | token tracking | Compaction 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)
| Name | Location | Type | Purpose |
|---|---|---|---|
GLOBAL_WRITE_APPROVAL_MODE | tools/filesystem_write_tool.rs | OnceLock<Mutex<ApprovalMode>> | Write-tool approval without coupling to UI |
REGISTRY | services/browser/registry.rs | LazyLock<Mutex<HashMap<String, Arc<BrowserManager>>>> | conversation_id → BrowserManager so the transcript can dock a live browser panel |
AZURE_TOKEN_CACHE | factories/agent_factory/provider_builder.rs | OnceLock<Option<AzureTokenCache>> | Azure OAuth token reuse |
PLUGIN_ENGINE | tools/plugin_tool.rs | OnceLock<wasmtime::Engine> | One Wasmtime engine for every spec plugin instance (PL-U2); limits are per store |
LLM_CLIENT | services/http_client.rs | LazyLock<reqwest::Client> | Shared connection pool behind llm_client(), handed to every provider agent builder |
MCP_WRITE_LOCK | settings/models/mcp_store.rs | LazyLock<Mutex<()>> | Serialize MCP JSON writes |
WRITE_SLOTS | settings/repositories/generic_json_repository.rs | LazyLock<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_AUGMENTED | auth/azure_auth.rs | OnceLock<()> | One-time PATH fix for Azure CLI |
LIVE_SANDBOXES | sandbox/manager.rs | LazyLock<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 |
FONTS | services/math_renderer_service.rs | LazyLock<(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;
pathis 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 fromlist-tools.arguments-json— the model’s arguments object, JSON-encoded once, exactly what the tool’sparameters-schemadescribes. 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:
kind | Meaning |
|---|---|
unknown-tool | No tool of that name |
invalid-arguments | The arguments did not parse or fit the schema |
denied | A host capability refused (not granted, no credits, a path outside the file root) |
failed | The 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.
| Limit | Default = ceiling | Enforcement | Error |
|---|---|---|---|
| Fuel | 10¹² units per call | Wasmtime fuel (≈1 unit per Wasm instruction) | fuel exhausted |
| Wall clock | 60 s per call, host time included | Epoch interruption (10 ms ticks); host imports (llm, file, billing) and WASI clock waits (sleep) stop waiting at the deadline | deadline exceeded |
| Memory | 256 MiB | Store memory limiter | memory limit |
| Output | 1 MiB per call | Size of each export’s return value | output 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
| Version | Change |
|---|---|
0.3.0 | Plugin 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.0 | Agent world chatty:module, with the file and billing imports. Refused. |
0.1.0 | First 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
| Provider | Endpoint | Transport | Docs |
|---|---|---|---|
| Hugging Face | https://hf.co/mcp | Streamable HTTP | https://huggingface.co/docs/hub/agents-mcp |
| Notion | https://mcp.notion.com/sse | SSE | https://developers.notion.com/docs/mcp |
| Atlassian (Jira + Confluence) | https://mcp.atlassian.com/v1/sse | SSE | https://www.atlassian.com/platform/remote-mcp-server |
| Google Calendar | https://calendarmcp.googleapis.com/mcp/v1 | Streamable HTTP | https://developers.google.com/calendar |
| Gmail | https://gmailmcp.googleapis.com/mcp/v1 | Streamable HTTP | https://developers.google.com/gmail |
| Google Drive | https://drivemcp.googleapis.com/mcp/v1 | Streamable HTTP | https://developers.google.com/drive |
Every entry has default_enabled: false — users opt in explicitly from
Settings → Extensions → Installed.
How users manage it
- Open Settings → Extensions.
- The curated entries appear under Installed with an
MCPbadge and an↗ Externalbadge (they are hosted by the provider, not run locally). - Click Enable on the entries you want. Chatty connects in the background and shows each entry’s connection status inline.
- Click Disable to disconnect; the entry stays in the list so you can re-enable it later.
- The enabled / disabled state is persisted to
extensions.jsonandmcp_servers.json, so it survives restarts.
Authentication
| Provider | How to authenticate |
|---|---|
| Hugging Face | Optional. Paste a Hugging Face access token into the API key field for private repos / higher rate limits. |
| Notion | OAuth — sign in with your Notion workspace when prompted by the MCP server. |
| Atlassian | OAuth — Atlassian Cloud sign-in is performed in the browser on first connect. |
| Google Calendar / Gmail / Drive | OAuth — 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.
| Server | Command | Env |
|---|---|---|
| GitHub | npx -y @modelcontextprotocol/server-github | GITHUB_TOKEN |
| Filesystem | npx -y @modelcontextprotocol/server-filesystem /path/to/dir | — |
| PostgreSQL | npx -y @modelcontextprotocol/server-postgres | POSTGRES_CONNECTION_STRING |
| Brave Search | npx -y @modelcontextprotocol/server-brave-search | BRAVE_API_KEY |
| Memory | npx -y @modelcontextprotocol/server-memory | — |
| Puppeteer | npx -y @modelcontextprotocol/server-puppeteer | — |
| Fetch | npx -y @modelcontextprotocol/server-fetch | — |
| Hugging Face (hosted) | URL https://huggingface.co/mcp | Access 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
- Append a
CuratedMcpEntry { … }toCURATED_CATALOGincrates/chatty-core/src/curated_mcp.rs. Every field is'static:id,slug,display_name,url,transport,description,docs_url,auth_notes,default_enabled. - Make the
idunique and prefixed withmcp-so it cannot collide with WASM module ids or A2A agent ids; theslugbecomes the MCP server’sname. - Add a row to the table above and document any auth quirks.
- Extend the unit tests in
curated_mcp.rs(catalog_contains_initial_providersasserts 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/ component | Cargo defaults | Dep debuginfo trimmed | + split debuginfo (current) |
|---|---|---|---|
| Total | > 27.5 GiB (died while linking; 12 of 17 binaries) | 18.5 GiB, complete | 15.8 GiB, complete (17.1 GiB after cargo clippy --all-features) |
deps/*.rlib (compiled dependencies) | 9.3 GiB | 5.2 GiB | 5.0 GiB (+ 0.9 GiB of .dwo) |
deps/ test + bin executables | 6.8 GiB for 12 of 17 | 5.4 GiB for all 17 | 3.3 GiB for all 17 |
build/libduckdb-sys-* (DuckDB C++ objects) | 4.6 GiB | 1.0 GiB | 1.0 GiB |
incremental/ (workspace crates only) | 2.8 GiB (gpui crate unfinished) | 3.1 GiB | 2.7 GiB |
| Largest single file | liblibduckdb_sys.rlib, 2.3 GiB | chatty desktop binary, 1.3 GiB | chatty desktop binary, 0.9 GiB |
| Wall clock | 28 min to the failure | 17 min | 15.5 min |
Two things dominate at the defaults:
- 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. Thechatty-coreunit-test binary alone was 2.2 GiB. - DuckDB compiled with
-g.libduckdb-sysbuilds DuckDB from source through thecccrate, which passes-gwhenever 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 owntarget/by default, so three worktrees cost three full builds. Point them at one directory withCARGO_TARGET_DIR(for example in~/.cargo/config.tomlunder[build] target-dir = "/path/to/shared-target"). - Don’t mix profiles you don’t need.
cargo build --releasewrites a second, separate tree undertarget/release/. Only build release locally when packaging. - Prune stale artifacts. Every dependency bump leaves the old version’s
artifacts behind until you clean.
cargo cleandrops everything;cargo-sweep(cargo sweep --time 14) removes only artifacts unused for N days and keeps the warm cache. - Keep
CARGO_INCREMENTALon locally, off in CI. Incremental state is only kept for workspace crates and is what makes edit-compile loops fast. CI already setsCARGO_INCREMENTAL=0because a fresh runner never reuses it. - The registry cache lives outside
target/.~/.cargo/registryholds ~2 GiB of crate sources for this lockfile.cargo cache --autoclean(fromcargo-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
| Target | Runs |
|---|---|
make / make help | Lists the targets below |
make setup | Linux: scripts/setup-linux.sh (apt packages for GPUI + rustup target add wasm32-wasip2). Other OSes: prints instructions and adds the wasm target |
make build | cargo build |
make build-release | cargo build --release |
make test | cargo test --all-features (matches CI) |
make test-fast | cargo test -p chatty-core --lib |
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 |
make lint | cargo clippy --all-features -- -D warnings |
make fmt | cargo fmt |
make fmt-check | cargo fmt --check |
make typecheck | cargo check --all-features |
make wasm-modules | Runs 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-gpui | cargo run -p chatty-gpui |
make run-tui | cargo run -p chatty-tui |
make ci | wasm-modules, test, fmt-check, lint, scripts/check-reserved.sh, scripts/check-rig-pins.sh |
make clean | cargo clean |
make docs-gen | scripts/gen-docs-reference.sh → docs/generated/*.md |
make docs-sync | scripts/docs-sync.sh — copies repo markdown (and docs-sized GIFs) into docs-site/src |
make docs | docs-gen + docs-sync + mdbook build docs-site, then installs llms.txt / llms-full.txt into the book |
make docs-serve | docs-gen + docs-sync + mdbook serve docs-site --open (port 3000) |
make docs-check-links | docs, 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-nav | scripts/check-docs-nav-drift.sh — docs/INDEX.md and SUMMARY.md completeness |
make docs-check-frontmatter | scripts/check-docs-frontmatter.sh — optional YAML frontmatter schema |
make docs-check-leakage | scripts/check-docs-user-leakage.sh — user guides carry no contributor material |
make docs-check-reference | scripts/check-docs-reference-drift.sh — reference tables match tool_registry.rs, StreamManagerEvent, event enums and settings structs |
make docs-check | All five docs-check-* targets |
make animations | scripts/animations/record.sh --all — re-records the README/docs GIFs |
Note
Two things the CI workflow runs that
make cidoes not: clippy is invoked with--all-targetsas well (cargo clippy --all-features --all-targets -- -D warnings, so lints intests/are reported), andscripts/check-no-core-reexports.shruns after the rig-pin check. Run both before pushing a change to test code or to thechatty-gpui/chatty-tuimodule trees.
GitHub workflows
| Workflow | Trigger | Purpose |
|---|---|---|
CI (ci.yml) | PR / push to main | Tests, 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 paths | gen-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 UTC | cargo 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_dispatch | Bumps 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 publish | Builds 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 manifests | Informational cargo update + cargo check against latest rig. |
Plugin e2e (plugin-e2e.yml) | Nightly, or manual | The 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/PRs | Interactive AI assistance. |
Update user docs (update-readme.yml) | PR merged to main | Claude 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 main | Claude 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 main | Claude 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 group | Required 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 review | Arms 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 manual | The 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 manual | Picks 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 group | Required 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 review | Enables 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 labeled | Only the repository owner and github-actions[bot] may apply ship:auto, `release:patch |
Dependency Check (dependency-check.yml) | Weekly (Monday 9:00 UTC) or manual | Checks 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
---
| Field | Required if fence present | Value |
|---|---|---|
audience | yes | List; each item one of contributor, agent, user |
source_files | no | List of repo-relative paths the page describes |
related | no | List 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.