The request flow of the machine 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_tools, or of a module listed inapi_disabled_modules, does 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 of its network bucket BEFORE the tool lookup (a miss inPhoenixKit.API.Registry.find/2costs about 2 ms, so a flood of unknown names must end in 429 without it). The caller's client token is read only after the lookup, because the tool declares which input field holds it (Tool.client_token): the call then pays its full price to the token's bucket (when it carries a token) and the rest of it (price minus 1) to the network bucket. So a call that the token's bucket refuses has already spent 1 unit of the network's. 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, and with a valid code it runs; every write of a key that requires approval, dangerous or not, is queued for approval instead (a repeat of an operation that already waits gets the same approval back, marked with the replay header but not stored, and still audited).
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 and closes every schema
(PhoenixKit.API.ControlFields; a write tool with replay: false gets no
idempotency_key). 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 PhoenixKit.API.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: core's own codes plus those the
module declares in PhoenixKit.Module.api_problems/0; an undeclared
code answers 500). 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;:scope, thePhoenixKit.Users.Auth.Scopebehind those scopes, for a handler that filters rows by the caller's rights: the key owner's full scope (Scope.for_user(owner, narrow: false)) for a key, the session's scope for a live session,nilfor a public caller (a handler answers nothing it would filter by it). A key's own scopes do not narrow it: rows filtered byctx.scopeare the ones the owner sees, whatever the key's scopes;:audience,:language,:currency,:hosts,:base_url(seePhoenixKit.API.RequestContext);:base_urlisnilfor a live session whose caller passed none;:request_id;:via, what asks on behalf of a live session: the:viaoption ofexecute_as/5andredeem_as/6("session"by default, e.g."agent"), the same value as the audit entry'smetadata.via, for a module's own audit of the change.nilfor a key, a public caller and an approved operation;: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.
On behalf of a live session
visible_for/3, execute_as/5 and redeem_as/6 run the same flow in process, without
a Plug.Conn, for a person's live browser session (PhoenixKit.Users.Auth.SessionRef,
an Actor of kind :user). What differs from a key:
- the scope is rebuilt from the session on every call
(
SessionRef.scope_for_session/2): a logged-out or revoked session, a blocked user, a user the web's account gate would stop (unconfirmed email, missing referral code), a session of another user, or a reference that is not a{session_token_uuid, user_uuid}pair is401 unauthorized, and the scope is narrowed to the role the session acts as; - the effective scopes are
PhoenixKit.API.Keys.held_scopes_of/1of that scope, and the tool's own permission is checked too (Scope.can?/2); a tool without a permission (system.*, which describe the calling key) is not callable; - reads only by default:
allow: :readrefuses a write tool (PhoenixKit.API.ControlFields.write_tool?/1) before validation and beforeFlow.gate/1, so nothing runs, andvisible_for/3lists none; allow: :allopens the write tools of the modules listed inapi_session_write_modules(the "Agent may act" boxes of Settings -> API, none by default;PhoenixKit.API.Config.session_write_modules/0). A write tool of another module is not listed and is refused with403 module_write_disabled, before anything runs or is stored. API keys never read that list;- a write never runs on the first call, dangerous or not:
Flow.gate/1answers the preview and holds the call in a confirmation row bound to the person (PhoenixKit.API.Confirm.issue_for_user/6),{"status": "confirmation_required", "confirmation_id", "preview", "expires_at", "expires_in_seconds"}, with no code. The person confirms withredeem_as/6, which repeats the whole path of the call with the same tool and arguments (see there). Aconfirm_tokena session sends is refused; - the request context comes from the
:request_contextoption, not from a connection, and the host's:api_request_contexthook (which expects aPlug.Conn) is not called; - the rate limit is the user's own bucket (
RateLimit.check_user/2), shared by the user's sessions;api_admin_enableddoes not apply (it switches the HTTP API); - reads are not audited, as for a key. A write call that was charged writes one
api.requestActivity entry withresource_type: "user", the user as actor and resource, andmetadatavia(the:viaoption,"session"by default),tool,request_id,outcome,codeandconfirmation_uuid(for a refusedredeem_as/6, the uuid it was asked for), plusreplaces_confirmation_uuidwhen a row whose values changed was replaced: never the input, the preview or the result. The handler reads the sameviaasctx.via; - maintenance mode is not checked on this path: the web shows the maintenance layout at mount, where nothing that calls the Executor is rendered.
The {session_token_uuid, user_uuid} pair is not a secret, but it is an in-process
credential: whatever code passes it to execute_as/5 or visible_for/3 acts as that
session. Take it only from the signed session of the LiveView that runs in that session
(SessionRef.from_session/1 in its connected mount), never from params or client events.
Summary
Types
One admin tool as tool_availability/1 shows it.
Why tool_availability/1 shows a tool closed to a live session.
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.
Runs tool tool_name with input on behalf of the live session session_ref
({session_token_uuid, user_uuid}), in process, and returns the outcome as
execute/5 does. The session's scope is rebuilt for this call; see "On behalf of a
live session" above for what differs from a key.
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.
The person confirms the write their session's execute_as/5 call was held for
(confirmation_uuid, the "confirmation_id" of that answer). The caller brings the
same tool_name and input again (without control fields: a dry_run,
confirm_token or idempotency_key is refused with 422); the core keeps only their
hash.
Every admin tool of every registered module, switched on or off, with whether a person's
live session may call it (visible_for/3 and execute_as/5 with allow: :all) and, if
not, why. Read-only: nothing is charged or written (no audit entry). API keys are not
affected (they never read api_session_write_modules).
The tools the live session session_ref ({session_token_uuid, user_uuid}, see
PhoenixKit.Users.Auth.SessionRef) may call, for the admin audience: the read tools
whose scope the session's scope holds (PhoenixKit.API.Keys.held_scopes_of/1) and whose
permission it holds. No system.* tool. Costs one unit of the user's rate-limit bucket.
Types
@type availability() :: %{ module_key: String.t() | nil, module_name: String.t(), name: String.t(), kind: :read | :write, permission: String.t(), scope: String.t(), danger: nil | :always | {module(), atom()}, status: :available | {:closed, [closed_reason(), ...]} }
One admin tool as tool_availability/1 shows it.
@type closed_reason() ::
:module_disabled
| :api_off
| :tool_hidden
| :agent_writes_off
| :no_permission
Why tool_availability/1 shows a tool closed to a live session.
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 list 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 execute_as( PhoenixKit.Users.Auth.SessionRef.t(), :admin, String.t(), term(), keyword() ) :: outcome()
Runs tool tool_name with input on behalf of the live session session_ref
({session_token_uuid, user_uuid}), in process, and returns the outcome as
execute/5 does. The session's scope is rebuilt for this call; see "On behalf of a
live session" above for what differs from a key.
Options:
:allow-:read(the default): a write tool is refused with403 forbiddenbefore anything runs.:all: a write tool of a module inapi_session_write_modulesis held for the person's confirmation (the answer has"status": "confirmation_required"and the"confirmation_id"to pass toredeem_as/6); one of another module is refused with403 module_write_disabled. Any other value raisesArgumentError.:request_context- keyword list::language,:base_url,:hosts,:currency(the handler context,PhoenixKit.API.RequestContext.from_options/1),:request_id(the context's:request_id) and:ip(the actor'sip).:via- what asks, for the audit entry of a write (metadata.via) and the handler'sctx.via: a non-empty string of at most 64 bytes,"session"by default. Anything else raisesArgumentError.
The core does not tell an impersonated session from the person's own: a caller that
passes allow: :all must refuse a session that
PhoenixKitWeb.Users.MultiSession.impersonated?/1 marks, before it calls, so an
administrator acting as somebody else cannot propose writes in their name.
@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 redeem_as( PhoenixKit.Users.Auth.SessionRef.t(), :admin, term(), String.t(), term(), keyword() ) :: outcome()
The person confirms the write their session's execute_as/5 call was held for
(confirmation_uuid, the "confirmation_id" of that answer). The caller brings the
same tool_name and input again (without control fields: a dry_run,
confirm_token or idempotency_key is refused with 422); the core keeps only their
hash.
The call passes the whole path again, with the rights of the session that confirms
now: the session must still act (401), the tool exist and its module be on
(api_disabled_modules, api_disabled_tools), the module be in
api_session_write_modules (403 module_write_disabled), the session hold the tool's
scope and permission (403 forbidden), the rate limit allow it, the input validate.
Then PhoenixKit.API.Flow.redeem_for_session/2: the row must be this person's, for the
same tool and arguments, still waiting; a fresh dry run must give the fingerprint of the
preview; the row is spent atomically, so a second confirmation of the same row is
refused (used). Only then the handler runs, with ctx.confirmation_uuid set.
Returns outcome() as execute_as/5 does: the handler's result; the prompt of a new
row when the values changed since the preview ("reason": "changed", the old row is
expired); or a refusal, 409 confirm_token_invalid with context.reason one of
invalid (unknown, or somebody else's: the same answer), other_arguments, used,
rejected, expired. A refusal before the row is spent leaves it waiting.
Options: :request_context and :via, as for execute_as/5.
As with execute_as/5 and allow: :all, the caller must refuse an impersonated session
(PhoenixKitWeb.Users.MultiSession.impersonated?/1) before it calls: the core cannot tell
it from the person's own.
@spec tool_availability(PhoenixKit.Users.Auth.Scope.t() | nil) :: [availability()]
Every admin tool of every registered module, switched on or off, with whether a person's
live session may call it (visible_for/3 and execute_as/5 with allow: :all) and, if
not, why. Read-only: nothing is charged or written (no audit entry). API keys are not
affected (they never read api_session_write_modules).
Each entry is %{module_key, module_name, name, kind, permission, scope, danger, status}:
kind is :read or :write (PhoenixKit.API.ControlFields.write_tool?/1), danger the
tool's own, status :available or {:closed, reasons}, the reasons in this order:
:module_disabled- the module is switched off;:api_off- the module's API is switched off (api_disabled_modules). Whenapi_disabled_toolsorapi_disabled_modulescannot be read every tool is closed as:api_off, since the registry then serves nothing;:tool_hidden- the tool is listed inapi_disabled_tools;:agent_writes_off- a write tool whose module is not inapi_session_write_modules(the "Agent may act" boxes of Settings -> API; a list that cannot be read is empty);:no_permission- only whenscope(aPhoenixKit.Users.Auth.Scope) is given. For a tool the settings leave open: the scope fails the checks ofvisible_for/3, its permission (PhoenixKit.Users.Auth.Scope.can?/2) or its scope (PhoenixKit.API.Keys.held_scopes_of/1). For a tool the settings close: the scope's roles do not grant the tool's permission, whether or not the module behind it is on. The scope rule is not applied to a closed tool, so opening it may still leave it closed as:no_permission, when another tool on its scope needs a permission the scope lacks.
The scope is taken as given, account checks included: a session's own is
SessionRef.scope_for_session/2 (which refuses an inactive or unconfirmed account),
narrowed to the role it acts as; a scope built otherwise is the caller's to vouch for.
Without scope the reasons are the install's settings alone. With the scope of a live
session, the :available tools are exactly what visible_for/3 lists for it with
allow: :all. api_admin_enabled switches the HTTP API and does not apply to a session,
so it is not a reason here. Core's system.* tools (never callable by a session) and
public tools are not listed; neither is a tool the registry ignores because another module
declared its name first. Sorted by module name, then by tool name.
The list names every admin tool whatever scope is given, so gate who may see it. It
walks every module's tools and reads the settings on each call: call it once per page
(mount or params), not per render.
@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.
@spec visible_for(PhoenixKit.Users.Auth.SessionRef.t(), :admin, keyword()) :: {:ok, [PhoenixKit.API.Tool.t()], headers()} | {:error, atom(), String.t(), map(), headers()}
The tools the live session session_ref ({session_token_uuid, user_uuid}, see
PhoenixKit.Users.Auth.SessionRef) may call, for the admin audience: the read tools
whose scope the session's scope holds (PhoenixKit.API.Keys.held_scopes_of/1) and whose
permission it holds. No system.* tool. Costs one unit of the user's rate-limit bucket.
Options: :allow, :read (the default: no write tool) or :all (also the write tools
the session's rights reach, of the modules in api_session_write_modules); any other
value raises ArgumentError.
Returns {:ok, tools, headers}, or {:error, code, detail, context, headers}
(:unauthorized with context.reason from t:SessionRef.refusal/0 when the session
cannot act, :rate_limited).