SDKs
Go SDK
Write an agent in Go. Typed tool handlers, schemas generated from structs, reconnects and graceful shutdown with core.
The Go SDK lets a program written in Go act as an agent: it dials out to the gateway, registers tools, resources and prompts, and answers the calls MCP clients make to your project's /mcp/<project-id> URL. You open no inbound port.
This page is the complete guide: install, a runnable agent, every option, typed tools whose JSON Schema is generated from Go structs, concurrency, graceful shutdown, telemetry, OpenTelemetry, testing, deployment, limits and troubleshooting. The core module has version 0.1.0 and depends only on the Go standard library.
Install
| Item | Value |
|---|---|
| Module | github.com/softmode/spacefix-go |
| Package name | core |
| Go | 1.22 or newer |
| Dependencies | None. core and telemetry use only the standard library |
| OpenTelemetry adapter | A separate module, github.com/softmode/spacefix-go/otel (package spacefixotel). It depends on go.opentelemetry.io/otel v1.47.0 and needs Go 1.26 or newer |
go get github.com/softmode/spacefix-go
The SDK has two packages, like the Node.js SDK's @spacefix/core and @spacefix/telemetry:
import (
"github.com/softmode/spacefix-go/core" // the agent
"github.com/softmode/spacefix-go/telemetry" // optional: metrics for the dashboard
)
Quick start
You need a project and an agent key (a full key also works). Create them on the dashboard; see Keys. Save this as main.go:
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"github.com/softmode/spacefix-go/core"
)
type GetOrderInput struct {
ID string `json:"id" desc:"The order id, for example o-1042"`
}
type Order struct {
ID string `json:"id"`
Status string `json:"status"`
Total float64 `json:"total"`
}
var orders = map[string]Order{
"o-1042": {ID: "o-1042", Status: "shipped", Total: 42.5},
}
func main() {
agent, err := core.New(core.Options{APIKey: os.Getenv("SPACEFIX_API_KEY")})
if err != nil {
log.Fatal(err)
}
err = core.AddTool(agent, "get_order", "Look up one order by id. Returns an error if it does not exist.",
func(ctx context.Context, in GetOrderInput) (Order, error) {
order, ok := orders[in.ID]
if !ok {
return Order{}, fmt.Errorf("no order %s; ask the user to confirm the id", in.ID)
}
return order, nil
})
if err != nil {
log.Fatal(err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := agent.Run(ctx); err != nil { // blocks until ctx is cancelled; returns nil on a clean shutdown
log.Fatal(err)
}
}
Run it with your agent key:
export SPACEFIX_API_KEY=sf_live_...
export SPACEFIX_SERVICE_URL=https://api.spacefix.io # leave unset to use the default host
go run .
2026/10/31 02:53:08 INFO [spacefix] connected with 1 tool(s)
The line format is the default log/slog handler's; the SDK's message is [spacefix] connected with 1 tool(s). The agent now appears on the Agents page of your project. To call the tool, use any MCP client (see Connecting clients) with a client key, or call the MCP endpoint directly:
curl -X POST "$SPACEFIX_SERVICE_URL/mcp/<project-id>" \
-H "Authorization: Bearer <client-key>" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_order","arguments":{"id":"o-1042"}}}'
The order comes back as JSON text in result.content[0].text. The reply may be plain JSON or a server-sent event that wraps the same JSON-RPC payload in data: lines; see the MCP endpoint.
What happens, in order: New validates the options and touches no network. AddTool generates the schema and checks the name and size against the gateway's rules. Run sends one registration to the gateway, reads the event stream, runs each call in its own goroutine and posts each answer back. If the connection drops, Run reconnects by itself.
Configuration
agent, err := core.New(core.Options{
APIKey: os.Getenv("SPACEFIX_API_KEY"), // required
URL: "https://api.spacefix.io", // optional; see below
Logger: core.NewSlogLogger(slog.Default()),
})
New returns ErrNoAPIKey (spacefix: APIKey is required) when APIKey is empty. Every other option has a default, and a zero value means "use the default".
| Option | Default | Meaning |
|---|---|---|
APIKey | none, required | The project's agent or full key. Sent as Authorization: Bearer <key> |
URL | SPACEFIX_SERVICE_URL if set and not empty, else https://api.spacefix.io | The gateway's base URL. Trailing slashes are removed. Read when New is called. Prefer the environment variable, so one build runs in every environment |
Logger | NewSlogLogger(slog.Default()) | Receives the SDK's messages. See Logging |
LogLevel | empty: SPACEFIX_LOG_LEVEL, else every message goes to Logger | LogSilent, LogError, LogWarn, LogInfo or LogDebug: limits what is logged. LogSilent turns the SDK's logging off. The option wins over SPACEFIX_LOG_LEVEL; New returns ErrInvalidLogLevel for an unknown value. See Logging |
HTTPClient | A client built for this job | Used for every request. Do not set Client.Timeout on one you supply: it would cut the stream. The built-in client has no overall timeout, a 30 s response-header timeout, HTTP/2 and 128 idle connections per host |
MaxConcurrency | 0 (unlimited) | The most handlers running at once. See Concurrency |
CallTimeout | 0 (none) | A deadline for each handler's context. The gateway gives up on a call after 30 s by default, so a value just under that stops work whose answer would be discarded |
ShutdownTimeout | 10 s | How long a shutdown waits for running calls before cancelling their contexts. See Shutting down |
Backoff.Initial | 1 s | First wait between connection attempts. Do not set it under 1 s in production |
Backoff.Max | 30 s | Longest wait |
Backoff.Jitter | 0.5 | Each wait is shortened by a random fraction of up to this (0 to 1), never below Initial. Zero means the default; a negative value turns jitter off |
StreamIdleTimeout | 90 s | Reconnect if no bytes (not even the gateway's 25 s heartbeat) arrive for this long. Negative disables |
ResultAttempts | 3 | How many times a result post is tried. Only network errors and 5xx responses are retried. Set 1 for no retries |
ResultTimeout | 30 s | The time allowed for one result post attempt |
StopOnPermanentError | false | Run returns the *ConnectError on a 400, 401 or 403 instead of retrying. See Failure classes |
MaxTools | 256 | Client-side check of the tool limit (the Free plan's value) |
MaxSchemaBytes | 65,536 | Client-side check of the per-tool schema size (the Free plan's value) |
MaxRegisterBytes | 524,288 | Client-side check of the registration size (512 kB) |
MaxResultBytes | 4,194,304 | Largest result posted (4 MB). A bigger result becomes an error result |
UserAgent | spacefix-sdk-go/<version> go/<go version> | The User-Agent header, shown on the Agents page. Setting it replaces the SDK's identity altogether, so the gateway's logs lose the SDK name and version; use AppInfo to add to the default instead |
AppInfo | empty | Your application's name and version, such as orders-agent/2.1.0, appended to the default User-Agent. Control characters are dropped and it is cut to 64 characters. Ignored when UserAgent is set |
Now, Sleep, Rand | time.Now, a timer sleep, math/rand.Float64 | Test hooks: they let a test run the backoff schedule without waiting |
The three Max* limits default to the Free plan's. Other plans raise them (see Limits); raise the options to match, or registration is refused locally before the gateway sees it.
Environment variables
| Variable | Used by | Meaning |
|---|---|---|
SPACEFIX_SERVICE_URL | the SDK | The gateway's base URL, used when Options.URL is empty |
SPACEFIX_LOG_LEVEL | the SDK | silent, error, warn, info or debug, used when Options.LogLevel is empty. Case-insensitive; off, none, false, no and 0 also mean silent. An unknown value is ignored with a warning |
SPACEFIX_API_KEY | the SDK | The agent key, used when you do not pass Options.APIKey. Keep it in your secret store |
Every variable also works in a short form with SF_ in place of SPACEFIX_ (SF_API_KEY, SF_SERVICE_URL, SF_LOG_LEVEL); when both are set, the SPACEFIX_ one wins.
Defining tools
A tool is a function the model can call. Register one with the generic function AddTool. It is a function, not a method, because Go methods cannot have type parameters:
type GetOrderInput struct {
ID string `json:"id" desc:"The order id, for example o-1042"`
}
err := core.AddTool(agent, "get_order", "Look up one order by id. Returns an error if it does not exist.",
func(ctx context.Context, in GetOrderInput) (Order, error) {
return orders.Find(ctx, in.ID)
})
func AddTool[In, Out any](a *Agent, name, description string,
handler func(ctx context.Context, in In) (Out, error), opts ...ToolOption) error
Inis the input type. The SDK generates the tool's JSON Schema from it (see Schema generation) and decodes each call's arguments into a freshInbefore your handler runs. Use a struct or amap[string]T.Outis anythingencoding/jsoncan marshal. It becomes the tool's result (see Results and errors).- The name must match
^[a-zA-Z0-9_-]{1,64}$. - The description is what the model reads to decide when to call the tool, together with the schema's property descriptions. Write it for the model: say what the tool does, when to use it, when not to, and what it returns.
AddToolreturns an error for an invalid or duplicate name, a type or tag that cannot be described, a schema overMaxSchemaBytes, more thanMaxToolstools, and for registering afterRunstarted. The error appears at the registration call, not when the first call arrives.
Things to know while you write handlers:
- The gateway validates arguments against your schema before your handler runs, so a malformed call never reaches your code. The caller sees
Input validation error: .... The gateway does not enforcepattern,patternPropertiesorformat; they are shown to clients as hints. Check them in your handler if they matter. - Arguments are decoded with
encoding/json. Field names match case-insensitively, unknown properties are ignored (unless you addStrictInput()), and a missing ornullargument object decodes to the zero value ofIn. - A returned error is shown to the model as the tool's error message. Make messages actionable and never put secrets in them.
- Handlers do not know who called them. Pass any identity you need as an argument.
- Calls are spread across replicas. Keep state in shared storage, not in process memory. See Replicas and shared state.
Tool options
| Option | Effect |
|---|---|
core.StrictInput() | The generated schema gets "additionalProperties": false on every object, and decoding rejects unknown fields, so a property your type does not declare is an error |
core.WithAnnotations(core.ToolAnnotations{...}) | Hints for MCP clients: Title (at most 200 characters) and the flags ReadOnly, Destructive, Idempotent, OpenWorld. The flags are *bool, so a flag you leave nil is not sent and the client applies its own default; core.Bool(true) makes a pointer. Also accepted by AddRawTool. They are hints, not enforcement, except that ReadOnly: core.Bool(true) is what lets read-only clients see the tool: one without it counts as a write |
core.WithInputSchema(raw) | Use raw (a JSON object) as the schema instead of the generated one. Your handler still receives a decoded In. For constraints tags cannot express (oneOf, dependentRequired) |
Schema generation
The generator walks the Go type of In with reflection.
Root. In (after removing pointers) must be a struct, a map whose key is a string, an integer or an encoding.TextUnmarshaler, any or json.RawMessage. any and json.RawMessage give {"type":"object"}. Any other root (a string, a slice, a number) is an error: MCP tool inputs are objects.
Type mapping.
| Go type | JSON Schema |
|---|---|
string | {"type":"string"} |
bool | {"type":"boolean"} |
int, int8 ... int64 | {"type":"integer"} |
uint, uint8 ... uint64, uintptr | {"type":"integer","minimum":0} |
float32, float64 | {"type":"number"} |
time.Time | {"type":"string","format":"date-time"} |
time.Duration | {"type":"integer"} (nanoseconds, as encoding/json writes it) |
json.Number | {"type":"number"} |
[]byte | {"type":"string","contentEncoding":"base64"} |
json.RawMessage, any, any interface | {} (unconstrained) |
A type with UnmarshalJSON | {} (unconstrained: the SDK cannot know its shape) |
A type with UnmarshalText | {"type":"string"} |
[]T | {"type":"array","items":<T>} |
[N]T | {"type":"array","items":<T>,"minItems":N,"maxItems":N} |
map[K]V | {"type":"object","additionalProperties":<V>} |
| struct | {"type":"object","properties":{...},"required":[...]} |
*T | Same as T; the field is optional |
chan, func, complex*, unsafe.Pointer | Error |
Fields.
- Names, skipping and flattening follow
encoding/json: thejsontag renames;json:"-"skips; unexported fields are skipped; without a tag the Go field name is used. - A field is required when its tag has no
omitemptyand noomitzero, it is not a pointer, and it is not reached through an embedded pointer. Everything else is optional. Therequiredlist is in declaration order. json:",string"on an integer, float or bool field makes the schema{"type":"string"}.- Embedded structs without a
jsonname are flattened into the parent. An embedded struct with ajsonname is an ordinary field. - Recursive types use
$defsand$ref, so a tree type produces a finite schema. - Properties appear in declaration order.
Struct tags. Tags refine a field's schema. A tag that does not fit the field's type is an error, so a typo is found at registration.
| Tag | Applies to | Effect |
|---|---|---|
desc:"..." | any | description: what the model sees for the argument |
enum:"a,b,c" | strings, integers, floats, bools, and slices of them | enum. Values are parsed as the field's type and trimmed of spaces; they cannot contain commas. On a slice the enum constrains each element |
default:"x" | any | default. Strings are taken literally (no quotes); numbers and booleans are parsed; for arrays, objects and unconstrained fields the tag must be valid JSON |
min:"1", max:"100" | integers, floats | minimum, maximum |
minLength:"1", maxLength:"80" | strings | minLength, maxLength |
minItems:"1", maxItems:"10" | slices and arrays | minItems, maxItems |
pattern:"^[a-z]+$" | strings | pattern (advertised to clients; not enforced by the gateway) |
format:"email" | strings | format (advertised; not enforced by the gateway) |
default is advertised in the schema for clients to read. The SDK does not fill defaults in for arguments the caller left out: your In receives the zero value. Treat the zero value as "use the default" in the handler.
An example:
type SearchInput struct {
Query string `json:"query" desc:"Free-text search" minLength:"1"`
Status string `json:"status,omitempty" desc:"Only orders in this status" enum:"open,shipped,closed" default:"open"`
Limit int `json:"limit,omitempty" desc:"Page size" min:"1" max:"100" default:"20"`
Since time.Time `json:"since,omitempty" desc:"Only orders created after this instant"`
Tags []string `json:"tags,omitempty" desc:"Match any of these tags"`
Debug *bool `json:"debug"`
}
generates this schema (the SDK sends it compact):
{
"type": "object",
"properties": {
"query": { "type": "string", "description": "Free-text search", "minLength": 1 },
"status": { "type": "string", "description": "Only orders in this status", "enum": ["open", "shipped", "closed"], "default": "open" },
"limit": { "type": "integer", "description": "Page size", "default": 20, "minimum": 1, "maximum": 100 },
"since": { "type": "string", "format": "date-time", "description": "Only orders created after this instant" },
"tags": { "type": "array", "items": { "type": "string" }, "description": "Match any of these tags" },
"debug": { "type": "boolean" }
},
"required": ["query"]
}
Only query is required: Status, Limit, Since and Tags have omitempty, and Debug is a pointer. Print what an agent will register with agent.Tools().
Raw schemas
When you already have a JSON Schema, or want to handle the arguments yourself, use AddRawTool. The handler receives the raw JSON arguments ({} when the caller sent none) and returns any value:
schema := json.RawMessage(`{
"type": "object",
"properties": { "id": { "type": "string" } },
"required": ["id"]
}`)
err := agent.AddRawTool("get_order", "Look up an order", schema,
func(ctx context.Context, args json.RawMessage) (any, error) {
var in struct {
ID string `json:"id"`
}
if err := json.Unmarshal(args, &in); err != nil {
return nil, err
}
return orders.Find(ctx, in.ID)
})
The schema must be a JSON object (an empty or nil schema means {"type":"object"}). The gateway compiles the schema when you connect, so an invalid one is a 400 at connect time: Schema for "<name>" is not valid JSON Schema: ....
Resources
A resource is read-only data a client fetches by URI: a catalogue, a config, a snapshot. Use one for things a model should be able to look at without taking an action.
err := agent.Resource("orders://all", core.ResourceOptions{
Name: "All orders",
Description: "Every order, oldest first",
MimeType: "application/json",
}, func(ctx context.Context) (any, error) {
return orders.All(ctx)
})
Namedefaults to the URI.MimeTypedefaults toapplication/json.- The handler's return value is sent as text: a Go
stringas is, anything else as its JSON. - The URI must be non-empty and unique.
- An error from a resource handler reaches the client as a JSON-RPC error, not a tool result.
Prompts
A prompt is a reusable message template a client offers its user. Prompt arguments are always strings, because MCP clients send them that way.
err := agent.Prompt("summarise_order", core.PromptOptions{
Title: "Summarise order",
Description: "Summarise an order for a support reply",
Args: []core.PromptArg{{Name: "id", Description: "Order id", Required: true}},
}, func(ctx context.Context, args map[string]string) (core.PromptResult, error) {
o, err := orders.Find(ctx, args["id"])
if err != nil {
return core.PromptResult{}, err
}
return core.PromptText("Summarise this order politely:\n" + o.String()), nil
})
- The name follows the tool-name rule. Argument names must be non-empty and unique.
PromptText(s)is one user message. For several messages, build aPromptResult{Messages: ...}withUserMessageandAssistantMessage. Content is text only.- An argument the caller left out is absent from the map. A
Requiredargument that is missing is refused by the gateway before your handler runs. - An error from a prompt handler reaches the client as a JSON-RPC error.
Results and errors
| Handler | What the MCP client sees |
|---|---|
Tool returns (v, nil) | A Go string becomes the text as is; any other value becomes its JSON text (null for a nil value): one text content block |
Tool returns a ToolResult (or anything with a content array) | Passed through untouched: several blocks, or isError you set yourself |
Tool returns (_, err) | A tool result with isError: true and your message |
| Tool panics | The same as an error, with the message panic: <value>. The stack is logged on the agent, never sent |
| Tool returns a value that cannot be marshalled | An error result, failed to encode result: .... The agent keeps running |
Result over MaxResultBytes | An error result, result is N bytes, which exceeds the gateway's limit of 4194304 bytes |
Arguments that do not decode into In | An error result, invalid arguments for tool "x": .... The gateway's schema check normally catches these first |
Resource returns (v, nil) | The resource text: a string as is, anything else as JSON |
Prompt returns (r, nil) | The messages. A nil Messages is sent as [] |
| Resource or prompt returns an error or panics | A JSON-RPC error |
Control the result the model sees with ToolResult:
return core.ToolResult{
Content: []core.Content{{Type: "text", Text: "Partial results: 3 of 10 shards answered"}},
IsError: true,
}, nil
The SDK never crashes on a bad result. The handler's Out is marshalled with encoding/json: unexported fields are omitted, time.Time is RFC 3339, []byte is base64, and a nil slice is null. Use omitempty or initialise slices if a client should see [].
Context, cancellation and call information
Every handler gets a context.Context that carries:
- Cancellation. The context is cancelled when the connection it belongs to ends (the stream dropped, the idle watchdog fired), when a shutdown's
ShutdownTimeoutruns out, or whenCallTimeoutexpires. Passctxto your database and HTTP calls so work stops when its answer can no longer be delivered. - The values of the context you passed to
Run. The handler context is derived withcontext.WithoutCancel, so your values survive, but cancellingRun's context does not interrupt running calls: they get the chance to finish. See Shutting down. - The call's trace context, through
CallFromContext:
info, ok := core.CallFromContext(ctx)
// info.Kind core.KindCall, KindRead or KindPrompt
// info.Name tool name, resource URI or prompt name
// info.RequestID the id on the gateway's Logs page
// info.TraceParent W3C traceparent: "00-<trace id>-<parent span id>-<flags>"
// info.TraceState W3C tracestate, if the caller sent one
// info.SentAt when the gateway pushed the call (gateway clock)
// info.Args the raw JSON arguments
ok is false for a context that did not come from the SDK (a unit test that calls a handler directly). ContextWithCall builds one for such tests.
info.TraceID() and info.ParentSpanID() parse TraceParent (empty if it is missing or malformed), and info.Inject(header) sets traceparent and tracestate on an http.Header, so a request your handler makes joins the caller's trace:
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, upstreamURL, nil)
if info, ok := core.CallFromContext(ctx); ok {
info.Inject(req.Header)
}
The gateway fills in a trace even when the MCP client sent none, so TraceParent and RequestID are present on every call except those started from the dashboard's Playground. With an OpenTelemetry adapter registered, the handler's context also carries the active span, and you should use OpenTelemetry's own propagation instead of Inject. See OpenTelemetry.
Connection lifecycle
err := agent.Run(ctx) // connects, reconnects, serves; blocks until ctx is cancelled
agent.Disconnect() // from another goroutine: same as cancelling ctx, then waits
Run returns nil on a clean shutdown. It returns an error only when retrying cannot help: a registration problem found before connecting (ErrRegistrationTooLarge), ErrAlreadyRunning if another Run on the same agent is active, or, with StopOnPermanentError, the gateway's refusal. Run may be called again after it returns.
Reconnects
| Step | Behaviour |
|---|---|
| Any failure | A non-2xx answer, a network error, a malformed event, an idle stream, or the stream ending for any reason, is a failure |
| First wait | 1 s (Backoff.Initial) |
| Growth | Doubles after each consecutive failure: 1 s, 2 s, 4 s, 8 s, 16 s, 30 s, 30 s ... |
| Ceiling | 30 s (Backoff.Max) |
| Reset | To 1 s when the gateway accepts the connection and sends its ready event, not merely when the response arrives |
| Jitter | Each wait is shortened by a random fraction of up to Backoff.Jitter (default 0.5), never below Backoff.Initial, so a fleet that lost the gateway together does not reconnect in lockstep |
Retry-After | On a 429 with the header, the wait is at least that long (capped at 5 minutes) |
| Interrupted | Cancelling the context or calling Disconnect ends the wait at once |
| Log line | [spacefix] <reason>; retrying in <ms>ms |
Failure classes
| What happened | What the SDK does |
|---|---|
Network error, 5xx, 503 Service is shutting down., stream ended | Retry with the schedule above |
429 Rate limit exceeded: <n> requests per minute. or Too many agent connections: the <plan> plan allows <n>. | Retry with the schedule, waiting at least Retry-After if sent |
| 400 (the gateway rejected the registration), 401 (wrong, revoked or missing key), 403 (a client key used as an agent key) | Retry at the longest backoff (30 s), because a wrong key will not heal by itself. With StopOnPermanentError, Run returns the *ConnectError instead |
403 for a suspended project (This project is suspended: ...) | Retried like a transient failure, even with StopOnPermanentError, because a suspension can be lifted |
| A frame whose JSON does not parse | Treated as a failed stream: reconnect |
No bytes for StreamIdleTimeout | Abort and reconnect (the gateway pings every 25 s) |
ConnectError.Permanent() implements the 400, 401 and 403 rule. A failing result post never affects the connection.
To crash on a bad key and let your orchestrator report it:
agent, err := core.New(core.Options{
APIKey: os.Getenv("SPACEFIX_API_KEY"),
StopOnPermanentError: true,
})
if err != nil {
log.Fatal(err)
}
// ... register tools ...
var ce *core.ConnectError
if err := agent.Run(ctx); errors.As(err, &ce) {
log.Fatalf("the gateway refused this agent: %v", ce) // Connect failed: 401 ...
}
Shutting down
Cancel the context you gave Run (for example from signal.NotifyContext), or call Disconnect. The sequence:
- The agent stops starting new calls. Calls that arrive from now on get an immediate error answer,
agent is shutting down, instead of waiting for a timeout. The gateway does not retry or re-route them: the caller sees that error. The stream stays open, because it is the only way to post the results still owed. - It waits up to
ShutdownTimeout(10 s) for calls already running to finish, and posts their results. - It cancels the context of anything still running, then closes the stream. Calls that were still running fail for the caller with
Agent disconnected before returning a result. - It waits one more second for those handlers to return, and logs
N call(s) are still running after the connection ended; leaving them behindif some did not. A handler that ignores its context cannot be stopped in Go; it is abandoned. Runreturnsnil.
If the connection is lost instead (not a shutdown), the contexts of the calls in flight are cancelled at once and their results are not posted, because the gateway has already failed them.
Disconnect is safe to call more than once, from any goroutine, including a signal handler, and does nothing if the agent is not running. It waits for Run to return, at most ShutdownTimeout plus two seconds. Your program owns the process lifecycle: install a signal handler as in the quick start, or SIGTERM from your orchestrator kills the process without a clean disconnect.
At most once
The gateway never re-sends a call. If the stream drops while your handler runs, the handler runs to completion but its result is lost, so a tool that changes state can have happened even though the caller was told it failed. Where that matters, make state-changing tools idempotent (accept a client-generated key).
Concurrency
- Every call runs in its own goroutine. A slow handler never delays the stream reader, the heartbeat or other calls.
- Your handlers must be safe for concurrent use. Guard shared state (maps, caches, counters) with a mutex,
sync/atomicor a database. Run your tests with-race. MaxConcurrencycaps the handlers running at once. A call that arrives when all slots are busy waits for a slot (and is abandoned if the connection ends first). The gateway still times the call out after its own deadline, so a cap that is too low shows up as timeouts, not as a refusal.- Panics are recovered. A handler that panics fails one call with
panic: <value>and a stack in the log; the agent and every other call carry on. The SDK recovers panics in its own goroutines only; a goroutine your handler starts itself is yours to protect. - Result posts are retried. A result post that fails on the network or with a 5xx is retried up to
ResultAttemptstimes (default 3, with 200 ms and 400 ms pauses). Retrying is safe because the gateway ignores a result it has already settled. A 4xx is logged and not retried. If every attempt fails, the result is dropped and logged asfailed to return result: ..., and the call times out for the caller.
Running in production
An agent is an ordinary long-running process that dials out, so it runs anywhere Go does, with outbound HTTPS to the gateway. It needs no inbound port, no service and no ingress.
Run it as an always-on process (containers, VMs, Fly, Render, Kubernetes), not as request-scoped serverless, which would reconnect on every invocation and lose calls.
Containers
Configuration is two environment variables: SPACEFIX_API_KEY (an agent key; keep it in your secret store) and SPACEFIX_SERVICE_URL; SPACEFIX_LOG_LEVEL (silent, error, warn, info, debug) is optional. CGO_ENABLED=0 gives a static binary, and the distroless static image includes the CA certificates the TLS connection needs. Use the exec form of ENTRYPOINT so the Go process is PID 1 and receives SIGTERM directly:
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -o /agent .
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /agent /agent
ENTRYPOINT ["/agent"]
If you use the OpenTelemetry adapter, build with a Go 1.26 or newer image.
On Kubernetes, a pod receives SIGTERM and is killed after terminationGracePeriodSeconds (30 s by default). Cancel the context passed to Run on SIGTERM, and keep ShutdownTimeout shorter than the grace period so the drain finishes before the kill.
Replicas and shared state
Run as many replicas as you like (the Free plan allows 32 connections per project). Each opens its own connection and the gateway spreads calls across them. A replica that dies drops out at once, and its in-flight calls fail fast.
Because calls are spread across replicas, state must live in shared storage: a database, not a map in process memory. Two replicas with their own in-memory copies will disagree about which orders exist. Give every replica the same tools: MCP clients see the tool list registered by the oldest enabled connection, so a tool that only some replicas register can be invisible.
Health checks
The agent has no inbound port, so there is nothing for the gateway to probe. Use:
- Liveness. The process is running: your orchestrator's check, or an HTTP health route your service already has.
- Readiness. An
Observerreceives connection changes, which is enough to build a readiness flag:type readiness struct { core.NopObserver connected atomic.Bool } func (r *readiness) ConnectionChanged(connected bool, detail string) { r.connected.Store(connected) }Attach it with
agent.UseObserver(r)beforeRun, and reportr.connected.Load()from your own health route. Use it for readiness, not liveness: a brief gateway outage should not restart your pods, because the agent reconnects by itself.
- The dashboard. The project's Agents page shows every connected agent.
Logging
The SDK reports through the Logger interface, whose method set is the one *slog.Logger already has (Debug, Info, Warn, Error, each (msg string, args ...any)):
// The default: slog.Default() with the "[spacefix] " prefix.
core.Options{Logger: core.NewSlogLogger(nil)}
// Your own *slog.Logger, with the prefix:
core.Options{Logger: core.NewSlogLogger(myLogger)}
// Your own *slog.Logger, without the prefix (it satisfies Logger directly):
core.Options{Logger: myLogger}
// Silence:
core.Options{Logger: core.DiscardLogger{}}
Log level. Options.LogLevel (or the SPACEFIX_LOG_LEVEL environment variable) limits what is logged: core.LogSilent writes nothing, LogError only errors, LogWarn adds warnings, LogInfo adds the connection line, and LogDebug adds one line per call (tools/call "add" received (request <id>) and ... finished in 3ms (request <id>)). The option wins over the variable, the variable is case-insensitive and also takes off, none, false, no and 0 for silent, and New returns ErrInvalidLogLevel for an unknown option. An unknown variable is ignored with one warning and info is used.
core.Options{LogLevel: core.LogWarn} // quieter
core.Options{LogLevel: core.LogSilent} // no SDK logging at all
SPACEFIX_LOG_LEVEL=debug ./agent
With no level set, every message goes to Logger as before. With LogDebug and no Logger of your own, debug lines are written to stderr (the default logger wraps slog.Default(), whose handler drops debug). The level also filters a Logger you supply, so a level of LogWarn keeps your *slog.Logger from receiving info and debug lines. core.ParseLogLevel reads a name.
| Level | Message (after the [spacefix] prefix) | When |
|---|---|---|
| Info | connected with 2 tool(s), 1 resource(s), 1 prompt(s) | The gateway accepted the connection. Resources and prompts appear only when there are some |
| Error | <reason>; retrying in <ms>ms | A failed attempt. <reason> is Connect failed: <status> <body>, Stream closed, no data from the gateway for 1m30s, malformed call event from the gateway, stream error: ... or the network error |
| Error | failed to return result: <reason> | A result post failed after its attempts, or got a 4xx |
| Error | tools/call "name" panicked: <value> (with a stack attribute) | A handler panicked |
| Warn | <n> call(s) are still running after the connection ended; leaving them behind | A shutdown or reconnect left handlers behind |
| Debug | tools/call "name" received and tools/call "name" finished in <ms>ms, each with (request <id>) when the gateway sent one | Each call (resources/read and prompts/get likewise) |
| Debug | telemetry post failed | A telemetry post failed |
Telemetry
Telemetry reports your process's health to the dashboard: memory, CPU, scheduler delay and call counts, posted every few seconds. It is optional; without it the agent reports nothing and carries no sampling code.
err := agent.Use(telemetry.New(telemetry.Options{
Interval: 10 * time.Second, // default
Report: func(m telemetry.Metrics) { /* optional: also feed your own metrics system */ },
}))
Call it before Run. Samples are posted with the connection's own key and URL; a sample taken while the agent is not connected is discarded, and a failed post is ignored (monitoring must never break tools). They appear on the Agents page, in the same tiles as a Node.js agent's. Report receives every sample in addition to the post to the gateway.
One sample describes the window since the previous one:
| Field | Unit | Go meaning |
|---|---|---|
uptime | seconds | Time since the process started, approximated by when the package was initialised |
rss | bytes | An estimate from runtime/metrics: total memory mapped by the Go runtime minus what was released to the OS. It excludes memory mapped by cgo or other libraries |
heapUsed | bytes | Heap objects in use |
heapTotal | bytes | Heap objects plus free and unused heap memory, excluding what was released to the OS |
cpu | ratio 0 to 1 | Share of one core the Go runtime used in the window, capped at 1. Counts garbage collection and your goroutines; not cgo or foreign threads |
eventLoopDelay | ms | Go has no event loop. Reported instead: the mean time goroutines spent runnable before the scheduler ran them. It rises when the process is overloaded |
eventLoopUtilization | ratio 0 to 1 | Go has no single loop. Reported instead: busy runtime CPU time divided by GOMAXPROCS times wall time |
tools | count | Tools registered |
calls, errors | counts | Calls handled in the window (tools, resource reads and prompts) and how many returned an error or panicked |
inFlight | count | Handlers running at the instant of sampling. Usually 0, because fast handlers finish between samples |
peakInFlight | count | Most handlers running at once in the window; read this for concurrency |
avgMs | ms | Mean handler duration in the window |
OpenTelemetry
The SDK can send a span, metrics and logs for every call into your own observability backend. The core module has no dependencies, so OpenTelemetry sits behind a small interface, Observer, and a separate module implements it. You import the adapter only if you want it.
go get github.com/softmode/spacefix-go/otel
import spacefixotel "github.com/softmode/spacefix-go/otel"
obs, err := spacefixotel.New(spacefixotel.Options{}) // uses the global tracer, meter and logger providers
if err != nil {
log.Fatal(err)
}
if err := agent.UseObserver(obs); err != nil { // before Run
log.Fatal(err)
}
The adapter uses only the OpenTelemetry API, so it adds no exporter and no configuration: spans, metrics and logs go out through the pipeline your application already runs. Until your application registers an OpenTelemetry SDK, it does nothing visible. You never give the gateway an endpoint or credentials for your backend.
Options has ArgKeys (also record argument names, never values) and optional TracerProvider, MeterProvider and LoggerProvider to override the globals. The instrumentation scope is @spacefix/core, the same as in the Node.js SDK, so agents in both languages produce one consistent set of telemetry.
Spans. One SERVER span per call, named by its MCP method: tools/call <name>, resources/read <uri>, prompts/get <name>. Its parent is the caller's trace, built from the gateway's traceparent and tracestate. The handler runs with the span active, so spans your code creates nest under it.
| Attribute | On | Value |
|---|---|---|
mcp.method.name | all | tools/call, resources/read or prompts/get |
gen_ai.tool.name | tool calls | The tool name |
gen_ai.operation.name | tool calls | execute_tool |
agent.resource.uri | resource reads | The URI |
gen_ai.prompt.name | prompts | The prompt name |
spacefix.request_id | all, if sent | The request id from the gateway's Logs page |
spacefix.gateway.queue_ms | all, if known and not negative | Milliseconds between the gateway pushing the call and the handler starting |
spacefix.arg_keys | only with ArgKeys | Up to 20 argument names, sorted |
error.type | failed calls | The Go type of the error, or panic |
A failing call records the exception, sets the status to Error with the message, and emits an ERROR log record <method> <name> failed: <message>.
Metrics.
| Name | Kind | Unit | Attributes |
|---|---|---|---|
mcp.server.operation.duration | Histogram | s | mcp.method.name, gen_ai.tool.name (tool calls), error.type (failures) |
spacefix.agent.reconnects | Counter | none | |
spacefix.agent.connected | Observable gauge | 1 while the agent holds a connection |
The adapter does not publish process gauges. For Go process metrics, use the telemetry package (agent.Use(telemetry.New(...))) (the dashboard) or OpenTelemetry's own runtime instrumentation.
Logs. Connected to the gateway, Disconnected from the gateway: <detail> and Reconnecting to the gateway in <ms>ms: <reason> (with spacefix.retry.delay_ms).
Trace context in handlers. With the adapter, the handler's context carries the active span: use otel.GetTextMapPropagator().Inject(ctx, ...) or an instrumented HTTP client, and the outbound request joins the caller's trace. Without the adapter, read the context yourself with CallFromContext and forward it with Inject (see Context).
Argument values and results are never recorded. Error messages are, so do not put secrets in them. Spans, metrics, backend recipes and troubleshooting are covered in OpenTelemetry.
If you want your own hook instead of the adapter, Observer is three methods; embed NopObserver to implement only the ones you need:
type Observer interface {
StartCall(ctx context.Context, call CallInfo) (context.Context, CallSpan)
ConnectionChanged(connected bool, detail string)
Reconnecting(delay time.Duration, reason string)
}
StartCall runs just before the handler and returns the context the handler receives. A panic inside an observer is recovered and logged.
Testing your tools
Unit tests need no gateway. CallTool, ReadResource and GetPrompt run a handler the way the run loop does (panic recovery, argument decoding, result encoding) in the calling goroutine:
func TestGetOrder(t *testing.T) {
agent, _ := core.New(core.Options{APIKey: "test"})
registerTools(agent) // your function that calls AddTool
out, err := agent.CallTool(context.Background(), "get_order", json.RawMessage(`{"id":"o-1"}`))
if err != nil {
t.Fatal(err)
}
var order Order
if err := json.Unmarshal(out, &order); err != nil {
t.Fatal(err)
}
if order.ID != "o-1" {
t.Errorf("got %+v", order)
}
}
To test a handler that reads the call's trace context, build the context with core.ContextWithCall. To check a schema, print agent.Tools()[i].InputSchema.
To test the whole path, run your agent against the gateway itself: create a throwaway project, start the agent with its key, and call your tools through POST /mcp/<project-id> with a client key, as in the quick start. The dashboard's Playground does the same from the UI.
Run tests with the race detector: go test -race ./.... Pass Options.Sleep (a function that records the delay and returns at once) to test your reconnect expectations without waiting. To avoid goroutine leaks in your tests, cancel the context and wait for Run to return.
Limits
The SDK checks the gateway's registration limits locally (the Free plan's values by default).
| Limit | Value | Enforced by |
|---|---|---|
| Tool and prompt name | ^[a-zA-Z0-9_-]{1,64}$ | The SDK at registration, and the gateway at connect |
| Tools per connection | 256 on Free | The SDK (MaxTools) and the gateway |
| Schema size per tool | 65,536 bytes on Free | The SDK (MaxSchemaBytes) and the gateway |
| Registration body | 512 kB | The SDK in Run (MaxRegisterBytes) and the gateway (413) |
| Result body | 4 MB | The SDK (MaxResultBytes) and the gateway (413) |
| One event the SDK will read | 16 MiB | The SDK (a larger one ends the stream) |
| Live connections per project | 32 on Free | The gateway (429) |
| Connect attempts per minute per project | 60 on Free | The gateway (429) |
| Call timeout | 30 s (the project owner can lower it) | The gateway |
| Duplicate tool, resource or prompt | Refused | The SDK (ErrDuplicate) |
Paid plans raise the plan-based limits; see Limits. Other things to know:
pattern,patternPropertiesandformatare advertised, not enforced. Enforce them in your handler.defaultis advertised, not applied. See Schema generation.- Numbers from JavaScript clients are doubles. An
int64argument above 2^53 can lose precision before it reaches you; send large identifiers as strings. - Annotations are hints. Clients may use them to skip or add a confirmation, but nothing enforces them. The one exception: the gateway hides tools that are not marked read-only from read-only clients. Gate writes behind an explicit confirmation argument too.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
spacefix: APIKey is required from New | SPACEFIX_API_KEY is empty | Export the key; create one under the project's keys in the dashboard |
Log: Connect failed: 401 {"error":"Invalid API key."}; retrying in 30000ms | The key is wrong, revoked, rotated or expired | Create a new agent key and restart. The agent keeps retrying slowly; set StopOnPermanentError to exit instead |
Log: Connect failed: 403 {"error":"This is a client key. Agents connect with an agent key."} | A client key was used | Use an agent or full key |
Log: Connect failed: 403 with This project is suspended | The project or account is suspended | The agent keeps retrying; contact support |
Log: Connect failed: 400 {"error":"Invalid tool name ..."} or Schema for "x" exceeds ... bytes. | A registration the gateway refused (usually caught locally first) | Rename or shrink; check MaxTools and friends match your plan |
Log: Connect failed: 429 {"error":"Too many agent connections: the Free plan allows 32."} | More replicas than the plan allows | Run fewer replicas or upgrade; the agent keeps retrying |
Log: Connect failed: 429 {"error":"Rate limit exceeded: 60 requests per minute."} | Connect attempts over the per-minute limit, often a crash loop | Fix the crash loop; the SDK waits for Retry-After |
Log: Connect failed: 503 {"error":"Service is shutting down."} | The gateway is restarting | Nothing; the agent reconnects |
Log: Post "...": dial tcp ...: connect: connection refused; retrying in ... | The gateway is unreachable, or SPACEFIX_SERVICE_URL is wrong | Check the URL and network. In Docker, localhost is the container |
Log: no data from the gateway for 1m30s; retrying in ... | A proxy or network path swallowed the stream | Make sure no proxy buffers server-sent events or times out idle responses under 25 s |
| Agent connected, but the client sees no tools | The oldest enabled agent registered a different tool set, the client key has a tool allow-list, or the client is read-only and no tool sets ReadOnly | Give all replicas the same tools; check the key; set ReadOnly: core.Bool(true) on tools that only read |
Client: No agent connected for server "<id>". | No agent is connected for the project | Start the agent; check the project id and key belong together |
Client: Every agent for server "<id>" is disabled. | All agents are disabled on the Agents page | Enable one |
Client: Request for tool "x" timed out after 30000ms. | The handler took longer than the call timeout | Make it faster, or return a job id and poll. The handler keeps running: set CallTimeout to stop it |
Client: Agent disconnected before returning a result. | The stream closed during the call (restart, network) | Retry the call; make state-changing tools idempotent |
Client: agent is shutting down | The call reached an agent that had begun shutting down | Retry; with other replicas up it will likely land on one of them |
Client: Input validation error: Invalid arguments for tool x: ... | The arguments did not match the generated schema | Check the schema with agent.Tools(); make the field optional (omitempty) if it should be |
Client: invalid arguments for tool "x": json: cannot unmarshal ... | The arguments passed the schema but did not decode into In | Align the struct and its tags |
Client: panic: ... | A handler panicked | The stack is in the agent's log; fix the handler |
Client: result is N bytes, which exceeds the gateway's limit of ... | The result is larger than MaxResultBytes | Return less, paginate, or return a reference |
Log: N call(s) are still running after the connection ended; leaving them behind | Handlers that ignore their context | Pass ctx to everything that can block |
Registration error spacefix: cannot generate schema: <path>: tag min:"1" applies to numeric fields only | A struct tag that does not fit the field's type | Fix the tag; the message names the field |
Registration error spacefix: cannot register after Run has started | A registration after Run | Register everything before Run |
| Agents page shows no process tiles for a Go agent | agent.Use(telemetry.New(...)) was not called, or was called after Run | Call Use before Run |
For wire-level problems, see Troubleshooting.
Differences from the Node.js SDK
The Go SDK speaks the same protocol and mirrors the Node.js SDK's capabilities. The differences are deliberate:
Node.js (@spacefix/core) | Go (core) | |
|---|---|---|
| Entry point | new Agent({ ... }), agent.tool/resource/prompt, connect(), disconnect() | core.New(Options), core.AddTool, agent.Resource/Prompt, Run(ctx), Disconnect() |
| Schema source | Standard Schema (Zod 4 and others) or plain JSON Schema | Generated from a Go struct and its tags, or a raw json.RawMessage |
| Registration errors | Reported by the gateway at connect | Checked at the registration call |
| Late registration | Allowed, advertised at the next reconnect | Refused with ErrFrozen after Run starts |
Bulk registration (register, defineTool) | Yes | No; call AddTool per tool |
| Handler cancellation | None | context.Context: cancelled on connection loss, shutdown timeout, CallTimeout |
| Call context in handlers | OpenTelemetry's active span | CallFromContext: request id, traceparent, tracestate, sentAt |
| Idle stream detection | None | 90 s with no bytes by default |
| Give up on a bad key | fatalStatuses | StopOnPermanentError (400, 401 and 403 as one group) |
| Result post retries | 3 retries | ResultAttempts, default 3 attempts |
| Stop | disconnect() waits up to 5 s for running calls | Drains for ShutdownTimeout (10 s), then cancels handlers; Run returns nil |
| Telemetry | agent.use(telemetry()) from @spacefix/telemetry; report replaces the post to the gateway | agent.Use(telemetry.New(...)) from spacefix-go/telemetry; Report is in addition |
| OpenTelemetry | Built in; on when @opentelemetry/api is installed | Observer hook in the core; adapter in the separate module go/otel |
| Process gauges through OpenTelemetry | @spacefix/telemetry publishes process.*, v8js.*, nodejs.eventloop.* | Not in the adapter |
| Logging | console.log, console.warn and console.error with [spacefix] ; logLevel option and SPACEFIX_LOG_LEVEL; optional logger | Logger interface, default slog adapter with the same prefix; LogLevel option and SPACEFIX_LOG_LEVEL |
Upgrading
The core module is at version 0.1.0; pin the version you depend on and read the release notes before you move. The go/otel module is a separate Go module and is versioned separately from the core.
Next steps
- Building an agent: the concepts behind tools, resources and prompts
- Keys: agent, client and full keys
- Connecting clients: call your tools from Claude, Cursor and others
- Running more than one copy: replicas and shared state
- OpenTelemetry: spans, metrics and backend recipes
- The agent protocol: what the SDK speaks on the wire