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.
Four entry points
Section titled “Four entry points”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 |
One prompt, one answer
Section titled “One prompt, one answer”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.
Streaming one answer
Section titled “Streaming one answer”stream takes callbacks rather than returning text, and the two halves differ
in what the call itself gives back.
// main — resolves when the stream endsawait 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, synchronouslyconst cancel = pluginAPI.ai.stream(prompt, { onChunk: (text) => setDraft((d) => d + text), onEnd: () => setDone(true), onError: (message) => pluginAPI.notify.error(message),});// later, e.g. on unmountcancel();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.
A session you own
Section titled “A session you own”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.
The object
Section titled “The object”| 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.
Ownership and teardown
Section titled “Ownership and teardown”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.
Events reaching your page
Section titled “Events reaching your page”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.
Which model runs
Section titled “Which model runs”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 passoptions.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? }.
Cancellation and errors
Section titled “Cancellation and errors”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 complete worked example
Section titled “A complete worked example”A support-triage app: one tool in the main half, one page whose session can call only that tool. This is the whole extension.
{ "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 } ] }}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.}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.
Two things the types do not admit
Section titled “Two things the types do not admit”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.
- Contributing tools — the other direction: the host’s agent calling you.
- Agents and skills — give the session a persona instead of a prompt suffix.
- Interface kit —
<Chat>,<ModelPicker>,<Markdown>, and the tokens around them. - Plugin API reference — every signature on both surfaces.