Your first tool
Sundae can receive mail and reply — but she's all talk. Ask her where an order is and she can only apologize charmingly, because nothing connects her to The Scoop's data. In this chapter we give her a 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 — and tools come only from toolkits attached to the agent. Sundae can reply because the Default Tools toolkit (which carries the engine's built-ins, send_email among them) attached itself when she was created. Detach it and she'd fall silent — not rude, just structurally unable to speak.
That fail-closed inversion is the second rule worth internalizing early: writing a tool doesn't arm anyone with it. Defining a tool class registers it in the manifest; which agents may use it, and on whose behalf, is decided by toolkits in the dashboard. You'll feel the difference in a moment.
Write LookupOrderTool
A tool is a subclass of Protege::Tool. Scaffold one:
bin/rails g protege:tool lookup_orderThe generator applies the Tool suffix — you get app/tools/lookup_order_tool.rb with class LookupOrderTool, while the tool's id (what the model calls it) stays lookup_order. In the dashboard's toolkit checklists it appears under its display name, Lookup Order, with its summary alongside. Fill it in — it looks up one of The Scoop's orders 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 and total."
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) # 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.summaryis for humans: the one-liner shown next to the tool in the dashboard's toolkit checklists.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.
Registered, not yet armed
Restart bin/dev and lookup_order is registered — it shows up in every toolkit's member checklist, and the next protege:toolkits:sync folds it into the All Tools system toolkit. But Sundae still can't call it: her only toolkit is Default Tools, and lookup_order isn't a member.
Give her a toolkit of her trade:
- Toolkits → New — name it Support, and check Lookup Order.
- On Sundae's page, under Agent Toolkits, attach Support.
No gate rules on the attachment means any sender who reaches Sundae may use its tools — right for a public support desk. (Gating tools per sender is chapter 5.)
Try it
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 reply carries the whole original participant set forward (reply-all: the sender and other To recipients in To, the original Cc kept in Cc, Sundae herself dropped).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.