Space App in Go

Build a Space App in Go: standard library only, one Serve() call for health, UI, REST and MCP — and the missing install step that catches every Go app once.

The Go SDK is the Space App contract in Go, standard library only: no module downloads before the first build, and go build works on an air-gapped machine.

go get github.com/NortonBen/SenClaw/senclaw-sdk/senclaw-app-sdk-go
import senclaw "github.com/NortonBen/SenClaw/senclaw-sdk/senclaw-app-sdk-go"

Space App SDKs covers what every language shares — the injected environment, the access token, lifecycle modes, the prepare step.

Read this first: a Go app has no install step

The daemon runs runtime.install for the node and python runners only. A Go app that declares "install": "go build -o app ." gets that command silently skipped, and then start points at a binary nobody built. The SDK's manifest.Validate flags it; nothing in the daemon will.

Two shapes actually work:

startrunnerTrade-off
Compiled (ship this)./my-appbinary (inferred from the ./)Starts in milliseconds. You build and ship a binary per platform
go run (fine for the demo)go run .shellNo build step, but needs requires.bin: ["go"], and the first launch compiles while the daemon's 30-second health window runs

For a shipped app, build before packing — and cross-compile for whatever the user runs:

GOOS=darwin GOARCH=arm64 go build -o my-app .
GOOS=linux  GOARCH=amd64 go build -o my-app .

A minimal app

main.go:

package main

import (
	"context"
	"net/http"

	senclaw "github.com/NortonBen/SenClaw/senclaw-sdk/senclaw-app-sdk-go"
)

func main() {
	space := senclaw.MustNew() // reads SENCLAW_SPACE_APP_ID + SENCLAW_BASE_URL
	mcp := senclaw.NewMCPServer("my-app-mcp", "1.0.0")

	mcp.Tool("myapp_summarise", "Summarise a piece of text", senclaw.Schema{
		"type":       "object",
		"properties": senclaw.Schema{"text": senclaw.Schema{"type": "string"}},
		"required":   []string{"text"},
	}, func(ctx context.Context, args map[string]any) (any, error) {
		// The app NEVER holds a provider API key — every model call goes
		// through the daemon, using the provider the user configured.
		return space.LLM(ctx, senclaw.LLMRequest{
			Prompt:    "Summarise in three sentences:\n\n" + senclaw.String(args, "text"),
			MaxTokens: 800,
		})
	})

	senclaw.Serve(senclaw.Config{
		Routes: map[string]http.Handler{
			"GET /api/status": senclaw.JSONHandler(func(*http.Request) (any, error) {
				return map[string]any{"ok": true}, nil
			}),
		},
		HealthPath:  "/api/status",
		MCPPath:     "/api/mcp/sse",
		MCP:         mcp,
		StaticDir:   "web",
		DefaultPort: 4830,
		// A session app is stopped with SIGTERM and killed two seconds later.
		OnShutdown: func(ctx context.Context) error { return db.Close() },
	})
}

senclaw-manifest.json for the compiled shape:

{
  "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": "binary",
    "start": "./my-app",
    "healthPath": "/api/status",
    "port": 4830,
    "idleTimeoutSecs": 60
  },
  "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=4830 go run .

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 is in senclaw-sdk/senclaw-app-sdk-go/examples/space-app-go-demo in the SenClaw repo.

What Serve() handles

BindHost()127.0.0.1 unless SENCLAW_BIND_HOST says otherwise. An app has no authentication of its own — binding 0.0.0.0 opens its whole REST + MCP surface to the LAN
Port()the port the daemon assigns via PORT, with DefaultPort as fallback
Serve(Config)health + static + REST + MCP on one port, and SIGTERM handling
Handler(Config)the same routing without listening — hand it to httptest.NewServer
MergeRoutes(...)combine route maps, e.g. the app's own plus the dispatch ones
JSONHandler(fn)a handler returning (any, error); Statusf(code, ...) sets the status
Bind(r, &dst)decode a JSON request body, capped at 32 MiB
Static servingpath-traversal guard plus an index.html fallback so a client-side router works

Routes are keyed "METHOD /path", and Middleware takes the usual func(http.Handler) http.Handler chain.

Daemon services

space := senclaw.MustNew(senclaw.WithAppID("my-app"))

space.Capabilities(ctx)                              // what this daemon supports

text, err := space.LLM(ctx, senclaw.LLMRequest{Prompt: p, System: s, MaxTokens: 4000})
reply, err := space.LLMDetailed(ctx, req)            // text, model, finish, usage
out, err := space.Agent(ctx, "do the thing")         // a full agent turn, with tools

space.KnowledgeSave(ctx, senclaw.Memory{Text: "remember this", Space: "proj"})
space.KnowledgeSearch(ctx, "a question", "proj", 10) // raw hits
space.KnowledgeRecall(ctx, senclaw.RecallQuery{Query: "a question", Space: "proj"})

space.GetConfig(ctx, "prefs", &prefs)                // the same KV the app's UI uses
space.SetConfig(ctx, "prefs", prefs)
space.SQLiteScan(ctx, &rows, "SELECT * FROM t WHERE a = ?", 1)

active, models, err := space.ListModels(ctx)
space.UsageReport(ctx, senclaw.Usage{Model: m, Provider: p, InputTokens: 100})
space.RegisterMCP(ctx, senclaw.MCPRegistration{Transport: "http", URL: u})

Three places this goes wrong:

  • A truncated reply is an error. LLM fails on Finish == "length", which means the model hit the MaxTokens ceiling mid-sentence — and half an answer is indistinguishable from a short one. Use LLMDetailed to handle it yourself.
  • A failed bridge action still answers HTTP 200, with {"status":"error"} in the body. The SDK turns that into an error; if you call the bridge by hand, check the status field or a dead provider reads as an empty string.
  • Pin the model per call, not globally. LLMRequest.Profile picks a model for that call; SetActiveModel moves the one the agent and every other app share.

Errors carry the daemon's own message and status: senclaw.StatusOf(err), or errors.As(err, &senclawErr). GetConfig returns a found bool rather than an error for a key that was never set. Options — WithBaseURL, WithTimeout, WithHTTPClient, WithAppToken, WithAPIVersion — cover running the app outside the daemon.

MCP without an MCP SDK

MCPServer implements the three JSON-RPC methods SenClaw's client actually sends — initialize, tools/list, tools/call — and it is itself an http.Handler, so Config.MCP takes it directly:

mcp := senclaw.NewMCPServer("my-app-mcp", "1.0.0")

mcp.Tool("myapp_list", "List the tracked items",
	senclaw.Schema{"type": "object", "properties": senclaw.Schema{}},
	func(ctx context.Context, args map[string]any) (any, error) {
		var rows []Item
		return rows, space.SQLiteScan(ctx, &rows, "SELECT id, name FROM items LIMIT 50")
	})

A tool that panics becomes a message, not a dead app. Return senclaw.ErrorContent("...") when the call should fail in a way the agent can act on — a readable sentence beats a JSON-RPC error code it cannot interpret. senclaw.String(args, "text") and friends read arguments without a type assertion at every call site. 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.

Dispatch

import "github.com/NortonBen/SenClaw/senclaw-sdk/senclaw-app-sdk-go/dispatch"

type Store struct{ dispatch.Unleased } // no-op Heartbeat + Reclaim

func (s *Store) ClaimReady(ctx context.Context, c dispatch.Capacity) ([]dispatch.WorkItem, error) {
	// must be atomic — an item handed out twice is run twice
}
func (s *Store) Finalize(ctx context.Context, id string, o dispatch.Outcome) error { return nil }

senclaw.Serve(senclaw.Config{
	Routes: senclaw.MergeRoutes(dispatch.Routes(&Store{}, ""), myRoutes),
})

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. WorkItem marshals empty slices as [] for the same reason: serde's Vec rejects an explicit null.

Manifest validation

go run github.com/NortonBen/SenClaw/senclaw-sdk/senclaw-app-sdk-go/cmd/senclaw-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 (no network at all), autoRegister with no path, idleTimeoutSecs below the floor of 15, and install on a runner that never runs it — the Go trap at the top of this page. From code it is manifest.Validate(m), returning the problems as strings; manifest.InferRunner(start) is what decides binary versus shell when the manifest omits runner.

Closing the app's own port

senclaw.Serve(senclaw.Config{
	RequireAppToken: true,
	HealthPath:      "/api/status",     // always exempt
	AuthSkipPaths:   []string{"/ws/*"}, // a browser extension dials this directly
})

With no token in the environment the guard is inert, so a bare go run . still works. Running against a live daemon, pass the app's token explicitly:

SENCLAW_TOKEN_ACCESS_APP=$(curl -s localhost:18788/api/space/apps/my-app/token | jq -r .token) go run .

Space App SDKs has the whole token picture.

Shipping it

A Go app ships the built binary plus the manifest, web_dist/ and any skills, flat at the zip root — there is no install step to build it for you. Cross-compile once per target and publish one artifact per platform id, exactly like a Rust app; the multi-platform release flow is on Publishing a Space App.

go test ./... in the SDK pins the two contracts that fail invisibly: the JSON-RPC methods SenClaw actually sends, and the exact keys the bridge and the dispatch engine parse.

Next