Skip to content

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:

ruby
# 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
end
  • description and input_schema define what the model sees. The schema is standard JSON Schema; its properties become the keyword arguments to use.
  • use(context:, **input) runs the action. It's use — not call or run. Return success(**data) or failure(reason:, **data); both are fed back to the model so it can react. reason may 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. LookupOrderTool becomes the id :lookup_order (the basename, with the trailing Tool stripped, 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:

MethodReturnsNotes
context.agentProtege::AgentThe agent handling the run (here, Sundae). Always present.
context.messageProtege::Message or nilThe inbound message being answered. Present on reply runs; nil on scheduled runs.
context.responsibilityProtege::Responsibility or nilThe scheduled duty being carried out. Present on scheduled runs; nil on replies.
context.deliver(mail:, attachments: [])delivery outcomeSend 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.configProtege::ConfigurationA frozen per-run snapshot of the configuration.
context.loggerLoggerThe engine logger (config.logger).
context.correlation_idString or nilThe 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:

ruby
success(refund_id: refund.id, amount_cents: refund.amount_cents)
failure(reason: "order already refunded")
failure(reason: e)   # an exception works too

A 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 add to/cc/bcc.
  • mode: "new" — start a fresh conversation. The agent supplies to and subject; 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):

ToolPurpose
send_emailThe 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_emailsFree-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_attachmentReads 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_fileTurns 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_searchKeyless best-effort web search (scrapes DuckDuckGo) returning {title, url, snippet} results the agent can then web_fetch.
web_fetchFetches 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.

Next

  • Resolvers — give the agent the right context before it reaches for a tool.
  • Toolkits — grant tools to an agent and gate who may reach them.