# `PhoenixKitAgent.Conversations`
[🔗](https://github.com/BeamLabEU/phoenix_kit_agent/blob/v0.1.0/lib/phoenix_kit_agent/conversations.ex#L1)

The chats and messages of the assistant (spec §5.1-5.2, §8, §10).

Every function acts for a **live browser session**, never for a bare user
uuid: the first argument is the `{session_token_uuid, user_uuid}` reference
that `PhoenixKit.Users.Auth.SessionRef.from_session/1` reads from a
LiveView's signed session (in its connected mount), and the last is a
keyword list that must carry `impersonated: boolean` — the answer of
`PhoenixKitWeb.Users.MultiSession.impersonated?/1` for the same session.
The flag has no default on purpose: a caller that forgets it raises
`ArgumentError` instead of being let in.

Each call is checked in this order, and the first failure is the answer:

  1. `impersonated: true` — `{:error, :impersonated}`. An operator who
     entered a user's session through impersonation would otherwise read the
     whole chat history (customer data from tool results) and write as the
     user, spending their limits (spec §8, В13). Nothing is read or written.
  2. The session's scope (`SessionRef.scope_for_session/2`): a revoked or
     expired session, a blocked user, a session of another user —
     `{:error, :session_gone | :mismatch | :inactive | :unconfirmed |
     :referral_required}`. The scope is rebuilt on every call, so a logout
     takes effect on the next one.
  3. The module is enabled, the scope holds `agent`, and the assistant is
     not switched off for the user (`user_settings.enabled`) —
     `{:error, :forbidden}`.
  4. For one chat: it belongs to the session's user. Anything else — someone
     else's chat, a deleted one, one that never existed, a malformed uuid —
     is the same `{:error, :not_found}`, so the answer does not reveal that
     a chat exists.

The owner is the only one who reads or writes a chat. A soft-deleted chat
(`status: "deleted"`) is gone for every function here; an archived one is
left out of `list_conversations/2` only, and can still be opened and
written to (a turn in flight when the user archives the chat must still be
able to save its answer). Renaming, archiving and deleting are one
conditional `UPDATE` each (the owner's chat, not deleted), so a concurrent
archive cannot bring a deleted chat back.

## Writing

A session writes only its user's text: `add_message/4` stores a `user`
message with `content` and ignores every other attribute. Model answers,
tool calls and results, notices and their tokens and cost are written by
the turn loop (`PhoenixKitAgent.Turns`) alone.

## Reading: what a client gets of a message

`get_conversation/3`, `get_message/4` and `list_messages/3` never hand out
what a tool returned, nor the arguments the model gave a tool (customer data
read under the rights the session had then): a `tool` message comes with
`tool_result: nil` and `tool_summary: %{name:, outcome:, count:,
truncated:}` (`count` — how many items it found, when the result has one
list or a `count`/`total`; `nil` otherwise), and the `tool_calls` of an
`assistant` message keep only `"id"` and `"name"`. The turn loop reads the
full rows on its own (spec §6.6).

## Events

Every stored message is broadcast as `{:agent_message_added,
conversation_uuid, message_uuid}` on `topic/1` (`"agent:conv:<uuid>"`)
after it is committed. The event carries uuids, not the message: a listener
reads it through `get_message/4` (or `list_messages/3` with `after:`), with
a fresh scope. `subscribe/3` subscribes only after the checks above;
`unsubscribe/1` undoes it (subscribing twice delivers every event twice).
The topic also hears `{:agent_attachment_processed, conversation_uuid,
attachment_uuid}` when a file added to the chat is ready or has failed
(`PhoenixKitAgent.Attachments`). PubSub is in-process and anyone in the VM
can subscribe to a topic
directly; the check guards the code that serves a user's browser, not
hostile code in the node.

# `cursor`

```elixir
@type cursor() :: {DateTime.t(), String.t()}
```

Where the next page of the list starts; opaque, taken from `list_conversations/2`.

# `refusal`

```elixir
@type refusal() ::
  :impersonated
  | PhoenixKit.Users.Auth.SessionRef.refusal()
  | :forbidden
  | :not_found
```

Why a call was refused.

# `session_ref`

```elixir
@type session_ref() :: PhoenixKit.Users.Auth.SessionRef.t()
```

`{session_token_uuid, user_uuid}` from `SessionRef.from_session/1`.

# `tool_summary`

```elixir
@type tool_summary() :: %{
  name: String.t() | nil,
  outcome: String.t() | nil,
  count: non_neg_integer() | nil,
  truncated: boolean()
}
```

What a client learns of a tool call's result (see the moduledoc).

# `add_message`

```elixir
@spec add_message(session_ref(), term(), map(), keyword()) ::
  {:ok, PhoenixKitAgent.Schemas.Message.t()}
  | {:error, refusal() | Ecto.Changeset.t()}
```

Adds the user's text to their chat, as a `user` message: only `content`
(`:content` or `"content"`, not blank, and no longer than
`agent_history_chars`, as a turn takes it) is taken from `attrs`, never a role,
tool fields, tokens or cost (see "Writing" in the moduledoc). The chat is
updated in the same transaction: the message count, `last_message_at` and —
when the chat has no title yet — the title, the first 60 characters of the
text. After the commit the chat's topic hears `{:agent_message_added,
conversation_uuid, message_uuid}`.

# `archive_conversation`

```elixir
@spec archive_conversation(session_ref(), term(), keyword()) ::
  {:ok, PhoenixKitAgent.Schemas.Conversation.t()}
  | {:error, refusal() | Ecto.Changeset.t()}
```

Archives a chat: it leaves the list, nothing is deleted.

# `authorize_session`

```elixir
@spec authorize_session(session_ref(), keyword()) ::
  {:ok, String.t()} | {:error, refusal()}
```

Checks 1-3 of the moduledoc for a session, without a chat: `{:ok,
user_uuid}` when the session may use the assistant now.

# `create_conversation`

```elixir
@spec create_conversation(session_ref(), map(), keyword()) ::
  {:ok, PhoenixKitAgent.Schemas.Conversation.t()}
  | {:error, refusal() | Ecto.Changeset.t()}
```

Creates an empty active chat for the session's user. `attrs` may carry a
`:title`; the owner and status are never taken from it.

# `delete_conversation`

```elixir
@spec delete_conversation(session_ref(), term(), keyword()) ::
  {:ok, PhoenixKitAgent.Schemas.Conversation.t()}
  | {:error, refusal() | Ecto.Changeset.t()}
```

Soft-deletes a chat: its row and messages stay, but no function here sees it
any more.

# `get_conversation`

```elixir
@spec get_conversation(session_ref(), term(), keyword()) ::
  {:ok, PhoenixKitAgent.Schemas.Conversation.t()} | {:error, refusal()}
```

The user's chat with all its messages, oldest first, as a client sees them
(see "Reading" in the moduledoc).

# `get_message`

```elixir
@spec get_message(session_ref(), term(), term(), keyword()) ::
  {:ok, PhoenixKitAgent.Schemas.Message.t()} | {:error, refusal()}
```

One message of the user's chat, as a client sees it (see "Reading" in the
moduledoc) — what a listener of `{:agent_message_added, conversation_uuid,
message_uuid}` reads. A message of another chat, or none, is
`{:error, :not_found}`.

# `list_conversations`

```elixir
@spec list_conversations(session_ref(), keyword()) ::
  {:ok,
   %{
     conversations: [PhoenixKitAgent.Schemas.Conversation.t()],
     next_cursor: cursor() | nil
   }}
  | {:error, refusal() | :invalid_cursor}
```

The user's active chats, newest `last_message_at` first, one page at a time.

Options (besides `impersonated:`): `:limit` (default 20, at most 50) and
`:cursor`, the `next_cursor` of the previous page. Returns the chats and the
cursor of the next page (`nil` on the last one). Archived and deleted chats
are not listed. Messages are not loaded.

# `list_messages`

```elixir
@spec list_messages(session_ref(), term(), keyword()) ::
  {:ok, [PhoenixKitAgent.Schemas.Message.t()]}
  | {:error, refusal() | :invalid_cursor}
```

The messages of the user's chat, oldest first, as a client sees them (see
"Reading" in the moduledoc).

Options (besides `impersonated:`): `:after` — the uuid of a message of this
chat; only the messages after it come; `:limit` — default 50, at most 200.
The next page starts after the last message of this one. From the other
end: `latest: true` — the last `limit` messages of the chat; `:before` — the
uuid of a message of this chat, the `limit` messages just before it (the
page before starts before the first message of this one). Every page is
oldest first. A cursor that is not a message of this chat, or `:after` with
`:before` / `:latest`, is `{:error, :invalid_cursor}`.

# `rename_conversation`

```elixir
@spec rename_conversation(session_ref(), term(), term(), keyword()) ::
  {:ok, PhoenixKitAgent.Schemas.Conversation.t()}
  | {:error, refusal() | Ecto.Changeset.t()}
```

Renames a chat. A blank title or one over 200 characters is an `{:error, changeset}`.

# `subscribe`

```elixir
@spec subscribe(session_ref(), term(), keyword()) :: :ok | {:error, refusal()}
```

Subscribes the calling process to the chat's topic, but only after the
checks of the moduledoc — a chat of someone else is `{:error, :not_found}`
and nothing is subscribed.

# `topic`

```elixir
@spec topic(String.t()) :: String.t()
```

The PubSub topic of a chat's events.

# `unsubscribe`

```elixir
@spec unsubscribe(String.t()) :: :ok
```

Unsubscribes the calling process from the chat's topic; no checks are needed.

---

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