Tools
Tools are the actions the model can take — the agent's hands in your Rails app. A tool is a subclass of Protege::Tool. Subclassing registers the tool automatically — no manual registry — but registration alone puts it in front of no one: an agent is offered a tool only when a toolkit containing it is attached to that agent.
Anatomy
Here is LookupOrderTool, the first tool The Scoop gives Sundae — it looks up an order by number:
# app/tools/lookup_order_tool.rb
class LookupOrderTool < Protege::Tool
description "Looks up an order by its number and returns its current status."
summary "Look up an order by number."
input_schema(
type: "object",
additionalProperties: false,
required: %w[order_number],
properties: {
order_number: { type: "string", description: "The order number, e.g. SCP-1042." }
}
)
def use(context:, order_number:)
order = Order.find_by(number: order_number) # your app's model
return failure(reason: "no order #{order_number}") unless order
success(status: order.status, total_cents: order.total_cents, placed_at: order.created_at)
end
enddescriptionandinput_schemadefine what the model sees. The schema is standard JSON Schema; itspropertiesbecome the keyword arguments touse.use(context:, **input)runs the action. It'suse— notcallorrun. Returnsuccess(**data)orfailure(reason:, **data); both are fed back to the model so it can react.reasonmay be a string or an exception.- This is the seam where the agent reaches into your app. A tool is ordinary Ruby, so it calls your models, services, and jobs directly.
- The class name is the tool's identity.
LookupOrderToolbecomes the id:lookup_order(the basename, with the trailingToolstripped, snake-cased). The suffix is optional on your own classes — the generator adds it, and the id comes out the same either way.
Loading the class registers it: lookup_order shows up in every toolkit's member checklist, and the All Tools system toolkit picks it up on the next bin/rails protege:toolkits:sync. But Sundae can't call it yet — to put it in her catalog, add it to a toolkit attached to her. Tool access is fail-closed: no toolkit, no tool.
Dashboard metadata
Two audiences read a tool's metadata, and each gets its own attribute:
summary "…"— the short, operator-facing one-liner shown next to the tool in the dashboard's toolkit pickers.display_name "…"— the human label for the tool. It defaults to the titleized id (lookup_order→ "Lookup Order"), so you rarely need to set it.
description stays model-facing — verbose, model-directed text published in the tool schema — so don't make one string serve both audiences.
The context
Every use receives a context — one frozen object shared by every tool and resolver invocation in the run. It is the complete surface; there is nothing else on it:
| Method | Returns | Notes |
|---|---|---|
context.agent | Protege::Agent | The agent handling the run (here, Sundae). Always present. |
context.message | Protege::Message or nil | The inbound message being answered. Present on reply runs; nil on scheduled runs. |
context.responsibility | Protege::Responsibility or nil | The scheduled duty being carried out. Present on scheduled runs; nil on replies. |
context.deliver(mail:, attachments: []) | delivery outcome | Send outbound mail through the Gateway under the current agent — transport, threading, and persistence handled. attachments are already-stored ActiveStorage::Blobs, attached to the persisted message by reference. |
context.config | Protege::Configuration | A frozen per-run snapshot of the configuration. |
context.logger | Logger | The engine logger (config.logger). |
context.correlation_id | String or nil | The id tying this run's events, log lines, and traces together. |
Exactly one of message / responsibility is set — that's how a tool tells a reply run from a scheduled one (the two concrete classes are Protege::Orchestrator::ReplyContext and ResponsibilityContext, but you only ever branch on which accessor is present). The object is frozen, so a tool can't smuggle state to a later invocation through it.
Tool calls and their results are emitted as events and persisted, so a later turn can remember what the agent did.
Success and failure
A tool must return a Protege::Result. The success/failure helpers build one:
success(refund_id: refund.id, amount_cents: refund.amount_cents)
failure(reason: "order already refunded")
failure(reason: e) # an exception works tooA failure is not an error — it's information. The model sees the reason and can adjust: apologize, ask for a missing detail, or try a different tool. If a tool raises, the engine catches it and turns it into a failure result too, so a bug in one tool never crashes the run — it just tells the model that call didn't work.
Sending mail is a tool
The agent's plain assistant text is never delivered — mail goes out only through the built-in send_email tool. Every agent that talks back needs it — which is why the Default Tools toolkit, attached automatically to every new agent, seeds it.
It has two modes:
mode: "reply"— continue the current conversation. The subject and threading are taken from the inbound message (not chosen by the model — an invented subject would split the thread in the customer's client), and the original sender is always a recipient. The agent may addto/cc/bcc.mode: "new"— start a fresh conversation. The agent suppliestoandsubject; no threading headers are set.
body is always required; From is always forced to the agent's own address. The agent can attach files by referencing blob ids (see Attachments).
Built-in tools
Protege ships a handful of tools. A new agent starts with only the Default Tools toolkit — seeded with just send_email — so it can reply from day one; the rest are granted by toolkit membership (the All Tools system toolkit grants the full set):
| Tool | Purpose |
|---|---|
send_email | The only way to send mail (From enforced). Modes "reply" (subject + threading from the inbound message; reply-all — the original sender and other To recipients carry into To, the original Cc is kept, the agent itself is dropped, everything de-duplicated; add to/cc/bcc) and "new" (agent supplies to + subject). Attach files by blob id via attachments: [123], subject to the attachment limits. |
search_emails | Free-text search across the agent's own archive (participants + subject/body), so it can recall conversations beyond the current thread. Returns matches newest-first with bodies and each message's attachments (filename, type, size, id). |
read_attachment | Reads a stored file by its id. Text files (txt/csv/markdown/json/xml) come back as text; images and PDFs are shown to the model to view (it sees the actual file); video and other types can't be read. |
create_file | Turns content the agent authors into a stored file it can attach. Give it a format (csv, txt, md, html, json, svg), a filename, and the full content as text; returns a blob_id. Feed that id to send_email. The agent only ever authors text — there's no binary generation yet. |
web_search | Keyless best-effort web search (scrapes DuckDuckGo) returning {title, url, snippet} results the agent can then web_fetch. |
web_fetch | Fetches an http(s) url and returns its readable text. Guards against SSRF (internal/private addresses refused) and caps redirects and response size. |
Keyed search is a host-app tool
web_search is deliberately keyless, so it's safe to expose to every agent. Provider-specific search (Tavily, Perplexity, Brave, …) needs an API key, so it belongs in your app as a tool in app/tools/ that reads the key from ENV — not an engine built-in. That keeps keyed tools opt-in rather than force-exposed everywhere.
Attachments
send_email, create_file, and read_attachment are the agent's hands for files. How inbound images/PDFs are auto-included for a multimodal model, the type-by-type behavior, the limits, and how to extend handling are covered in the tutorial: More tools & attachments.