The Space App API
Everything the daemon offers an app: bridge actions, REST endpoints, MCP in both directions, the iframe bridge, and chat widgets.
This is the official contact surface between an app and the SenClaw daemon. The URL root is always SENCLAW_BASE_URL (default http://127.0.0.1:18788), injected when the daemon starts the app — Building a Space App lists the injected environment. At the end of this page is a survey of which shipped apps use which surface, so every pattern here has a living example.
The bridge: one endpoint, many actions
POST /api/space/apps/{id}/bridge
Body: { "action": "<action name>", "payload": { ... } }The response is always HTTP 200 shaped { "appId", "status": "ok" | "error", ... } — 400 only when a required field is missing.
| Action | Payload | Returns | Used today by |
|---|---|---|---|
capabilities | — | the list of available actions | (probe) |
llm.request | prompt required; optional system, maxTokens, profile | text, model, finish, usage or null | ~40 apps — every app with AI |
agent.run | prompt required; optional system, tools[], model, space, workspace, timeoutSeconds | text, durationMs, usage | ai-chat, ai-office, discuss, rule-engine, search, video-flow, zeach |
knowledge.save | text required; optional space, tags[], source | chunksAdded, entitiesAdded | ai-chat, ai-office, crm, lakehouse, moltbook, rule-engine, search, tiktok-activity, youtube, zeach |
knowledge.search | query required; optional space, mode, limit (1–30, default 6) | hits[] of id, kind, name, summary, score | (same set) |
knowledge.recall | query required; optional space, mode, limit, hops (1–6, default 2) | answer, grounded, sources[] | (same set) |
usage.report | model required, inputTokens / outputTokens (at least one nonzero); optional provider, cacheReadTokens, cacheCreationTokens, latencyMs, estimated | ok | no app yet — see below |
mcp.call | — | status: "pending" — not enabled yet | — |
mcp.call is reserved but not enabled. An app that needs to invoke tools uses agent.run, or app-to-app /api/mcp/message (below).
llm.request — one-shot completion on SenClaw's LLM
use app_space_sdk::SpaceClient;
let sc = SpaceClient::from_env(); // reads SENCLAW_BASE_URL + SENCLAW_SPACE_APP_ID
// Short form: (text, model)
let (text, model) = sc.llm_request("You are an assistant...", "Hello", 4000).await?;
// Full form: text + model + finish + usage (Option) — prefer this one
let reply = sc.llm_request_usage("system", "prompt", 4000, None).await?;
if reply.finish == "length" { /* truncated — raise maxTokens or split the input */ }
// Run on a dedicated LLM profile (an id or label from /api/llm-config)
// -> the app gets its own model without touching the daemon-wide active model
let r = sc.llm_request_on("system", "prompt", 4000, Some("llm_abc123")).await?;The curl equivalent, for apps not written in Rust (18 shipped apps call the bridge raw like this):
curl -s http://127.0.0.1:18788/api/space/apps/my-app/bridge \
-H 'Content-Type: application/json' \
-d '{"action":"llm.request","payload":{"system":"You are an assistant","prompt":"Hello","maxTokens":4000}}'What you must know, distilled from the shipped apps:
- There is no temperature — the payload takes only system, prompt, maxTokens and profile.
- Be generous with
maxTokens(several apps go to 32000), and treatfinish == "length"as an error — never quietly accept truncated text. Very large inputs may be summarized silently — chunk them yourself first. usageis null when the provider does not report it (some local models) — estimate yourself if you need numbers.- The daemon records this call's usage itself, under the jid
app:{id}shown on the /usage page — do not also callusage.reportfor the same call, or it counts double. - The SDK-side timeout is 125 seconds.
agent.run — a full agent, headless
Where llm.request is one shot with no tools, agent.run runs a complete agent: the default tool set, plus the app's own MCP, plus browser and web search — looping until done and returning the final text.
curl -s http://127.0.0.1:18788/api/space/apps/my-app/bridge \
-H 'Content-Type: application/json' \
-d '{
"action": "agent.run",
"payload": {
"prompt": "Research the current gold price, then record it with the myapp_add tool",
"system": "You are the My App assistant. Use tools when they help.",
"tools": ["mcp__my-app-mcp__myapp_add", "WebSearch"],
"model": "llm_abc123",
"timeoutSeconds": 300
}
}'tools(optional) is an exact allowlist — when present the agent gets only these tools; when absent it gets the whole pool. This is how an app enforces per-bot policy (ai-chat gives each support bot its own tools and model).model(optional) — a model hint for this run only.space(optional) — the agent's memory folder, defaultspace-app-<id>.timeoutSecondsis clamped to 10–1800. At most 4 concurrent sessions per app — extra calls queue, so design long jobs accordingly.- Usage is already recorded per underlying LLM call (the anti-double-count rule) — the totals in the response are for the app's reference only.
- The SDK has no wrapper for
agent.runyet — post the raw JSON as above, which is what every shipped app does.
knowledge.* — partitioned long-term memory
Every app gets its own knowledge space keyed by its id — fully isolated from other apps. The optional space field subdivides further, for example one space per internal bot (the ai-chat and discuss pattern).
// Remember — the text is cognified into chunks + entities
sc.knowledge_save("Customer A prefers morning deliveries", None, Some("crm-note")).await?;
// Raw search: Vec<(name, summary, score)>
let hits = sc.knowledge_search("customer A delivery", None, 6).await?;
// Recall: the LLM composes an answer with [n] citations over that space
let answer = sc.knowledge_recall("when does customer A want deliveries?", None).await?;knowledge.savealso takestags— each tag becomes a global NodeSet, useful for grouping across apps — andsource.knowledge.recalldegrades to stitched snippets when the user has not configured a cognitive LLM;answeris empty when the space holds nothing relevant.- The error
"cognitive system is not initialized"means the user has not enabled Knowledge in SenClaw — the app must tolerate it. AI memory is an enhancement, never the core path.
usage.report — when the app calls a provider directly
Only for apps that hold their own key and call a provider directly (the video-cloner case, which sends video to Gemini — the bridge cannot carry video). Report so the /usage page stays complete:
sc.usage_report("gemini-2.5-pro", "google", in_tokens, out_tokens, latency_ms, /*estimated*/ false).await?;Set estimated: true when the numbers are a chars/4 estimate rather than provider-reported. Fire-and-forget is fine. No shipped app calls this yet — if yours calls a provider directly, be the first to do it right.
REST beyond the bridge
| Group | Endpoint | Notes | Used by |
|---|---|---|---|
| Models | GET /api/llm-config | {activeId, configs[{id, modelName, provider}]} — SDK list_models() | mini-browser (model picker) |
POST /api/llm-config/active with {id} | switches the global active model — SDK set_active_model(); think twice, it affects every other user | ||
| Per-app config | GET /api/space/apps/{id}/config | lists every key | many apps (user-entered settings) |
GET / PUT / DELETE /api/space/apps/{id}/config/{key} | PUT body {value: any JSON}; stored in the daemon's space_app_config table — survives app reinstall | ||
| Hosted SQLite | POST /api/space/apps/{id}/sqlite/query | {sql, params?} runs on <appDir>/app.sqlite; select/with/pragma return {rows}, everything else {rowsAffected, lastInsertRowId}. For UI-only apps with no server; server apps manage their own DB as usual | |
| Static and proxy | GET /api/space/apps/{id}/static/* | static files from the app directory | |
ANY /api/space/apps/{id}/proxy/* | the daemon forwards into the app's port — the origin the iframe UI loads from, and the way to mint a same-origin URL an agent can embed in chat via emit_widget (the drawio pattern: export SVG, return its URL, emit kind image) | drawio | |
| Logs | GET / DELETE /api/space/apps/{id}/logs | the app's stdout/stderr — where to look when the app dies | |
| Env discovery | GET /api/space/apps/{id}/env | returns appDir plus every endpoint above, so the UI hardcodes nothing | |
| Calendar | GET / POST /api/space/calendar/events, GET .../events/search, GET / PATCH / DELETE .../events/{id}, POST .../events/{id}/reminder | idempotent sync: remember the event_id and update it instead of inserting twice | study, google-workspace |
| Wiki | GET /api/wiki/tree, GET / PUT / DELETE /api/wiki/file, GET /api/wiki/search + stats, history, tags, POST /api/wiki/mkdir, POST /api/wiki/upload (12 MB), DELETE /api/wiki/dir | SenClaw's git-backed knowledge base | ai-chat, ai-office, crm, moltbook, search, video-cloner, zeach |
| External links | POST /api/ui/open-url | mandatory for every external link opened from an app UI | every app with external links |
| App management | GET /api/space/apps, POST /api/space/apps/install-zip (body up to 64 MB), POST .../register-local, POST .../{id}/update and .../restart, DELETE .../{id}, GET .../updates | the install and update machinery — see Publishing a Space App | CLI + web |
A calendar event's link field accepts internal routes only, of the form /space/app/<id>?... — clicking the event (or its reminder) deep-links straight into the right screen of the app (study links each session with ?session=<id>).
MCP, in both directions
Direction 1 — the app exposes tools to agents (the main one):
- Declared in the manifest with
mcp.autoRegister: true— the daemon registers it when the app runs. The app servesGET /api/mcp/sse(the SSE channel, at the path the manifest names) andPOST /api/mcp/message— the JSON-RPC sibling that actually carries initialize, tools/list and tools/call. - Agents — every chat channel plus Claude Code — call tools as
mcp__<mcp.name>__<tool>. The naming rules are mandatory:mcp.nameis<app-id>-mcp, tools are snake_case behind one consistent prefix (myapp_list,myapp_add). Never invent shortened names. - Runtime checks:
GET /api/mcp-servers(every server with status),GET /api/space/apps/{id}/mcp(manifest block, live status, tools). mcp.toolAliasesin the manifest renames or overrides tools — aliases import disabled, and the user enables them under Plugins, Alias.- The fastest start is to copy
apps/mindmap/src/mcp.rsfrom the SenClaw repo — the reference implementation — and swap the tool list.
Direction 2 — dynamic registration, for apps that skip autoRegister or UI-only apps adding a server:
POST /api/space/apps/{id}/mcp/register
{ "name": "my-app-mcp", "transport": "http" | "sse" | "stdio",
"url": "...", "command": "...", "args": [], "env": {}, "use_tools": [], "enabled": true }The daemon adds SENCLAW_SPACE_APP_ID to the registered server's environment on its own.
App to app: one app calls another's tools by POSTing JSON-RPC straight to http://127.0.0.1:<other-app-port>/api/mcp/message — the search app's federated-search pattern. Trust rides on loopback, so this only works on the same machine.
agent.run and MCP: the agent spawned by agent.run receives the app's own MCP automatically — an app can talk to its own tools through an agent without calling them directly.
The iframe bridge (postMessage)
The app's UI loads through the proxy, so it is same-origin with the SenClaw web UI — the simplest correct move is to fetch the REST and bridge endpoints above directly; no postMessage needed. The protocol exists for apps that want structured theme and env:
iframe → host : { type: 'senclaw:ready' }
host → iframe: { type: 'senclaw:init', appId, theme: 'dark'|'light',
env: { apiBase, coreBase, staticBase, bridgeEndpoint,
configEndpoint, sqliteEndpoint, mcpRegisterEndpoint },
capabilities: ['llm.request','mcp.call','space.rest'] }
host → iframe: { type: 'senclaw:theme', theme } // every time the user switches theme
iframe → host : { type: 'senclaw:request', requestId, action, payload } // forwarded into the bridge
host → iframe: { type: 'senclaw:response', requestId, ok, payload | error }The outer query string (/space/app/my-app?d=3) is forwarded into the iframe untouched — that is the deep-link channel from chat and calendar. Ignore params you do not recognise.
Widgets: app UI inside the chat box
A widget is a card rendered inline in chat (Web and Desktop) and on the dashboard. An app has two ways in.
Path 1 — built-in kinds (chart, image, clock, weather, video, audio): the agent emits these itself; the app only supplies the data or a same-origin URL. The drawio pattern: an MCP tool returns svg_path — a URL through /api/space/apps/{id}/proxy/... — with a description telling the agent to pass it to emit_widget kind image, and the diagram lands in chat with no further work.
Path 2 — the app kind: an iframe widget served by the app itself, declared in the manifest:
// senclaw-manifest.json
"widgets": [
{
"id": "pipeline", // required — the full id becomes "<app-id>.pipeline"
"name": "Sales funnel",
"description": "Write this WELL — agents read it via widget_list to decide when to embed",
"entryUrl": "/widget/pipeline.html", // an HTML page the app serves
"size": "medium", // small | medium | large | tall
"refreshMs": 30000, // hint for the client to reload
"surfaces": ["dashboard", "chat"], // DEFAULT is ["dashboard"] — add "chat" or it never shows in chat
"params": { // JSON Schema — the daemon validates on emit
"type": "object",
"properties": { "stage": { "type": "string" } },
"required": ["stage"]
},
"textFallback": "Funnel at stage {stage} — open the app to view" // {param} template for text-only channels
}
]How it runs:
- The agent calls the emit_widget tool with
{ kind: "app", widget: "<app-id>.<widget-id>", params: {...} }— it passeswidgetandparams, neverdata. The widget_list tool shows agents the catalog with each params schema. - The daemon resolves the id in the widget registry (the
widgets[]of every enabled app, plus plugin widgets), validatesparamsagainst the schema, then builds the entry: the app's runtime origin (falling back to/api/space/apps/<id>/proxy) with params attached as a query string — params can never change the path. - The widget is persisted (the
chat_widgetstable, FIFO per jid; history loads return it as role "widget") and pushed as a WebSocket frame{ "type": "chat:widget", ... }— Web and Desktop render a sandboxed iframe. - On text-only channels (Telegram, Zalo — any jid that is not web or app), the WebSocket cannot reach, so the daemon sends
textFallbackwith the{param}slots filled, as a normal message with a deep link into the app. In practice that makes textFallback required if your users chat from external channels.
What the app ships:
- A small self-contained HTML page at
entryUrl(widget-pack keeps them inweb/widget/*.html, built into web_dist), which reads its params from the query string and renders inside the declared size. - A skill that teaches the agent to use the widgets — the widget-pack pattern: frontmatter allows the emit_widget and widget_list tools, and the body shows a real emit example per widget with real params. Without the skill, agents rarely think to embed one.
An emit, agent-side:
emit_widget { "kind": "app", "widget": "widget-pack.countdown",
"params": { "to": "2026-12-31", "label": "New Year countdown" } }Administration and limits:
- Catalog:
GET /api/widgets(with an enabled flag per widget); toggle withPUT /api/widgets/:id. Users manage widgets under Plugins, Widget. A disabled widget makes the emit fail — the app must tolerate that. - Agents can also embed widgets in reply text through chart and widget code fences — but kind
appmust go through the tool, because registry resolution is daemon-side. - Eleven shipped apps declare
widgets[]today: ai-office, clock, crm, email, hub, luna-calendar, mindmap, moltbook, predict, rule-engine and widget-pack — the last being the pure-widget sample app (countdown, progress, data table).
Who uses what (survey of shipped apps, August 2026)
| Surface | Apps using it |
|---|---|
| SDK SpaceClient (Rust) | autotest, cafe, capital, code-ide, crm, docx-editor, drawio, facebook-pro, ipscout, luna-calendar, mindmap, mini-browser, news, ontology, predict, search, secscan, sentinel, shopee, skill-builder, thinking, warehouse, youtube, zeach |
| Raw bridge calls (no SDK) | ai-chat, ai-office, ba, deepwiki, discuss, kaen, lakehouse, moltbook, rewrite-story, rule-engine, study, tiktok-activity, video-cloner, video-flow |
| agent.run | ai-chat (per-bot tools and model), ai-office (personas), discuss (multi-agent rooms), rule-engine (AI nodes), search, video-flow, zeach (deep research) |
| knowledge.* | ai-chat, ai-office, crm, lakehouse, moltbook, rule-engine, search, tiktok-activity, youtube, zeach |
| Wiki REST | ai-chat, ai-office, crm, moltbook, search, video-cloner, zeach |
| Calendar REST | study (study schedule with lesson deep-links), google-workspace |
| Widget kind app (manifest widgets[]) | ai-office, clock, crm, email, hub, luna-calendar, mindmap, moltbook, predict, rule-engine, widget-pack |
| Built-in widgets via same-origin URL | drawio (SVG to emit_widget kind image) |
| App-to-app MCP | search (calls other apps' /api/mcp/message) |
| usage.report | no app yet — the API is ready for direct provider callers |
| Direct provider calls (the exception) | video-cloner (Gemini video — the bridge cannot carry video) |
To see a living example of any pattern, open the app in the right column — they live under apps/* in the SenClaw repo.