Tools & Abilities

How Chalie acts — the ability model, tool discovery, the result contract, dispatch, and permission gating.

Reasoning is only half of what Chalie does; the other half is acting. Every action the model can take — checking your calendar, sending an email, browsing the web, recalling a memory — is an ability: a small, self-contained unit with a uniform shape. This page explains that shape, how the model finds the right ability mid-turn, the contract every ability returns on, and the single gate every call passes through before it runs.

Anatomy of an ability

An ability declares what the tool is through five zero-argument descriptors and what it does through one run method:

Descriptor Purpose
Name The string the model calls (e.g. weather)
Summary The model-facing description of what the tool does
Examples 6–8 natural phrases showing when the tool applies
Search tooltip The one-line label shown beside the name in the discovery roster
Parameters Plain JSON Schema for the run method’s inputs

An ability may also declare search aliases — the alternative names it can be asked for by (chalie documentation for chalie_docs). Aliases resolve in discovery exactly like the canonical name.

The run method does the work and returns a tool result — never a string, dict, or raw value. That’s the whole interface. There is no decorator, manifest, or registration call: the registry collects abilities automatically at startup. Drop an ability in, and the tool exists.

The parts that must stay uniform are sealed by the framework. The full tool descriptor the model sees is assembled in exactly one place — a method declared final, which individual abilities cannot override — and that same method injects two framework fields into every tool: act_summary (a short tooltip the call shows the user) and, on channels that allow it, async (run this call in the background instead of blocking the turn). Because the assembler cannot fork per tool, neither can the action-trail or backgrounding contracts.

Descriptors must return deterministic text when no live request is attached, because the same descriptors are read outside any live turn to assemble the discovery roster. An ability can optionally declare a map of action → required parameters; the dispatcher validates it before anything else, so a malformed call bounces with a self-correcting error instead of running.

Tool discovery

A model that could see every tool on every turn would waste most of its context window on descriptions it doesn’t need. So Chalie pre-loads almost nothing and lets the model discover tools on demand.

Two meta-tools plus memory are the only abilities available on a normal turn by default:

  • find_tools — activate the tools you need for this turn.
  • find_skills — pull a step-by-step playbook for a complex task.

Every other ability is discoverable by default and is reachable only by the model calling find_tools. An ability that opts out of discoverability is absent from the discovery roster entirely — it can only reach a turn by being pinned directly into a channel’s always-available list. That single flag, plus whether a channel even carries find_tools, is the whole of tool isolation: it’s how the raw web tools stay scoped to the web-search and web-browse delegate channels rather than leaking into a normal chat.

Picking from the menu

find_tools is a precise menu, not a search engine. Its own description carries the full roster of discoverable tools, grouped under plain category headings — file operations, web, information, and so on — in a fixed order, with tools listed alphabetically inside each and each one showing its one-line tooltip, so the model reads what exists before it asks. Anything already active in the current turn is left out of the roster — offering a tool the model can already call would just be noise — and if every discoverable tool is already active, the roster is replaced by a one-line note that there’s nothing left to load. It takes a query array — one tool name per entry, so one call can fetch everything the turn needs — and each entry is resolved by exact match against the roster:

  • Canonical names and aliases both work. An ability’s declared search aliases are honoured exactly like its canonical name — chalie documentation resolves to chalie_docs. Two abilities claiming the same alias is a startup error, never a silent guess.
  • Normalisation is lenient at the edges. Case, surrounding punctuation, and separator noise are stripped — _Weather_, weather:, and WEATHER all resolve — but there is no fuzzy or semantic matching behind it. A name either resolves or it doesn’t.
  • Misses are honest. An entry that matches nothing lands in not_found with a pointer back to the roster, never a best-effort substitute.

Results are deduplicated but never truncated — there is deliberately no cap across the array, because the point of the array is to let one call surface every tool the turn requires. find_tools returns a structured body the model can act on directly — an injected list of {name, summary} plus the not_found list — with counts in the envelope so even a small model can tell exactly what it got.

find_skills keeps its own search: skill playbooks are prose, so each query entry runs through an exact-title → title-keyword → semantic escalation over the skill index. It returns the full playbook text for each matching skill, along with any personalisation rules derived from your behaviour, and surfaces a corrupt or unreadable index as a loud error rather than letting it masquerade as “nothing found”.

This is the current mechanism. Earlier descriptions of tool discovery as a keyword-plus-semantic search cascade describe a previous design and no longer apply to find_tools.

MCP tools

Tools joined from connected MCP servers are not in the find_tools roster. They have their own gateway, mcp_tools — itself a discoverable ability — with two actions:

  • list — every connected MCP server with its status, plus the full tool list (name and summary) for each server that is enabled and online.
  • activate — exact tool names to enable for this turn, the same way find_tools activates built-ins. Unknown names come back under not_found with a pointer back to list.

mcp_tools itself is always allowed (internal) — it only lists and activates. The activated MCP tools still pass the permission gate on every call, like any other tool.

The result contract

Every ability returns a tool result — a frozen value with exactly two constructors:

  • Success: a body (string shown verbatim, or dict/list rendered as compact JSON), an optional rich-media payload for the chat UI, and flat scalar metadata shown in the result’s opening tag.
  • Error: a stable kebab-case machine code (required), a one-line recovery hint for the model, and optional valid values when the model passed an invalid one.

The ability never formats the wire output. The dispatcher renders the one envelope the model sees:

[weather(status=success)]
{"location":"Valletta, MT","condition":"Clear","temperature_c":24.1}
[end:weather]

[memory(status=error, code=no-query-or-location, action=recall)]
recall requires either a query or a location.
hint: pass query= to search by topic, or location= to filter by place.
[end:memory]

This is the design principle that makes errors safe: errors are data for the model, not exceptions. A failed call is returned into the loop as a structured error with a recovery hint, so the model can correct itself and try again. It never crashes the turn. The contract is enforced — an ability that returns anything other than a tool result hard-fails with a non-canonical-result error code, and a raised exception is caught and rendered as an error envelope rather than propagating.

Dispatch and permissions

Every tool call — model-issued, framework seed, or background pass — flows through one chokepoint: the tool dispatcher. It is the only path from the act loop to ability execution. In order, it:

  1. Resolves the tool to a fresh, per-call ability instance bound to the invoking turn; an MCP-prefixed name resolves to an MCP proxy instead.
  2. Pre-validates the action-required map, so a hallucinated action or a missing parameter bounces with a self-correcting error before the permission gate.
  3. Gates the call through the policy manager (below).
  4. Executes — inline, or on a background thread when the call sets the async flag.
  5. Renders the envelope and records the call on the turn’s action trail.

The permission gate

Because tools take real actions, every call passes a policy gate before it runs. The gate is a flat lookup on (channel, permission), where the permission is the tool name optionally qualified by an action (e.g. calendar.delete_event). Each entry resolves to one of four settings:

Setting Behaviour
allow Run without asking
ask Prompt you for confirmation, then run if you approve
deny Block, and tell the model not to retry
internal Always allowed; hidden from the policy surface

A few read-only and infrastructure tools — discovery, memory, reading, the compactors — always bypass the gate as internal. For everything else, an unseen permission defaults to ask: a brand-new action prompts you the first time rather than running silently. You adjust these in the Brain dashboard’s policy manager, and the same permission can carry a different setting per channel.

Crucially, the channel matters. There are three policy channels — chat, the background subconscious, and external agents. On the two channels with no human present to answer a prompt, an ask automatically becomes a deny — so a background reflection or a connected agent can never silently trigger a confirmation that nobody will see. And the risk class a call is gated on is derived from its inputs through the ability itself, never trusted from a model-supplied field, so a prompt-injected “action” can’t talk its way past the gate.

Adding a tool

The shape above is the whole contract, so adding a tool is small:

  1. Create an ability with the five descriptors plus a run method returning a tool result.
  2. Leave discoverability on so the tool joins the global find_tools roster — declaring as search aliases any alternative names it might be asked for by — or turn discoverability off and pin the tool into the always-available list of every channel that should reach it.

That’s it — no engine changes, no wiring. The same uniformity is what lets external tools join through Agent & MCP Integration and dispatch through this exact pipeline. For how a turn assembles requests and feeds tool results back to the model, see Message Flow; for the runtime these tools live in, see the Architecture Overview; and for how memory and the background mind use these same abilities, see Memory & Cognition.