Space App in Node

Build a Space App in TypeScript with @senclaw/space-sdk: the daemon client, the lifecycle helpers, an MCP server in a few lines, and dispatch.

@senclaw/space-sdk is the Space App contract, typed. The app is an ordinary HTTP server: the daemon installs it, launches it, health-checks it, embeds its UI in an iframe and puts its MCP tools in every agent's roster — and the app reaches AI, memory, storage and settings through the daemon instead of holding provider keys of its own.

npm install @senclaw/space-sdk

Node 18 or newer; the types ship with the package. Space App SDKs covers what every language shares — the injected environment, the access token, lifecycle modes, the prepare step.

What you import

ImportWhat is in itRuns where
@senclaw/space-sdkSenclawSpace — AI bridge, knowledge, config KV, per-app SQLite, model listbrowser + Node
@senclaw/space-sdk/lifecyclebind host, port, graceful shutdown, manifest types + validationNode
@senclaw/space-sdk/mcpserveSpaceMcp — a working MCP server in a few linesNode
@senclaw/space-sdk/dispatchbe driven by the daemon's autonomous work dispatcherNode
npx senclaw-manifestvalidate senclaw-manifest.json in CICLI

The root export touches nothing but fetch, so it is safe inside the app's own browser bundle. The three subpaths are Node-only — /mcp and /dispatch reach for express, and /lifecycle reads process.env and installs signal handlers.

A minimal app

import { SenclawSpace } from '@senclaw/space-sdk';
import { appPort, bindHost, onShutdown } from '@senclaw/space-sdk/lifecycle';
import express from 'express';

const space = SenclawSpace.forDaemon(process.env.SENCLAW_SPACE_APP_ID!);
const app = express();

app.get('/api/status', (_req, res) => res.json({ ok: true }));

// Loopback unless the operator explicitly opted out. A Space App authenticates
// nothing of its own, so binding 0.0.0.0 publishes its whole REST + MCP surface
// to anyone on the network.
const server = app.listen(appPort(4820), bindHost());

// A session app is stopped when it goes idle: SIGTERM, then SIGKILL ~2s later.
onShutdown(async () => { server.close(); await db.close(); });

appPort(4820) reads PORT and falls back to the manifest port; bindHost() reads SENCLAW_BIND_HOST and falls back to 127.0.0.1; onShutdown runs your callback on SIGTERM with a budget of about 1.5 seconds and then exits. Close the server and flush — do not start new work in there.

Alongside it, 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",            // "background" to stay resident
    "runner": "node",
    "start": "node server.mjs",
    "install": "npm install --omit=dev",
    "healthPath": "/api/status",
    "port": 4820,
    "idleTimeoutSecs": 60
  },
  "requires": { "node": ">=18" },
  "integration": { "type": "iframe", "url": "/" },
  "mcp": {
    "name": "my-app-mcp",
    "transport": "http",
    "path": "/api/mcp/sse",
    "autoRegister": true
  }
}

Install it into a running daemon without publishing anything:

curl -X POST http://127.0.0.1:18788/api/space/apps/register-local \
  -H 'Content-Type: application/json' -d "{\"path\": \"$(pwd)\"}"

A complete runnable app — two MCP tools, a UI page, health, SIGTERM — is in senclaw-sdk/senclaw-app-sdk/examples/space-app-node-demo in the SenClaw repo. It hand-rolls the JSON-RPC on purpose, so it doubles as a reference for what the wire protocol actually is.

Daemon services

The app never holds a provider API key. Everything below runs on whatever provider the user configured, through the daemon.

const space = SenclawSpace.forDaemon('my-app');     // server process
const space = await SenclawSpace.init();            // browser (waits for the host)

await space.capabilities();                          // what this daemon supports

const text = await space.llm({ prompt, system, maxTokens, profile });
const full = await space.llmDetailed({ prompt });    // + model, finish, usage
const done = await space.agent('Find every TODO and file them');  // full agent turn

await space.knowledgeSave('remember this', { space: 'proj', tags: ['x'] });
await space.knowledgeSearch('query', { space: 'proj' });   // raw hits
await space.knowledgeRecall('query', { space: 'proj' });   // synthesized answer

const { activeId, models } = await space.listModels();
await space.usageReport({ model, provider, inputTokens, outputTokens });
  • forDaemon() builds absolute URLs from SENCLAW_BASE_URL — use it in any server process. init() is the browser path: it posts senclaw:ready to the host, waits for senclaw:init, and falls back to GET /api/space/apps/<id>/env if no host answers within 1.5 seconds.
  • llm() throws when finish === 'length' — a truncated reply is an error, not a short answer. Use llmDetailed() to handle it yourself; its usage is null when the provider reported none, which means unknown, not zero.
  • agent() takes an optional tool allowlist as its second argument and runs a full agent turn: tools, multiple steps, far slower and far more capable than llm().
  • usageReport() swallows its own errors — accounting must never fail the work it describes — and is only for apps that called a provider directly with their own key. Everything through llm() is already recorded.
  • setActiveModel() exists but is global: the agent and every other app share the active model. Pin your app's model with profile instead.
  • space.core(path, init) reaches any other daemon endpoint — the wiki, calendar and the rest of The Space App API — with the access token and version header already attached.

Config KV and per-app SQLite

await space.setConfig('settings', { days: 30 });
const settings = await space.getConfig('settings');   // null when unset
await space.listConfig();                             // every key, with updated_at

await space.sqlite('CREATE TABLE IF NOT EXISTS runs (id INTEGER PRIMARY KEY, at INTEGER)');
await space.sqlite('INSERT INTO runs (at) VALUES (?1)', [Date.now()]);
const { rows } = await space.sqlite('SELECT * FROM runs ORDER BY id DESC LIMIT 10');

Config KV is the same store the app's own UI reads and writes, and it survives reinstalls — use it rather than a file in the app directory, which an update overwrites. SQLite is a private database per app; always pass values as parameters, never format them into the SQL.

MCP server

/mcp turns the app into an MCP server: Streamable HTTP transport, a settings tool pair over the config KV, Origin protection, and an Accept-header shim so SenClaw's Rust MCP client (which sends none) interoperates with the strict MCP TypeScript transport that would otherwise answer HTTP 406.

import { serveSpaceMcp } from '@senclaw/space-sdk/mcp';

await serveSpaceMcp({
  appId: 'my-app',
  toolPrefix: 'myapp',                 // → myapp_get_settings / myapp_set_settings
  settings: {
    key: 'my-app-settings',            // shared with the app UI
    defaults: { days: 7, mcpPort: 4820 },
    normalize,                         // coerce a stored value → typed settings
    patchSchema,                       // optional Zod shape for a typed set_settings
  },
  registerTools: (ctx) => {
    ctx.server.registerTool('myapp_sync', { /* ... */ }, async (args) => {
      const r = await ctx.space.core('space/sync/my-app', { method: 'POST' });
      return { content: [{ type: 'text', text: JSON.stringify(r) }], structuredContent: r };
    });
  },
  autoRegister: true,                  // self-register with SenClaw on startup
});

Defaults worth knowing: port PORT or 4107, path /mcp, server name <app-id>-mcp-server, tool prefix derived from the app id with non-alphanumerics turned into underscores. A fresh server and transport are created per request, so there is no session state to collide.

Already running your own MCP server? Register it instead:

await space.registerMcp({
  name: 'my-app-mcp',
  transport: 'http',
  url: 'http://127.0.0.1:4820/mcp',
  description: 'Tools exposed by My App',
});

SenClaw persists it in project scope and connects it through the normal MCP manager. Tools then resolve as mcp__my-app-mcp__myapp_sync — keep mcp.name at <app-id>-mcp and tool names snake_case behind one consistent prefix, and see Aliasing MCP tools for renaming them without breaking that.

Dispatch

Make the app drivable by the daemon's autonomous work dispatcher — it claims work from you, keeps leases alive, recovers items whose worker died, and reports terminal outcomes back:

import { dispatchRouter, outcome } from '@senclaw/space-sdk/dispatch';

app.use('/api/dispatch', await dispatchRouter({
  claimReady: (cap) => store.claim(cap.total),    // must be atomic
  finalize: (id, o) => store.close(id, o),
}));

heartbeat and reclaim are optional — a source with no lease model should not have to write two empty functions. Not on Express? handleDispatch(provider, action, body) returns {status, body} for any server. Errors become 500 {error} rather than propagating, because the engine reads that field and backs off, while an exception escaping into the HTTP layer reaches it as a connection reset it cannot tell from a crash.

Field names are snake_case (depends_on, timeout_secs, item_id) because the engine parses them with serde: camelCase is dropped silently, which surfaces as a dependency that never held rather than as an error.

Manifest validation

npx senclaw-manifest senclaw-manifest.json

Non-zero exit on the mistakes that otherwise fail silently: a misspelled runtime.mode (which quietly falls back to session, so an always-on app stops after a minute of idle), network: "hosts" with an empty allowlist (which leaves the app with no network at all), mcp.autoRegister with neither path nor url, idleTimeoutSecs below the floor of 15. The same checks are available in code:

import { defineManifest, validateManifest } from '@senclaw/space-sdk/lifecycle';

export default defineManifest({ id: 'my-app', runtime: { /* ... */ } });  // throws
const problems: string[] = validateManifest(json);                        // or inspect

Closing the app's own port

The app's REST and MCP endpoints have no authentication of their own — the port is open to every process on the machine. Mount the guard and the only caller that gets through is the daemon:

import { requireAppToken, serveSpaceMcp } from '@senclaw/space-sdk/mcp';

app.use(requireAppToken({ skip: ['/health', '/public/*'] }));

// or, through the MCP harness:
await serveSpaceMcp({ appId: 'my-app', requireAppToken: true, authSkipPaths: ['/ws/*'] });

Add anything a client dials directly, such as a browser extension's WebSocket. A missing token in the environment leaves the guard inert, so a bare npm start outside SenClaw still works — see Space App SDKs for the whole token picture.

Shipping it

A Node app ships source: server.mjs (or a build output), package.json, the lockfile, the manifest, web_dist/ and any skills, flat at the zip root. The daemon runs the install step itself after unpacking, so do not ship node_modules. One artifact runs everywhere — publish it with platform any. The rest of the release process is language-independent: Publishing a Space App.

Next