Space App SDKs
One daemon contract, four SDKs — Rust, Node, Python and Go: what each covers, what every app shares, and how to choose.
Building a Space App writes the app in Rust, because most of the apps shipping inside SenClaw are Rust. The contract itself is HTTP and JSON, so it has nothing to do with the language — and three more SDKs implement it, each published to its own registry so an app in its own repo installs one package instead of cloning SenClaw.
| SDK | Package | Install | Guide |
|---|---|---|---|
| Rust | app-space-sdk (in the SenClaw repo) | git dependency, see Building a Space App | Building a Space App |
| Node / TypeScript | @senclaw/space-sdk (npm) | npm install @senclaw/space-sdk | Space App in Node |
| Python | senclaw-space-sdk (PyPI) | pip install senclaw-space-sdk | Space App in Python |
| Go | github.com/NortonBen/SenClaw/senclaw-sdk/senclaw-app-sdk-go | go get that path | Space App in Go |
Every one of them ships a runnable example app under examples/ — installing that with register-local gives you a working app in the daemon with nothing to build first.
Which one to pick
| Rust | The app lives inside the SenClaw monorepo under apps/*, or wants the smallest footprint and fastest start |
| Node | The app is mostly a web UI, or leans on an npm library. The only SDK with an MCP harness built on the official MCP SDK |
| Python | The app leans on the Python ecosystem — ML, scraping, data. The SDK is standard library only, so an app with no other dependency has no install step at all |
| Go | A single static binary with no runtime to install on the user's machine — but a Go app gets no install step from the daemon, so it ships pre-built or compiles inside start |
Parity
The Rust column is the reference; the others follow it.
| Rust | Node | Python | Go | |
|---|---|---|---|---|
llm.request with system / prompt / maxTokens / profile | yes | yes | yes | yes |
| Full reply: text + model + finish + usage | llm_request_usage | llmDetailed | llm_detailed | LLMDetailed |
agent.run — a full agent turn with tools | raw HTTP | yes | yes | yes |
knowledge.save / .search / .recall | yes | yes | yes | yes |
usage.report | yes | yes | yes | yes |
| List / switch the active model | yes | yes | yes | yes |
capabilities — ask the daemon what it supports | raw HTTP | yes | yes | yes |
| Per-app config KV + hosted SQLite | raw HTTP | yes | yes | yes |
| Register an MCP server | raw HTTP | yes | yes | yes |
| Built-in MCP server harness | uses rmcp | /mcp | McpServer | MCPServer |
| Dispatch: poll / heartbeat / reclaim / finalize | yes | /dispatch | dispatch.py | /dispatch |
| Manifest types, validation and a CLI | hand-written | /lifecycle + senclaw-manifest | senclaw_space.manifest | manifest + cmd/senclaw-manifest |
Bind host, PORT, graceful stop | manual | /lifecycle | serve() | Serve() |
| Access token on every daemon call | yes | yes | yes | yes |
| Guard closing the app's own port to all but the daemon | auth::require_app_token | requireAppToken | require_app_token=True | RequireAppToken |
"raw HTTP" is not a missing capability — a Rust app posts to /api/space/apps/<id>/bridge itself, because SpaceClient::bridge_action is private. The Rust SDK's events, fs and net modules have no equivalent elsewhere and need none: they reproduce for Rust what Node, Python and Go already have in their standard libraries.
The environment every app is launched with
Identical in all four languages — each SDK just reads it for you.
| Variable | Meaning |
|---|---|
PORT | The port assigned to this launch. Always prefer it over the manifest's port |
SENCLAW_SPACE_APP_ID | This app's id, the one in the manifest |
SENCLAW_BASE_URL | Daemon base URL, default http://127.0.0.1:18788 |
SENCLAW_BIND_HOST | Interface to bind. Absent means loopback, and loopback is the right default |
SENCLAW_TOKEN_ACCESS_APP | This app's access token — its identity to the daemon |
SENCLAW_API_VERSION | Space-App API contract version, currently 2 |
Six rules the SDKs encode for you
These are the same in every language, and each exists because the failure is silent:
- Bind loopback. A Space App authenticates nothing of its own — the daemon reaches it over
127.0.0.1and its UI is same-origin. Binding0.0.0.0publishes the whole REST and MCP surface to the network. (Next.js binds0.0.0.0unless you pass-H.) - Handle SIGTERM. A
sessionapp is stopped when it goes idle: SIGTERM to the process group, SIGKILL about two seconds later. Whatever was not flushed is gone. - A failed bridge action arrives as HTTP 200, carrying
{"status": "error", "message": ...}. Checking only the HTTP status turns a dead provider into an empty string, which reads downstream as "the model had nothing to say". Every SDK raises instead. - A truncated reply is an error.
finish == "length"means the model hitmaxTokensmid-sentence, and half an answer is indistinguishable from a short one. The plainllmcall throws; the detailed variant hands youfinishso you can decide. - Pin your app's model per call, never globally. The
profilefield picks a model for that one call; the active model is shared with the agent and every other app. - Knowledge is partitioned by app. Omit
spaceand you get the app's own private partition, named after the app id, so an app that never passes one can neither read nor pollute anybody else's memory.
Details of each action, with payloads and limits, are on The Space App API.
The app's access token
The daemon mints one access token per installed app and puts it in the launched process's environment. It is the app's identity: a token is bound to one app id, and using it against another is refused. Without it, any local process that knows an app's id — which is public — could read that app's settings, query its database and drive its AI bridge.
Outbound is automatic. Every SDK client reads SENCLAW_TOKEN_ACCESS_APP and sends it, plus X-SenClaw-Api-Version, on every daemon call. In a browser there is no token by design: the app's page is trusted same-origin, and a secret handed to page JS is a secret in every extension the user has installed.
Inbound is opt-in. An app's own REST and MCP endpoints have no authentication: the port is open to every process on the machine. Turn on the guard and the only caller that gets through is the daemon, whose proxy stamps the token on everything it forwards — the UI iframe, the app's own fetches, MCP tool calls.
Two things are never refused: a missing token in the environment (that is the app run by hand outside SenClaw, and 401ing the health check would make it look permanently down), and the paths you exempt. A daemon serving an older contract version still answers; asked for a version it does not implement, it replies 426 rather than half-answering.
The lifecycle block, in any language
{
"runtime": {
"kind": "server",
"mode": "session", // "background" | "session" (default "session")
"runner": "python", // "binary" | "node" | "python" | "shell" — omit when inferable
"start": "python main.py",
"install": "pip install -r requirements.txt", // runs once after install/update
"venv": true, // default true for the python runner
"healthPath": "/api/status",
"port": 4810,
"idleTimeoutSecs": 60 // session only; the floor is 15
}
}background | session (default) | |
|---|---|---|
| At daemon startup | starts immediately | does not start |
| Runs when | always | the user opens the app, or an agent calls one of its MCP tools |
| Stops when | the daemon stops, or the user presses Stop | idleTimeoutSecs after the last request, default 60 |
| Supervisor restarts it after a crash | yes | no — "not running" is the resting state |
| For | apps that work on their own: inbound messages, schedules, a WebSocket an extension dials | everything else |
A session app keeps its tools in every agent's roster while stopped: the tool list is cached on disk, and the registered MCP URL points at the daemon's proxy, which starts the app before forwarding. That is why on-demand is not just "don't launch at boot".
The trap: a misspelled mode ("backgroud", "always-on") is not an error anywhere — it falls back to session, and an app meant to run 24/7 quietly stops after a minute. Validate the manifest; each SDK ships the check as a one-line command.
What the daemon installs before the first launch
POST /api/space/apps/register-local {"path": "/path/to/app"}Registering a local directory is how you test in any language. What happens next depends on runner:
runner | Prepare step |
|---|---|
binary, shell | nothing — whatever start names must already be runnable |
node | npm ci --omit=dev with a lockfile, pnpm or yarn with theirs, otherwise npm install --omit=dev. runtime.install overrides |
python | creates .venv inside the app directory, then installs requirements.txt (or runtime.install) into it, and runs the app with .venv/bin first on PATH |
Python gets a venv and Node does not because npm install writes into node_modules in the app directory by design, while pip install writes into whichever interpreter it finds — usually the user's system Python, where one app's pins quietly become every app's pins.
The stamp that decides whether to re-run is a hash of the contents of package.json / the lockfile / requirements.txt plus the command itself. Unpacking an update rewrites every file's mtime, so an mtime-keyed stamp would reinstall on every update. The prepare step runs outside the app's sandbox — installing dependencies needs network and write access to the app directory.
Go is the one to watch: it has no prepare step. An install command like go build -o app . is silently skipped, and then start points at a binary nobody built. The Go page covers the two shapes that actually work.
requires — what the machine must have
"requires": {
"node": ">=18",
"python": ">=3.10",
"bin": ["ffmpeg", "git"],
"optionalBin": ["yt-dlp"],
"env": ["SOME_TOKEN"],
"os": ["macos", "linux"]
}Checked twice: at install time (the result is in the install response and at GET /api/space/apps/:id/requirements) and again before every launch, because the install-time answer was only true for that machine on that day. Anything mandatory that is missing stops the app from starting, with a sentence a human can read rather than exit 127 buried in a log. Version ranges compare numerically — 3.9 does not satisfy >=3.10 — and a range the daemon cannot parse is treated as satisfied. optionalBin and optionalEnv report without blocking.
The sandbox block an app can declare for itself is on Sandboxing a Space App.
Publishing an app that has no binary
A Node or Python app ships source, so one artifact runs everywhere. Publish it with platform any (all and universal normalise to the same thing) and the installer will take it on any machine: it looks for an exact platform match first and falls back to the portable artifact. A Go app compiled per target publishes one artifact per platform id, exactly like a Rust app.
Everything else about the release — senclaw-hub.json, tokens, the 50 MB upload cap, updates — is the same in every language: Publishing a Space App.
Next
- Space App in Node —
@senclaw/space-sdk, the MCP harness, dispatch - Space App in Python — standard library only, venv behaviour
- Space App in Go — one binary, and the missing install step
- The Space App API — every action and endpoint the daemon offers