Unified LLM client — the host loop (SPEC §8, §10).
Give it a plain base_url + a style ("openai" | "anthropic") and it runs the whole
tool-calling agent loop against a toolkit — either a plain list of %Toolnexus.Tool{} or
any struct/map with :tools and :prompt fields (the skills prompt).
client = Toolnexus.Client.create(base_url: "...", style: "openai", model: "gpt-4o-mini")
result = Toolnexus.Client.run(client, "add 2 and 3", tools)
Summary
Functions
Stateful ask. With an :id (or a bare conversation-id string as the 4th argument), the
client's store remembers the conversation: load transcript → run → save. Without an id,
a stateless one-shot identical to run/4. With :on_text, streams and forwards text
deltas while still returning the final %RunResult{}.
§8 Gap 4. The client's conversation store — the exact instance passed in :store,
else the default in-memory store the client created.
Create a client. Options (SPEC §8 ClientOptions)
Prometheus text exposition of cumulative metrics (§8). Empty-but-valid before activity.
Run the agent loop: system prompt → LLM call → execute tool calls (concurrently, results
fed back in call order) → repeat to :max_turns. Returns a %RunResult{}.
Streaming variant: returns an Enumerable of event maps —
%{type: "text", delta}, %{type: "tool_call", id, name, args},
%{type: "tool_result", id, name, output, is_error}, %{type: "usage", usage},
%{type: "pending", request} (§10), %{type: "done", result}.
Single-turn translation (§11): exactly ONE provider call, returned in OpenAI shape. No agent loop, no tool execution, no conversation state — every call is self-contained, so this may be run statelessly and concurrently.
Types
@type t() :: %Toolnexus.Client{ api_key: term(), base_url: term(), body_transform: term(), deadline: term(), headers: term(), hooks: term(), http_options: term(), max_turns: term(), model: term(), on_error: term(), on_metric: term(), registry: term(), request_params: term(), retries: term(), retry_base_ms: term(), store: term(), style: term(), system_prompt: term(), timeout_ms: term(), transport: term(), wait_for: term() }
Functions
Stateful ask. With an :id (or a bare conversation-id string as the 4th argument), the
client's store remembers the conversation: load transcript → run → save. Without an id,
a stateless one-shot identical to run/4. With :on_text, streams and forwards text
deltas while still returning the final %RunResult{}.
§8 Gap 4. The client's conversation store — the exact instance passed in :store,
else the default in-memory store the client created.
Create a client. Options (SPEC §8 ClientOptions):
:base_url,:style("openai"|"anthropic"),:model,:api_key(env fallback:OPENROUTER_API_KEY/OPENAI_API_KEY/ANTHROPIC_API_KEY),:headers:system_prompt— prepended to the toolkit's skills prompt (joined with"\n\n"):max_turns(default 10):on_error—(%{error?, status?, attempt, retryable} -> :retry | :fail)classifies each failed LLM attempt (§8 Resilience). Absent ⇒ default (retryable ⇒ :retry, else :fail), byte-identical to today. A:retryis bounded by:retries. No:suspendtier.:hooks— map with:before_llm,:after_llm,:before_tool,:after_tool:retries(default 2),:retry_base_ms(default 500),:timeout_ms(whole-run deadline):store— aToolnexus.Client.ConversationStorestruct (default: in-memory):on_metric—(event_map -> any)semantic observability sink:wait_for— §10 suspension resolver,(Request -> Answer):request_params— map shallow-merged into every LLM body AFTER base keys (caller wins;messages/tools/streamforbidden) — §8 Gap 1:body_transform—(body -> body)run LAST after the merge — §8 Gap 1:http_options— extraReqoptions for the LLM path (§8 Gap 2, in-process variant):transport— §8 Gap 2, the first-class injectable HTTP transport for the LLM path: a function(request -> {:ok, response} | {:error, Exception.t()})whererequestis%{method: :post, url: String.t(), headers: %{String.t() => String.t()}, body: map(), receive_timeout: non_neg_integer() | nil}(bodyis the un-marshalled JSON body) andresponseis%{status: integer(), headers: map() | list(), body: term()}(bodydecoded for JSON endpoints, raw binary for SSE). It replaces the wire call only — retries, backoff,Retry-After, deadline, and error classification still run around it, and it composes against a realbase_url(unlike:http_options:plug, which is in-process only).nil⇒ the defaultReqtransport, byte-identical behavior.:registry— inject a sharedMetricsRegistry(e.g. one runtime-wide registry across many clients);nil⇒ a fresh private registry, byte-identical behavior
Prometheus text exposition of cumulative metrics (§8). Empty-but-valid before activity.
@spec run(t(), String.t(), term(), keyword()) :: Toolnexus.Client.RunResult.t()
Run the agent loop: system prompt → LLM call → execute tool calls (concurrently, results
fed back in call order) → repeat to :max_turns. Returns a %RunResult{}.
opts: :history — a prior transcript to continue.
@spec stream(t(), String.t(), term(), keyword()) :: Enumerable.t()
Streaming variant: returns an Enumerable of event maps —
%{type: "text", delta}, %{type: "tool_call", id, name, args},
%{type: "tool_result", id, name, output, is_error}, %{type: "usage", usage},
%{type: "pending", request} (§10), %{type: "done", result}.
With an :id it is stateful like ask: history is loaded before streaming and the
transcript is saved back to the store on the terminal done event.
@spec translate(t(), [map()], keyword()) :: Toolnexus.Translate.Result.t()
Single-turn translation (§11): exactly ONE provider call, returned in OpenAI shape. No agent loop, no tool execution, no conversation state — every call is self-contained, so this may be run statelessly and concurrently.
The inbound half of the adapters: to_anthropic/1/to_gemini/1 send declarations out,
this reads the provider's tool calls back in. A :toolkit in opts is DECLARED and
never executed.
messages is the OpenAI messages list, taken verbatim. Options:
:tools— the OpenAItoolsarray, verbatim (declaration-only):toolkit— an ordinary toolkit, declared but never executed; composes with:tools:tool_choice— the OpenAItool_choice, verbatim:system— overrides the system prompt:max_tokens— overrides the per-provider default
Retries/backoff, request-param merging and the llm metric are shared with the loop;
before_llm/after_llm fire once and tool hooks never fire.
Returns a Toolnexus.Translate.Result.