One operation a module offers through the API (REST today, MCP later).
A module returns a list of tools from the optional PhoenixKit.Module.api_tools/0
callback. Core collects them (PhoenixKit.API.Registry), checks the caller's rights,
validates the input against input_schema, calls the handler and shapes the answer.
Fields
:name-"<namespace>.<verb>_<noun>", lowercase, stable ("catalogue.set_prices"), at most 100 characters.:audience-:admin(a key with scopes) or:public(no key).:scope- the scope a key must carry,"<module>.<resource>:<action>".nilfor public tools and for core's ownsystem.*tools, which every valid key may call. A public tool must have no scope: it is called without a key, so one could never be met, andvalidate/1refuses the tool. Thesystem.namespace belongs to core: a tool a module returns fromapi_tools/0under that name is ignored, so a module cannot declare a tool that skips the scope and permission checks.:permission- the role permission the key's owner must hold right now ("catalogue").nilunder the same rule as:scope; a public tool must have no permission either.:title,:description- written for a person and for an agent. The title defaults to the name, the description to"".:input_schema- JSON Schema (string keys) of the input, limited to the keywordsPhoenixKit.API.Validatorenforces (Validator.check_schema/2refuses the rest). Defaults to an object with no properties andadditionalProperties: false. Keep it static: build it from literals, not from data that changes at runtime (an enum of database values, say). The result of the schema check is remembered per distinct schema for the life of the node and never erased, so a schema that keeps changing adds an entry each time.:output_schema- JSON Schema of a success answer, ornil.:handler-{module, function}, called asapply(module, function, [input, ctx]).:module- the module that declared the tool. Filled in by the registry.:annotations- hints for clients::read_only,:destructive,:idempotent,:open_world(allfalseunless given).:danger- when a call needs a person's confirmation:nil(never, except the generic batch-size rule for admin writes),:always, or{module, function}, a checkfunction(input, ctx) :: boolean()called with a dry-run context. Any other value is refused byvalidate/1. A key marked "requires approval" holds every write for a person regardless of:danger.:replay- whether a retry of a write call with the sameidempotency_keygets the first answer back.trueby default. Setfalseon a write tool whose answer carries a credential (a session token, a claim URL): such an answer is never stored (PhoenixKit.API.Idempotency.credential_bearing?/2), so the key could not be honoured. The tool then takes noidempotency_key: the schemas, MCP and OpenAPI do not offer it, a body that sends it is a422, and theIdempotency-Keyheader is ignored, as it is on a read tool. Say in the description that a repeated call is not the same call. The field is newer than core 2.63.0, wherenew/1refuses it. A module that must also run there passes it only whenCode.ensure_loaded?(PhoenixKit.API.ControlFields) and function_exported?(PhoenixKit.API.ControlFields, :replay_tool?, 1).:client_token- for a:publictool, the name of the input field that carries the anonymous client's token ("session_token"), ornil(default). It must be a string property ofinput_schema. Core hashes the value (sha256, url-safe base64, never kept in clear) intoPhoenixKit.API.Actor.client_token_hashand uses the hash as the caller's identity where there is no key: the per-token rate-limit bucket, the scope of theidempotency_keyand the binding of a confirmation code. A public tool with adanger:must declare it and list it inrequired; a call without the token is a422naming the field. An:admintool may not declare one. The field is a credential like a key and is redacted from stored answers. It is refused in a URL, and filtered from the request log, only when core's credential rule knows its name (the listed names and*_token; seePhoenixKit.API.Auth.credential_name?/1), so name it*_token. The field is newer than core 2.63.x, wherenew/1refuses it. A module that must also run there sets it only whenMap.has_key?(PhoenixKit.API.Tool.__struct__(), :client_token)(a struct key is not a function, sofunction_exported?/3cannot tell; and%PhoenixKit.API.Tool{}does not compile, it has required keys).:credentials- names of output fields in which the tool hands out a secret that is not named*_token(a link with a claim code in it, say). An answer carrying one of them is never stored for replay and is redacted from an approval's result. The tool's:client_tokencounts as one without being listed. Same rule as:client_tokenfor older cores: pass it only when the struct has the key.:max_items- the largest batch the tool accepts, ornil.:cost- the rate-limit units one call costs.nil(default) means the executor's rule: 1, or for a non-read-only admin toolmax(1, ceil(longest list / 50)). A positive integer up to 1000 is a fixed price.{Module, :function}is called asModule.function(input)with the raw, unvalidated input and returns a positive integer (a failure falls back to the default rule). Use it for a batch read:cost: {MyModule.API, :batch_cost}.
Building a tool
Always build it with new!/1 (or new/1 when the caller wants the error list) so the
defaults above are filled in and the shape is checked:
PhoenixKit.API.Tool.new!(
name: "catalogue.list_products",
audience: :admin,
scope: "catalogue.products:read",
permission: "catalogue",
title: "List products",
handler: {MyModule.API, :list_products},
annotations: %{read_only: true}
)A tool returned from api_tools/0 that fails validate/1 is ignored with one warning
per module; the module's other tools still work.
Summary
Types
Who may call the tool: :admin needs a key, :public needs nothing.
{module, function}, called as apply(module, function, [input, ctx]).
Functions
The output field names whose values must never be stored or shown in clear for tool:
its :credentials and its :client_token. PhoenixKit.API.Idempotency adds them to the
names core already treats as credentials.
Builds a tool from a map or keyword list, fills the defaults and validates it.
Like new/1, but raises ArgumentError listing every problem.
True for core's own system.* tools.
Checks the shape of a tool struct (the registry runs this on everything a module returns).
Types
@type audience() :: :admin | :public
Who may call the tool: :admin needs a key, :public needs nothing.
{module, function}, called as apply(module, function, [input, ctx]).
@type t() :: %PhoenixKit.API.Tool{ annotations: term(), audience: term(), client_token: term(), cost: term(), credentials: term(), danger: term(), description: term(), handler: term(), input_schema: term(), max_items: term(), module: term(), name: term(), output_schema: term(), permission: term(), replay: term(), scope: term(), title: term() }
Functions
The output field names whose values must never be stored or shown in clear for tool:
its :credentials and its :client_token. PhoenixKit.API.Idempotency adds them to the
names core already treats as credentials.
Builds a tool from a map or keyword list, fills the defaults and validates it.
Returns {:ok, tool} or {:error, messages} (every problem found, not only the first).
Like new/1, but raises ArgumentError listing every problem.
True for core's own system.* tools.
Checks the shape of a tool struct (the registry runs this on everything a module returns).
An :admin tool needs a :scope and a :permission, except a core system.* tool,
which every valid key may call. This check does not know who built the tool: that the
system. namespace is core's alone is enforced where module tools are collected
(PhoenixKit.ModuleRegistry.all_api_tools/0).