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-goimport 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:
start | runner | Trade-off | |
|---|---|---|---|
| Compiled (ship this) | ./my-app | binary (inferred from the ./) | Starts in milliseconds. You build and ship a binary per platform |
go run (fine for the demo) | go run . | shell | No 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 serving | path-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.
LLMfails onFinish == "length", which means the model hit theMaxTokensceiling mid-sentence — and half an answer is indistinguishable from a short one. UseLLMDetailedto 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 thestatusfield or a dead provider reads as an empty string. - Pin the model per call, not globally.
LLMRequest.Profilepicks a model for that call;SetActiveModelmoves 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.jsonIt 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
- The Space App API — every action, endpoint and widget surface the daemon offers
- Space App SDKs — the shared contract, and the other three languages
- Sandboxing a Space App — declaring the app's own confinement
- Monitoring a Space App — what the process view shows when it misbehaves