
# Custom agents

The chat UI does not depend on Pi. It only needs a `ChatSession`.

If your app does not import `agentak/pi`, the included agent loop and provider code are not
part of your bundle.

## Create a session

This small session adds the user's message to the transcript. Replace `send()` with your
own agent or backend call.

```ts
import { mountChat, type ChatSession, type ChatSnapshot } from "agentak";

const listeners = new Set<() => void>();
let state: ChatSnapshot = { isStreaming: false, messages: [] };

function notify(next: ChatSnapshot) {
  state = next;
  for (const listener of listeners) listener();
}

const session: ChatSession = {
  subscribe(listener) {
    listeners.add(listener);
    return () => listeners.delete(listener);
  },

  // Return the same object until notify() changes the state.
  snapshot: () => state,

  send(text) {
    notify({
      ...state,
      messages: [
        ...state.messages,
        {
          id: crypto.randomUUID(),
          role: "user",
          parts: [{ kind: "text", text }],
        },
      ],
    });
  },

  stop() {},
  reset() {
    notify({ isStreaming: false, messages: [] });
  },
};

mountChat("#chat", { session });
```

You can pass the same session to `ChatPanel` in React, Vue, or Preact.

## Required methods

A session needs five methods:

| Method                | Purpose                                                          |
| --------------------- | ---------------------------------------------------------------- |
| `subscribe(listener)` | Registers a change listener and returns an unsubscribe function. |
| `snapshot()`          | Returns the current chat state.                                  |
| `send(text)`          | Starts a user message.                                           |
| `stop()`              | Stops the current answer.                                        |
| `reset()`             | Clears or replaces the conversation.                             |

Follow two important rules:

1. Keep `snapshot()` fast and stable. Return the same object until the state changes.
2. Update the state before you call subscribers.

The chat may read the snapshot more than once during one render. Returning a new object on
every call causes unnecessary transcript renders.

## Optional methods

Add only the features that your session supports. Optional methods connect visible
controls to your session. The related snapshot fields decide which controls appear.

| Method               | What it enables                                            |
| -------------------- | ---------------------------------------------------------- |
| `respondToTool`      | Approve or deny a tool call and provide a reason.          |
| `setToolPolicy`      | Confirm tool calls, or bypass the confirmation.            |
| `callTool`           | Run a tool the person picked from the composer.            |
| `compact`            | Summarize the conversation from inside the meter.          |
| `dequeue`            | Remove a waiting message.                                  |
| `dismissError`       | Close the current error.                                   |
| `retry`              | Run the failed request again.                              |
| `fork`               | Rewind to a user message in a new conversation.            |
| `retryFrom`          | Rewind to a user message and send it again.                |
| `selectProvider`     | Choose a provider.                                         |
| `selectModel`        | Choose a model.                                            |
| `setThinkingLevel`   | Choose the thinking level for the current model.           |
| `saveKey`            | Save an API key for a provider.                            |
| `forgetKey`          | Remove the saved API key of a provider.                    |
| `setKeyLock`         | Lock the stored keys to the device, or unlock.             |
| `unlockKeys`         | Ask the device for the keys, for this visit.               |
| `setPickerOpen`      | Let the session control the settings page.                 |
| `openConversation`   | Open a stored conversation from the history page.          |
| `forgetConversation` | Delete a stored conversation.                              |
| `setHistoryOpen`     | Let the session control the history page.                  |
| `setOptions`         | Receive changes such as `generateTitle` and `autoCompact`. |

`dispose()` is not part of `ChatSession`. If your session needs cleanup, add your own
method and call it from the code that created the session. Agentak never disposes of a
session for you.

## Snapshot fields

Every snapshot needs these fields:

- `messages`
- `isStreaming`

All other fields are optional:

- `error`
- `title`
- `agent`
- `usage`
- `toolPolicy`
- `queued`
- `providers`, `providerId`, and `providerLabel`
- `models`, `modelsLoading`, and `modelId`
- `thinkingLevel` and `thinkingLevels`
- `keyLock`
- `pickerOpen`
- `history`, `conversationId`, and `historyOpen`

Some fields and methods form a pair. Provide both parts:

- `models` and `selectModel`
- `providers` and `selectProvider`
- `queued` and `dequeue`
- `thinkingLevels` and `setThinkingLevel`
- `history` and `openConversation`
- `keyLock` and both `setKeyLock` and `unlockKeys`
- `toolPolicy` and `setToolPolicy`
- `usage` and `compact`

A field without its method shows a control that the user cannot use. A method without its
field is never called.

If your session provides `setPickerOpen`, it must also return `pickerOpen`. The session
then controls whether the settings page is open. The page replaces the transcript, and the
chat header leads back.

`callTool` receives the name of a tool from your `agent.tools`, and nothing else. The
composer lists those names under its two commands, and Enter on one calls this method. Run
the tool, add the call and its result to the transcript, then continue the conversation so
the model reads the result and answers from it. No arguments are passed, because the chat
has no way to type any: let a tool that needs arguments fail, and record the failure as
the tool result. The model then reads what the tool wanted and calls it again itself.

Without `callTool`, the tool rows stay in the list and pick differently: the name goes into
the message and you send it like any other.

`fork` and `retryFrom` both receive the `id` of a user message in your own `messages`
array, and both cut the transcript to the turns before that message:

- `retryFrom` then sends that message again in the current conversation. The answer it got
  before, and everything after it, is gone.
- `fork` reports the cut transcript as a new conversation and leaves the message out. The
  chat puts its text back in the composer. Do not change the conversation you forked
  from — store it if your session stores conversations, the same way `reset()` does.

Each button appears only where its method exists. `retryFrom` is not `retry`: `retry` runs
a failed request again from the error row and takes no id.

If you leave out `providers`, the page shows one model list under `providerLabel`. If
you leave out `usage`, the chat hides the context meter. Your session sets
`usage.nearLimit`, so it also decides when the meter turns amber.

Add `compact` to put a **Compact** button in that meter. Your session decides what a
compaction is: summarize the old turns, keep the recent ones, and replace the transcript
with both. Report `usage.compacting` while one runs, so the button waits for it, and
`usage.canCompact: false` while one would change nothing, so the button says so rather
than being a click that does nothing. Without `compact`, the meter reads and offers
nothing.

`toolPolicy` is `"ask"` or `"bypass"`, and the bar shows the switch for it only while both
it and `setToolPolicy` are there. Leave the field out while your session has no tools to
gate. `"ask"` means whatever confirmation your session applies; `"bypass"` means it applies
none.

## Store custom conversations

`ChatSession` does not define a storage format. Your session can add its own `save()` method
or store changes as they happen.

To show the history page, return a `history` array and add `openConversation`:

```ts
const session: ChatSession = {
  // …the required members
  snapshot: () => cached, // includes history and conversationId
  openConversation(id) {
    // replace this session's own state with the stored conversation
  },
  forgetConversation(id) {
    // delete it, then notify
  },
};
```

Each `history` entry has an `id`, a `title`, and an optional `updated` time in milliseconds.
List the newest entry first. The chat marks the entry that matches `conversationId`, and
shows a forget button only when the session provides `forgetConversation`.

The chat never reads or writes storage itself. It shows the list your session reports, and
calls back when the user selects a row. A session that reports no `history` shows no history
button, and your app can still switch conversations by passing another session to the
component.
