@anna-ai/app-runtime
Browser runtime SDK for Anna Apps. A native ES module imported by every Anna
App iframe; exports AnnaAppRuntime, which proxies RPC calls over
postMessage to the host page (Anna dashboard in production, the
anna-app dev harness in local development).
0.3.1+: the SDK is a pure ES module. Load it with
<script type="module">+import { AnnaAppRuntime } from "…". The old classic<script src>+window.AnnaAppRuntimeglobal is removed.
Use inside an Anna App bundle
Make your entry script a module and import the SDK directly:
<!-- index.html -->
<script src="./app.js" type="module"></script>
// app.js
import { AnnaAppRuntime } from "/static/anna-apps/_sdk/latest/index.js";
const anna = await AnnaAppRuntime.connect();
await anna.window.set_title({ title: "My App" });
const out = await anna.tools.invoke({
tool_id: "tool-twitter-handle-twitter-tool-abcd1234",
method: "post_tweet",
args: { text: "hi" },
});
anna.on("entry_payload", (p) => console.log(p));
Don't use inline
<script>. The bundle CSP isscript-src 'self' <sdk-origin>with nounsafe-inline/nonce, so any inline script (including an inline module that bridges the SDK to a global) is blocked. Always make your entry an external module (<script src="app.js" type="module">) andimportthe SDK inside it.
Wire envelope
{ kind: "req"|"res"|"event", id, ns, method, args | result | error }
The host validates every call against the app's manifest ACL before forwarding to the backend.
RPC timeouts
Each call has a per-namespace default; apps can override per call:
// Use the namespace default (image: 180s)
const out = await anna.image.generate({ prompt: "..." });
// Override for one call (e.g. high-res / many images)
const out = await anna.image.generate(
{ prompt: "...", n: 4, size: "2048x2048" },
{ timeoutMs: 240000 }
);
| Namespace | Default | Rationale |
|---|---|---|
image.* | 180 s | Matches host's upstream provider timeout (Seedream / Qwen / native) |
upload.* | 120 s | Large multipart / negotiate+confirm |
agent.* | 300 s | session.run loops model + tools many turns |
llm.* | 180 s | Long completions / large context |
| everything else | 30 s | storage / chat / window / prefs / fs / tools / artifact |
The timeout is a client-side deadline. The host bridge fetch has no
abort signal — the backend always runs to completion, but a timed-out
promise rejects with Error("RPC <ns>.<method> timed out after Nms")
and the eventual response is discarded.
Where the script comes from
| Environment | Source |
|---|---|
| Production (Anna dashboard) | matrix-nexus static server, path /static/anna-apps/_sdk/<version>/index.js |
Local dev (anna-app dev) | @anna-ai/cli harness server resolves this package from its node_modules and serves the same path |
The bundle author writes the same
import { AnnaAppRuntime } from "/static/anna-apps/_sdk/latest/index.js"
either way; the URL is part of the host contract.
Versioning
This package's version tracks dispatcher_version (wire protocol). See
packages/VERSIONS.md for the full pin matrix and bump
procedure.
Contents
Single file dist/index.js. Native ES module (ends in
export { AnnaAppRuntime, ... }), no build step, runs directly in any modern
browser via <script type="module"> / import.
Agent run output cap & truncation (v0.26.0)
Host ≥ 1.1.0-beta.200 / dispatcher 0.30.0 (forum #403). The per-call output
cap of agent.session.run is min(model output cap, 65 536) clamped to the
model's context-budget reserve (10 % of context, ≤ 65 536) — e.g. 65 536 on
1M-context models, 20 000 on 200K-context models. It is disclosed per run and
truncation of the final message is now signalled:
const sess = await anna.agent.session({ submode: "auto" });
for await (const ev of sess.run({ content: prompt, maxTokens: 16384 })) {
if (ev.event === "run_meta") console.log("cap:", ev.max_output_tokens);
const tc = ev.choices?.[0]?.delta?.task_complete;
if (tc?.truncated) {
// status is still "completed": the graph ended normally, but the final
// assistant message hit the per-call cap. Continue in the same session:
// sess.run({ content: "continue exactly from: " + tail })
}
}
maxTokens?(1..65536) can only lower the cap — never above platform ∩ model. Omit to use the model's own output cap.task_complete.stop_reason∈endTurn | maxTokens | contentFilter | toolUse(same set asllm.complete'sstopReason);truncated === (stop_reason === "maxTokens").- Older hosts omit
max_output_tokens/stop_reason/truncated.
Host context: theme & language (v0.24.0)
The dashboard tells every App which theme and UI language the user
chose, so Apps no longer guess from prefers-color-scheme / navigator.language.
No manifest grant is involved — these are one-way host→app broadcasts (like
auth.refresh), never callable RPC methods.
const anna = await AnnaAppRuntime.connect();
anna.theme; // 'light' | 'dark' — already correct before the first paint
anna.themeMode; // 'light' | 'dark' | 'system' — what the user picked
anna.locale; // 'en' | 'zh-CN' | … — dashboard UI language
// Subscribe; both replay the current value once, then fire on every change.
anna.onThemeChange(({ theme }) => { document.documentElement.dataset.theme = theme; });
anna.onLocaleChange(({ lang }) => i18n.setLocale(lang));
Wire details: initial values ride on the iframe URL
(…?wid=&t=&theme=light&themeMode=system&lang=zh-CN) and are read synchronously
in connect(); live changes arrive as {kind:"event", event:"theme.changed", payload:{theme, mode}} and {event:"locale.changed", payload:{lang}}. Hosts older
than this release send neither — the SDK falls back to prefers-color-scheme and
navigator.language, so keep a CSS fallback for standalone previews:
:root[data-theme="light"] { /* light tokens */ }
:root[data-theme="dark"] { /* dark tokens */ }
@media (prefers-color-scheme: dark) { :root:not([data-theme]) { /* dark tokens */ } }
Media generation (v0.20.0)
Async video jobs + sync audio, gated by the per-app media_grant
(manifest must declare ui.host_api.video / .audio):
// One-shot sugar: submit + media_job events + getJob polling fallback
const job = await anna.video.generateAwait(
{ prompt: "Aerial drone shot over a misty pine forest", durationSec: 5,
resolution: "768p", clientTag: "scene-3-broll" },
{ onProgress: (p) => bar.update(p), signal: abortController.signal }
);
video.src = job.result.url; // presigned GET, ~30 min TTL
// URL expired (403)? Re-sign via getJob:
const fresh = await anna.video.getJob({ jobId: job.jobId });
// Raw job API: generate / getJob / cancelJob / listJobs
const { jobId, estimatedCostCU } = await anna.video.generate({ prompt: "..." });
// Sync TTS / STT
const speech = await anna.audio.speak({ text: "Welcome!", voice: "Cherry" });
const { text } = await anna.audio.transcribe({ audioUrl: "https://..." });
// Capability discovery: models, parameter space, CU pricing, remaining quota
const catalog = await anna.llm.catalog({ serviceType: "video_gen" });
Music generation (v0.23.0)
Async music jobs (mjob_…) with the exact same job API, media_job events and
pre-hold → settle → refund billing as video. Requires ui.host_api.music +
per-app media_grant.music.
// Capability discovery first — models differ (lyrics vs prompt-embedded lyrics,
// fixed vs range length, mp3 vs wav, reference image …). Nothing is silently
// downgraded: an unsupported field fails with MUSIC_FEATURE_UNSUPPORTED (-32165)
// and error.data.alternatives lists models that do support it.
const { models } = await anna.llm.catalog({ serviceType: "music_gen" });
const m = models.find(x => x.isPlatformDefault); // fal-ai/minimax-music/v2.6
const song = await anna.music.generateAwait(
{ prompt: "melancholic cinematic piano, slow ~70 BPM, rainy night",
lyrics: "[Verse 1]\n…\n[Chorus]\n…", // only when m.capabilities.lyrics
model: m.modelName,
clientTag: "scene-3-score",
idempotencyKey: "canvas-node-8f3a-v2" }, // 24h replay → same job, no re-charge
{ onProgress: (p) => bar.update(p), signal: abortController.signal }
);
audio.src = song.result.url; // presigned GET, ~30 min TTL; song.result.lyrics when returned
song.idempotentReplay; // true when the key hit an existing job
// Range-length models (ElevenLabs / Stable Audio / ACE-Step) take durationSec;
// fixed-length models (MiniMax / Lyria) reject it. Instrumental-only models
// (Stable Audio) reject lyrics and instrumental:false.
await anna.music.generate({ prompt: "ambient pad", model: "fal-ai/ace-step", durationSec: 30, instrumental: true });
Image async jobs & re-signing (v0.25.0)
anna.image.generate / anna.image.edit stay synchronous and unchanged. For
2K/4K or quality: "high" renders (60–120 s) opt into the same async-job shape
as video / music — ijob_… ids, media_job events, pre-hold → settle (on the
actual number of images) → refund — and re-sign any already-paid artifact for
7 days instead of losing it after the 30-min URL TTL. Requires
ui.host_api.image listing the new method names + the existing image_grant
(allowGenerate / allowEdit; no new grant).
// Same request body as image.generate, plus clientTag / idempotencyKey.
const job = await anna.image.generateAwait(
{ prompt: "A bold art-deco poster of the planet Mars", n: 2,
model: "fal-ai/nano-banana-2", aspect_ratio: "9:16", params: { resolution: "4K" },
clientTag: "poster-v3", idempotencyKey: `poster-v3:${attempt}` }, // 24h replay → same job, no re-charge
{ onProgress: (p) => status.textContent = `${p.state} · queue ${p.queuePosition ?? "-"}`,
signal: abortController.signal } // → image.cancelJob
);
job.result.images; // [{url, r2Key, mimeType, width, height, expiresIn}] — url re-signed by getJob
job.billedCostCU; // settled on actual output (≤ estimatedCostCU)
// Edit (optionally mask-based) as a job:
await anna.image.editAwait({ image_url, prompt: "replace the sky with aurora", mask_url, model: "openai/gpt-image-2" });
// Reload recovery / correlation — same four-pack as video / music:
const { jobs } = await anna.image.listJobs({ state: "running", clientTag: "poster-v3" });
const fresh = await anna.image.getJob({ jobId }); // every result.images[].url re-signed (~30 min)
// Re-sign already-paid artifacts (sync `images[].r2_key` or job `r2Key`), up to 7 days:
const { urls } = await anna.image.resign({ r2Keys: [img.images[0].r2_key, job.result.images[0].r2Key] });
// One key that is not yours → whole call fails with IMAGE_ASSET_NOT_FOUND (details.data.index).
Errors surface as HostRpcError names: IMAGE_JOB_NOT_FOUND,
IMAGE_JOB_NOT_CANCELLABLE, IMAGE_CONTENT_REJECTED (also replaces the sync
path's former APP_PROVIDER_ERROR for provider safety rejections),
IMAGE_JOB_EXPIRED, IMAGE_ASSET_NOT_FOUND; error.details.data now carries
allowed / alternatives / index. anna.llm.catalog image entries report
capabilities.jobs and, for image_edit, the real options.n.max.
Model-aware options (v0.22.0)
Every TTS / image model describes its knobs in capabilities.options
(schema v2: {type, min, max, step, values, default, scope, …}). Pass any of
them in params; keys a model does not declare are dropped and echoed in
ignoredParams, never surcharged.
const { models } = await anna.llm.catalog({ serviceType: "tts", voiceLanguage: "zh" });
const tts = models.find(m => m.isUserPreferred) ?? models.find(m => m.isPlatformDefault);
const out = await anna.audio.speak({
text: "Welcome!",
model: tts.modelName,
voice: tts.capabilities.defaultVoice,
params: { stability: 0.3, similarity_boost: 0.8 }, // keys from capabilities.options
});
out.params; // what reached the provider
out.ignoredParams; // e.g. ["similarity_boost"] on a model that only exposes stability
// Image: explicit model + size policy. `fit: "strict"` fails with
// IMAGE_SIZE_UNSUPPORTED (+ data.allowed) instead of snapping to the nearest size.
const img = await anna.image.generate({
prompt: "A bold art-deco poster of the planet Mars",
model: "fal-ai/nano-banana-2",
aspect_ratio: "9:16",
params: { resolution: "2K" },
fit: "strict",
});
img.size; img.sizeAdjusted; img.images[0].width; // real decoded dimensions
Render capabilities.options as a form with the shared
ModelOptionsForm
component — the same file the platform uses on /llm and in the Developer
Console Playground.
Costs are pre-charged from the estimate shown in estimatedCostCU and
fully refunded on failure/cancel/expiry. Aborting generateAwait cancels
the job (queued = full refund; running cancel is best-effort).