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

ItemValue
Modulegithub.com/softmode/spacefix-go
Package namecore
Go1.22 or newer
DependenciesNone. core and telemetry use only the standard library
OpenTelemetry adapterA 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
bash
go get github.com/softmode/spacefix-go

The SDK has two packages, like the Node.js SDK's @spacefix/core and @spacefix/telemetry:

go
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:

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:

bash
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:

bash
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

go
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".

OptionDefaultMeaning
APIKeynone, requiredThe project's agent or full key. Sent as Authorization: Bearer <key>
URLSPACEFIX_SERVICE_URL if set and not empty, else https://api.spacefix.ioThe gateway's base URL. Trailing slashes are removed. Read when New is called. Prefer the environment variable, so one build runs in every environment
LoggerNewSlogLogger(slog.Default())Receives the SDK's messages. See Logging
LogLevelempty: SPACEFIX_LOG_LEVEL, else every message goes to LoggerLogSilent, 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
HTTPClientA client built for this jobUsed 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
MaxConcurrency0 (unlimited)The most handlers running at once. See Concurrency
CallTimeout0 (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
ShutdownTimeout10 sHow long a shutdown waits for running calls before cancelling their contexts. See Shutting down
Backoff.Initial1 sFirst wait between connection attempts. Do not set it under 1 s in production
Backoff.Max30 sLongest wait
Backoff.Jitter0.5Each 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
StreamIdleTimeout90 sReconnect if no bytes (not even the gateway's 25 s heartbeat) arrive for this long. Negative disables
ResultAttempts3How many times a result post is tried. Only network errors and 5xx responses are retried. Set 1 for no retries
ResultTimeout30 sThe time allowed for one result post attempt
StopOnPermanentErrorfalseRun returns the *ConnectError on a 400, 401 or 403 instead of retrying. See Failure classes
MaxTools256Client-side check of the tool limit (the Free plan's value)
MaxSchemaBytes65,536Client-side check of the per-tool schema size (the Free plan's value)
MaxRegisterBytes524,288Client-side check of the registration size (512 kB)
MaxResultBytes4,194,304Largest result posted (4 MB). A bigger result becomes an error result
UserAgentspacefix-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
AppInfoemptyYour 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, Randtime.Now, a timer sleep, math/rand.Float64Test 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

VariableUsed byMeaning
SPACEFIX_SERVICE_URLthe SDKThe gateway's base URL, used when Options.URL is empty
SPACEFIX_LOG_LEVELthe SDKsilent, 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_KEYthe SDKThe 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:

go
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)
	})
go
func AddTool[In, Out any](a *Agent, name, description string,
	handler func(ctx context.Context, in In) (Out, error), opts ...ToolOption) error
  • In is the input type. The SDK generates the tool's JSON Schema from it (see Schema generation) and decodes each call's arguments into a fresh In before your handler runs. Use a struct or a map[string]T.
  • Out is anything encoding/json can 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.
  • AddTool returns an error for an invalid or duplicate name, a type or tag that cannot be described, a schema over MaxSchemaBytes, more than MaxTools tools, and for registering after Run started. 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 enforce pattern, patternProperties or format; 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 add StrictInput()), and a missing or null argument object decodes to the zero value of In.
  • 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

OptionEffect
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 typeJSON 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":[...]}
*TSame as T; the field is optional
chan, func, complex*, unsafe.PointerError

Fields.

  • Names, skipping and flattening follow encoding/json: the json tag 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 omitempty and no omitzero, it is not a pointer, and it is not reached through an embedded pointer. Everything else is optional. The required list is in declaration order.
  • json:",string" on an integer, float or bool field makes the schema {"type":"string"}.
  • Embedded structs without a json name are flattened into the parent. An embedded struct with a json name is an ordinary field.
  • Recursive types use $defs and $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.

TagApplies toEffect
desc:"..."anydescription: what the model sees for the argument
enum:"a,b,c"strings, integers, floats, bools, and slices of themenum. 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"anydefault. 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, floatsminimum, maximum
minLength:"1", maxLength:"80"stringsminLength, maxLength
minItems:"1", maxItems:"10"slices and arraysminItems, maxItems
pattern:"^[a-z]+$"stringspattern (advertised to clients; not enforced by the gateway)
format:"email"stringsformat (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:

go
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):

json
{
  "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:

go
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.

go
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)
})
  • Name defaults to the URI. MimeType defaults to application/json.
  • The handler's return value is sent as text: a Go string as 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.

go
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 a PromptResult{Messages: ...} with UserMessage and AssistantMessage. Content is text only.
  • An argument the caller left out is absent from the map. A Required argument 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

HandlerWhat 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 panicsThe 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 marshalledAn error result, failed to encode result: .... The agent keeps running
Result over MaxResultBytesAn error result, result is N bytes, which exceeds the gateway's limit of 4194304 bytes
Arguments that do not decode into InAn 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 panicsA JSON-RPC error

Control the result the model sees with ToolResult:

go
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 ShutdownTimeout runs out, or when CallTimeout expires. Pass ctx to 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 with context.WithoutCancel, so your values survive, but cancelling Run's context does not interrupt running calls: they get the chance to finish. See Shutting down.
  • The call's trace context, through CallFromContext:
go
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:

go
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

go
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

StepBehaviour
Any failureA non-2xx answer, a network error, a malformed event, an idle stream, or the stream ending for any reason, is a failure
First wait1 s (Backoff.Initial)
GrowthDoubles after each consecutive failure: 1 s, 2 s, 4 s, 8 s, 16 s, 30 s, 30 s ...
Ceiling30 s (Backoff.Max)
ResetTo 1 s when the gateway accepts the connection and sends its ready event, not merely when the response arrives
JitterEach 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-AfterOn a 429 with the header, the wait is at least that long (capped at 5 minutes)
InterruptedCancelling the context or calling Disconnect ends the wait at once
Log line[spacefix] <reason>; retrying in <ms>ms

Failure classes

What happenedWhat the SDK does
Network error, 5xx, 503 Service is shutting down., stream endedRetry 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 parseTreated as a failed stream: reconnect
No bytes for StreamIdleTimeoutAbort 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:

go
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:

  1. 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.
  2. It waits up to ShutdownTimeout (10 s) for calls already running to finish, and posts their results.
  3. 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.
  4. It waits one more second for those handlers to return, and logs N call(s) are still running after the connection ended; leaving them behind if some did not. A handler that ignores its context cannot be stopped in Go; it is abandoned.
  5. Run returns nil.

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/atomic or a database. Run your tests with -race.
  • MaxConcurrency caps 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 ResultAttempts times (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 as failed 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:

Dockerfile
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 Observer receives connection changes, which is enough to build a readiness flag:

    go
    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) before Run, and report r.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)):

go
// 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.

go
core.Options{LogLevel: core.LogWarn}   // quieter
core.Options{LogLevel: core.LogSilent} // no SDK logging at all
bash
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.

LevelMessage (after the [spacefix] prefix)When
Infoconnected 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>msA 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
Errorfailed to return result: <reason>A result post failed after its attempts, or got a 4xx
Errortools/call "name" panicked: <value> (with a stack attribute)A handler panicked
Warn<n> call(s) are still running after the connection ended; leaving them behindA shutdown or reconnect left handlers behind
Debugtools/call "name" received and tools/call "name" finished in <ms>ms, each with (request <id>) when the gateway sent oneEach call (resources/read and prompts/get likewise)
Debugtelemetry post failedA 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.

go
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:

FieldUnitGo meaning
uptimesecondsTime since the process started, approximated by when the package was initialised
rssbytesAn 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
heapUsedbytesHeap objects in use
heapTotalbytesHeap objects plus free and unused heap memory, excluding what was released to the OS
cpuratio 0 to 1Share of one core the Go runtime used in the window, capped at 1. Counts garbage collection and your goroutines; not cgo or foreign threads
eventLoopDelaymsGo has no event loop. Reported instead: the mean time goroutines spent runnable before the scheduler ran them. It rises when the process is overloaded
eventLoopUtilizationratio 0 to 1Go has no single loop. Reported instead: busy runtime CPU time divided by GOMAXPROCS times wall time
toolscountTools registered
calls, errorscountsCalls handled in the window (tools, resource reads and prompts) and how many returned an error or panicked
inFlightcountHandlers running at the instant of sampling. Usually 0, because fast handlers finish between samples
peakInFlightcountMost handlers running at once in the window; read this for concurrency
avgMsmsMean 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.

bash
go get github.com/softmode/spacefix-go/otel
go
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.

AttributeOnValue
mcp.method.namealltools/call, resources/read or prompts/get
gen_ai.tool.nametool callsThe tool name
gen_ai.operation.nametool callsexecute_tool
agent.resource.uriresource readsThe URI
gen_ai.prompt.namepromptsThe prompt name
spacefix.request_idall, if sentThe request id from the gateway's Logs page
spacefix.gateway.queue_msall, if known and not negativeMilliseconds between the gateway pushing the call and the handler starting
spacefix.arg_keysonly with ArgKeysUp to 20 argument names, sorted
error.typefailed callsThe 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.

NameKindUnitAttributes
mcp.server.operation.durationHistogramsmcp.method.name, gen_ai.tool.name (tool calls), error.type (failures)
spacefix.agent.reconnectsCounternone
spacefix.agent.connectedObservable gauge1 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:

go
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:

go
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).

LimitValueEnforced by
Tool and prompt name^[a-zA-Z0-9_-]{1,64}$The SDK at registration, and the gateway at connect
Tools per connection256 on FreeThe SDK (MaxTools) and the gateway
Schema size per tool65,536 bytes on FreeThe SDK (MaxSchemaBytes) and the gateway
Registration body512 kBThe SDK in Run (MaxRegisterBytes) and the gateway (413)
Result body4 MBThe SDK (MaxResultBytes) and the gateway (413)
One event the SDK will read16 MiBThe SDK (a larger one ends the stream)
Live connections per project32 on FreeThe gateway (429)
Connect attempts per minute per project60 on FreeThe gateway (429)
Call timeout30 s (the project owner can lower it)The gateway
Duplicate tool, resource or promptRefusedThe SDK (ErrDuplicate)

Paid plans raise the plan-based limits; see Limits. Other things to know:

  • pattern, patternProperties and format are advertised, not enforced. Enforce them in your handler.
  • default is advertised, not applied. See Schema generation.
  • Numbers from JavaScript clients are doubles. An int64 argument 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

SymptomCauseFix
spacefix: APIKey is required from NewSPACEFIX_API_KEY is emptyExport the key; create one under the project's keys in the dashboard
Log: Connect failed: 401 {"error":"Invalid API key."}; retrying in 30000msThe key is wrong, revoked, rotated or expiredCreate 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 usedUse an agent or full key
Log: Connect failed: 403 with This project is suspendedThe project or account is suspendedThe 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 allowsRun 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 loopFix the crash loop; the SDK waits for Retry-After
Log: Connect failed: 503 {"error":"Service is shutting down."}The gateway is restartingNothing; the agent reconnects
Log: Post "...": dial tcp ...: connect: connection refused; retrying in ...The gateway is unreachable, or SPACEFIX_SERVICE_URL is wrongCheck 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 streamMake sure no proxy buffers server-sent events or times out idle responses under 25 s
Agent connected, but the client sees no toolsThe 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 ReadOnlyGive 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 projectStart 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 pageEnable one
Client: Request for tool "x" timed out after 30000ms.The handler took longer than the call timeoutMake 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 downThe call reached an agent that had begun shutting downRetry; 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 schemaCheck 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 InAlign the struct and its tags
Client: panic: ...A handler panickedThe 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 MaxResultBytesReturn less, paginate, or return a reference
Log: N call(s) are still running after the connection ended; leaving them behindHandlers that ignore their contextPass ctx to everything that can block
Registration error spacefix: cannot generate schema: <path>: tag min:"1" applies to numeric fields onlyA struct tag that does not fit the field's typeFix the tag; the message names the field
Registration error spacefix: cannot register after Run has startedA registration after RunRegister everything before Run
Agents page shows no process tiles for a Go agentagent.Use(telemetry.New(...)) was not called, or was called after RunCall 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 pointnew Agent({ ... }), agent.tool/resource/prompt, connect(), disconnect()core.New(Options), core.AddTool, agent.Resource/Prompt, Run(ctx), Disconnect()
Schema sourceStandard Schema (Zod 4 and others) or plain JSON SchemaGenerated from a Go struct and its tags, or a raw json.RawMessage
Registration errorsReported by the gateway at connectChecked at the registration call
Late registrationAllowed, advertised at the next reconnectRefused with ErrFrozen after Run starts
Bulk registration (register, defineTool)YesNo; call AddTool per tool
Handler cancellationNonecontext.Context: cancelled on connection loss, shutdown timeout, CallTimeout
Call context in handlersOpenTelemetry's active spanCallFromContext: request id, traceparent, tracestate, sentAt
Idle stream detectionNone90 s with no bytes by default
Give up on a bad keyfatalStatusesStopOnPermanentError (400, 401 and 403 as one group)
Result post retries3 retriesResultAttempts, default 3 attempts
Stopdisconnect() waits up to 5 s for running callsDrains for ShutdownTimeout (10 s), then cancels handlers; Run returns nil
Telemetryagent.use(telemetry()) from @spacefix/telemetry; report replaces the post to the gatewayagent.Use(telemetry.New(...)) from spacefix-go/telemetry; Report is in addition
OpenTelemetryBuilt in; on when @opentelemetry/api is installedObserver 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
Loggingconsole.log, console.warn and console.error with [spacefix] ; logLevel option and SPACEFIX_LOG_LEVEL; optional loggerLogger 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