axn-ruby_llm
Call LLMs from Axn actions using RubyLLM, with declarative error handling, schema-based structured output, configurable defaults, and cost/token tracking — and wrap any Axn as a RubyLLM::Tool a chat can call.
RubyLLM 2.0 required. As of
0.3.0, this gem requiresruby_llm >= 2.0, < 3.0and no longer supports RubyLLM 1.x.Upgrading to 0.4.0? Two behaviour changes: a response cut off by the output token limit or blocked by a content filter now fails instead of succeeding, and the OpenTelemetry attribute
gen_ai.usage.input_tokensnow includes cached tokens.input_tokens/prompt_tokensare deprecated in favour ofuncached_input_tokens/total_input_tokens. See CHANGELOG.md for the full list and what to check.
Part of the axn-* extension ecosystem — see also axn-mcp.
Why use this over calling RubyLLM directly?
Five things you'd otherwise hand-build:
-
Structured error handling. The Axn error DSL declaratively maps
RateLimitError,JSON::ParserError, truncated or content-filtered responses, and genericStandardErrorto clean failure messages. Callers checkresult.ok?instead of wrapping every call inbegin/rescue. -
Production gating. A single
c.enabled = -> { Rails.env.production? }in an initializer stubs every LLM call in non-prod environments — no per-callsite guards needed. The stub is typed (stubbed: true,total_input_tokens: 0, etc.) so downstream code doesn't need to branch on it either. -
Cost/token tracking, exposed automatically. Every call exposes
total_input_tokens,uncached_input_tokens,cache_read_tokens,cache_write_tokens,output_tokens,thinking_tokens,server_tool_use,cost, andcost_breakdown, read straight off RubyLLM's own usage ledger (Chat#tokens/Chat#cost) — no manual model lookup, and a tool-call loop's retries and multiple round-trips are already aggregated for you. If your app uses OpenTelemetry, these values are also set as attributes on the existingaxn.callspan — no configuration required. -
Author-once tools.
Axn::RubyLLM.wrapturns any Axn into aRubyLLM::Toolyour chat can call — reuse the same Axn classes you already expose through axn-mcp, or plain Axns, with no rewrite. The tool's name, JSON Schema, and argument validation all come from the Axn's own contract. -
Remote MCP tools.
Axn::RubyLLM.remote_mcp_toolswraps a remote MCP server's tools so they sit next to your Axn tools, with an allowlist, a call budget, timeouts, and bearer-token or OAuth auth. Or let the provider connect to the server itself withprovider_tools:.
Scope note:
askruns one prompt through to a final answer and covers everyRubyLLM::Chat#with_*setting, plus attachments, seeded history, streaming, and a tool-call cap (a spec fails CI when a RubyLLM release adds a method that isn't covered or explicitly skipped). For anything beyond one prompt-to-answer run, such as keeping a chat alive across turns, stepping the loop by hand, or RubyLLM's lifecycle callbacks, driveRubyLLM::Chatdirectly; see "Using wrapped tools with RubyLLM directly" below. RubyLLM's non-chat capabilities (embeddings, images, transcription, and so on) aren't wrapped. Feedback and pull requests are very welcome.
Installation
gem "axn-ruby_llm"Configure RubyLLM as normal (e.g. in config/initializers/ruby_llm.rb). The default model is gpt-4o-mini, but any RubyLLM-supported provider works — just configure the appropriate API key and pass model: to override:
RubyLLM.configure do |c|
c.openai_api_key = ENV["OPENAI_API_KEY"] # OpenAI
c.anthropic_api_key = ENV["ANTHROPIC_API_KEY"] # or Anthropic, Gemini, etc.
endOptionally configure gem-level defaults:
Axn::RubyLLM.configure do |c|
c.default_model = "gpt-4o-mini" # default; override with any RubyLLM model ID
c.error_headline = "LLM request failed" # default; prefixes every result.error (see Errors below)
endUsage
result = Axn::RubyLLM.ask(
prompt: "Summarize this Slack thread: #{thread_text}"
)
result.response # => "The team decided to..."
# With system prompt and model override
result = Axn::RubyLLM.ask(
prompt: user_message,
system_prompt: "You are a concise financial analyst.",
model: "gpt-4o",
temperature: 0.2
)Chat options
Every other RubyLLM::Chat#with_* setting has a matching ask input. Each is forwarded only when given, so omitting it leaves RubyLLM's default in place:
ask input |
Forwarded to | Notes |
|---|---|---|
provider:, protocol:, assume_model_exists:
|
RubyLLM.chat (alongside model:) |
Choose the provider or wire protocol yourself instead of relying on autodetection. assume_model_exists: true skips the registry lookup and requires provider:. |
context: |
RubyLLM.chat(context:) |
A RubyLLM.context { |c| ... } whose API keys and base URLs replace the global config for this call. |
fallbacks:, fallback_on:
|
with_fallbacks(*fallbacks, on: fallback_on) |
Models to try in order when generation fails. fallback_on: defaults to RubyLLM's transient provider and network errors. |
cache_system_prompt: |
with_instructions(system_prompt, cache_until_here: true) |
Marks the system prompt as an explicit prompt-cache boundary. Worth it for a long system prompt you reuse. |
max_output_tokens: |
with_max_output_tokens |
|
thinking: |
with_thinking |
true, false, or { effort:, budget:, display: }. |
citations: |
with_citations |
Use with attachments: to get raw_message.citations back. |
caching: |
with_caching |
true, false, or { ttl:, id: }. |
compaction: |
with_compaction |
true, false, or { at:, instructions:, pause_after: }. |
end_user: |
with_end_user |
An opaque id. RubyLLM sends it as given, so never pass PII. |
provider_options: |
with_provider_options |
Raw keys merged into the request payload, e.g. { service_tier: "flex" }. |
headers: |
with_headers |
Extra HTTP headers, e.g. a provider beta flag. |
thinking:, citations:, caching:, and compaction: also forward an explicit false, which you'd use, for example, to turn off thinking on a model that enables it by default. context:, headers:, and provider_tools: are marked sensitive, so axn keeps them out of logs.
Attachments, history, and streaming
Axn::RubyLLM.ask(
prompt: "What changed since last quarter?",
attachments: ["q3.pdf", "https://example.com/q2.pdf"], # Chat#ask's with:
history: prior_turns, # seeded before the prompt
on_chunk: ->(chunk) { print chunk.content }, # streamed as it arrives
)-
attachments:is passed toChat#askaswith:. RubyLLM reads a String from disk or fetches it over HTTP, so never pass an unvalidated, user-supplied path or URL. -
history:is anythingChat#messages=accepts:RubyLLM::Messages,{ role:, content: }Hashes, or records that respond to#to_llm.system_prompt:replaces any system message in it.transcriptincludes only the turns this call exchanged, not the seeded ones. -
on_chunk:receives eachRubyLLM::Chunk.responseandraw_messageare still the complete message, soschema:works as usual. The stubbed (disabled) path never calls it.
Structured output via schema
Pass schema: to enable provider-enforced structured output (e.g. OpenAI strict mode) via RubyLLM::Chat#with_schema. The result's response is the parsed Hash — read via RubyLLM::Message#parsed under the hood, not #content (which is now always the raw JSON text).
schema: accepts any of three forms:
A Schematist::Schema class or instance — anything RubyLLM::Chat#with_schema itself accepts:
class CompanyMatch < Schematist::Schema
integer :company_id, description: "ID of the matched company, or null"
number :confidence, description: "0.0–1.0"
string :reasoning
end
result = Axn::RubyLLM.ask(
prompt: "Which company is this thread about?\n\n#{thread_text}",
schema: CompanyMatch,
)
result.response # => { "company_id" => 42, "confidence" => 0.92, "reasoning" => "..." }The schematist gem (installed automatically by RubyLLM 2.0) is recommended but not required; declare it in your own Gemfile if you want the DSL.
A raw JSON Schema Hash, passed straight through unchanged:
Axn::RubyLLM.ask(prompt: "...", schema: { type: "object", properties: { answer: { type: "string" } } })An Axn class, via its own output_schema reflection — the same contract you'd already write with exposes, no separate schema to maintain:
class CompanyMatch
include Axn
exposes :company_id, type: Integer, allow_nil: true
exposes :confidence, type: Float
exposes :reasoning, type: String
end
result = Axn::RubyLLM.ask(prompt: "...", schema: CompanyMatch)
result.response # => { "company_id" => 42, "confidence" => 0.92, "reasoning" => "..." }A few adjustments happen automatically here, confirmed against real provider calls (Anthropic live; OpenAI by its documented contract):
-
additionalProperties: falseis injected on every fixed-shape object node — required unconditionally by Anthropic's structured output and by OpenAI's strict mode, and axn'sexposescontract has no reason to emit it on its own. -
minProperties/maxPropertiesare stripped — axn emitsminProperties: 1by default on a nested fixed-shapeHashfield, and Anthropic's schema validator rejects it outright (confirmed live). -
strict: falseis always sent, rather than left to RubyLLM's own strict-inference (which would otherwise turn on for the common case of every property being required).
You get the declared shape, required keys, and "no extra keys" enforcement; you don't get OpenAI's full strict-mode guarantee, which additionally requires every property to appear in required — even conceptually optional ones, via a nullable type — which axn's reflection doesn't promise. Pass a Schematist::Schema instead if you need that.
A
Hashmap field (type: Hash, of: {...}) doesn't survive this path against every provider. Confirmed live: Anthropic's structured output rejectsadditionalPropertiesset to anything but the literalfalse("additionalProperties: object' is not supported. Please set 'additionalProperties' to false"), and OpenAI's strict mode has the same restriction by design — neither provider's structured-output feature represents dynamic/arbitrary keys, only a fixed shape. This isn't something the adapter can paper over (there's no schema-legal way to say "arbitrary keys, but still typed" in either provider's strict mode), so a map field is left as-is and the provider's own rejection surfaces as the request's error. Use a fixed-shapeHash(shape:) instead, or pass your own schema Hash /Schematist::Schemaif you specifically need a map with that provider.
Token counts and cost
Every successful result exposes token usage and cost, read off RubyLLM's own usage ledger (Chat#tokens / Chat#cost) — which already sums every provider attempt for the call, including a tool loop's multiple round-trips and any retries:
result = Axn::RubyLLM.ask(prompt: "...")
result.total_input_tokens # => 512 (every input token: uncached + cache reads + cache writes — use this for prompt size)
result.uncached_input_tokens # => 412 (standard-rate input only — RubyLLM's Tokens#input)
result.cache_read_tokens # => 80 (served from the provider's prompt cache)
result.cache_write_tokens # => 20 (written to the provider's prompt cache)
result.output_tokens # => 78
result.thinking_tokens # => 40 (reasoning tokens, where the provider reports them separately)
result.server_tool_use # => { "web_search_requests" => 2 } (per-use counters for provider-hosted tools)
result.cost # => 0.00056 (Float USD total; nil if RubyLLM has no pricing for the model)
# Full breakdown — RubyLLM::Cost, RubyLLM's own aggregated-cost object
result.cost_breakdown # => #<Cost input: 0.0004, output: 0.00016, cache_read: 0.0, ..., total: 0.00056>
# Raw RubyLLM::Message for thinking tokens, raw provider data, etc.
result.raw_message # => #<RubyLLM::Message ...>Use total_input_tokens for prompt size. With prompt caching (explicit via caching:, or automatic on OpenAI's newer models), a tool loop's repeated prefix is billed as cache reads and new content as cache writes, so uncached_input_tokens can be single digits on a 50k-token prompt. cost prices each bucket at its own rate, so it's right either way.
There's deliberately no plain input_tokens here: RubyLLM's Tokens#input means uncached input (a billing bucket), while OpenTelemetry's gen_ai.usage.input_tokens means all input, and each field name says which one it is. The older input_tokens (= uncached_input_tokens) and prompt_tokens (= total_input_tokens) still work but are deprecated; see DEPRECATIONS.md.
cost is nil when RubyLLM lacks pricing for the model (e.g. unknown/custom endpoints); cost_breakdown itself is still a Cost object in that case (only its component readers are nil). Token counts are nil only if the provider did not return them. total_input_tokens is nil only if all three input token fields are nil.
Errors
Errors are handled via Axn's declarative error DSL. Every failure shares a consistent "LLM request failed: <reason>" headline (the headline itself is configurable via c.error_headline =, e.g. to "Something went wrong calling the LLM"; the reasons below are unaffected):
- The response hit the output token limit (
finish_reason: :max_tokens) →"LLM request failed: Response was cut off by the output token limit before it finished". This is checked beforeschema:parsing, so a truncated structured response reports this rather than a JSON error. - A provider content filter blocked the response (
finish_reason: :content_filter) →"LLM request failed: Response was blocked by the provider's content filter" -
JSON::ParserError→"LLM request failed: Response was not valid JSON" -
RubyLLM::RateLimitError(HTTP 429, provider-agnostic) →"LLM request failed: Rate limit reached: <message>" -
RubyLLM::OverloadedError/ServiceUnavailableError/ServerError(5xx, transient) →"LLM request failed: Provider temporarily unavailable, try again later: <message>" -
RubyLLM::ContextLengthExceededError→"LLM request failed: Prompt exceeds the model's context window: <message>"(message retains the provider's token counts) -
schema:set but LLM returned non-JSON, or valid JSON that isn't an object →"LLM request failed: Response was not valid JSON"(malformed JSON text) or"LLM request failed: Schema response was not valid JSON"(valid JSON, wrong shape) - Any other known RubyLLM error —
RubyLLM::Error(auth, bad request, payment, etc.),RubyLLM::ConfigurationError,ModelNotFoundError,ModelRegistryError,PromptNotFoundError,InvalidRoleError,InvalidToolChoiceError,PendingToolCallsError,CancelledError,UnsupportedAttachmentError— orFaraday::Error(network/transport failure) →"LLM request failed: <message>" - Any other
StandardError(i.e. not a recognized RubyLLM/network failure — most likely a bug) →"LLM request failed", with no exception detail leaked into the message
The truncation and content-filter failures still expose the usage fields, cost, raw_message, transcript and finish_reason, since the call was paid for. result.finish_reason is also set on success (normally :stop).
Tool adapter — wrap any Axn as a RubyLLM::Tool
Any Axn can be exposed as a ::RubyLLM::Tool — no adapter-specific mixin required, it's just a normal Axn:
class CreateWidget
include Axn
description "Creates a widget with the given name"
expects :name, type: String
exposes :widget_id
def call
expose widget_id: Widget.create!(name:).id
end
endPass Axns to Axn::RubyLLM.ask via tools: and the model can call them as part of the request — RubyLLM runs the tool-call loop internally and result.response is the model's final reply:
result = Axn::RubyLLM.ask(
prompt: "Create a widget called Sprocket, then tell me its id.",
tools: [CreateWidget],
)
result.response # => "Created widget Sprocket (id: 42)."tools: accepts a mix of bare Axn classes (wrapped automatically) and already-wrapped tools from Axn::RubyLLM.wrap (a class, or an instance that closed over ambient_context: — see below). Pass tools: Axn::RubyLLM.tools to expose everything registered under the :ruby_llm adapter (see Enumerating tools). The same Axn classes you expose through axn-mcp work here unchanged.
Token/cost in a tool loop: a tool call makes multiple model round-trips inside one
ask. The token counts,cost, andcost_breakdowncome from RubyLLM's own usage ledger (Chat#tokens/Chat#cost), which already aggregates every turn — so they reflect the whole call, not just the final response. (raw_messageis still the final response.)
Axn::RubyLLM.wrap is also available directly if you're driving RubyLLM.chat yourself rather than going through ask — see Using wrapped tools with RubyLLM directly.
The tool's name, description, and JSON Schema parameters come straight from the Axn's own contract — the same description/expects/exposes you'd write for any Axn — so a minimal class just works (the tool name defaults from the class name: CreateWidget → create_widget). Arguments the model supplies are run through axn core's tool Invoker: wire types are coerced, and any contract violation — a missing required field, an out-of-schema argument, a wrong type, or a value outside an inclusion set (validated at full depth, not just the top-level type) — comes back to the model as a clean, correctable { error: "Invalid tool arguments: <reason>" }, and does not page on_exception as though it were a bug. A model-supplied ambient_context is stripped before the Axn runs, so a prompt-injected context can never override the caller's — the wrap's own ambient_context: (below) is injected instead. The Invoker also stamps every call (including any nested sub-Axn) with the invoked_via: :ruby_llm dimension, so a Datadog dashboard can query tool-driven traffic separately from ordinary direct .calls — see OpenTelemetry below.
On success, execute returns the exposed values (via Axn::RubyLLM.serialize_exposed, honoring reject_opaque_exposed_values — see below) as a JSON string, not a Hash — RubyLLM::Tool.split_result sends a returned String through as-is, but a returned Hash/Array is #to_json'd via Ruby's own dispatch, which may not match axn's own serialization contract (Symbol keys/values, BigDecimal, Time, opaque-value rejection); serializing ourselves keeps the wire form aligned with what output_schema advertises. On failure, { error: result.error }. The same CreateWidget class can be wrapped for other transports (e.g. Axn::MCP.wrap) with no changes — the contract is declared once.
Options, settable either per-call via wrap keywords or once on the Axn via axn's namespaced per-class configure(:ruby_llm) { |c| ... } (a wrap keyword wins when both are present, then the class-level configure(:ruby_llm) value, then this gem's own Axn::RubyLLM.configure { |c| ... } global, then the default below):
| Option | Effect |
|---|---|
provider_options: |
Hash merged into the tool definition sent to the provider (via RubyLLM's Tool.provider_options) — an escape hatch for provider-specific tool fields RubyLLM doesn't model first-class (e.g. OpenAI strict function calling, Anthropic tool cache_control). Keys mirror that provider's tool shape. Default {}. |
present_as: |
:structured (default) returns the exposed values as a JSON string; :message returns result.message instead. Same knob as axn-mcp's present_as:. |
Set a default once on the Axn with configure(:ruby_llm), and still override per call:
class CreateWidget
include Axn
configure(:ruby_llm) { |c| c.present_as = :message } # default for this tool
# ...
end
Axn::RubyLLM.wrap(CreateWidget) # returns result.message
Axn::RubyLLM.wrap(CreateWidget, present_as: :structured) # per-call overrideNo more
halt_after:. RubyLLM 2.0 removedTool::Haltalong with the rest of the auto-halting machinery — the conversation loop is now caller-controlled. If you need a tool call to stop the loop, drive it yourself:loop { chat.step; break if chat.complete? || done_condition }. See RubyLLM's Agentic Workflows guide.
configure(:ruby_llm) needs no include beyond Axn — every Axn gets it for free (core's namespaced per-class config). It's usually written in the class body as above, but since it's a plain class method you can also call it from outside — e.g. SomeThirdPartyAxn.configure(:ruby_llm) { |c| ... } in an initializer, to configure an Axn you don't own. Namespacing is what lets one base Axn be configured for multiple adapters at once, each in its own namespace, without collision even when two adapters share a setting name (both this gem and axn-mcp expose present_as):
class CreateWidget
include Axn
# ...
configure(:ruby_llm) { |c| c.present_as = :message } # how the RubyLLM tool presents its result
configure(:mcp) { |c| c.present_as = :structured } # same setting name, different namespace — no collision
endPass ambient_context: to close over explicit caller context (e.g. current_user, company) at wrap time, instead of relying on axn's reflective Current-based default resolved when the tool runs. This matters when the tool executes somewhere Current isn't the right context — e.g. the chat (and therefore the tool call) runs in a background job or a different thread than the request that built the tools:
Axn::RubyLLM.wrap(CreateWidget, ambient_context: { company_id: current_company.id })Passing ambient_context: returns a tool instance (closing over that context) rather than the tool class, since chat.with_tools accepts either.
Using wrapped tools with RubyLLM directly
Axn::RubyLLM.ask runs one prompt through to a final answer. Drive RubyLLM.chat yourself when you need more control than that: keeping one chat alive across turns, stepping the loop by hand (ask_later/step), or RubyLLM's lifecycle callbacks (before_tool_call, after_tool_result, after_message, before_request, before_fallback). Register wrapped tools with RubyLLM's own with_tools, which accepts one or many RubyLLM::Tool classes/instances:
chat = RubyLLM.chat.with_tools(Axn::RubyLLM.wrap(CreateWidget))
chat.ask("Create a widget called Sprocket")
# or register everything under the :ruby_llm adapter at once:
chat = RubyLLM.chat.with_tools(*Axn::RubyLLM.tools)Provider-hosted tools and remote MCP (provider_tools:)
provider_tools: forwards verbatim to RubyLLM's Chat#with_provider_tools — a Hash of alias => options. This is how the provider (not your app) runs a tool, including a remote MCP server: the provider connects to the server directly, with whatever headers: you pass, and its own tool calls happen inside the provider's request rather than yours.
Axn::RubyLLM.ask(
prompt: "Why did this company's margin drop?",
provider_tools: {
mcp: {
name: "metabase", url: ENV.fetch("METABASE_MCP_URL"),
headers: { "X-API-KEY" => ENV.fetch("METABASE_MCP_API_KEY") },
allowed_tools: %w[search execute_sql],
require_approval: "never",
},
},
)See RubyLLM's Chat#with_provider_tools for the other aliases (:web_search, :code_execution, ...) and which providers support each one.
App-side remote MCP (Axn::RubyLLM.remote_mcp_tools)
remote_mcp_tools works the other way round: your app connects to the MCP server, using the official mcp gem, and wraps each of the server's tools as an ordinary RubyLLM::Tool. They sit next to your Axn tools in tools:, and every call is one your app makes, logs, times out, and can cap.
toolset = Axn::RubyLLM.remote_mcp_tools(
url: ENV.fetch("METABASE_MCP_URL"),
headers: { "X-API-KEY" => ENV.fetch("METABASE_MCP_API_KEY") },
allowed_tools: %w[search execute_sql], # pass it: a server's full list can include write/admin tools
max_calls: 20, # shared across the whole toolset, for its lifetime (default 20)
)
Axn::RubyLLM.ask(prompt: "...", tools: [*Axn::RubyLLM.tools, *toolset.tools])
toolset.closeThe max_calls budget never resets, so connect a fresh toolset per request (and close it) when you want a per-request cap; a toolset reused across requests shares one budget between them. timeout: (default 60s) and max_result_chars: (default 20,000) bound each call. When a server wants more than a static header for auth:
-
bearer_token:takes a String, or a callable that returns one, and sends it asAuthorization: Bearer <token>. A callable runs on every request, so your code owns fetching, caching, and rotating the token, e.g.bearer_token: -> { MyOAuthStore.current_token }. -
oauth:takes anMCP::Client::OAuth::ClientCredentialsProvider(machine-to-machine) orMCP::Client::OAuth::Provider(interactive authorization code + PKCE). It's passed straight toMCP::Client::HTTP, which handles discovery, token exchange, refresh, and retrying after a 401.
Pass one or the other, not both. Both refuse a URL that is neither https nor loopback http.
Pausing on approval before a provider-hosted call runs (on_remote_tool_approval:)
Set require_approval: "always" (or per-tool) on a provider_tools: entry and the provider pauses before running the call, asking your app to approve or deny it first. Without a decision, ask would return whatever the chat produced at that pause — not a final answer. Pass on_remote_tool_approval:, a callable given each pending RubyLLM::ToolCall (.name, .arguments, .remote?), and ask drives the chat past every approval until it's genuinely done:
Axn::RubyLLM.ask(
prompt: "...",
provider_tools: { mcp: { name: "metabase", url: ..., require_approval: "always" } },
on_remote_tool_approval: ->(tool_call) {
Rails.logger.info("[probe] #{tool_call.name}: #{tool_call.arguments}")
tool_call.name == "execute_sql" # only allow the tools you actually want to approve
},
)The same callback also resolves a local tool declared with Tool.requires_approval — pending_approvals doesn't distinguish where the call runs, only whether a decision is still owed.
Tool concurrency and other chat options (tool_options:)
tool_options: forwards verbatim to Chat#with_tool_options (choice:, calls:, concurrency:). concurrency: :threads or :fibers runs one turn's local Axn tool calls in parallel — useful when a tool is pure I/O (an HTTP call, a remote MCP round-trip wrapped as a local tool) and each call doesn't share mutable state. It is not a good fit for a tool that checks out an ActiveRecord connection: parallel local calls check out one connection each, from a pool sized for the process's normal concurrency.
Axn::RubyLLM.ask(prompt: "...", tools: [...], tool_options: { concurrency: :threads, calls: :many })Capping tool calls (max_tool_calls:)
ask has no limit of its own on how long the tool loop runs, because RubyLLM 2.0 removed halt_after. max_tool_calls: caps how many tool calls your app runs in one ask, counted across every tool in tools:. Once the cap is reached, each further call returns { error: "Tool call budget exhausted ... write your final answer with what you have so far." } and the model is left to finish. The cap is soft on purpose: raising would throw away everything the chat had gathered. Calls to remote_mcp_tools tools count here as well as against their own max_calls. Provider-hosted calls (provider_tools:) never reach your app, so they aren't counted. The caller's tool instances are copied, not modified, so reusing a wrap(axn, ambient_context:) instance across calls is safe.
Axn::RubyLLM.ask(prompt: "...", tools: Axn::RubyLLM.tools, max_tool_calls: 15)Reading what the chat actually did (transcript)
ask always exposes transcript: every message the chat exchanged (the system prompt excluded), each shaped as { role:, content:, tool_calls:, tool_call_id:, server_tool_calls: }. server_tool_calls is where a provider-hosted MCP call's name, arguments, and result show up — your app never receives that request, so this is the only place to see what query actually ran.
result = Axn::RubyLLM.ask(prompt: "...", provider_tools: { mcp: { ... } })
result.transcript.flat_map { |m| m[:server_tool_calls]&.values || [] }.each { |c| puts "#{c.name}: #{c.arguments}" }Tool naming
The name is axn core's canonical, provider-safe tool_name: lowercased to [a-z0-9_], leading configured prefixes stripped, snake_cased with single underscores, and never blank (Admin::CreateWidget → admin_create_widget; a truly anonymous Axn → "tool"). Declare axn_name "..." on the Axn to override the default. Because it's the same core derivation every adapter uses, a class wrapped by both Axn::RubyLLM.wrap and Axn::MCP.wrap advertises an identical name — the contract is declared once.
Enumerating tools from the registry
Rather than wiring each tool up by hand, let axn's tool registry find them and build the whole chat tool list in one call:
chat = RubyLLM.chat.with_tools(*Axn::RubyLLM.tools)Axn::RubyLLM.tools returns every Axn registered under the :ruby_llm adapter, already wrapped as a ::RubyLLM::Tool — sugar for Axn::Tools.for(:ruby_llm).map { |axn| Axn::RubyLLM.wrap(axn) }, in a stable, tool_name-sorted order.
Membership = (directory grant ∪ declaration grant) − exclusions. An Axn is a :ruby_llm tool if either:
-
Directory grant — its file lives under one of the adapter's
tool_roots. The default is["agent_tools"](→app/agent_toolsin a Rails app), so an Axn dropped inapp/agent_toolsis a tool with no declaration at all. Configure the roots withAxn::RubyLLM.configure { |c| c.tool_roots = ["agent_tools", "actions/tools"] }; each entry must be a narrow subdir (app/,actions,., and..are rejected, so you can't bulk-expose every action). -
Declaration grant — it declares
tool(every adapter),tool :ruby_llm, or carries aconfigure(:ruby_llm)bag.
…unless it opts out: tool false (no adapter) or tool except: :ruby_llm (keep the directory grant, drop this adapter).
class CreateWidget
include Axn
tool :ruby_llm # add :ruby_llm (on top of any directory grant)
tool ruby_llm: { present_as: :message } # …or add it AND set per-adapter options inline
# ...
endBecause both adapters read the registry and the same canonical tool_name, the identical set of Axns exposed via Axn::MCP.tools advertises identical names — and since both default tool_roots to agent_tools, an Axn there is authored once and is a tool on both surfaces.
Upgrading from the pre-registry API:
tool :ruby_llmnow adds to the directory grant rather than replacing it (declare all adapters,name:,except:, and per-adapter options in a singletoolcall). And the old globalAxn.config.tool_pathsis gone — each adapter owns its owntool_roots, so pointAxn::RubyLLM.config.tool_roots(andAxn::MCP's) at your tool dirs instead.
Date/Time/Symbol/Integer/Float fields — declare coerce:
A provider always sends tool-call arguments as JSON, so a Date/Time/DateTime/Symbol/Integer/Float-typed field arrives as a String (e.g. "2026-07-08"), which the plain type: validator rejects — it checks value.is_a?(klass), not a parse. Declare coerce: on that expects field so axn parses the wire string before validation runs:
expects :scheduled_for, coerce: Date # sugar for type: { klass: Date, coerce: true }
expects :priority, type: { klass: Symbol, coerce: true } # explicit form, e.g. alongside other type: optionsOpt-in per field — a field with no coerce: is unaffected. A non-String value (a direct Ruby caller's real Date, a JSON-native number) is left untouched either way.
Opaque exposed values — reject_opaque_exposed_values
A tool's result is the Axn's exposed values serialized to JSON. A value with no author-declared JSON form — no to_json/as_json of its own — has no honest representation and can only render as an opaque blob: "#<User:0x000…>" outside Rails, or ActiveSupport's generic instance-variable dump under it. By default that blob ships, because for an LLM tool result an ugly-but-honest string usually beats a failed call.
Set reject_opaque_exposed_values (default false) to reject it instead. Serialization then raises Axn::Extensions::Serialization::UnserializableValue (naming the path, e.g. records[3].owner), which the adapter's transport-boundary guard (below) turns into a generic tool error rather than shipping the blob:
CreateWidget.configure(:ruby_llm) { |c| c.reject_opaque_exposed_values = true } # per tool (wins)
Axn::RubyLLM.configure { |c| c.reject_opaque_exposed_values = true } # gem-wide defaultScope:
-
Output-side only. It governs
exposesserialization, never inboundcoerce:onexpects. -
Narrow. Values with no honest JSON form at all — reference cycles, non-finite Floats, non-UTF-8 bytes, two Hash keys colliding onto one property — raise
UnserializableValueregardless of this flag (and surface as a tool error).reject_opaque_exposed_valuesonly adds the extra "was this rendering author-declared?" check on top.
Transport-boundary never-raises guard
A wrapped Axn's own .call never raises — core catches action exceptions into a failed Result and pages on_exception itself. But the transport step that runs after the Axn settles — serializing the exposed values, encoding them to JSON — happens outside core's executor and can raise (an unserializable value as above, a structure past the JSON encoder's max_nesting, or a plain gem bug). Since RubyLLM has no rescue around a tool's execute, an escaping exception there would break the whole chat.
So the adapter guards that mapping step (only — the Axn call already reports its own exceptions): any StandardError is reported through Axn.config.on_exception and the tool returns a generic error, "The tool could not produce a valid response". The message is deliberately generic — the actionable detail (exception class, path) rides on the reported exception, not the tool's response. In development (per core's best_effort_raises_in_dev) the exception is re-raised instead, so a real bug surfaces loudly rather than being masked. This mirrors axn-mcp's adapter-boundary guard.
Schema reflection — provider notes
The advertised tool schema is axn's reflected input_schema, passed through to RubyLLM unmodified — the adapter does no per-provider schema rewriting. RubyLLM 2.0's Gemini protocol reads a tool's schema via parametersJsonSchema, the wire form verbatim, rather than rebuilding each property from a fixed whitelist (as its 1.x converter did), so nullable fields, additionalProperties, and entry-count bounds all reach Gemini the same way they reach OpenAI and Anthropic.
A few things worth knowing about what axn itself reflects, independent of RubyLLM version:
-
Array fields — declare
of:.expects :ids, type: Arrayreflects to{type: array}with noitems, so the element type isn't advertised (some providers then assumestring; OpenAI strict mode requiresitems). Declare the element type —expects :ids, type: Array, of: Integer— to advertiseitemscorrectly. -
Enums are advertised — use the top-level
inclusion:key.expects :color, type: String, inclusion: %w[red green blue](a bare Array, or the long forminclusion: { in: %w[red green blue] }) reflects to{ type: "string", enum: ["red", "green", "blue"] }, so the model is told the allowed set (anoptional:field keepsnullin the enum; a dynamicin: -> { ... }is correctly skipped rather than guessed). Note this is the top-levelinclusion:option, notvalidate: { inclusion: ... }—validate:is axn's custom-callable hook (it needswith:), so that spelling neither enforces nor reflects the set. -
Conditional expectations (
expects :token, if: :use_token) reflect to a JSON SchemaallOf/if/thenclause. Whether a given provider's tool-schema handling honors that clause is between RubyLLM and the provider, not something this adapter controls — check the provider's own function-calling docs if it matters for your case. Encoding the rule indescription:remains the portable option regardless.
Testing
In your specs, require the helpers and use stub_axn_ruby_llm to stub RubyLLM so Axn::RubyLLM.ask returns a canned response without a real API call:
require "axn/ruby_llm/rspec"
it "summarizes the thread" do
stub_axn_ruby_llm("The team agreed to ship on Friday.")
result = Axn::RubyLLM.ask(prompt: "...")
expect(result.response).to include("ship on Friday")
endThe response is the only required argument — pass it positionally (as above) or as response:. Pass schema: to route a Hash response through the schema path (stubbing raw_message.parsed so result.response gets the Hash back unparsed, matching what a real schema: call returns). Token counts and cost default to zero and can be set explicitly to exercise cost/usage logic:
stub_axn_ruby_llm({ "company_id" => 42 }, schema: CompanyMatch)
stub_axn_ruby_llm("...", model: "gpt-4o", input_tokens: 100, output_tokens: 50, cost: 0.0023)
stub_axn_ruby_llm("...", cache_read_tokens: 500, cache_write_tokens: 200)
stub_axn_ruby_llm("...", thinking_tokens: 300, server_tool_use: { "web_search_requests" => 1 })
stub_axn_ruby_llm("partial", finish_reason: :max_tokens) # exercises Ask's truncation failureRemaining keywords: model:, schema:, input_tokens: (RubyLLM's uncached count, so it becomes uncached_input_tokens), output_tokens:, cache_read_tokens:, cache_write_tokens:, thinking_tokens:, server_tool_use:, cost:, and finish_reason: (default :stop). It stubs every RubyLLM::Chat#with_* method, so specs can pass any Ask option. Returns the stubbed chat instance double for further assertions if you need it.
OpenTelemetry
If your app uses OpenTelemetry, axn already wraps every action in an axn.call span. This gem enriches that span with LLM-specific attributes automatically — no configuration required:
| Attribute | Value |
|---|---|
gen_ai.request.model |
The model requested |
gen_ai.response.model |
The model that responded |
gen_ai.usage.input_tokens |
total_input_tokens — all input, cached included, per the OTel GenAI conventions |
gen_ai.usage.cache_read.input_tokens |
cache_read_tokens (omitted when the provider doesn't report it) |
gen_ai.usage.cache_creation.input_tokens |
cache_write_tokens (omitted when the provider doesn't report it) |
gen_ai.usage.output_tokens |
Completion token count |
gen_ai.usage.cost |
USD total (non-standard; useful for spend filtering) |
axn.ruby_llm.stubbed |
true when production gating returned a stub |
axn.ruby_llm.version |
This gem's version — separates spans from before and after a change in an attribute's meaning |
axn.dimension.invoked_via |
"ruby_llm" — set by axn core on every tool call (including nested sub-Axns), not by this gem; lets you separate tool-driven traffic from ordinary direct .calls in the same span schema |
For LLM-level tracing (individual RubyLLM.chat calls, tool calls, embeddings, prompt content), add opentelemetry-instrumentation-ruby_llm to your own Gemfile and configure it per its README. It is not a dependency of this gem.
Production gating
Set the enabled config to gate LLM calls — useful for skipping spend in non-production environments. Accepts a Boolean or a callable (evaluated per call):
Axn::RubyLLM.configure do |c|
c.enabled = -> { Rails.env.production? }
# c.enabled = false # always stub
# c.enabled = true # default; always run
endTo read the resolved gate, use Axn::RubyLLM.enabled? — it invokes an assigned callable and returns a Boolean. (Don't use the DSL-generated Axn::RubyLLM.config.enabled?: axn's Configurable returns an assigned Proc as-is rather than calling it, so for a callable that predicate is always truthy.)
When disabled, Axn::RubyLLM.ask returns a success result with obvious stub content, so callers don't need per-callsite branching:
| Field | Stubbed value |
|---|---|
response |
"stubbed response value" (plain) / { "stubbed" => true } (schema:) |
raw_message |
Stub struct with .content, .tokens (a real RubyLLM::Tokens, all zero), .model, .parsed
|
total_input_tokens / uncached_input_tokens / cache_read_tokens / cache_write_tokens / output_tokens
|
0 (the deprecated input_tokens / prompt_tokens too) |
thinking_tokens / server_tool_use / finish_reason
|
nil |
transcript |
[] |
cost |
0.0 |
cost_breakdown |
nil |
stubbed |
true |
Check result.stubbed if you need to branch on it (e.g. skip downstream writes that would otherwise persist stub LLM output). result.success is "LLM request completed (using stubbed values - actual LLM request disabled)" for the same purpose; a normal (non-stubbed) call succeeds with "LLM request completed".