Agents and tools
Avo AI is built from two kinds of pieces. Agents are the model-facing side: each one pairs a system prompt with a set of tools and drives a conversation with the provider. Tools are the Rails-facing side: typed abilities that run inside your application, against your policies, and report back to the model.
This page is the reference for both — which agents exist, what every tool does, and how conversations get their names. For the narrative of how a chat turn unfolds (schema, confirmation cards, authorization), see How the assistant works.
The agents
The chat agent
The chat agent powers every conversation — the floating bar, full-page chats, and chats attached to a record. On every message it rebuilds its system prompt from the ERB files under app/prompts/ (which is what keeps the current date, signed-in user, and attached subject fresh) and assembles its tool roster for the signed-in user who owns the chat.
The roster is built per run, not baked in: what the assistant can do in a conversation always reflects your current tool configuration, and every call it then makes is authorized against your policies at that moment.
The conversation renamer
A second, much smaller agent does exactly one job: reading a conversation's messages and giving the chat a short, specific title. It runs in the background, on the same model the conversation itself uses, and applies its title through the same rename tool the chat agent carries — so a generated title and a user-requested one take the same path to your database and screens.
It runs on three occasions:
- automatically, after the first message of a new conversation
- when you pick Rename with AI from the ⋯ menu, in the chat bar or on a chat page
- when you ask the assistant to rename the conversation without giving it a name
All three go through the rename_conversation tool, so excluding that tool switches the renamer off along with it — unless you registered a replacement under the same wire name (as ejecting it does), in which case the renamer uses your copy. Naming a chat by hand keeps working.
The tools
Tool names below are how calls appear in a message's Tool calls field and in the chat's debug view (see Debug levels).
A tool's name is the stable part of it: the model calls the tool by that name, every stored tool call keeps it, and it's how you name a tool in configuration. The roster isn't fixed either — you can take any of these away, add tools of your own, or replace one with an editable copy. See Choose which tools the assistant gets.
| Tool | What it does | Writes apply |
|---|---|---|
ask_user | Asks you one clarifying question — free-form, or with clickable options — then ends the turn | — |
schema_inspector | Reports the database structure — scoped to the resources the signed-in user is allowed to see | read-only |
resource_inspector | The deep report on one resource: field types, actions, filters, associations, attachments | read-only |
active_record_query | Runs read-only, paginated, policy-scoped queries against a resource — filters, scopes, grouping, free-text search through the resource's own configured search, and up to three answer-specific chip fields when the resource enables them | read-only |
count_records | Counts records — a total, per value, per day, week or month, distinct values, or within one parent record — with the same filters, scopes, search and field rules as active_record_query. Returns numbers only, never a row | read-only |
active_storage_insights | Storage reports — totals, orphans, duplicates, growth, biggest files — and finding/showing files | read-only |
active_storage_attachment | Attaches or detaches a Media Library blob (or a chat upload) on a record's attachment; can also fetch a URL onto one | immediately¹ |
read_file | Reads a markdown, text, CSV, TSV, or JSON file in windows — a file you uploaded in the chat, a file on a record you can open, or a Media Library file — by blob id or filename | read-only |
create_skill | Proposes a reusable skill from pasted instructions or a .md or .txt upload in this chat; respects the skill creation policy⁴ | after you confirm |
create_record | Creates a record | immediately |
update_record | Changes a record's attributes | after you confirm |
update_records | Proposes the same field changes across many records of one resource, as a single card | after you confirm³ |
delete_record | Deletes a record | after you confirm |
run_action | Lists the actions available — one resource's, or every action in the app — and proposes running one on the records you name | after you confirm |
import_records | Proposes creating records for one resource from a CSV or TSV file, with a column-to-field mapping | after you confirm² |
write_history | Lists the writes made earlier in the conversation and proposes undoing one, or a whole batch on one card | after you confirm |
rename_conversation | Renames the current conversation — with your exact title, or by regenerating one⁴ | immediately |
"After you confirm" means the tool call produces a card describing the pending change; your click applies it, not the model. "Immediately" is reserved for actions that are reversible (a detached file can be re-attached, a conversation can be renamed again) or additive (creating a record).
¹ Attaching and detaching existing blobs apply immediately; the attach_from_url operation is the exception — a server-side download always goes through a confirmation card showing the URL, and nothing is fetched until you click Attach. See Getting new files in.
² An import is the one create that waits for you: the call produces a card naming the file and where it came from, the resource, the row count, the mapping, and the first rows, and nothing is created until you click Confirm. The rows are then created on the server — the model never carries them — under the same authorization and field rules a single create uses; a row that fails validation is skipped and reported on the card with its row number and reason, and the rest are still created. See Reading files and importing from them.
³ A batch update is one card for the whole set: it names the change once and then every record it will touch, capped at config.ai.max_update_records (50 by default, at most 500). Nothing is written until you click Confirm, and the records are then updated on the server one at a time, under the same authorization and field rules a single update uses — a record that fails validation is skipped and named on the card, and the rest are still updated. Each updated record is recorded on its own, but write_history lists the batch as one entry and undoes it on one card, restoring every record or only the ones you name. If something that isn't about one record stops the run partway — the database going away, say — the records already saved are kept and counted, and the card reads as stopped rather than done, naming the ones it never reached. When the resource registers an action that does what you asked, the assistant runs that instead — see run_action above.
⁴ Rarely needed mid-chat, so the chat agent doesn't spend every request's context on its definition: on a provider that supports tool search, the model loads the tool when it needs it, and a provider without tool search gets it up front. Asking for it works either way. The conversation renamer always gets rename_conversation up front, since it's that agent's only tool.
Tools from connected MCP servers
When your app has MCP connections turned on and the chat's user can use a connected one, two tools join the roster: search_tool and execute_tool. The servers' own tools never join it. The assistant searches the tool list stored for each connection, gets back the best matches with their arguments, and calls one by its connection and name. A connection with dozens of tools costs a request nothing until the assistant needs one of them.
Each connection's tool list is stored when it connects and when its owner clicks Refresh tools, and it's read again just before every call, so a tool the server dropped or changed is never called on an old definition. A tool its owner switched Off never comes back from a search. A server that's down is found on the call that needs it: nothing is sent, Avo's own tools keep working, and the assistant says which connection failed.
Every remote call waits on a card unless the user always-allowed that tool, and that card only takes a click. See Approve a call.
config.ai.excluded_tools can't name a remote tool, or search_tool and execute_tool. To take one remote tool away, set it to Off on its connection's page. To take them all away, turn the setting off.
The shapes an ask can take
Every ask is one card, but the card takes a few shapes — the model chooses per question, through the arguments it passes:
| Arguments | The card |
|---|---|
| the question alone | A plain question; you answer in the composer |
plus options (up to 10, short) | Numbered options you can click — or reply with an option's number — beside a free-text box |
plus free_text: false | The options only, for questions whose answer set really is closed (yes/no, an enum's values) |
plus multiple: true | Checkboxes and a text box, sent together as one comma-separated reply |
Both flags only mean anything alongside options: a question with none is a free-form ask, and closing that off would leave nothing to answer with.
Clicking an option posts the option's text through the same path a typed reply takes, so answering a card is an ordinary turn — one that can never confirm a pending write, even when the option reads "Yes". See Answering the assistant's questions.
Where tools get their context
A tool only accepts from the model the arguments listed in its schema — a resource name, attributes, a title. Everything sensitive is injected server-side when the roster is built: the acting user and the current conversation are set on each tool instance in Ruby, and are not parameters the model can pass.
That is a deliberate security boundary. There is no argument the model could invent to act as a different user, and no id it could pass to rename or read a conversation other than the one it is speaking in. Combined with authorization at the tool layer, it means a prompt-injection attempt riding in on your data has no lever to reach beyond what the chat's owner could already do in the Avo UI.
The same goes for a connected MCP server. Its tool descriptions and results are text an outside system wrote, and the assistant treats them as data. Whatever they say, a remote call waits for the user's click, and connection records are out of the tools' reach.
Schema comes back with the answer
Every result the query, write, and action tools return carries the resource it touched under resource_schema: its real columns and their types, the model's named scopes, and the attributes a create cannot omit. Success or error alike — a query that names a column you don't have comes back with the ones you do. This is done in the tools, not merely requested in the prompt, and is what makes the assistant work from your real columns and scopes instead of guessed ones, without spending a round trip asking first.
It is sent once per resource per turn, and only for resources the signed-in user is allowed to list. resource_inspector remains for the deeper report — field types, a resource's actions and filters, its associations and attachments — when the assistant wants structure it cannot get from a result. Excluding it costs you that report, not the grounding: every result still comes back with its schema.
run_action adds two checks of its own: the resource's act_on? policy method and the action's self.visible and self.authorize blocks, both checked when the run is proposed and again when you confirm it. See What it's allowed to run.
Renaming conversations
A new conversation starts without a name and gets one automatically after your first message — the renamer reads the opening of the conversation and titles it.
The renamer is its own agent and never receives the conversation's images, so it's given the names of any files you attached alongside the text. A message whose whole subject is the file it carries — "what do you see here?" — is titled from the filename rather than from a question that names nothing. A reply that isn't a plausible title (one that asks a question, or runs long) is dropped instead of applied, so the conversation stays Untitled chat rather than being named after the model's request to see the image.
After that, renaming is a chat feature like any other:
- "rename this conversation to 'Q3 invoices'" — the assistant passes your exact wording to
rename_conversationand the title applies immediately. - "rename this conversation" — with no name given, the assistant triggers the renamer agent instead of inventing a title itself. The regenerated title reflects where the conversation actually went, not just how it started.
- Rename with AI does the same regeneration without involving the assistant. It sits in the ⋯ menu, both in the chat bar and on a full-page chat.
- Rename chat, in the same menu, skips the model entirely: the title becomes editable in place and what you type is what it's called. See The conversation menu.
Wherever the conversation's name is on screen, it updates live when a rename lands — the chat's tab in the floating bar, the breadcrumb on its full-page view, and the title, name field, and breadcrumb on its resource page.
INFO
Titles are generated by the conversation's own model. If that model doesn't support tool calling, the renamer falls back to reading the title from the model's plain-text reply — renaming keeps working, the mechanics just degrade gracefully.
Seeing a conversation's roster
The Chats resource's show page has an Agent tools field listing exactly the tools the assistant will get on that conversation's next run. It's computed live, by the same code the conversation itself runs, so it's the quickest way to confirm that a change to your tool configuration took effect.
A list that surprises you reads like this:
| What you see | What it means |
|---|---|
| A shipped tool is missing | It's named in config.ai.excluded_tools |
| A tool you registered isn't listed | The app hasn't been restarted since its config.ai.extra_tools entry was added — a broken entry never goes missing quietly, it shows up as the error row below |
| The list is empty | Either everything is excluded, or this conversation's provider/model combination rejects tools while extended thinking is enabled — no configuration puts them back on such a run |
| An error message where the names should be | An extra_tools entry naming a class that won't load, isn't a RubyLLM::Tool, or claims a wire name another tool already has |
That last row is deliberate: the field renders the error instead of failing the page, because this is the page you came to in order to read it. The same configuration raises on an actual chat run — a missing tool the app asked for isn't something to pass over quietly.
Tools from connected MCP servers aren't listed: they depend on which servers answer at the start of a reply. /mcp in the chat lists each connection's tools.
The roster doesn't change with your authorization policies. Every tool checks permissions when it's called, rather than being withheld from the list, so a policy change shows up in what a tool does — not in what's listed here.
For per-call detail — which tools ran, with which arguments, and what they returned — set the viewer's debug level to :tools.