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.

SDKPackageInstallGuide
Rustapp-space-sdk (in the SenClaw repo)git dependency, see Building a Space AppBuilding a Space App
Node / TypeScript@senclaw/space-sdk (npm)npm install @senclaw/space-sdkSpace App in Node
Pythonsenclaw-space-sdk (PyPI)pip install senclaw-space-sdkSpace App in Python
Gogithub.com/NortonBen/SenClaw/senclaw-sdk/senclaw-app-sdk-gogo get that pathSpace 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

RustThe app lives inside the SenClaw monorepo under apps/*, or wants the smallest footprint and fastest start
NodeThe 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
PythonThe 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
GoA 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.

RustNodePythonGo
llm.request with system / prompt / maxTokens / profileyesyesyesyes
Full reply: text + model + finish + usagellm_request_usagellmDetailedllm_detailedLLMDetailed
agent.run — a full agent turn with toolsraw HTTPyesyesyes
knowledge.save / .search / .recallyesyesyesyes
usage.reportyesyesyesyes
List / switch the active modelyesyesyesyes
capabilities — ask the daemon what it supportsraw HTTPyesyesyes
Per-app config KV + hosted SQLiteraw HTTPyesyesyes
Register an MCP serverraw HTTPyesyesyes
Built-in MCP server harnessuses rmcp/mcpMcpServerMCPServer
Dispatch: poll / heartbeat / reclaim / finalizeyes/dispatchdispatch.py/dispatch
Manifest types, validation and a CLIhand-written/lifecycle + senclaw-manifestsenclaw_space.manifestmanifest + cmd/senclaw-manifest
Bind host, PORT, graceful stopmanual/lifecycleserve()Serve()
Access token on every daemon callyesyesyesyes
Guard closing the app's own port to all but the daemonauth::require_app_tokenrequireAppTokenrequire_app_token=TrueRequireAppToken

"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.

VariableMeaning
PORTThe port assigned to this launch. Always prefer it over the manifest's port
SENCLAW_SPACE_APP_IDThis app's id, the one in the manifest
SENCLAW_BASE_URLDaemon base URL, default http://127.0.0.1:18788
SENCLAW_BIND_HOSTInterface to bind. Absent means loopback, and loopback is the right default
SENCLAW_TOKEN_ACCESS_APPThis app's access token — its identity to the daemon
SENCLAW_API_VERSIONSpace-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:

  1. Bind loopback. A Space App authenticates nothing of its own — the daemon reaches it over 127.0.0.1 and its UI is same-origin. Binding 0.0.0.0 publishes the whole REST and MCP surface to the network. (Next.js binds 0.0.0.0 unless you pass -H.)
  2. Handle SIGTERM. A session app is stopped when it goes idle: SIGTERM to the process group, SIGKILL about two seconds later. Whatever was not flushed is gone.
  3. 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.
  4. A truncated reply is an error. finish == "length" means the model hit maxTokens mid-sentence, and half an answer is indistinguishable from a short one. The plain llm call throws; the detailed variant hands you finish so you can decide.
  5. Pin your app's model per call, never globally. The profile field picks a model for that one call; the active model is shared with the agent and every other app.
  6. Knowledge is partitioned by app. Omit space and 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
  }
}
backgroundsession (default)
At daemon startupstarts immediatelydoes not start
Runs whenalwaysthe user opens the app, or an agent calls one of its MCP tools
Stops whenthe daemon stops, or the user presses StopidleTimeoutSecs after the last request, default 60
Supervisor restarts it after a crashyesno — "not running" is the resting state
Forapps that work on their own: inbound messages, schedules, a WebSocket an extension dialseverything 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:

runnerPrepare step
binary, shellnothing — whatever start names must already be runnable
nodenpm ci --omit=dev with a lockfile, pnpm or yarn with theirs, otherwise npm install --omit=dev. runtime.install overrides
pythoncreates .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