The request flow of the shop API: what happens between "this key is authentic" and "here is the response".
PhoenixKit.API.Auth has already done steps 1 to 3 of the flow (API
switch, refusal of a credential in the URL, authentication). execute/4 and
visible/2 do the rest and return their outcome; call/4 and list/2 are
thin senders over them for the REST transport:
- Tool visible. The tool exists for this audience (
404 not_found), its module is enabled (404 module_disabled), and the key's effective scopes include the tool's scope (403 forbidden). A tool listed inapi_disabled_toolsdoes not exist forPhoenixKit.API.Registry, so it isnot_foundtoo. - Rate limit, cost weighted (
PhoenixKit.API.RateLimit,cost/2): a call costs 1 unit; only a write tool of the admin audience (not read-only) costs 1 per 50 rows of its longest top-level list, counted up to the tool'smax_items(or, for a tool without one, up to 1000 rows) at most, so a request that is refused for size cannot use up the key's budget. Reads and public tools cost 1 however long their lists are. A tool that needs another price (1 per query of a search batch) declares it withTool.cost. The public audience pays 1 unit BEFORE the tool lookup (a miss inRegistry.find/2costs about 2 ms, so a flood of unknown names must end in 429 without it) and a tool with a dearer price pays the difference after the lookup; the admin audience is charged once, after the scope check, because its price depends on the tool. A call the lookup or the scope check refuses (unknown, hidden, forbidden or disabled-module tool) never reaches that charge, so it pays one unit instead: a key cannot loop on invented names for free. The answer is the same refusal, or429when the key's budget is spent; the refusal writes no audit entry. - Validate the input against
max_itemsand against the tool'sinput_schemaclosed byPhoenixKit.API.Validator.close_schema/2(so a schema that forgotadditionalProperties: falsestill refuses unknown fields, the control fields included).list/2shows the closed schema (input_schema/1), the oneexecute/4validates against. Before that, over REST theIdempotency-Keyheader of a write call is merged into the input (Flow.merge_header/1), so it is validated like the field; MCP takes the key from the argument only. - Run the handler,
apply(module, function, [input, ctx]). - Log. A call to a write tool (
annotations.read_onlyis nottrue) from a key writes oneapi.requestActivity entry: tool, key prefix, row count, outcome. Calls refused before step 6 (unknown tool, missing scope, rate limited) write nothing, so a refused request costs no write; so does a replay of a stored answer. The entry's outcome isok,error,preview(a dry run),confirmation_required(the call asked for a code) orpending_approval(queued for an approver). The module logs its own domain changes. - Shape the response:
200with the handler's map as JSON, or a problem document (PhoenixKit.API.Problem).
Steps 7 and 8 are PhoenixKit.API.Flow:
- Idempotency (
Flow.idempotency/1): a write call with anIdempotency-Keyclaims it; a finished twin is answered from the store, a running twin is409. - Danger (
Flow.gate/1):dry_runanswers the preview; a dangerous operation answers a preview and a confirmation code, or is queued for approval (a repeat of an operation that already waits gets the same approval back, marked with the replay header but not stored, and still audited); with a valid code it runs.
Each step is one function taking and returning the request state, chained
in execute/4. A step may also end the request with {:done, state}: the
body is already set (a preview, a replay) and the handler does not run.
The executor adds the control fields dry_run, confirm_token and
idempotency_key to the schema of every write tool
(PhoenixKit.API.ControlFields) and closes every schema; a read tool has
none of them, so a client that sends one gets 422 validation_failed: a
field that would change what a write does is refused, never silently
ignored.
Scopes and work per request
PhoenixKit.API.Keys.verify/2 (called by PhoenixKit.API.Auth) has
already computed the effective scopes of this request into actor.scopes
(the key's scopes filtered by the owner's permissions right now, never
cached). The executor trusts those scopes and does not compute them
again: it would only repeat the owner lookup and the scope filtering.
What a request still scans twice is the module registry
(ModuleRegistry.all_api_tools/0: once inside verify/2 to know the
scopes that exist, once in Registry.find/2); that is two walks over a
handful of modules, and sharing the result would need an API change in
Registry, so 1a accepts it.
The handler
A tool's handler returns {:ok, map}, {:ok, map, effects} (the effects
list is added to the response under "effects") or
{:error, code, message, context} (code is an atom from
PhoenixKit.API.Problem.statuses/0). Anything else, and any exception,
exit or throw, answers 500 internal_error with a generic message; the
detail goes to the log, never to the client.
The context is a map:
:actorthePhoenixKit.API.Actor;:scopesits effective scopes;:audience,:language,:currency,:hosts,:base_url(seePhoenixKit.API.RequestContext);:request_id;:dry_run?,truewhile the danger gate asks for a preview (the handler must then write nothing and answer{:preview, preview, fingerprint}, wherefingerprintis a hash of the current values the operation would touch: 1 to 64 bytes of UTF-8, e.g. a SHA-256 in hex; any other answer is a500);:approved_by_uuid, the person who approved the operation (nilunless it runs from the approval queue),:confirmation_uuid, the confirmation code or approval row it runs under (nilfor an ordinary call) and:progress, a one-argument function a long batch may call with its partial results. It also keeps an approved operation alive: one that runs longer than 15 minutes without calling it is markedfailed(interrupted) by the stuck-operation sweep, so a long handler calls it at least every few minutes.
Summary
Types
Response headers to send with an outcome: the rate-limit headers.
What execute/4 returns: the handler's result (the decoded map and its JSON
encoding) or the error in PhoenixKit.API.Problem terms, each with the
headers of the rate limit.
Functions
Runs tool tool_name for the actor PhoenixKit.API.Auth assigned, with
input (the decoded request body), and sends the response.
Rate-limit units one call of tool with input costs (the cost
is clamped).
Runs tool tool_name for the actor PhoenixKit.API.Auth assigned, with
input, and returns the outcome instead of sending it. This is what
call/4 does, minus the HTTP response, and what the MCP transport uses.
The input schema the executor enforces for tool: the tool's own schema,
closed at the root with additionalProperties: false, with string keys, and for a write
tool with the control fields (dry_run, confirm_token, idempotency_key).
MCP's inputSchema and OpenAPI's request body are built from it, so a client
is told exactly what the validator will accept.
Nested objects retain the module's policy; declare additionalProperties: false
on each nested object that must reject unknown fields.
Sends the list of tools the actor may call: name, title, description, scope, schemas and annotations. For the admin audience the list is filtered by the key's effective scopes.
Types
Response headers to send with an outcome: the rate-limit headers.
@type outcome() :: {:ok, %{result: map(), json: binary()}, headers()} | {:error, atom(), String.t(), map(), headers()}
What execute/4 returns: the handler's result (the decoded map and its JSON
encoding) or the error in PhoenixKit.API.Problem terms, each with the
headers of the rate limit.
Functions
@spec call(Plug.Conn.t(), PhoenixKit.API.Tool.audience(), String.t(), term()) :: Plug.Conn.t()
Runs tool tool_name for the actor PhoenixKit.API.Auth assigned, with
input (the decoded request body), and sends the response.
@spec cost(PhoenixKit.API.Tool.t() | nil, term()) :: pos_integer()
Rate-limit units one call of tool with input costs (the cost
is clamped).
1 for everything, except a tool of the admin audience that is not
read-only: it costs max(1, ceil(rows / 50)), where rows is the length
of the longest top-level list in the input, clamped:
- a tool with
max_items:rowscounts up tomax_itemsat most, so the price is at mostceil(max_items / 50). A list abovemax_itemsis refused with 422, and a refused request must not use up the key's budget, so it is charged as a full batch, not as its real size; - a tool without
max_items:rowscounts up to 1000 at most, so the price is at most 20 units, for the same reason (the 2 MB limit ofPhoenixKit.API.BodyGuardbounds the work of such a request).
So an admin read with a long fields list costs 1, a public call with a
50-line cart costs 1 and an admin write of 120 rows costs 3. nil (the
discovery list) and an input that is not a map cost 1.
A tool that declares cost: (PhoenixKit.API.Tool) is priced by it
instead.
@spec execute( Plug.Conn.t(), PhoenixKit.API.Tool.audience(), String.t(), term(), keyword() ) :: outcome()
Runs tool tool_name for the actor PhoenixKit.API.Auth assigned, with
input, and returns the outcome instead of sending it. This is what
call/4 does, minus the HTTP response, and what the MCP transport uses.
Returns {:ok, %{result: map, json: binary}, headers} or
{:error, code, detail, context, headers} (code is an atom of
PhoenixKit.API.Problem.statuses/0; context is the map the problem
document carries). headers are the rate-limit headers when the request got
as far as the limiter, else []. The api.request audit entry of a write
call is written here, before returning.
The option :resolve_name is a function from the name as the caller gave it
to the name of the tool to look up. It runs after the public charge, in the
lookup step, so a name that costs registry walks to resolve is not resolved
for a caller the limiter has already refused.
The option :idempotency_header (default true) says whether the
Idempotency-Key HTTP header is read. The REST transport reads it; MCP
passes false, because an MCP client sends the key as the idempotency_key
argument and a header on the transport request is not part of the call.
@spec input_schema(PhoenixKit.API.Tool.t()) :: map()
The input schema the executor enforces for tool: the tool's own schema,
closed at the root with additionalProperties: false, with string keys, and for a write
tool with the control fields (dry_run, confirm_token, idempotency_key).
MCP's inputSchema and OpenAPI's request body are built from it, so a client
is told exactly what the validator will accept.
Nested objects retain the module's policy; declare additionalProperties: false
on each nested object that must reject unknown fields.
@spec list(Plug.Conn.t(), PhoenixKit.API.Tool.audience()) :: Plug.Conn.t()
Sends the list of tools the actor may call: name, title, description, scope, schemas and annotations. For the admin audience the list is filtered by the key's effective scopes.
@spec visible(Plug.Conn.t(), PhoenixKit.API.Tool.audience()) :: {:ok, [PhoenixKit.API.Tool.t()], headers()} | {:error, atom(), String.t(), map(), headers()}
The tools the actor may call, as list/2 shows them, returned instead
of sent. For the admin audience: the tools whose scope the key effectively
holds, plus the scope-less ones (system.*); for the public audience: every
public tool. Costs one rate-limit unit, like list/2.