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-sdk

Python 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

SenclawSpacethe daemon's API for this app: settings, its own SQLite database, the AI bridge
McpServerthe 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.manifestbuilding and checking senclaw-manifest.json
senclaw_space.dispatchbeing 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:

ArgumentEffect
routesmaps (method, path) to a handler. A path ending in /* matches by prefix and the handler gets the full path
health_paththe path runtime.healthPath names. A default {"ok": true} answers it only if you registered no route of your own there
static_dirserves the UI, with a path-traversal guard and an index.html fallback so a client-side router works
mcp_path + mcp_handlerthe app's MCP endpoint: JSON-RPC over HTTP POST
on_shutdownruns on SIGTERM before the listener closes
default_portthe fallback when PORT is absent — bind host and port both come from the environment first
require_app_token + auth_skip_pathsclose 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 endpoint

Three places this goes wrong:

  • A truncated reply raises. llm() fails on finish == "length", which means the model hit max_tokens mid-sentence — and a fragment is indistinguishable from a short answer. Use llm_detailed() to handle it yourself; its usage is None when 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 raises SenclawError; 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 profile to llm(); set_active_model moves 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_content

Return 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:

  1. 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;
  2. creates .venv inside the app directory and installs requirements.txt (or runtime.install) into it, if the app has either — never into the user's system Python;
  3. runs runtime.start with .venv/bin first on PATH.

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

It 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