Building a Space App
A Space App from zero: its own repo on app-space-sdk, a loopback server with an embedded UI and MCP, packed into an installable zip.
A Space App is an app the SenClaw agent installs into its Space: a standalone HTTP server binary, a static web UI, and a manifest, shipped together as one zip. The daemon downloads the zip, unpacks it, runs runtime.start, health-checks runtime.healthPath, embeds the UI in an iframe, and registers the app's MCP server. This page covers the build half — repo, server, manifest, zip — in a repository of its own, outside the SenClaw monorepo. The Space App API covers everything the daemon offers a running app, and Publishing a Space App covers getting it onto the registry and shipping updates.
your app repo (my-app) hub (senclaw.bacnd.com) user's machine
┌──────────────────────┐ publish ┌───────────────────────┐ install/ ┌──────────────────┐
│ src/ web/ skills/ │ ───────────► │ /api/v1/publish │ update │ SenClaw daemon │
│ senclaw-manifest.json│ (hub CLI) │ /api/v1/packages/... │ ─────────► │ /api/space/apps/*│
│ senclaw-hub.json │ │ /dl/... (artifact) │ (hub CLI / │ runs app :port │
│ scripts/pack.sh ─►zip│ └───────────────────────┘ web badge) └──────────────────┘
└──────────────────────┘Three facts shape everything below:
- An app is an independent HTTP server. The daemon starts it, health-checks it, and fronts its UI — the app never listens beyond loopback on its own.
- Every AI service goes through the daemon. The bridge carries
llm.request,agent.runand the knowledge actions — the app never holds a provider API key. See The Space App API. - The registry is immutable. A published
name@versionnever changes; an update is a new version. See Publishing.
Prerequisites
| You need | Notes |
|---|---|
senclaw binary on PATH | Ships inside the SenClaw desktop app (Resources), or build it with cargo build --release in the SenClaw repo |
| Rust 1.85 or newer | app-space-sdk uses edition 2024 |
| Node 18 or newer | Builds the web UI (Vite) |
| An account here, with a username | Sign in on the login page and set a username — without one, publishing fails with 403 no_handle |
A publish token snc_pat_... | Mint one under API tokens with the publish scope |
| A running SenClaw daemon | To test installs. The UI server defaults to http://127.0.0.1:18788 (SENCLAW_UI_PORT if changed) |
The account and token only matter at publish time — Publishing a Space App picks them up.
Repo layout
mkdir my-app && cd my-app && git initThe standard structure, matching the apps that ship in the SenClaw monorepo (apps/mindmap there is the reference to compare against):
my-app/
├── Cargo.toml
├── src/
│ ├── main.rs # axum server: REST + MCP + serves the static UI
│ └── mcp.rs # tools/list + tools/call
├── web/ # React + Vite (the UI embedded in the iframe)
├── skills/
│ └── my-app-manager/SKILL.md
├── personas/
│ └── my-app-keeper.md
├── senclaw-manifest.json # RUNTIME manifest — the daemon reads it to install and run
├── senclaw-hub.json # HUB metadata — generated by senclaw hub init
├── scripts/pack.sh # build + zip
├── README.md # uploaded as the package page on the hub
└── .gitignoreA minimal .gitignore:
/target
/release
*.zip
web/node_modules
web/distCreate the GitHub repository and push — its URL later goes into senclaw-hub.json as repo_url and shows on the package page:
gh repo create <you>/my-app --private --source . --pushDepending on app-space-sdk
The SDK lives inside the SenClaw repository as a workspace member, so an external repo points at it one of two ways.
Option A — git dependency, the right default for a standalone repo:
[package]
name = "my-app"
version = "0.1.0"
edition = "2021"
[dependencies]
app-space-sdk = { git = "https://github.com/NortonBen/SenClaw.git", branch = "main" }
tokio = { version = "1", features = ["full"] }
axum = { version = "0.7", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.32", features = ["bundled"] }
anyhow = "1"Cargo finds the app-space-sdk package inside that repo's workspace on its own. If the repo is private, the build machine needs access (SSH agent or gh auth). The first fetch clones the whole SenClaw repo, which is large — and pin a commit with rev = "<sha>" so builds reproduce.
Option B — path dependency, when a SenClaw clone sits next door:
app-space-sdk = { path = "../SenClaw/app-space-sdk" }Day-to-day development is faster on B; switch to A before publishing, or keep B and accept the sibling-clone requirement — all that matters is that scripts/pack.sh builds.
The server
One rule is load-bearing: apps have no auth of their own, so the trust boundary is the loopback interface. Never hardcode 0.0.0.0 — bind SENCLAW_BIND_HOST and let 127.0.0.1 be the default:
use axum::{routing::get, Router};
#[tokio::main]
async fn main() {
// The daemon assigns PORT when it spawns the app — ALWAYS prefer it; the manifest port is the fallback.
let port: u16 = std::env::var("PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(4800);
let app = Router::new()
.route("/api/status", get(|| async { axum::Json(serde_json::json!({ "ok": true })) }))
// .route("/api/mcp/sse", ...) // the MCP endpoint — see the API page
// .route("/api/mcp/message", ...) // its JSON-RPC POST sibling
// .nest_service("/", ServeDir::new(web_dist)) // static UI next to the binary
;
// Loopback by default. SENCLAW_BIND_HOST=0.0.0.0 is an explicit opt-in.
let host = std::env::var("SENCLAW_BIND_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let listener = tokio::net::TcpListener::bind(format!("{host}:{port}")).await.unwrap();
axum::serve(listener, app).await.unwrap();
}/api/statusmust return 200 — the daemon health-checks whatever pathruntime.healthPathnames.- Static UI: serve a
web_dist/directory sitting next to the binary — that is where the zip puts it. Do not point at absolute dev paths. - Port: pick a fixed one no other app uses (existing apps occupy 4310–4760; the daemon holds 18788 and 18789) and declare exactly that port in the manifest.
The environment the daemon injects when it spawns the app:
| Env | Value | Used for |
|---|---|---|
PORT | the port the daemon assigned | binding the server — takes precedence over the manifest port |
SENCLAW_BASE_URL | http://127.0.0.1:18788 | calling back into the daemon (bridge and REST) |
SENCLAW_SPACE_APP_ID | the app's id | building bridge URLs /api/space/apps/{id}/... |
SENCLAW_BIND_HOST | not injected — read from the user's env | defaults to 127.0.0.1 |
Talking to the daemon
Everything the daemon offers a running app — the bridge (llm.request, agent.run, the knowledge actions), per-app config, hosted SQLite, calendar, wiki, MCP in both directions, and chat widgets — has its own page: The Space App API. The two-line version:
use app_space_sdk::SpaceClient;
let sc = SpaceClient::from_env(); // reads SENCLAW_BASE_URL + SENCLAW_SPACE_APP_ID
let reply = sc.llm_request_usage("system", "prompt", 4000, None).await?;
if reply.finish == "length" { /* truncated — treat it as an error */ }The app's own MCP server — name <app-id>-mcp, serving GET /api/mcp/sse plus POST /api/mcp/message — is how agents call the app's tools; the naming rules and both registration directions are on the API page too.
Beyond bridge, the SDK also ships events (an async EventEmitter), fs (UTF-8 read/write), net (TCP listeners) and dispatch (DAG workers) — see app-space-sdk/src/lib.rs in the SenClaw repo.
The runtime manifest
senclaw-manifest.json is what the daemon reads to install, run, embed and register the app. The id is the global identifier: it becomes the package name on the registry (<scope>/<id>) and the install directory name. The full shape:
{
"id": "my-app",
"name": "My App",
"description": "One line on what the app does — required; the registry rejects packages without one.",
"icon": "🧩",
"runtime": {
"kind": "server",
"start": "./my-app",
"healthPath": "/api/status",
"port": 4800
},
"integration": { "type": "iframe", "url": "/" },
"bridge": {
"postMessage": true,
"capabilities": ["space.rest", "llm.request"]
},
"mcp": {
"name": "my-app-mcp",
"transport": "http",
"path": "/api/mcp/sse",
"description": "What the tools do — agents pick tools by this; list the main tool groups.",
"autoRegister": true
},
"skills": [
{ "name": "my-app-manager", "path": "skills/my-app-manager", "triggers": ["keyword one", "keyword two"] }
],
"personas": [
{ "name": "my-app-keeper", "path": "personas/my-app-keeper.md", "description": "..." }
]
}runtime.startis a path relative to the zip root (./my-appmeans the binary sits at the root).- Write
descriptionproperly — it is both the package description on the registry and the context an agent gets about the app. - The optional
widgets[]field puts mini-UI from the app straight into the chat box — see the widgets section of The Space App API. - Links that leave the app must go through the daemon's openExternal flow (
POST /api/ui/open-url) rather than navigating the embedded webview.
Two files, two readers, deliberately: this runtime manifest is for the daemon. senclaw-hub.json — covered in Publishing a Space App — is registry metadata, and senclaw hub publish fills the registry's own manifest from the pair, so you never write that one by hand.
Packing the zip
An installable zip has a flat layout: binary, manifest, skills, personas and web_dist at the zip root. scripts/pack.sh for a standalone repo:
#!/usr/bin/env bash
# Build my-app and pack it into my-app-app.zip, installable by SenClaw.
# release/ <- flat staging dir
# my-app (release binary; manifest runtime.start = ./my-app)
# senclaw-manifest.json
# skills/ personas/
# web_dist/ (built UI — the server serves web_dist next to the binary)
# my-app-app.zip <- the artifact to install / publish
# Usage: scripts/pack.sh [--skip-build]
set -euo pipefail
APP_DIR="$(cd "$(dirname "$0")/.." && pwd)"
REL="$APP_DIR/release"
ZIP="$APP_DIR/my-app-app.zip"
BIN="$APP_DIR/target/release/my-app"
if [[ "${1:-}" != "--skip-build" ]]; then
echo "==> building web UI"
( cd "$APP_DIR/web" && npm install --silent && npm run build )
echo "==> building release binary"
( cd "$APP_DIR" && cargo build --release )
fi
[[ -f "$BIN" ]] || { echo "missing $BIN"; exit 1; }
[[ -d "$APP_DIR/web/dist" ]] || { echo "missing web/dist"; exit 1; }
echo "==> staging release/"
rm -rf "$REL" "$ZIP"
mkdir -p "$REL"
cp "$BIN" "$REL/my-app" && chmod +x "$REL/my-app"
cp "$APP_DIR/senclaw-manifest.json" "$REL/"
cp -R "$APP_DIR/skills" "$REL/skills"
cp -R "$APP_DIR/personas" "$REL/personas"
cp -R "$APP_DIR/web/dist" "$REL/web_dist"
echo "==> zipping -> $ZIP"
( cd "$REL" && zip -rq "$ZIP" . -x '*.DS_Store' )
echo "done: $ZIP ($(du -h "$ZIP" | cut -f1))"The constraints that matter:
- The default artifact name is
<id>-app.zipin the app directory —senclaw hub publishlooks for exactly that name (override it with theartifactfield insenclaw-hub.json). - Hub uploads cap at 20 MB — a local install through the daemon accepts far larger zips (~50 MB). Real app zips run 3–4 MB; if yours balloons, check for node_modules or stray assets in the zip and set
strip = true,lto = trueunder[profile.release]. - A binary only runs on the platform it was built for. The platform is recorded in
senclaw-hub.json, defaulting to the build machine —darwin-arm64, for example.
Test locally before publishing
scripts/pack.sh
curl -F "file=@my-app-app.zip" http://127.0.0.1:18788/api/space/apps/install-zipThe daemon unpacks the zip, starts the binary, health-checks it, and the app appears in the Space of the web UI. Then check quickly:
curl -s http://127.0.0.1:18788/api/mcp-servers | grep my-app-mcp # MCP registered?
curl -s http://127.0.0.1:18788/api/space/apps/my-app/logs # is the app complaining?An install like this carries no registry provenance — fine for testing, but it can never auto-update. The provenance section of Publishing a Space App explains why.
Next
- The Space App API — the full daemon surface: bridge, REST, MCP, iframe bridge, widgets
- Publishing a Space App — registry metadata, the first publish, shipping updates
- Permissions — the declaration shown before every install
- Manifest reference — the registry-side fields