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:
---role: Support Deskmodel: claude-opus-4-8tools: 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.
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 endendBuilding 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
- Least privilege by construction. A junior’s tool list simply does not
contain
issue_refund. It is not a runtime refusal the model can argue with; the capability is never offered. - Gates come from the file. Flip
gate: managerto nothing in the definition and the same senior tool stops parking for approval, no code change. Theneeds_approval:a builder sets is ordinary human approval: the refund suspends, a manager decides later from any process, andresumecarries on. - Identity comes from the run. The refund records who issued it through
context.app[:user](see App context), so the tools are defined once and still act as the signed-in rep. - Typos fail at boot. An unknown tool name in the definition raises in
build, and an unfilled{rep_name}raises inrender, so a broken definition never ships a half-built agent.