Space App in Python
Build a Space App in Python with senclaw-space-sdk: standard library only, one serve() call for health, UI, REST and MCP, and a venv the daemon manages.
senclaw-space-sdk is the Space App contract in Python, written against the standard library only. That is a deliberate design choice with a practical consequence: an app with no other dependency has no install step at all, so the daemon starts it in the time Python takes to boot.
pip install senclaw-space-sdkPython 3.10 or newer. Space App SDKs covers what every language shares — the injected environment, the access token, lifecycle modes, the prepare step.
What the package gives you
SenclawSpace | the daemon's API for this app: settings, its own SQLite database, the AI bridge |
McpServer | the app's tools, exposed to agents over MCP — no MCP SDK needed |
serve() | one HTTP server for health, the UI, the REST API and MCP, with SIGTERM handling |
senclaw_space.manifest | building and checking senclaw-manifest.json |
senclaw_space.dispatch | being driven by the daemon's work dispatcher (imported separately, on purpose) |
A minimal app
main.py:
from senclaw_space import McpServer, SenclawSpace, serve
space = SenclawSpace() # reads SENCLAW_SPACE_APP_ID + SENCLAW_BASE_URL
mcp = McpServer("my-app-mcp")
@mcp.tool("myapp_summarise", "Summarise a piece of text", {
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
})
def summarise(args):
# The app NEVER holds a provider API key — every model call goes through
# the daemon, using the provider the user configured.
return space.llm(f"Summarise in three sentences:\n\n{args['text']}", max_tokens=800)
serve(
{("GET", "/api/status"): lambda req: {"ok": True}},
health_path="/api/status",
mcp_path="/api/mcp/sse",
mcp_handler=mcp.handle,
static_dir="web",
on_shutdown=lambda: db.close(),
default_port=4810,
)senclaw-manifest.json:
{
"id": "my-app",
"name": "My App",
"description": "One line on what the app does — the registry rejects packages without one.",
"icon": "🐍",
"runtime": {
"kind": "server",
"mode": "session",
"runner": "python",
"start": "python main.py",
"healthPath": "/api/status",
"port": 4810,
"idleTimeoutSecs": 60
},
"requires": { "python": ">=3.10" },
"integration": { "type": "iframe", "url": "/" },
"mcp": {
"name": "my-app-mcp",
"transport": "http",
"path": "/api/mcp/sse",
"autoRegister": true
}
}Run it by hand, then install it into a running daemon:
SENCLAW_SPACE_APP_ID=my-app PORT=4810 python main.py
curl -X POST http://127.0.0.1:18788/api/space/apps/register-local \
-H 'Content-Type: application/json' -d "{\"path\": \"$(pwd)\"}"A complete example app — two MCP tools, a UI page, health, SIGTERM — is in senclaw-sdk/senclaw-app-sdk-python/examples/space-app-python-demo in the SenClaw repo.
What serve() does
One listener carries everything the daemon expects from an app, on the port it handed out:
| Argument | Effect |
|---|---|
routes | maps (method, path) to a handler. A path ending in /* matches by prefix and the handler gets the full path |
health_path | the path runtime.healthPath names. A default {"ok": true} answers it only if you registered no route of your own there |
static_dir | serves the UI, with a path-traversal guard and an index.html fallback so a client-side router works |
mcp_path + mcp_handler | the app's MCP endpoint: JSON-RPC over HTTP POST |
on_shutdown | runs on SIGTERM before the listener closes |
default_port | the fallback when PORT is absent — bind host and port both come from the environment first |
require_app_token + auth_skip_paths | close the app's own port to everything but the daemon |
A handler returns a dict, list or string and gets JSON or text with 200; return Response(body, status=...) when you need control. A handler that raises becomes a 500 with the message — an app bug never takes the server down.
SIGTERM matters here. A session app is stopped when it goes idle: the daemon signals the process group, then SIGKILLs two seconds later. serve() installs the handler and runs on_shutdown; do not block in it for longer than that. Serving from a thread other than the main one is allowed but logs a warning, because only the main thread can install signal handlers — such an app gets no graceful stop.
Daemon services
sc = SenclawSpace(app_id="my-app")
sc.capabilities() # what this daemon supports
sc.llm(prompt, system=..., max_tokens=..., profile=...) # -> str
sc.llm_detailed(prompt) # -> text, model, finish, usage
sc.agent("do the thing", tools=["WebSearch"]) # a full agent turn
sc.knowledge_save("remember this", space="proj", tags=["x"])
sc.knowledge_search("a question", space="proj") # raw hits
sc.knowledge_recall("a question", space="proj") # synthesized answer
sc.get_config("prefs", default={}) # the same KV the app UI uses
sc.set_config("prefs", {"days": 30})
sc.sqlite("SELECT * FROM runs WHERE at > ?", [cutoff])
active, models = sc.list_models()
sc.usage_report(model, provider, input_tokens, output_tokens)
sc.register_mcp({"transport": "sse", "url": "..."})
sc.core("/api/wiki/tree") # any other daemon endpointThree places this goes wrong:
- A truncated reply raises.
llm()fails onfinish == "length", which means the model hitmax_tokensmid-sentence — and a fragment is indistinguishable from a short answer. Usellm_detailed()to handle it yourself; itsusageisNonewhen the provider reported none, meaning unknown rather than zero. - A failed bridge action still answers HTTP 200, with
{"status": "error"}in the body. The SDK raisesSenclawError; if you call the bridge by hand, check that field or a dead provider reads as an empty string. - Pin the model per call, not globally. Pass
profiletollm();set_active_modelmoves the active model, which the agent and every other app share.
SenclawError carries the daemon's HTTP status in .status, so a 404 from the config KV is distinguishable from a real failure (get_config already does that for you and returns your default).
MCP without an MCP SDK
McpServer implements the three JSON-RPC methods SenClaw's client actually sends — initialize, tools/list, tools/call — and nothing else:
mcp = McpServer("my-app-mcp", "1.0.0")
@mcp.tool("myapp_list", "List the tracked items", {"type": "object", "properties": {}})
def list_items(args):
return space.sqlite("SELECT id, name FROM items ORDER BY id DESC LIMIT 50")
# A readable sentence beats a JSON-RPC error: the agent has to know what to do
# differently, and an error code tells it nothing.
from senclaw_space import error_contentReturn a dict, list or string and the SDK wraps it as tool content; return error_content("...") to fail a call in a way the agent can act on. Keep mcp.name at <app-id>-mcp and tool names snake_case behind one prefix — agents call them as mcp__my-app-mcp__myapp_list, and Aliasing MCP tools is how to rename them safely.
The venv the daemon builds
When the manifest declares runner: "python" (or start begins with python), the daemon:
- checks
requires.python— if it is not satisfied the app does not start, and the reason is in the error message rather than buried in a log; - creates
.venvinside the app directory and installsrequirements.txt(orruntime.install) into it, if the app has either — never into the user's system Python; - runs
runtime.startwith.venv/binfirst onPATH.
Step 2 re-runs only when the contents of requirements.txt or the install command change, so unpacking an update (which rewrites every mtime) does not trigger a reinstall. Set "venv": false if the app manages its own environment.
In practice: an app that sticks to the standard library skips all of this. If you do add dependencies, pin them — the venv is per app, so pinning costs the user nothing.
Dispatch
Let the daemon's dispatcher drive the app:
from senclaw_space.dispatch import DispatchProvider, dispatch_routes
class Store(DispatchProvider):
def claim_ready(self, capacity): ... # must be atomic
def finalize(self, item_id, outcome): ...
serve(routes={**dispatch_routes(Store()), ("GET", "/api/status"): status})heartbeat and reclaim have no-op defaults. Field names are snake_case (depends_on, timeout_secs, item_id) because the engine parses them with serde — camelCase is dropped silently, and it surfaces as a dependency that never held rather than as an error.
Manifest validation
python -m senclaw_space.manifest senclaw-manifest.jsonIt catches exactly the silent-failure class: "mode": "backgroud" (misspelled, so it becomes session and an always-on app quietly stops), network: "hosts" with an empty host list (the app gets no network at all), autoRegister with no path. The same checks are callable as senclaw_space.manifest.validate(data), and the builders runtime(), requires(), sandbox() and manifest() assemble the file in code.
Closing the app's own port
serve(
routes,
health_path="/api/status", # always exempt
require_app_token=True,
auth_skip_paths=["/public/*"], # something dials this directly
)The comparison is constant-time, and the token is also accepted as an app_token query parameter for clients that cannot set headers. With no token in the environment the guard is inert, so a bare python main.py still works — Space App SDKs has the whole token picture.
Shipping it
A Python app ships source: main.py, requirements.txt if it has one, the manifest, web_dist/ and any skills, flat at the zip root. The daemon builds the venv itself after unpacking, so do not ship .venv. One artifact runs everywhere — publish it with platform any. The rest of the release process is language-independent: Publishing a Space App.
Next
- The Space App API — every action, endpoint and widget surface the daemon offers
- Space App SDKs — the shared contract, and the other three languages
- Sandboxing a Space App — declaring the app's own confinement
- Monitoring a Space App — what the process view shows when it misbehaves