Compile-time traps that are easy to miss when deriving tools from @spec and @doc. Release behaviour is covered in design.md; the type subset and runtime boundaries are covered in design.md and number-handling.md.

@spec carries no parameter names

@spec f(String.t()) is a value domain, not a record. The schema needs property names, so the library requires annotated arguments:

@spec get_weather(city :: String.t()) :: ...

The quoted AST for that argument is {:"::", _, [{:city, _, nil}, type]}, so the name survives compilation. An unannotated argument can also take a name from a @doc parameter bullet; otherwise it falls back to the head variable, then argN, with a compile-time warning.

@spec is an accumulative attribute

Module.get_attribute(mod, :spec) contains specs for earlier functions too:

@spec get_weather(String.t()) :: ...
@spec two_specs(integer()) :: ...

The library filters by {name, arity} and warns when a function has several specs; overloads cannot be represented as one JSON Schema.

@doc is single-valued and consumed immediately

@doc is not an accumulating attribute. Reading it later can attach the last function's text to every tool, or return nil. The library captures it from @on_definition, before the attribute is reset; @doc false opts a function out.

JSON Schema integer accepts 1.0

Draft 2020-12 defines integer as a number with a zero fractional part, so a decoder can produce 1.0. ToolCall.Coerce truncates integral floats and rejects fractional ones.

float() parameters can receive integers

{"type":"number"} accepts 2, while is_float(2) is false. ToolCall.Coerce widens integers to floats before dispatch.

JSON numbers Elixir cannot represent

1e309 is valid JSON but not a finite Erlang float; the decoder raises before the library sees it. The schema cannot prevent this. See number-handling.md.

@on_definition fires for private functions and every clause

The hook sees private helpers and each clause of a multi-clause function. The library ignores non-def callbacks and keeps the first {name, arity} entry.

@spec names are documentation, not syntax

The compiler allows duplicate and placeholder parameter names:

@spec f(city :: String.t(), city :: integer()) :: String.t()
@spec g(_ :: String.t(), _ :: integer()) :: String.t()

The library renames duplicates (city_2) and falls back from _ to the head variable or argN, with a warning. Two arities of one function share a tool name; only the first is exposed.

@doc metadata never reaches the :doc attribute

Metadata-only docs such as @doc deprecated: "use bar/1" do not reach the attribute read by @on_definition. They also live in the Docs chunk, which a stripped release removes. A tool declared that way falls back to its function name; write text as well if the model should see a description.

@typedoc is consumed before @before_compile

@typedoc is consumed by the following @type before @before_compile runs, so the phase-one IR cannot see it. ToolCall recovers docs-visible @type and @opaque text in @after_compile from the just-compiled Docs chunk and stores the enriched literals in a companion module. It is only a fallback parameter description: a @doc ## Parameters bullet or a @tool_param description wins. Local alias chains are followed to the nearest documented type, resolving current-module references inside alias bodies; nested uses such as list(address()) do not inherit the typedoc.

@typep is different: Elixir discards its typedoc at definition time, so no docs entry exists to recover. A private type is still resolved for the schema, but it never contributes a description.

Stale typedoc companions are inert and cleaned by a compile or task

The companion beam is not registered in Mix's compile manifest. Source-file companions carry a .source sidecar with their source path; every ToolCall compile prunes companions whose source file is gone. If no ToolCall module compiles after deleting a source file, or a module was removed from an existing file, run mix tool_call.clean before assembling a release. The task also scans companion beams directly, using the parent beam and a live source from either the .source sidecar or the beam's compile_info.source; a stripped compile_info leaves only the parent-beam check.

The compile-generation token makes any remaining stale companion unusable by the new module, so it falls back to phase-one data instead of serving stale descriptions. A companion generated by a ToolCall build that predates the __tool_call_companion__/0 marker is treated as foreign, so upgrade with one mix clean or ToolCall warns and skips typedoc enrichment rather than overwriting it.

A missing Docs chunk is not always a problem

docs: false intentionally omits the chunk. ToolCall skips typedoc silently in that compile and only warns about a missing chunk when docs are enabled. An undecodable or structurally invalid Docs chunk is reported as corrupt and falls back to phase-one descriptions.

@on_definition accumulates

Unlike @doc, @on_definition is an accumulating attribute. A use ToolCall does not replace another library's definition hook; both callbacks fire, including for generated functions such as __tools__/0.

Remaining design boundaries

Some limits are intentional: nested/remote/provider typedoc inheritance, @typep typedoc recovery, overloaded specs, two arities of one function, tuple defaults, JSON numbers outside Erlang's finite range, @doc metadata-only entries and type-provider typedoc. See the Design boundaries section in design.md.