Skip to content

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:

bash
bin/rails g protege:tool lookup_order

Then fill it in — it looks up one of The Scoop's orders by number:

ruby
# 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
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.
  • 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.

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 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.