Back to Cherry Studio

Capabilities

docs/references/mini-app/capabilities.md

2.0.1014.4 KB
Original Source

Capabilities

Everything the host offers is on window.cherry. Types are in cherry.d.ts.

Conventions

ConventionDetail
Every method returns a PromiseIncluding argument errors: cherry.storage.set(k, v).catch(...) sees them. cherry.on is the one synchronous call
Binary is base64file.save / file.load data and network.fetch request and response bodies. There are no Blob, ArrayBuffer or stream parameters
Errors are { name, message }A plain object, not an Error instance: instanceof Error is false and there is no stack. name is one of seven fixed strings; branch on it, never on message. No host paths
Limits are enforced twiceCheap length caps run in the page before the call crosses to the host; the host re-validates everything. Both reject with InvalidArgument
Nothing is confirmed at runtimeA granted method runs without a prompt. A missing grant rejects immediately with PermissionDenied

Errors

nameWhen
PermissionDeniedThe method is not granted, or network.fetch was given a URL outside the declared hosts
QuotaExceededA byte or item budget would be exceeded (storage file, file sandbox, request or response body)
RateLimitedToo many writes, notifications, AI calls or requests in the window; too many in flight. Wait, then retry — except when the message says a background budget is exhausted, which waiting never refills: that one comes back when the user opens your app again
UnavailableThe host cannot serve the call right now: the app is being updated, rolled back, reinstalled, cleared or uninstalled, a remote request timed out or failed — an ai.chat stream the model host could not complete included — ai.* found no model configured for the requested slot and no global default, or your save file exists but could not be read
InvalidArgumentArgument validation failed, an unknown method, or ai.chat reused a callId that is still in flight
CancelledAn ai.chat stream was aborted and the abort surfaced as an error
InternalAnything else. The message is always Internal error
js
try {
  await cherry.storage.set('save', data)
} catch (e) {
  if (e.name === 'QuotaExceeded') showStorageFullDialog()
  // Back off rather than loop: a background budget does not refill on a timer.
  else if (e.name === 'RateLimited') retryLater()
  else throw e
}

cherry.app

Environment reads. No permission needed.

MethodReturns
getInfo(){ appId, version, hostVersion, locale } — your manifest version, the Cherry Studio version, and the UI locale (zh-CN, en-US, …)
getPermissions(){ [leaf]: boolean } for every leaf your manifest declares (required and optional). Undeclared methods are absent, not false

There is no theme field: use matchMedia('(prefers-color-scheme: dark)'), which also reports changes. See Theming.

cherry.ai

MethodGateReturns
chat(params, { onChunk, callId? })ai.chat{ ok: true } when the stream ends. Text arrives through onChunk(text) as plain string deltas
cancel(callId)none{ ok: true }. Unknown or finished ids are ignored
getCapabilities({ model? }?)sibling of ai.*{ available: true, reasoning: boolean, contextWindow: number | null } for that slot, or { available: false } when it has no usable model

params:

ts
{
  messages: { role: 'system' | 'user' | 'assistant'; content: string }[]  // 1–64 messages, text only
  reasoning?: 'on' | 'off'  // whether a reasoning model may think first; 'off' when omitted
  model?: 'default' | 'quick'  // which of the user's two model slots answers; 'default' when omitted
}

Which model answers is the user's choice, never yours: every app has a default and a quick slot (the same two Cherry keeps globally), each falling back to the global model of that name, and neither is ever revealed. Use quick for short, latency-sensitive calls. getCapabilities({ model }) describes the slot you are about to use — whether it reasons and how large its context is — so you can degrade instead of crash when the user swaps it. A slot the user has left empty, or whose model they deleted, comes back as { available: false } instead of rejecting: check it before calling chat, which does reject in that state. There is no image input and no tool calling; vision and tools are deliberately not reported.

callId is your own label for the call. It must be unique among your in-flight calls (reusing one rejects with InvalidArgument) and is what cancel takes. After cancel, no further chunks arrive and the chat Promise settles — normally resolving { ok: true }, or rejecting with Cancelled when the abort surfaces as an error. Handle both; whatever onChunk already delivered stays delivered.

LimitValue
Messages per call64
Total prompt size256 KB (262,144 UTF-8 bytes) across all content — an abuse stop; the model's context window is the real ceiling
OutputNot capped by Cherry — the model's own limit applies
In flight per app2
Calls per minute per app60
Calls while hidden5 per hidden stretch — see lifecycle
callId≤ 64 characters

There is no spending budget in tokens or money. Every completed call is attributed to your app in the user's usage ledger; the concurrency and burst limits throttle you while the user is watching, and the background allowance bounds what you can spend while they are not.

js
let out = ''
await cherry.ai.chat(
  { messages: [{ role: 'user', content: 'Name a color.' }] },
  { onChunk: (t) => (out += t), callId: 'hint-1' }
)

cherry.storage

A single JSON save file per app: string keys, string values, persistent, never evicted. The whole file is rewritten on every write, and a write is committed when its Promise resolves.

MethodGateReturns
get(key)storage.get{ value: string | null }null when absent. A save file that exists but cannot be read rejects Unavailable rather than reading as empty, so a retry cannot overwrite it
set(key, value)storage.set{ ok: true }
delete(key)storage.delete{ ok: true } — idempotent
keys()storage.keys{ keys: string[] }, sorted
usage()sibling of storage.*{ bytes, count, bytesLimit, countLimit }. Unlike get, it never rejects: a save file it cannot read is reported at its size on disk with count: 0, so a damaged file reads as bytes-without-items rather than as nothing
LimitValue
Whole save file1 MB (serialized JSON, UTF-8 bytes)
Keys1,000
Key length256 UTF-8 bytes
Writes (set + delete)20 per second per app
Write volume12 MB burst, refilling at 8 MB/s

There are no multi-key transactions. State that must change together belongs in one key as one JSON string.

cherry.file

A flat namespace of named blobs, separate from storage, for larger payloads. Names are logical — there are no directories and no paths.

MethodGateReturns
save(name, base64)file.save{ ok: true }. Overwrites an existing name atomically
load(name)file.load{ data: string | null } — base64, null when absent
list()file.list{ names: string[] }, sorted
delete(name)file.delete{ ok: true } — idempotent
usage()sibling of file.*{ bytes, count, bytesLimit, countLimit } — decoded bytes
export(name, { suggestedName? }?)file.export{ saved: boolean }false when the user cancels the save dialog. Nothing is copied if the world moved while that dialog stood open: rejects InvalidArgument when the file was deleted meanwhile, Unavailable when the app's data was cleared or it was uninstalled
LimitValue
Name1–128 characters, no / or \, not . or ..
Single file10 MB decoded
Per app total20 MB, 200 files
Writes (save + delete)20 per second per app
Write volume12 MB burst, refilling at 8 MB/s
Concurrent loads (all apps)Bounded; a burst rejects with RateLimited — retry shortly

data must be valid base64; a malformed string rejects with InvalidArgument rather than being silently repaired.

js
const bytes = new Uint8Array(await blob.arrayBuffer())
await cherry.file.save('level1.bin', btoa(String.fromCharCode(...bytes)))

Exporting

export is the only way a sandbox file reaches the user's disk: the host opens its own save dialog, parented to the window showing your app and titled with your app's name, and copies the file to whatever path the user picks. That path is never returned to you.

RuleValue
VisibilityOnly while the app's pane is visible — a hidden pooled app rejects PermissionDenied before any dialog opens. This is the pane's state, not the window's: see Lifecycle
DialogsOne at a time; a second call while one is open rejects RateLimited
Rate10 per minute per app
suggestedNameOptional default file name in the dialog, same rules as a logical name; defaults to name
Unknown nameInvalidArgument

Browser downloads (<a download>, URL.createObjectURL + click) and the File System Access pickers are blocked in the sandbox — see Sandbox.

cherry.notification

MethodGateReturns
show({ title, body? })notification.show{ ok: true }
RuleValue
titlerequired, shown truncated to 64 characters
bodyoptional, truncated to 256 characters
Rate5 per minute per app
AttributionThe notification is prefixed with your app id and name; you cannot impersonate the host
User switchIf the user disabled mini app notifications, the call resolves ok and shows nothing

Notifications are one-way: there is no click event back to the app.

cherry.clipboard

MethodGateReturns
read()clipboard.read{ text: string }'' when the clipboard holds no text
write({ text })clipboard.write{ ok: true }

Plain text only. Both calls require the app to be visible and to have keyboard focus: while the user is typing or clicking elsewhere in Cherry, or while your app sits hidden in the pool, they reject PermissionDenied. Focus is the one signal the user gives without a dialog; without it a background app could read what they copied elsewhere or replace what they are about to paste. Call from a click handler and you have it.

LimitValue
text≤ 1,048,576 characters; longer rejects InvalidArgument
ReadClipped to 1,048,576 characters, never rejected for length
Rate10 reads and 30 writes per minute per app — a read is one user action, never a poll

navigator.clipboard stays denied. Pasting into your own inputs with the keyboard is a browser behaviour and needs nothing.

cherry.network

MethodGateReturns
fetch({ url, method?, headers?, body? })network.fetch{ status, headers, body }body base64, headers lowercase-keyed

The request is made by the host, not by the page, so it is not subject to CORS. A non-2xx status is a result, not a rejection.

RuleValue
URLhttps:// only, default port only, no IP literals, hostname must be in the manifest's network list; ≤ 2048 characters
Private addressesA declared host that resolves to any non-global address — loopback, link-local, RFC 1918, shared address space (100.64.0.0/10), multicast, reserved, ULA, or a NAT64 / 6to4 / Teredo prefix, in plain or IPv4-mapped form — is refused (PermissionDenied). The check resolves the name once before the connection and Chromium resolves it again for the connection, so an answer that flips in between (DNS rebinding) is not caught — the residual risk of granting network.fetch; every call is in the app's activity log either way
methodGET (default), POST, PUT, PATCH, DELETE, HEAD
headers≤ 32; name ≤ 128, value ≤ 4096 characters. host, connection, content-length, transfer-encoding, upgrade, origin, referer, cookie are rejected (InvalidArgument), not stripped. authorization is allowed
Request bodybase64, ≤ 1 MB decoded
Response body≤ 5 MB, else QuotaExceeded
RedirectsRefused — the call rejects Unavailable
Timeout30 s for the whole exchange, then Unavailable
CredentialsNever sent. The host's cookies and sessions are not yours
Rate60 per minute per app, 4 in flight. While hidden, 10 requests per hidden stretch — see lifecycle
js
const { status, body } = await cherry.network.fetch({
  url: 'https://api.example.com/scores',
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: btoa(JSON.stringify({ score: 42 }))
})
const json = JSON.parse(atob(body))

cherry.on

ts
const off = cherry.on('app.visibilityChange', ({ visible }) => { ... })
off()
EventPayloadFires when
app.visibilityChange{ visible: boolean }The user switches to or away from the app. Page Visibility does not fire inside the host's keep-alive pool — use this
app.localeChange{ locale: string }The user changes the UI language. navigator.language does not update — use this

Both are fire-and-forget: the host does not wait for your handler, and a handler that throws or rejects affects nothing. There is no destroy event, no permission-change event and no theme event — see Lifecycle.

Guest-side length caps

These run inside the page before anything is sent, so an oversized payload never leaves your process. They are the same numbers as the host's, expressed in characters:

InputCap
storage key256
storage value1,048,576
file name128
file database64 of 10 MB
ai.chat messages64; each content 262,144
callId64
network.fetch url / header count / header name / header value / body2048 / 32 / 128 / 4096 / base64 of 1 MB
notification title / body64 / 256 — truncated, not rejected
clipboard.write text1,048,576
file.export suggestedName128