Agents from definition files

You run a support desk where junior reps and senior reps use the same agent but not the same tools: everyone can search and read tickets, only seniors can issue refunds, and a refund still parks for a manager. The clean way to express that is an agent definition that declares tool names, plus a registry that turns those names into the tools a given user actually gets.

The definition

The tool list uses the map form so each tool can carry options in your own vocabulary. Here issue_refund carries a gate:

app/agents/support_desk.md
---
role: Support Desk
model: claude-opus-4-8
tools:
search_tickets:
ticket_details:
issue_refund:
gate: manager
---
You help the support team resolve tickets. Search before you answer,
and address the rep as {rep_name}.

The registry

The registry maps each tool name to a builder. A builder receives the per-tool options from the definition and the acting user, and returns a Mistri::Tool, or nil to leave the tool out entirely. Building from an explicit map (not send) means only known names resolve and a typo in a file raises rather than reaching into the object.

app/agents/tool_registry.rb
class ToolRegistry
def initialize
@builders = {
"search_tickets" => method(:search_tickets),
"ticket_details" => method(:ticket_details),
"issue_refund" => method(:issue_refund),
}
end
# definition.tools is a name => options map; turn it into the tools this
# user is allowed, dropping the ones a builder declines.
def build(declared, user)
declared.filter_map do |name, options|
builder = @builders.fetch(name) { raise ArgumentError, "no builder for tool #{name.inspect}" }
builder.call(options, user)
end
end
private
def search_tickets(_options, _user)
Mistri::Tool.define("search_tickets", "Searches support tickets.",
schema: -> { string :query, "Search terms", required: true }) do |args|
Ticket.search(args["query"]).limit(20).map(&:summary)
end
end
def ticket_details(_options, _user)
Mistri::Tool.define("ticket_details", "Full detail for one ticket.",
schema: -> { string :id, "Ticket id", required: true }) do |args|
Ticket.find(args["id"]).to_h
end
end
# Juniors never see this tool: the builder returns nil, so it is not in the
# list and the model is never told it exists. The gate option decides whether
# a senior's refund still needs a manager's approval.
def issue_refund(options, user)
return nil unless user.senior?
Mistri::Tool.define("issue_refund", "Refunds a charge on a ticket.",
needs_approval: options["gate"] == "manager",
schema: lambda {
string :ticket_id, "Ticket id", required: true
integer :amount_cents, "Refund amount in cents", required: true
}) do |args, context|
Refund.create!(ticket_id: args["ticket_id"], amount_cents: args["amount_cents"],
issued_by: context.app[:user])
end
end
end

Building the agent

Load the definition, render its prompt, and let the registry build the tools for whoever is signed in:

definition = Mistri::Definition.load("app/agents/support_desk.md")
registry = ToolRegistry.new
agent = Mistri.agent(
definition.model,
system: definition.render(rep_name: current_user.first_name),
tools: registry.build(definition.tools, current_user),
context: { user: current_user },
)

Why it holds together