# `PhoenixKit.API.Executor`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v2.64.1/lib/phoenix_kit/api/executor.ex#L1)

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:

  4. **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 in
     `api_disabled_tools`, or of a module listed in `api_disabled_modules`,
     does not exist for `PhoenixKit.API.Registry`, so it is `not_found` too.
  5. **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's `max_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 with `Tool.cost`. The public
     audience pays 1 unit of its network bucket BEFORE the tool lookup (a
     miss in `PhoenixKit.API.Registry.find/2` costs 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, or `429` when the key's budget is spent; the refusal
     writes no audit entry.
  6. **Validate** the input against `max_items` and against the tool's
     `input_schema` **closed by `PhoenixKit.API.Validator.close_schema/2`**
     (so a schema that forgot `additionalProperties: false` still refuses
     unknown fields, the control fields included). `list/2` shows the
     closed schema (`input_schema/1`), the one `execute/4` validates
     against. Before that, over REST the `Idempotency-Key` header 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.
  9. **Run** the handler, `apply(module, function, [input, ctx])`.
 10. **Log.** A call to a write tool (`annotations.read_only` is not
     `true`) from a key writes one `api.request` Activity 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 is `ok`, `error`, `preview` (a dry run), `confirmation_required`
     (the call asked for a code) or `pending_approval` (queued for an
     approver). The module logs its own domain changes.
 11. **Shape** the response: `200` with the handler's map as JSON, or a
     problem document (`PhoenixKit.API.Problem`).

Steps 7 and 8 are `PhoenixKit.API.Flow`:

  7. **Idempotency** (`Flow.idempotency/1`): a write call with an
     `Idempotency-Key` claims it; a finished twin is answered from the
     store, a running twin is `409`.
  8. **Danger** (`Flow.gate/1`): `dry_run` answers 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 `c: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:

  * `:actor` the `PhoenixKit.API.Actor`; `:scopes` its effective scopes;
  * `:scope`, the `PhoenixKit.Users.Auth.Scope` behind 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, `nil` for a public caller (a handler answers nothing it would filter by it).
    A key's own scopes do not narrow it: rows filtered by `ctx.scope` are the ones the
    owner sees, whatever the key's scopes;
  * `:audience`, `:language`, `:currency`, `:hosts`, `:base_url` (see
    `PhoenixKit.API.RequestContext`); `:base_url` is `nil` for a live session whose
    caller passed none;
  * `:request_id`;
  * `:via`, what asks on behalf of a live session: the `:via` option of `execute_as/5` and
    `redeem_as/6` (`"session"` by default, e.g. `"agent"`), the same value as the audit
    entry's `metadata.via`, for a module's own audit of the change. `nil` for a key, a
    public caller and an approved operation;
  * `:dry_run?`, `true` while the danger gate asks for a preview (the handler
    must then write nothing and answer `{:preview, preview, fingerprint}`, where `fingerprint`
    is 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 a `500`);
  * `:approved_by_uuid`, the person who approved the operation (`nil` unless it
    runs from the approval queue), `:confirmation_uuid`, the confirmation code
    or approval row it runs under (`nil` for 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 marked `failed` (`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 is `401 unauthorized`, and the scope is
    narrowed to the role the session acts as;
  * the effective scopes are `PhoenixKit.API.Keys.held_scopes_of/1` of 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: :read` refuses a write tool
    (`PhoenixKit.API.ControlFields.write_tool?/1`) before validation and before
    `Flow.gate/1`, so nothing runs, and `visible_for/3` lists none;
  * `allow: :all` opens the write tools of the modules listed in
    `api_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 with `403 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/1` answers 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 with `redeem_as/6`, which repeats the whole path of the call with the
    same tool and arguments (see there). A `confirm_token` a session sends is refused;
  * the request context comes from the `:request_context` option, not from a
    connection, and the host's `:api_request_context` hook (which expects a
    `Plug.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_enabled` does not apply (it switches the HTTP API);
  * reads are not audited, as for a key. A write call that was charged writes one
    `api.request` Activity entry with `resource_type: "user"`, the user as actor and
    resource, and `metadata` `via` (the `:via` option, `"session"` by default), `tool`,
    `request_id`, `outcome`, `code` and `confirmation_uuid` (for a refused `redeem_as/6`,
    the uuid it was asked for), plus `replaces_confirmation_uuid` when a row whose values
    changed was replaced: never the input, the preview or the result. The handler reads
    the same `via` as `ctx.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.

# `availability`

```elixir
@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.

# `closed_reason`

```elixir
@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.

# `headers`

```elixir
@type headers() :: [{String.t(), String.t()}]
```

Response headers to send with an outcome: the rate-limit headers.

# `outcome`

```elixir
@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.

# `call`

```elixir
@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.

# `cost`

```elixir
@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`: `rows` counts up to `max_items` at most, so
    the price is at most `ceil(max_items / 50)`. A list above `max_items`
    is 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`: `rows` counts up to 1000 at most, so the
    price is at most 20 units, for the same reason (the 2 MB limit of
    `PhoenixKit.API.BodyGuard` bounds 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.

# `execute`

```elixir
@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.

# `execute_as`

```elixir
@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 with `403 forbidden` before
    anything runs. `:all`: a write tool of a module in `api_session_write_modules` is
    held for the person's confirmation (the answer has `"status":
    "confirmation_required"` and the `"confirmation_id"` to pass to `redeem_as/6`); one
    of another module is refused with `403 module_write_disabled`. Any other value
    raises `ArgumentError`.
  * `: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's `ip`).
  * `:via` - what asks, for the audit entry of a write (`metadata.via`) and the handler's
    `ctx.via`: a non-empty string of at most 64 bytes, `"session"` by default. Anything
    else raises `ArgumentError`.

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.

# `input_schema`

```elixir
@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.

# `list`

```elixir
@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.

# `redeem_as`

```elixir
@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.

# `tool_availability`

```elixir
@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`). When
    `api_disabled_tools` or `api_disabled_modules` cannot be read every tool is closed as
    `:api_off`, since the registry then serves nothing;
  * `:tool_hidden` - the tool is listed in `api_disabled_tools`;
  * `:agent_writes_off` - a write tool whose module is not in `api_session_write_modules`
    (the "Agent may act" boxes of Settings -> API; a list that cannot be read is empty);
  * `:no_permission` - only when `scope` (a `PhoenixKit.Users.Auth.Scope`) is given. For a
    tool the settings leave open: the scope fails the checks of `visible_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.

# `visible`

```elixir
@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`.

# `visible_for`

```elixir
@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`).

---

*Consult [api-reference.md](api-reference.md) for complete listing*
