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.run and the knowledge actions — the app never holds a provider API key. See The Space App API.
  • The registry is immutable. A published name@version never changes; an update is a new version. See Publishing.

Prerequisites

You needNotes
senclaw binary on PATHShips inside the SenClaw desktop app (Resources), or build it with cargo build --release in the SenClaw repo
Rust 1.85 or newerapp-space-sdk uses edition 2024
Node 18 or newerBuilds the web UI (Vite)
An account here, with a usernameSign 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 daemonTo 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 init

The 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
└── .gitignore

A minimal .gitignore:

/target
/release
*.zip
web/node_modules
web/dist

Create 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 . --push

Depending 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/status must return 200 — the daemon health-checks whatever path runtime.healthPath names.
  • 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:

EnvValueUsed for
PORTthe port the daemon assignedbinding the server — takes precedence over the manifest port
SENCLAW_BASE_URLhttp://127.0.0.1:18788calling back into the daemon (bridge and REST)
SENCLAW_SPACE_APP_IDthe app's idbuilding bridge URLs /api/space/apps/{id}/...
SENCLAW_BIND_HOSTnot injected — read from the user's envdefaults 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.start is a path relative to the zip root (./my-app means the binary sits at the root).
  • Write description properly — 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.zip in the app directory — senclaw hub publish looks for exactly that name (override it with the artifact field in senclaw-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 = true under [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-zip

The 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