Custom agents
Connect Agentak's chat UI to your own agent or backend.
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.
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:
snapshot() fast and stable. Return the same object until the state changes.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:
messagesisStreaming
All other fields are optional:
errortitleagentusagetoolPolicyqueuedproviders,providerId, andproviderLabelmodels,modelsLoading, andmodelIdthinkingLevelandthinkingLevelskeyLockpickerOpenhistory,conversationId, andhistoryOpen
Some fields and methods form a pair. Provide both parts:
modelsandselectModelprovidersandselectProviderqueuedanddequeuethinkingLevelsandsetThinkingLevelhistoryandopenConversationkeyLockand bothsetKeyLockandunlockKeystoolPolicyandsetToolPolicyusageandcompact
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:
retryFromthen sends that message again in the current conversation. The answer it got before, and everything after it, is gone.forkreports 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 wayreset()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:
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.