The router guesses because it doesn't know what our squads own. One way to
fix that is to let it look it up. A tool is an Elixir function the model
may call; Imp.react/3 builds an agent that calls tools until it has an
answer.
A tool is a function
Each squad has a one-line charter. Here is a tool that returns one:
charters = %{
"atlas" => "atlas owns money: charges, refunds, invoices, plans, taxes, receipts.",
"harbor" => "harbor owns the platform: outages, errors, latency, queues, and technical failures even when they involve payments or email delivery.",
"beacon" => "beacon owns identity and trust: accounts, credentials, sessions, permissions, and data exposure, including routine lockouts.",
"quill" => "quill owns the product experience: feature requests, how-to questions, and documentation."
}
charter =
Imp.tool(
:charter,
"Read what a squad owns.",
fn %{"team" => team} -> Map.get(charters, team, "There is no squad called #{team}.") end,
schema: %{
"type" => "object",
"properties" => %{"team" => %{"type" => "string", "enum" => Map.keys(charters)}},
"required" => ["team"]
}
)The model sees the tool's name, its description, and its schema, the JSON
Schema of its arguments. As with signatures, the names are part of the
program: charter and team tell the model what the tool is for.
Imp checks the arguments against the schema before the function runs. A call that doesn't fit becomes an observation the model can read and correct, not an exception in our code.
An agent that uses it
agent =
Imp.react(
Imp.signature(
"ticket -> team: enum[atlas,harbor,beacon,quill], reason: string",
"Route the support ticket to the squad that owns it."
),
[charter],
lm: lm,
max_iters: 5
)
{:ok, prediction} =
Imp.call(agent, %{ticket: "Refund attempts fail with a gateway timeout error."})
{Imp.get(prediction, :team), Imp.get(prediction, :reason)}
#=> {"atlas", "Refunds are a payments/money domain issue, and atlas owns refunds. The gateway timeout appears during refund attempts, but the underlying feature is refund processing rather than a general platform incident."}This ticket is the hard kind: it mentions refunds, which are atlas's, but the failure is a gateway timeout, and our charters give technical failures to harbor even when they involve payments. The label says harbor. On this run the agent weighed it and said atlas; on other runs it read harbor's charter and said harbor. A tool gives the model the facts; it doesn't guarantee the model applies them the way we would. That is why the next pages measure programs on many tickets rather than judging them by one.
We added a reason output so we can see why it chose. The agent works in
steps: each step, the model either calls tools or finishes by calling
submit, a tool Imp adds whose arguments are the signature's outputs. Those
arguments are validated like any prediction, so the loop can't return a team
that doesn't exist. max_iters bounds the steps; a turn that runs out is made
to submit what it has.
Reading the trajectory
The steps the agent took are in the prediction's metadata:
for step <- prediction.metadata.history.messages,
result <- step.tool_call_results do
IO.puts("#{result.name}: #{inspect(result.result)}")
endcharter: "atlas owns money: charges, refunds, invoices, plans, taxes, receipts."
charter: "harbor owns the platform: outages, errors, latency, queues, and technical failures even when they involve payments or email delivery."
charter: "beacon owns identity and trust: accounts, credentials, sessions, permissions, and data exposure, including routine lockouts."
charter: "quill owns the product experience: feature requests, how-to questions, and documentation."
submit: %{reason: "Refunds are a payments/money domain issue, and atlas owns refunds. The gateway timeout appears during refund attempts, but the underlying feature is refund processing rather than a general platform incident.", team: "atlas"}Here the model read all four charters, then submitted. Other runs read only one; the model decides. When an agent does something surprising, the trajectory is the first thing to read.
Keeping tools in bounds
A tool runs with whatever authority its function has. Keep authorization, timeouts, and idempotency in the functions themselves, as you would for any code a request can reach. When your code already knows which function to call, call it directly; an agent is for when the model has to choose.
Tools can also come from an MCP server, a standard way for services to
publish tools to models: Imp.MCP.connect/2 turns a server's tools into
Imp.Tool values that work here unchanged. A server may publish twenty tools
when a program should use two. tool_policy: names the tools a program may
call, and anything else is refused before it runs. submit is a tool like
the others, so the list must name it too:
Imp.react(
"question -> answer: string, source: string",
imported.tools,
lm: lm,
tool_policy: ["search_docs", "read_page", :submit]
)Tools and MCP and ReAct go further: tool schemas, policies, MCP servers, and how the loop ends.
Livebook 04 runs tools, ReAct, MCP imports and RLM in a notebook.
Next: Composing programs →