Toolnexus.Client.create
Elixir · package toolnexus · SPEC §8 · elixir/lib/toolnexus/client.ex
Toolnexus.Client.create(hooks: %{ before_llm: (%{messages: [map()], tools: [map()], model: String.t(), turn: non_neg_integer()} -> map() | nil), after_llm: (%{response: map(), model: String.t(), turn: non_neg_integer()} -> any()), before_tool: (%{name: String.t(), args: map(), id: String.t(), turn: non_neg_integer()} -> map() | nil), after_tool: (%{name: String.t(), args: map(), result: Toolnexus.ToolResult.t(), id: String.t(), turn: non_neg_integer()} -> map() | nil)})There is no separate Hooks type — hooks is a plain map of up to four 1-arity functions passed
to Toolnexus.Client.create/1. All four are optional; an absent key
behaves as a no-op. Every hook must be 1-arity — a wrong-arity function raises ArgumentError
immediately rather than silently never firing.
When to use it
Section titled “When to use it”- Guardrails — deny a dangerous tool call in
before_toolbefore it ever runs. - Context compaction —
before_llmcan replacemessages/toolsfor the rest of the run (trim history, drop tools the model doesn’t need this turn). - Cost / audit logging —
after_llmsees the raw provider response (it carriesusage) on every turn; log it, don’t rewrite it. - Redaction —
after_toolcan replace a tool’sresultbefore it enters the transcript, so a secret a tool returned never reaches the model or your logs.
Why this and not the alternative
Section titled “Why this and not the alternative”Hooks and :on_metric reach the client identically whether set directly on create/1 or
forwarded by the §7D agent runtime — nothing is renamed, dropped, or reordered on that path.
Examples
Section titled “Examples”1. before_tool — deny a tool call before it runs
Section titled “1. before_tool — deny a tool call before it runs”Returning %{result: %ToolResult{}} short-circuits the tool: execute never runs, and the
returned result is what the model sees instead.
alias Toolnexus.{Client, Context, Tool, ToolResult}
deleted = fn -> raise "should never run" end
delete_all = %Tool{ name: "delete_all", description: "Deletes everything", input_schema: %{"type" => "object", "properties" => %{}}, source: "native", execute: fn _args, %Context{} -> deleted.() end }
plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn)
resp = %{ "choices" => [ %{ "message" => %{ "role" => "assistant", "content" => nil, "tool_calls" => [%{"id" => "c1", "type" => "function", "function" => %{"name" => "delete_all", "arguments" => "{}"}}] } } ], "usage" => %{"prompt_tokens" => 3, "completion_tokens" => 2, "total_tokens" => 5} }
conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp))end
client = Client.create( base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug], # a second turn would loop forever asking for the same tool, so cap it low — # the assertion below only needs the FIRST turn's denial. max_turns: 1, hooks: %{ before_tool: fn %{name: "delete_all"} -> %{result: ToolResult.error("denied: destructive tools are disabled")} end } )
result = Client.run(client, "delete everything", [delete_all])
true = result.status == "incomplete"[call] = result.tool_callstrue = call.name == "delete_all"true = call.is_errortrue = call.output == "denied: destructive tools are disabled"
IO.puts("ok: #{call.output}")2. before_llm — context compaction (rewrite messages and tools mid-run)
Section titled “2. before_llm — context compaction (rewrite messages and tools mid-run)”Returning %{messages: ..., tools: ...} replaces the working transcript/tool list for the
rest of the run — the canonical use is trimming history or hiding tools the model doesn’t need
this turn. The plug below echoes back what it actually received, so we can see the rewrite land
on the wire.
alias Toolnexus.{Client, Context, Tool}
noisy_tool = %Tool{ name: "noisy", description: "A tool we want hidden after turn 0", input_schema: %{"type" => "object", "properties" => %{}}, source: "native", execute: fn _args, %Context{} -> "noise" end}
keep_tool = %Tool{ name: "keep", description: "A tool that should still reach the model", input_schema: %{"type" => "object", "properties" => %{}}, source: "native", execute: fn _args, %Context{} -> "kept" end}
{:ok, seen} = Agent.start_link(fn -> [] end)
plug = fn conn -> {:ok, raw, conn} = Plug.Conn.read_body(conn) body = Jason.decode!(raw) Agent.update(seen, fn s -> s ++ [body] end)
resp = %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => "done"}}], "usage" => %{"prompt_tokens" => 2, "completion_tokens" => 1, "total_tokens" => 3} }
conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp))end
client = Client.create( base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug], hooks: %{ before_llm: fn %{tools: tools} -> # drop "noisy" from what the model is shown this turn %{tools: Enum.reject(tools, &(&1["function"]["name"] == "noisy"))} end } )
_result = Client.run(client, "hi", [noisy_tool, keep_tool])
[sent] = Agent.get(seen, & &1)true = length(sent["tools"]) == 1true = hd(sent["tools"])["function"]["name"] == "keep"
IO.puts("ok: before_llm dropped 1 of 2 tools before they hit the wire")3. after_llm + after_tool — audit the raw response, redact a tool’s output
Section titled “3. after_llm + after_tool — audit the raw response, redact a tool’s output”after_llm is observe-only (its return value is ignored) but sees the raw provider payload,
including usage. after_tool can transform a result before it joins the transcript.
alias Toolnexus.{Client, Context, Tool, ToolResult}
secret_tool = %Tool{ name: "whoami", description: "Returns account info", input_schema: %{"type" => "object", "properties" => %{}}, source: "native", execute: fn _args, %Context{} -> ToolResult.ok("api_key=sk-super-secret-value") end}
{:ok, audit} = Agent.start_link(fn -> [] end){:ok, calls} = Agent.start_link(fn -> 0 end)
plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) n = Agent.get_and_update(calls, fn c -> {c + 1, c + 1} end)
resp = if n == 1 do %{ "choices" => [ %{ "message" => %{ "role" => "assistant", "content" => nil, "tool_calls" => [%{"id" => "c1", "type" => "function", "function" => %{"name" => "whoami", "arguments" => "{}"}}] } } ], "usage" => %{"prompt_tokens" => 4, "completion_tokens" => 2, "total_tokens" => 6} } else %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => "ok, redacted"}}], "usage" => %{"prompt_tokens" => 3, "completion_tokens" => 2, "total_tokens" => 5} } end
conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp))end
client = Client.create( base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug], hooks: %{ after_llm: fn %{response: response, turn: turn} -> Agent.update(audit, fn a -> a ++ [{turn, response["usage"]["total_tokens"]}] end) end, after_tool: fn %{name: "whoami", result: %ToolResult{} = r} -> %{result: %ToolResult{r | output: "api_key=[REDACTED]"}} end } )
result = Client.run(client, "who am I?", [secret_tool])
true = result.text == "ok, redacted"[call] = result.tool_callstrue = call.output == "api_key=[REDACTED]"
audited = Agent.get(audit, & &1)true = audited == [{0, 6}, {1, 5}]
IO.puts("ok: redacted output=#{call.output}, audited #{length(audited)} llm call(s)")Hook shapes
Section titled “Hook shapes”| Hook | Receives | Return to act on it | Effect |
|---|---|---|---|
:before_llm |
%{messages, tools, model, turn} |
%{messages: ..., tools: ...} |
Replaces the working transcript/tool list for the rest of the run. |
:after_llm |
%{response, model, turn} |
(ignored) | Observe the raw provider payload — logging, cost, tracing. |
:before_tool |
%{name, args, id, turn} |
%{result: %ToolResult{}} or %{args: map()} |
Short-circuits the tool (deny/cache/dry-run), or rewrites its arguments. |
:after_tool |
%{name, args, result, id, turn} |
%{result: %ToolResult{}} |
Transforms the result (redact, annotate) before it joins the transcript. |
See also
Section titled “See also”Toolnexus.Client.create— the unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory.Toolnexus.Client.run— send a prompt, let the loop call tools until the model stops, get a RunResult.Toolnexus.Client.metrics— observe-only per-call events, pre-shaped, no mutation.Toolnexus.Client.resilience—:on_error— the other host-classification seam, scoped to LLM failures.