Your first tool
Sundae can receive mail but can't do anything with it. In this chapter we give her hands: the ability to reply, and her first real capability — looking up an order.
Replying is a tool
Start with the most important rule in Protege: an agent's plain assistant text is never delivered. Whatever the model "says" stays internal. Mail leaves the building only through the built-in send_email tool. So every agent that talks to anyone needs send_email in scope — and Sundae already has it, because a persona with no tools grant may use every registered tool, and send_email is a built-in.
That's why she was silent in setup: she generated a reply but had no way to send it. Actually, she did have send_email — what she lacked was anything worth saying. Let's give her something to look up.
Write LookupOrder
A tool is a subclass of Protege::Tool. Scaffold one:
bin/rails g protege:tool lookup_orderThen fill it in — it looks up one of The Scoop's orders by number:
# app/tools/lookup_order.rb
class LookupOrder < Protege::Tool
description "Looks up an order by its number and returns its current status and total."
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) # The Scoop's own model
return failure(reason: "no order #{order_number}") unless order
success(
status: order.status,
total_cents: order.total_cents,
placed_at: order.created_at.to_date.to_s
)
end
endA few things to notice:
descriptionandinput_schemaare what the model sees. The schema'spropertiesbecome the keyword arguments touse— soorder_numberarrives as a keyword.use(context:, **input)is where the work happens. It'suse, notcallorrun.success(**data)/failure(reason:)are both fed back to the model. A failure isn't an error — it's information Sundae can act on ("I couldn't find that order; could you double-check the number?").- This is ordinary Ruby reaching into The Scoop's app.
Orderis your model, not an engine class.
There's no wiring step. Tools are discovered by subclass, so the moment LookupOrder loads it's in Sundae's catalog. (We enabled config.eager_load = true in setup so this happens in development.)
Try it
Restart bin/dev, then message Sundae from the dashboard console:
Hi, can you tell me where order SCP-1042 is?
Watch the introspection panel on the thread. You'll see Sundae work — call lookup_order with { "order_number": "SCP-1042" }, get the status back, and then call send_email in reply mode to answer — threaded to your message, from support@thescoop.com. The plain assistant text in between never leaves; only the send_email body does.
The two modes of send_email
You didn't write send_email — it's built in — but it's worth knowing its shape, since it's the tool every agent leans on:
mode: "reply"continues the current thread. The subject and threading come from the inbound message (not the model — an invented subject would fork the conversation in Gmail), and the original sender is always a recipient.mode: "new"starts a fresh conversation; the model suppliestoandsubject.
In both, From is forced to Sundae's own address, and body is required. We'll use attachments in a later chapter.
Next
Sundae can look up an order and reply — but she's answering blind, with no idea who is emailing. Next we give her context.
→ Context & memory — load the customer's record and remember the conversation.

