A turn of the assistant (spec §6.1): the user's message, then rounds of
model calls and tool calls until the model answers, or until a write waits
for the user's confirmation (PhoenixKitAgent.Confirmations).
Like PhoenixKitAgent.Conversations, every function acts for a live
browser session — the {session_token_uuid, user_uuid} reference of
PhoenixKit.Users.Auth.SessionRef.from_session/1 — and takes the answer of
PhoenixKitWeb.Users.MultiSession.impersonated?/1 for it, a boolean (anything
else raises ArgumentError). An impersonated session is refused before
anything is read or written.
start/6, in order (the first failure is the answer; a start that fails
leaves no lock behind and writes nothing to the chat — except for a limit
reached, below):
0-1. the checks of Conversations — impersonation, the session's scope,
the `agent` permission, the module and the user's switch, the chat is
the user's;
- a non-blank message of at most `agent_history_chars` characters, the
AI module on, an endpoint chosen (the user's own, else
`agent_default_endpoint_uuid`; `PhoenixKitAgent.UserConfig`), no more
attachments than `max_attachments_per_message` allows, and each one
ready, the user's own, of this chat and not sent yet
(`PhoenixKitAgent.Attachments`);- the locks (
PhoenixKitAgent.Turns.Locks): one turn per chat (:conversation_busy) and per user (:turn_in_flight, or:click_in_flightwhile a click on a confirmation card runs); the history is read only once they are taken, so it holds the previous turn's end; - the limits (
PhoenixKitAgent.Limits.check/3), under the locks: the user's previous turn is over, so all it spent is in the AI journal. A limit used up is{:error, {:limit_reached, kind}}: the locks are released, the model is not called, the user's text is not stored, and — the one refusal that writes — anoticegoes into the chat (errordaily_cost_limit,monthly_cost_limitordaily_turn_limit) and the limit is announced (PhoenixKitAgent.Limits.announce_reached/2); 3a. the AI guard admits the turn (PhoenixKitAI.Guard.admit/4, taskagent.chat, for as long as the turn's lock may live): the user's block ({:error, :task_blocked}) and the task's own limits ({:error, :task_limit_exceeded}) are checked here once, and the rounds pass the admission on (guard_admission:), so a turn admitted before a block or a limit runs to its end. A refusal is like a limit reached: nothing runs or is stored but anotice(errortask_blockedortask_limit_exceeded), once a day per chat (a monthly limit: once a month); an answer of the guard this module does not know starts no turn either (model_error, a log line without it); - the task starts (
PhoenixKitAgent.Turns.Monitor), and{:ok, turn_ref}is returned; there the user message is stored (and the attachments linked to it), then the rounds run (PhoenixKitAgent.Turns.Runner). A turn that started under its limits runs to its end.
The chat's topic (Conversations.topic/1) hears every stored message
({:agent_message_added, conversation_uuid, message_uuid}) and the end of
the turn ({:agent_turn_finished, conversation_uuid, turn_ref}), after its
locks are released. A listener matches the turn_ref it got from
start/6: a start that failed half-way is announced as finished too.
A refusal never carries turn data: an unavailable monitor or a task that
did not start is {:monitor_unavailable, class} / {:not_started, class}
with only the class of the exit (PhoenixKitAgent.Turns.Monitor.exit_class/0).
NUL characters are cut from the text before anything else.
The assistant acts with the session's rights only: tools are listed and run
by PhoenixKit.API.Executor.visible_for/3 / execute_as/5 with allow: :all, for this session; there is no service user and no API key. A write
is never run by the turn: core holds it for the user's click
(PhoenixKitAgent.Turns.Runner, "A write"), and only modules that let a
session write (core's api_session_write_modules) show any.
Summary
Functions
What became of the turn the user's chat is locked by, for a client that
opens the chat (decision of 2026-10-20, spec §6.1): {:ok, :idle} — no
turn; {:ok, {:running, turn_ref}} — a turn runs (its task is here, or its
lock is younger than its TTL: it may run on another node);
{:ok, :interrupted} — the turn was lost (its node went down, its monitor
with it): its lock is older than the TTL and no task runs it here, so its
locks are released, the chat gets the notice "the answer was interrupted"
(error interrupted) and its topic hears {:agent_turn_finished, conversation_uuid, turn_ref}. Of two clients that open the chat at once,
only one writes the notice. Refusals are those of Conversations.
Starts a turn in the user's chat with text and the attachments
attachment_uuids (see the moduledoc). Returns {:ok, turn_ref} as soon as
the turn runs; its first act is to store the user message (the chat's topic
hears it).
Stops the turn running in the user's chat. The turn is asked to stop and
ends at its next step — a model call under way is let finish, so the AI
journal records what it cost, but its answer is not kept — or is killed if
it has not stopped within the grace period
(PhoenixKitAgent.Turns.Monitor). Its locks are then released and the
chat's topic hears :agent_turn_finished; :ok means the request was
made. With turn_ref, only that turn is stopped — a newer one in the chat
keeps running. {:error, :not_running} when no such turn runs there (or
the monitor is restarting); someone else's chat is {:error, :not_found}.
Types
@type refusal() :: PhoenixKitAgent.Conversations.refusal() | :empty_message | :message_too_long | :ai_disabled | :no_endpoint | :invalid_attachments | :too_many_attachments | :conversation_busy | :turn_in_flight | :click_in_flight | {:limit_reached, PhoenixKitAgent.Limits.kind()} | :task_blocked | :task_limit_exceeded | :model_error | :not_running | {:not_started, PhoenixKitAgent.Turns.Monitor.exit_class()} | {:monitor_unavailable, PhoenixKitAgent.Turns.Monitor.exit_class()}
Why a turn was not started, or not stopped.
Functions
@spec settle_interrupted( PhoenixKitAgent.Conversations.session_ref(), boolean(), term() ) :: {:ok, :idle | :interrupted | {:running, String.t()}} | {:error, refusal()}
What became of the turn the user's chat is locked by, for a client that
opens the chat (decision of 2026-10-20, spec §6.1): {:ok, :idle} — no
turn; {:ok, {:running, turn_ref}} — a turn runs (its task is here, or its
lock is younger than its TTL: it may run on another node);
{:ok, :interrupted} — the turn was lost (its node went down, its monitor
with it): its lock is older than the TTL and no task runs it here, so its
locks are released, the chat gets the notice "the answer was interrupted"
(error interrupted) and its topic hears {:agent_turn_finished, conversation_uuid, turn_ref}. Of two clients that open the chat at once,
only one writes the notice. Refusals are those of Conversations.
@spec start( PhoenixKitAgent.Conversations.session_ref(), boolean(), term(), term(), [term()], keyword() ) :: {:ok, String.t()} | {:error, refusal()}
Starts a turn in the user's chat with text and the attachments
attachment_uuids (see the moduledoc). Returns {:ok, turn_ref} as soon as
the turn runs; its first act is to store the user message (the chat's topic
hears it).
Options:
:request_context— what the tools' handlers get as context (PhoenixKit.API.Executor.execute_as/5)::language,:base_url,:hosts,:currency,:ip;:request_iddefaults to the turn ref;:deadline_ms— a shorter deadline thanagent_turn_deadline_s; it can only shorten it;:page— where the user is, for the model's context: the page's path (what the host says of it comes from itspage_contexthook, called with this session's scope —PhoenixKitAgent.Prompt);:locale— the user's locale; else the:languageof the request context, else the process's Gettext locale. Both come from a browser and are checked byPhoenixKitAgent.Prompt.
@spec stop( PhoenixKitAgent.Conversations.session_ref(), boolean(), term(), String.t() | nil ) :: :ok | {:error, refusal()}
Stops the turn running in the user's chat. The turn is asked to stop and
ends at its next step — a model call under way is let finish, so the AI
journal records what it cost, but its answer is not kept — or is killed if
it has not stopped within the grace period
(PhoenixKitAgent.Turns.Monitor). Its locks are then released and the
chat's topic hears :agent_turn_finished; :ok means the request was
made. With turn_ref, only that turn is stopped — a newer one in the chat
keeps running. {:error, :not_running} when no such turn runs there (or
the monitor is restarting); someone else's chat is {:error, :not_found}.
Who may stop is checked here, with the session's scope as it is now; the
turn is the one the chat's lock names. When its task is not on this node
while the lock is younger than its TTL, the turn may run on another node:
the request goes out over PubSub (PhoenixKitAgent.Turns.Monitor.broadcast_stop/1,
the turn ref only) and :ok is returned — the node that runs the turn stops
it the same way, and the chat's topic hears its end. A lock past its TTL is
a turn no node runs: {:error, :not_running}.