Developer Guide
Runtime APIs
ctx.runtime is your toolbox inside tools and hooks. Components get a bound subset as props.runtime. Every call is scoped to your extension key and the active persona and session.
Where each API is available
ctx.runtimetools and hooksThe full API: emitImage, emitPromptInstruction, appendAssistantInstruction, emergencyStop, variables, memories, chat, persona, media, ui, and optional devtools.props.runtimeSettings, Footer, Panelvariables and emergencyStop.props.runtimeOverlayvariables, emergencyStop, chat, and ui.noneStatusThe top-bar dropdown component gets extension and onConnect only. Read module-level state instead.emitImage, every method returns a result object with ok instead of throwing for expected failures such as a missing key or no active session. Network failures can still reject, so wrap calls in try/catch inside components and hooks.variables
Persisted JSON state owned by your extension. Values can be strings, numbers, booleans, null, arrays, or objects. Use them for counters, flags, remembered pairings, and scene state. Use settings, not variables, for anything the user should edit in a form.
get(key, { scope, ownerExtensionKey?, defaultValue? })=> { ok, found, value?, defaultValueUsed?, variable, error? }Missing keys return found: false and the default, never a throw.set(key, value, { scope, visibility? })=> { ok, variable, error? }Creates or replaces. visibility defaults to 'private'.delete(key, { scope })=> { ok, deleted, error? }Removes one variable.list({ scope, ownerExtensionKey? })=> { ok, variables, error? }All variables in the scope. Each record has key, scope, persona_id, visibility, value, and timestamps.scope'user' | 'persona'user is one value per account. persona is one value per persona.visibility'private' | 'shared'shared lets other extensions read it by passing your key as ownerExtensionKey. Writes are always owner-only.const got = await ctx.runtime.variables.get('disposition', {
scope: 'persona',
defaultValue: { score: 0 },
});
// got: { ok, found, value, defaultValueUsed, variable, error? }
await ctx.runtime.variables.set('disposition', { score: got.value.score + 1 }, {
scope: 'persona',
visibility: 'private',
});
// Read another extension's shared variable
const theirs = await ctx.runtime.variables.get('arousal_level', {
scope: 'persona',
ownerExtensionKey: 'arousal-state',
});Variables survive uninstall so a reinstall picks them back up. Settings do not. Renaming your manifest key abandons the old data.
memories
The persona's long-term memory store, the same one the app's memory features use.
create({ content, type?, importance?, metadata? })=> { ok, memory, error? }type is 'user', 'persona', 'fact', 'preference', or 'event'. importance is a number the app uses for ranking.search({ query, types?, limit?, minSimilarity? })=> { ok, results: [{ memory, similarity? }], error? }Semantic search over the active persona's memories.delete(memoryId)=> { ok, deleted, error? }Removes one memory.chat
list({ position?, speaker?, limit? })=> { ok, messages, error? }position is 'first' or 'last'. speaker is 'user', 'persona', 'both', or 'all' (all includes system and tool messages). Messages come back in chronological order.sendMessage({ role, content })=> { ok, messageId?, error? }v6. Inserts a message into the transcript as 'user', 'assistant', or 'system'. The type also accepts an attribution field, but the app does not store it yet.runTurn({ kind?, userMessage? })=> { ok, error? }v6. Runs a model turn. Omit userMessage for a silent advance. kind defaults to 'extension'.isBusy?()=> booleanTrue while a turn is streaming or the user is typing. Check it before a proactive runTurn. Present in tools and hooks, absent on the overlay runtime, so feature-detect.const recent = await ctx.runtime.chat.list({ position: 'last', speaker: 'both', limit: 6 });
// recent.messages[i]: { id, sessionId, role, speaker, content, createdAt, metadata? }
await ctx.runtime.chat.sendMessage({
role: 'system',
content: 'Heart rate crossed 120 bpm.',
});
// From onAfterAssistantMessage or a timer, never from inside executeTool
if (!ctx.runtime.chat.isBusy?.()) {
await ctx.runtime.chat.runTurn({ userMessage: 'Timer finished. Continue.' });
}runTurn from inside executeTool starts a turn inside a turn and can loop. Call it from onAfterAssistantMessage, a timer, a webhook callback, or a button.persona
get()=> { ok, persona, error? }The active persona: id, name, description, avatarUrl, systemPrompt, personalityTraits, aiProvider, modelName, temperature, image settings, tags, and timestamps.media
Resolves ids from the user's My Media library. Users paste a media id into one of your settings fields; you turn it into a URL at runtime.
get(mediaId)=> { ok, media, error? }Record with filename, extension, contentType, sizeBytes, sha256, status, fileUrl, and hidden or imported flags.getUrl(mediaId)=> { ok, url, error? }A URL you can hand straight to an img or audio element.const track = await ctx.runtime.media.getUrl(ctx.extensionState.resolvedSettings.introTrackId);
if (track.ok && track.url) {
await new Audio(track.url).play();
}ui
showModal({ title, body, actions?, dismissable? })=> Promise<{ actionId }>Opens an app-owned modal and waits. body is a string or React node. Each action has id, label, and optional variant 'primary', 'ghost', or 'danger'. actionId is 'dismiss' when closed without choosing.toast({ message, level?, durationMs?, actionLabel?, onAction? })=> voidv6. Non-blocking notification. level is 'info', 'success', 'warning', or 'error'. durationMs defaults to 4000; 0 means stay until dismissed. The toast closes after onAction runs.const choice = await ctx.runtime.ui.showModal({
title: 'Reconnect device?',
body: 'The device dropped its connection.',
actions: [
{ id: 'reconnect', label: 'Reconnect', variant: 'primary' },
{ id: 'ignore', label: 'Not now', variant: 'ghost' },
],
dismissable: true,
});
// choice.actionId is 'reconnect', 'ignore', or 'dismiss'
ctx.runtime.ui.toast({
message: 'Spotify connected',
level: 'success',
durationMs: 4000,
actionLabel: 'Open',
onAction: () => window.open('https://open.spotify.com'),
});Artifacts and instructions
emitImage({ url? | blob?, mimeType?, prompt?, messageId?, sourceExtensionKey?, sourceToolName?, sourceKind?, displayMode? })=> Promise<{ id, messageId, displayMode }>Adds an image to the current assistant message. displayMode is 'gallery', 'inline', or 'auto'. sourceKind is 'tool', 'lifecycle', or 'builtin'. Rejects when there is no active message, so catch it.emitPromptInstruction({ text, placement, priority?, id? })=> voidQueues a contribution for the next model round of this turn.appendAssistantInstruction(text, placement?, priority?)=> voidSame thing for a plain string. placement defaults to 'system_post'.Placement rules are on the Prompt contributions page.
emergencyStop
emergencyStop({ reason? })=> Promise<{ ok, stoppedCount, error? }>v5. Runs the same stop as the top-bar Toy E-Stop. stoppedCount is how many participants completed their stop, toy extensions plus host players such as the pattern player. ok is false when no persona is active.ctx.llm
A non-streaming completion helper on the user's configured provider. It is on the context object itself, not under runtime. The provider key never reaches your code.
complete(messages, { model?, temperature?, maxTokens? })=> Promise<string>Chat-style messages in, assistant text out.describeImage(base64, prompt?, opts?)=> Promise<string>Sends an image to a vision model. Pass a data: URL for PNG; bare base64 is assumed to be JPEG. On OpenRouter and cloud providers the app picks its own vision model regardless of opts.model.supportsVision()=> booleanBest-effort guess from the provider and model name. No network call.const summary = await ctx.llm.complete(
[
{ role: 'system', content: 'Summarise in one sentence.' },
{ role: 'user', content: transcript },
],
{ temperature: 0.2, maxTokens: 120 },
);
if (ctx.llm.supportsVision()) {
const caption = await ctx.llm.describeImage(base64Jpeg, 'Describe the outfit.');
}devtools
Optional structured logging that shows in the Logs tab of the in-app extension devtools panel, tagged with your key. Older hosts do not expose it, so feature-detect with ctx.runtime.devtools?.log(...).
log / info / warn / error(message, data?)=> voidOne entry each.group(label, fn)=> Promise<T>Entries logged inside fn are grouped under label. Returns fn's value.time(label)=> () => voidStarts a stopwatch. Call the returned function to record the duration.patterns
runtime.patterns exists for the app's built-in pattern-capable toy extensions. It plays saved patterns through an extension's own pattern sink, which the JS loader does not yet read from modules, so JS extensions should not rely on it.