ToolCall (ToolCall v0.1.0)

Copy Markdown

Derive LLM tool-call schemas from @spec and @doc.

defmodule MyApp.Tools.Weather do
  use ToolCall

  @doc """
  Get the forecast for a city.

  ## Parameters
    * `city` - city name
    * `days` - how many days ahead
  """
  @spec get_weather(city :: String.t(), days :: pos_integer() | nil) ::
          {:ok, %{temp: float()}} | {:error, String.t()}
  def get_weather(city, days \\ 3)
end

ToolCall.emit(MyApp.Tools.Weather, :openai)
ToolCall.run(MyApp.Tools.Weather, :get_weather, %{"city" => "Shanghai"})

Everything is generated at compile time from module attributes. No runtime path reads the Docs or Dbgi chunks, because release builds strip them. Visible local @typedoc text is recovered in a second compile phase (@after_compile) and embedded in a generated companion module; runtime code never calls Code.fetch_docs/1. The companion is ordinary compiled code, loaded on first use like any other module.

Supported typespec subset

See docs/design.md. @type, @typep and @opaque declarations in the same module are resolved before mapping, including parameterised ones. Unknown types degrade to {} (any value) rather than failing compilation unless a ToolCall.TypeProvider registered with use ToolCall, types: [...] resolves them; types that JSON cannot express at all (functions, pids, refs) are flagged with a compile-time warning.

Summary

Functions

Returns a provider-neutral description of every tool in module.

Emits schemas for target (:openai or :anthropic).

Runs a tool by name. args uses string keys, as decoded from JSON.

Returns the tool names derived from a module, in declaration order.

Returns the tools derived from a module.

Functions

describe(module, opts \\ [])

@spec describe(module(), keyword()) :: [
  %{
    module: module(),
    name: String.t(),
    description: String.t(),
    parameters: map()
  }
]

Returns a provider-neutral description of every tool in module.

Each entry has :module, :name, :description and :parameters, where :parameters is the JSON Schema object for the arguments with no provider envelope around it. Use this when your client owns the request format; use emit/3 when you want the OpenAI or Anthropic envelope.

Options

  • :strict - render the strict JSON Schema subset (see emit/3).

emit(module_or_tools, target, opts \\ [])

@spec emit(
  module() | [ToolCall.IR.Tool.t()] | ToolCall.IR.Tool.t(),
  :openai | :anthropic,
  keyword()
) ::
  map() | [map()]

Emits schemas for target (:openai or :anthropic).

For a module the supported variants are rendered at compile time and embedded as a literal, so this is a direct or guarded map lookup and strict may be a runtime boolean. A module returns a list with one schema per tool; a single ToolCall.IR.Tool returns one schema map and a list of them a list, rendered on demand, which is what dynamically built tools need. Runtime IR is validated before rendering; invalid types raise ArgumentError.

Options

  • :strict - OpenAI strict mode. The tool object carries "strict": true, every property becomes required, optional values become nullable and every object is closed, which is the only shape the API accepts when strict: true is set. Ignored for :anthropic.

run(module, name, args)

@spec run(module(), atom() | String.t(), term()) :: {:ok, term()} | {:error, term()}

Runs a tool by name. args uses string keys, as decoded from JSON.

tool_names(module)

@spec tool_names(module()) :: [String.t()]

Returns the tool names derived from a module, in declaration order.

tools(module)

@spec tools(module()) :: [ToolCall.IR.Tool.t()]

Returns the tools derived from a module.

The embedded IR keeps Type.source, which charlist handling needs at runtime, and drops the spec and parameter ASTs, which are compile-time provenance only.