Skip to content

AI sessions

There are two ways an extension and the AI meet. In one, you contribute a tool and the host’s agent decides to call it — the conversation belongs to the user, and your code is a function inside it. In the other, covered here, your extension is the caller: you own the prompt, the model, the tool subset, and the transcript, and the user may never see a chat at all.

After this page you can pick between a one-shot completion, structured output, a stream, and a multi-turn session; get streamed text onto your own page; know which model runs and who chose it; and handle cancellation and every error type the surface throws.

The same four exist in both halves of an extension, under ctx.api.ai in the main process and pluginAPI.ai in the window.

Call Use it for Threads history
complete(input, options?) Summarize, classify, rewrite, tag — one prompt, one answer no
generateObject(input, schema, options?) The same, when you need fields rather than prose no
stream(input, callbacks, options?) A one-shot answer you want to show as it arrives no
createSession(opts) Anything conversational, or anything that needs tools yes

The first three do not thread history, so calling them in a loop and hoping the model remembers is a bug, not a shortcut. The moment a second turn depends on the first, you want a session.

input is a plain string or a { role, content }[] array. options carries four fields and nothing else:

Option Default
model The user’s current model — see Which model runs
system none
maxTokens 4096
temperature the provider’s default
main/activate.ts
const summary = await ctx.api.ai.complete(
`Summarize this support ticket in one line:\n\n${body}`,
{ maxTokens: 200 },
);

For fields instead of prose, hand generateObject a JSON Schema. The host does not ask the model to write JSON and hope: it defines a single tool from your schema, requires the model to call it, and validates the arguments with Ajv before returning them.

type Triage = { severity: 'low' | 'normal' | 'urgent'; product: string; tags: string[] };
const triage = await ctx.api.ai.generateObject<Triage>(body, {
type: 'object',
properties: {
severity: { type: 'string', enum: ['low', 'normal', 'urgent'] },
product: { type: 'string' },
tags: { type: 'array', items: { type: 'string' } },
},
required: ['severity', 'product', 'tags'],
additionalProperties: false,
});

Two ways it throws, both worth catching: the model answered with text instead of calling the tool (AI did not return structured output), or it called the tool with arguments that fail the schema (AI returned structured output that failed schema validation: /severity must be equal to one of the allowed values). A schema that does not compile is a different case — validation is skipped and the raw object comes back, so a malformed schema silently buys you nothing.

stream takes callbacks rather than returning text, and the two halves differ in what the call itself gives back.

// main — resolves when the stream ends
await ctx.api.ai.stream(prompt, {
onChunk: (text) => buffer.push(text),
onEnd: (usage, stopReason) => {
if (stopReason === 'max_tokens') ctx.log.warn('answer was cut off');
},
onError: (err) => ctx.log.error(err.message),
});
// window — returns a cancel function, synchronously
const cancel = pluginAPI.ai.stream(prompt, {
onChunk: (text) => setDraft((d) => d + text),
onEnd: () => setDone(true),
onError: (message) => pluginAPI.notify.error(message),
});
// later, e.g. on unmount
cancel();

onEnd’s second argument is the provider’s stop reason. 'max_tokens' means the answer was truncated, not finished; an app that ignores it shows a half-sentence as a complete reply. Note the other asymmetry while you are here: onError receives an Error in main and a string in the window.

createSession gives you a conversation object: its own id, its own history, a tool subset, an event stream, and a lifecycle you control.

const session = ctx.api.ai.createSession({
agent: 'meta',
conversationId: 'support-triage.main',
systemPromptExtension: 'You triage support tickets. Be terse and specific.',
tools: { include: ['support_*'] },
});
const result = await session.send('Triage the newest ticket.');
ctx.log.info(result.text, result.toolsUsed, result.stopReason);
Option Meaning
agent Required. An agent id — supplies the base system prompt and the tool set. An unknown id throws Unknown agent: '…'
conversationId Stable id. Generated when omitted. Reusing a live id throws Session id '…' is already in use
systemPromptExtension Appended to the agent’s base prompt
tools { include?, exclude? }. Names match exactly; one trailing * matches a prefix. include: [] gives the session no tools
model Override model resolution for this session
maxTurns Cap on loop turns per send
maxTokens Output cap per turn
persistence A load / replace / appendHint adapter. Main only — functions do not cross the window boundary
hooks beforeCall / onUsage / afterTurn. Main only, same reason

Call ctx.api.agents.getAvailable() (or pluginAPI.agents.list()) before hardcoding an agent id; the set is dynamic, and meta is the platform’s generalist.

Member What it does
send(input, opts?) Runs a turn. Resolves with the TurnResult once the model call and every tool round-trip settle
events.on(name, handler) Subscribe to chunk, tool_use, turn_end, error. Returns an unsubscribe
appendContext(text, opts?) Add a model-visible transcript entry with no model call
cancel() Abort the in-flight send. No-op when idle
fork(newId, persistor?) An independent session with the same config and a copy of the history. Main process only — a window session throws RemoteAISession.fork is not yet supported; create a second session with a fresh conversationId instead
destroy() Abort, clear listeners, mark destroyed. Idempotent
history() A fresh copy of the transcript
id, extensionId, isBusy, isDestroyed Read-only state

send takes an optional { hidden }: the model sees hidden + input and keeps seeing it in history, while your UI shows the bare input. That is the supported way to ground a turn in data the user should not have to read.

appendContext is the one to reach for after your own code has already done something — “the invoice was marked paid” — instead of paying an inference turn to narrate it. The entry is user-role and marked as an event; assistant text can never be forged.

Sessions belong to the extension that created them. The host destroys every session an extension still owns when it deactivates or reloads, so a leftover session cannot outlive its code — but a session you keep for the life of the extension is holding a transcript in memory, so destroy() the ones tied to a dialog or a screen.

Every event carries a monotonic seq. A gap means an event was dropped, which is the only way to notice one.

Event Payload
chunk { seq, delta } — append delta to your running text
tool_use { seq, toolUseId, name, input }
turn_end { seq, text, toolsUsed, usage, stopReason, turns, history }
error { seq, message, cause? }

turn_end carries the authoritative history deliberately: reading session.history() from inside a turn_end handler would otherwise race the event and show stale state.

In a page, do not wire those by hand. useAISession from @wamp/ui creates the session on mount, subscribes, destroys on unmount, and re-keys cleanly when conversationId changes:

import { Chat, useAISession } from '@wamp/ui';
const ai = useAISession({
agent: 'meta',
conversationId: 'support-triage.main',
tools: { include: ['support_*'] },
persist: true,
});
// ai.messages, ai.isStreaming, ai.streamingText, ai.error,
// ai.send(text), ai.appendContext(text), ai.cancel(), ai.session
return <Chat session={ai} />;

Two hook-only options exist because the window cannot pass functions across the boundary: persist: true stores the transcript in the host (durable across unmount and restart — it needs a stable conversationId), and loadInitialHistory is an async seed awaited once before the session is created, for a transcript you restore from your own backend. The longer of the two wins.

<Chat session={ai} /> is a full surface — thread, composer, Enter-to-send, Send→Stop while streaming. <ChatThread> and <ChatComposer> are the halves if you want your own layout.

You almost never pick, and that is deliberate: the user’s model choice should apply to your app too.

  • One-shot calls (complete, generateObject, stream) use the user’s current chat model unless you pass options.model.
  • Sessions resolve in order: opts.model, then the agent definition’s own model, then that agent’s tier, then the user’s agent model.

Pass model when the work has a different shape from conversation — a cheap classifier on every keystroke, or a deliberately strong model for one hard extraction. In the window, pluginAPI.ai.listModels() returns { models, aliases } for a picker, and <ModelPicker> from @wamp/ui renders it; persist the id the user picked and hand it back as model.

Token spend is observable without threading it through every call site. ctx.api.tokens.onUsage(handler) fires for every AI call with { input_tokens, output_tokens, model?, conversationId? }.

session.cancel() aborts the in-flight model call and any running tools. The pending send promise still resolves — with stopReason: 'aborted' — so a cancel is a normal outcome to render, not an exception to catch. In the window, ai.cancel() on the hook does the same, and <Chat> wires it to the Stop button for you.

Four failures throw by name. In the main process they are Error subclasses carrying a code; a window session throws a plain Error with the same message, so match on the message rather than the class if your code runs in both halves. The first three come from send:

Thrown When What to do
SessionBusyError (SESSION_BUSY) send while another send is in flight Await the first. In main, fork(newId) runs the parallel turn; in a window, create a second session
SessionDestroyedError (SESSION_DESTROYED) send, cancel, or fork after destroy() Create a new session
ModelDoesNotSupportToolsError The session has tools but the resolved model cannot call them Pass a tool-capable model, or set tools: { include: [] }
Unknown agent: '…' createSession with an unregistered agent id Check agents.getAvailable()

That third one exists because of how it used to fail. Without the check the model silently loses its tools and answers in plain text — which reads as “the AI replies but never does anything,” the single most confusing failure in extension sessions on free or non-tool-capable models.

Anything the loop fails on is emitted as an error event and rejects the send promise. Handle one of the two; handling neither means a failed turn looks like a turn that never ended.

A support-triage app: one tool in the main half, one page whose session can call only that tool. This is the whole extension.

extension.json
{
"name": "Support Triage",
"version": "1.0.0",
"engines": { "wamp": "^12.0.0" },
"compat": { "pluginApi": "^1.2.0" },
"description": "Triage the support queue with an assistant that can read it.",
"main": "dist/main.js",
"icon": "LifeBuoy",
"permissions": ["ai"],
"activationEvents": ["onStartupFinished"],
"contributes": {
"pages": [{ "id": "support-triage", "title": "Triage", "icon": "LifeBuoy" }],
"toolMetadata": [
{ "id": "support_open_tickets", "label": "Open tickets", "readOnly": true }
]
}
}
main/activate.ts
import type { PluginContext } from '@wamp/extension-sdk';
const QUEUE = [
{ id: 'T-1', subject: 'Cannot export CSV', body: 'The export button spins forever.' },
{ id: 'T-2', subject: 'Billing question', body: 'Charged twice this month.' },
];
export async function activate(ctx: PluginContext): Promise<void> {
ctx.api.disposables.add(
ctx.api.tools.register({
name: 'support_open_tickets',
description: 'List open support tickets. Returns JSON: [{ id, subject, body }].',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
policy: { readOnly: true },
execute: async () => JSON.stringify(QUEUE),
}),
);
}
export async function deactivate(): Promise<void> {
// Disposables added to ctx.api.disposables unregister the tool.
}
ui/index.tsx
import { useState } from 'react';
import { Button, Chat, EmptyState, useAISession } from '@wamp/ui';
import { pluginAPI } from '@wamp/plugin-api';
import { LifeBuoy } from 'lucide-react';
function TriagePage() {
const [model, setModel] = useState<string | undefined>(undefined);
const ai = useAISession({
agent: 'meta',
conversationId: 'support-triage.main',
model,
persist: true,
tools: { include: ['support_*'] },
systemPromptExtension:
'You triage a support queue. Call support_open_tickets to read it. '
+ 'Answer with one line per ticket: id, severity, and the next action.',
});
const onModelChange = (next: string) => {
setModel(next);
void pluginAPI.storage.set('triage.model', next);
};
return (
<div className="h-full flex flex-col bg-background text-foreground">
<Chat
session={ai}
modelPicker
model={model}
onModelChange={onModelChange}
header={
<div className="flex items-center gap-2 text-sm font-semibold">
<LifeBuoy className="w-4 h-4" />
Triage
</div>
}
welcome={
<EmptyState
icon={<LifeBuoy className="w-8 h-8" />}
title="Nothing triaged yet"
description="Ask for the open queue to get started."
action={<Button onClick={() => void ai.send('Triage the open queue.')}>Triage now</Button>}
/>
}
/>
{ai.error && (
<p className="border-t border-border px-4 py-2 text-xs text-destructive">{ai.error}</p>
)}
</div>
);
}
export const views = { 'support-triage': TriagePage };
export default TriagePage;

The session can call support_open_tickets and nothing else, so the model is choosing between one tool rather than the platform’s several dozen — and no prompt wording can make it write a file.

ctx.api.ai carries chat, chatWithUsage, and completeWithUsage at runtime, and none of the three is in the published SDK declaration — calling them fails the typecheck even though they work. There is nothing to gain from them: chat is complete with a different name, and usage is available from tokens.onUsage. The same applies to ai.abort(requestId) and stream’s onRequestStart callback, which are the main-side cancel path for a one-shot stream; the window’s stream returns a cancel function instead, and that one is typed.

Where the runtime and the declarations disagree, the plugin API reference marks it per method.