Skip to content

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:

bash
bin/rails g protege:tool lookup_order

The 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:

ruby
# 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
end

A few things to notice:

  • description and input_schema are what the model sees. The schema's properties become the keyword arguments to use — so order_number arrives as a keyword.
  • summary is 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's use, not call or run.
  • 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. Order is 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:

  1. Toolkits → New — name it Support, and check Lookup Order.
  2. 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 supplies to and subject.

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.