SDKs
Node.js SDK
Expose functions from your Node.js service as MCP tools, resources and prompts with @spacefix/core.
@spacefix/core lets a Node.js service expose functions it already has as MCP tools, resources and prompts, without building or hosting an MCP server. Your process opens one outbound connection to the gateway, registers what you declare, and runs your handlers when calls arrive. There is no endpoint to host and no inbound port to open.
This page is the complete guide: install, a runnable agent, every option, how to define tools, running in production, telemetry, OpenTelemetry, testing, limits and troubleshooting. The package is at version 1.0.0.
Install
| Item | Value |
|---|---|
| Package | @spacefix/core |
| Node.js | 20 or newer |
| Module format | ESM only ("type": "module"), with TypeScript types included |
| Dependencies | None. OpenTelemetry is an optional peer dependency |
| Optional packages | @spacefix/telemetry (process metrics on the dashboard), @opentelemetry/api and @opentelemetry/api-logs (OpenTelemetry) |
npm install @spacefix/core zod
Zod is not required. It is used in the examples below to describe tool arguments; you can also pass plain JSON Schema. See Defining tools.
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 agent.mjs:
import { Agent } from '@spacefix/core';
import { z } from 'zod';
const orders = new Map([['o-1042', { id: 'o-1042', status: 'shipped', total: 42.5 }]]);
const agent = new Agent({ apiKey: process.env.SPACEFIX_API_KEY });
agent.tool(
'get_order',
{
description: 'Look up one order by id. Returns an error if it does not exist.',
inputSchema: z.object({ id: z.string().describe('The order id, for example o-1042') })
},
({ id }) => {
const order = orders.get(id);
if (!order) throw new Error(`No order ${id}. Ask the user to confirm the id.`);
return order;
}
);
async function shutdown() {
await agent.disconnect(); // waits up to 5 s for running calls to finish
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
void agent.connect();
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
node agent.mjs
[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.
How it works
Your process dials out, the gateway pushes calls down that connection, and your results go back as ordinary HTTPS requests. Only the value you return crosses the boundary: the gateway holds each tool's name, description and JSON Schema, never your code. Your handlers run in your process, with your database and your secrets.
Configuration
import { Agent } from '@spacefix/core';
const agent = new Agent({
apiKey: process.env.SPACEFIX_API_KEY!, // required: the project's agent key
url: 'https://api.spacefix.io', // optional: see below
otel: { argKeys: true }, // optional: OpenTelemetry bridge
fatalStatuses: [401, 403], // optional: give up on these connect statuses
logLevel: 'info' // optional: silent, error, warn, info or debug (or SPACEFIX_LOG_LEVEL)
});
| Option | Type | Default | Meaning |
|---|---|---|---|
apiKey | string | none, required | The project's agent (or full) key. An empty value throws Error('apiKey is required.') when you construct the agent |
url | string | SPACEFIX_SERVICE_URL, then https://api.spacefix.io | The gateway's base URL. A trailing / is removed. The environment variable is read when the agent is constructed. An empty SPACEFIX_SERVICE_URL is not treated as unset: unset the variable instead |
otel | false or { argKeys?: boolean } | {} (bridge on) | false turns the OpenTelemetry bridge off. { argKeys: true } also records the names (never the values) of each call's arguments. See OpenTelemetry |
fatalStatuses | number[] | [] | HTTP statuses from the gateway's connect endpoint that make the agent give up: connect() rejects instead of retrying. Use [401, 403] to fail fast on a wrong key. See Reconnects |
The agent also exposes two read-only properties, apiKey and url (the normalised URL).
Environment variables
| Variable | Used by | Meaning |
|---|---|---|
SPACEFIX_SERVICE_URL | the SDK | The gateway's base URL, used when you do not pass url. Set it per environment, so one build runs everywhere on configuration alone |
SPACEFIX_LOG_LEVEL | the SDK | silent, error, warn, info (default) or debug: how much the SDK logs to the console. The logLevel option wins. See Logging |
SPACEFIX_API_KEY | the SDK | The agent key, used when you do not pass 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.
For local development against a gateway on your machine:
SPACEFIX_SERVICE_URL=http://localhost:3000 SPACEFIX_API_KEY=sf_live_... node agent.mjs
Defining tools
A tool is a function the model can call. Register one with agent.tool(name, config, handler).
| Use it when | Registered with | |
|---|---|---|
| Tool | The model should do something or look something up, with arguments | agent.tool(name, config, handler) |
| Resource | The model should read a current view of something, by uri, with no arguments | agent.resource(uri, config, handler) |
| Prompt | You want to ship message templates users pick from a client's menu | agent.prompt(name, config, handler) |
All three travel down the same connection and are traced and metered the same way. tool, resource, prompt, register and use return the agent, so calls chain. Registering the same name (or uri) twice replaces the earlier one.
Schemas
inputSchema takes a Standard Schema that can describe itself as JSON Schema (Zod 4 is what this page uses), or plain JSON Schema. With a Standard Schema the handler's argument type is inferred:
import { z } from 'zod';
agent.tool(
'get_order',
{
description: 'Look up a single order by its id. Returns null if it does not exist.',
inputSchema: z.object({ id: z.string().describe('The order id, for example o-1042') })
},
async ({ id }) => orderService.findById(id) // the function you already have
);
The SDK converts the schema to JSON Schema (draft 2020-12) before sending, so the gateway never knows which library you use. With plain JSON Schema, pass the argument type yourself, because there is nothing to infer it from:
agent.tool<{ item: string; quantity: number }>(
'create_order',
{
description: 'Create an order.',
inputSchema: {
type: 'object',
properties: { item: { type: 'string' }, quantity: { type: 'integer', minimum: 1 } },
required: ['item', 'quantity']
}
},
async ({ item, quantity }) => orderService.create({ item, quantity })
);
A tool without inputSchema is registered without one, and the gateway treats it as {"type":"object"}.
Descriptions are the prompt
The name, description and schema are everything the model has to choose and call a tool. 'Get order' selects worse than 'Look up a single order by its id. Returns null if it does not exist. To search by customer, use search_orders instead.' Naming the sibling tool often prevents a wrong call. Describe every argument too (.describe(...) on a Zod field), including units and the shape of ids.
Tool and prompt names must match ^[a-zA-Z0-9_-]{1,64}$. The project owner can override a tool's description from the dashboard without redeploying.
Tool annotations
Annotations tell MCP clients what a tool does, so a client can decide how much to confirm before running it. A read-only lookup can run without asking; a tool that deletes data can get a stronger prompt.
agent.tool(
'delete_order',
{
description: 'Cancel and delete an order by id.',
inputSchema: z.object({ id: z.string() }),
annotations: { title: 'Delete order', destructiveHint: true, idempotentHint: true }
},
async ({ id }) => orders.delete(id)
);
| Key | Meaning |
|---|---|
title | A readable name shown instead of the tool's identifier. At most 200 characters |
readOnlyHint | The tool does not change anything. The gateway shows read-only clients only tools with readOnlyHint: true |
destructiveHint | The tool may delete or overwrite data. Only meaningful when it is not read-only |
idempotentHint | Calling it again with the same arguments has no further effect |
openWorldHint | The tool reaches systems outside your own, such as the public internet |
Leave a key out to let the client apply its own default. defineTool takes the same annotations. They are hints: clients are told not to trust them for security, so they are not a replacement for checks inside your handler. readOnlyHint is the one the gateway acts on: read-only keys, projects whose Client access is Read only, and mcp:read OAuth tokens see only the tools that set it to true (or that the owner marks Read in the dashboard). Leave it out and the tool counts as a write, so set it on every tool that only reads. A wrongly typed value fails the connect with a 400 that names the tool, and the dashboard shows the hints next to each tool on the project page.
Results and errors
Return any JSON-serialisable value. A string is sent as text; anything else is sent as JSON text. Return an object with a
contentarray and it passes through untouched, so you can return several blocks or setisErroryourself:agent.tool('partial', { description: 'Returns a partial result.' }, () => ({ content: [{ type: 'text', text: 'Partial results: 3 of 10 shards answered' }], isError: true }));
- Throw to report a failure. The message reaches the model as a tool result with
isError: true. Throw something the model can act on ('No order 1234. Ask the user to confirm the id.') and never leak internals or secrets: the string goes to whoever drives the agent. Only the message is sent, not the stack. A thrown string or an object with amessageproperty is also sent as text. - A result that cannot be serialised (a
BigInt, a circular structure) is turned into an error result,Result could not be serialised: <reason>, and the agent keeps running. Convert BigInts and dates yourself. - A result over 4 MB is replaced by the error
Result too large for the gateway (limit 4194304 bytes). - 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: ....pattern,patternPropertiesandformatare shown to clients as hints but are not enforced by the gateway; check them in your handler if they matter. - Calls time out after 30 seconds by default; the project owner can lower the limit per project. Your handler is not interrupted, and its late result is discarded. For slower work, return a job id and add a second tool to poll it.
- Handlers do not know who called them. A handler receives arguments only. Pass any identity you need as an argument.
- Calls are spread across replicas. Keep state in a database, not in process memory. See Replicas and shared state.
Resources
A tool is a function a client calls; a resource is data a client reads by uri. Use one when there is nothing to decide: no arguments, no side effects, just a current view of something the model should be able to look at.
agent.resource(
'orders://all',
{ name: 'All orders', description: 'Every order currently stored', mimeType: 'application/json' },
() => orderService.find()
);
A string is sent as is; anything else is serialised as JSON. name defaults to the uri and mimeType to application/json. Resources take no arguments by design: anything parameterised belongs in a tool.
Prompts
A prompt is a reusable message template. MCP clients list it in their menus, the user fills in the arguments, and the rendered messages start the conversation. Use one to ship the phrasing that works best with your tools.
agent.prompt(
'summarise_order',
{ description: 'Summarise an order for a support reply.', argsSchema: z.object({ id: z.string() }) },
async ({ id }) => `Summarise this order politely:\n${JSON.stringify(await orderService.findById(id))}`
);
Return a string (sent as one user message), { messages: [{ role, content }] } for a longer exchange, or any JSON-serialisable value (sent as text):
agent.prompt('triage', { argsSchema: z.object({ text: z.string() }) }, ({ text }) => ({
messages: [
{ role: 'user', content: { type: 'text', text: `Triage this ticket:\n${text}` } },
{ role: 'assistant', content: { type: 'text', text: 'Understood. First I will check the order history.' } }
]
}));
MCP clients send every prompt argument as a string, and the gateway refuses a call that omits a required one before your code runs. Only the top-level properties and required of argsSchema matter: they become the argument list { name, description, required } that clients see. title (shown in menus) defaults to the name.
Splitting across files
Past a handful of tools, keep each in its own file and collect them in one place. defineTool exists because TypeScript cannot infer a handler's argument from a sibling property of the same object literal; it returns the definition unchanged, to recover the inference agent.tool(...) gives you.
import { defineTool } from '@spacefix/core';
import { z } from 'zod';
export const getOrder = defineTool({
name: 'get_order',
description: 'Look up a single order by its id.',
inputSchema: z.object({ id: z.string() }),
handler: ({ id }) => orderService.findById(id) // id is string
});
import { Agent } from '@spacefix/core';
import { getOrder, createOrder } from './tools/index.js';
const agent = new Agent({ apiKey: process.env.SPACEFIX_API_KEY! });
agent.register([getOrder, createOrder]);
With plain JSON Schema, pass the type: defineTool<{ item: string }>({ ... }). defineResource and definePrompt are the counterparts. register accepts tools, resources and prompts in one array (told apart by uri and kind: 'prompt'), so a mixed array works:
agent.register([...tools, ...resources, ...prompts]);
One module listing every tool is also your review boundary: the whole agent-visible surface in one place.
Trace context and cancellation
A handler receives its arguments and nothing else: there is no context object and no cancellation signal. A handler that is still running when the gateway gives up (after the call timeout, or because the connection dropped) finishes anyway, and its result is discarded.
Trace context comes through OpenTelemetry. When @opentelemetry/api is installed, the SDK runs your handler inside a span that continues the caller's trace, so the active span is available in your code:
import { trace } from '@opentelemetry/api';
agent.tool('lookup', { inputSchema: z.object({ id: z.string() }) }, ({ id }) => {
const ctx = trace.getActiveSpan()?.spanContext();
console.log({ trace_id: ctx?.traceId, span_id: ctx?.spanId }); // add these to your own logs
return orderService.findById(id);
});
See OpenTelemetry.
Connection lifecycle
Reconnects
connect() resolves only when you call disconnect(). If the network drops or the gateway restarts, the agent reconnects by itself, re-registering everything.
- Backoff. The wait doubles after each consecutive failure (1 s, 2 s, 4 s ... up to 30 s). Each wait is randomised ("jittered") inside that envelope and never shorter than 1 s, so a fleet of replicas that lost the gateway together does not reconnect in lockstep.
Retry-After. If the gateway answers a connect attempt with aRetry-Afterheader (for example a 429), the wait is at least that long, capped at 5 minutes.- Reset. The backoff returns to its start when the gateway accepts a connection and sends its
readyevent, so an agent that has been healthy for a week and loses its connection retries quickly, not after the 30 s it reached the last time the network was down. Every failure is retried, including a 401 from a wrong or revoked key, unless you list its status in
fatalStatuses. WithfatalStatuses: [401, 403],connect()rejects withConnect failed: 401 ...so your process can exit and let your orchestrator report it:const agent = new Agent({ apiKey: process.env.SPACEFIX_API_KEY!, fatalStatuses: [401, 403] }); try { await agent.connect(); } catch (err) { console.error(err); process.exit(1); }
The SDK logs to the console:
| Output | When |
|---|---|
[spacefix] connected with N tool(s) (plus , M resource(s) and , K prompt(s) when present) | The gateway sent ready (stdout) |
[spacefix] <reason>; retrying in <ms>ms | A connection attempt or the stream failed (stderr) |
[spacefix] failed to return result for "<name>": <reason> | A result post failed after its retries (stderr) |
[spacefix] gateway rejected the result for "<name>": <status> | The gateway refused a result with a 4xx status (stderr) |
<reason> is API key was rejected (401). Check SPACEFIX_API_KEY (also for 403), Registration was rejected (400): <message>, Connect failed: <status> <body>, Stream closed, or the network error text.
Results are retried
A result that fails to post because of a network error or a 5xx response is retried up to three more times (200 ms, 400 ms, 800 ms apart), as long as the call's 30 s deadline allows. A 4xx response is not retried. The gateway ignores a result it has already settled, so a retry is safe.
At most once
The gateway never re-sends a call. If the connection drops while your handler runs, the handler runs to completion but its result is lost and the caller is told the call failed. A tool that changes state may therefore have happened even though the caller saw an error. Where that matters, make state-changing tools idempotent: accept a client-generated key and ignore a repeat.
Shutting down
async function shutdown() {
await agent.disconnect(); // waits up to 5 s for running calls to finish
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
disconnect() stops reconnecting at once, even in the middle of a backoff wait. Calls already running get up to 5 seconds (graceMs) to finish and post their results before the stream is closed; calls that arrive meanwhile are answered with the error Agent is shutting down. The gateway does not retry those calls elsewhere, so the caller sees that error. It returns a promise that resolves once the agent has stopped, and is safe to call more than once.
await agent.disconnect({ graceMs: 5000 }); // the default; 0 skips the wait
process.exit is deliberate. Registering a SIGINT listener replaces Node's default "terminate now", so if your handler does not end the process, Ctrl-C does nothing visible; and disconnect() cannot end the process itself, because Node's global fetch keeps an idle connection pool open that is shared with the rest of your app. If you export OpenTelemetry data, flush it after disconnect() and before exiting (await sdk.shutdown()).
Running in production
The SDK holds one long-lived connection, and that decides where it can run.
Works: anything with a process that stays alive: ECS or Fargate, Kubernetes, EC2, Fly, Render, Railway, Heroku, a VM, a Docker container, your laptop.
Does not work: request-scoped serverless. AWS Lambda, Vercel functions and standard Cloudflare Workers freeze or discard the process between requests, so the connection dies. Google Cloud Run works only with min-instances of at least 1 and CPU always allocated. If your API is serverless, run the agent as a separate always-on worker that shares the same codebase and service layer.
Processes and containers
Run the agent as its own process type next to your web service. It needs outbound HTTPS to the gateway and nothing else: no port, no ingress, no service. Configuration is two environment variables, SPACEFIX_API_KEY and SPACEFIX_SERVICE_URL. A container needs nothing special; start Node directly so it receives SIGTERM:
FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "agent.mjs"]
Use the exec form of CMD (as above) so Node is the main process and your SIGTERM handler runs when the orchestrator stops the container. On Kubernetes, set terminationGracePeriodSeconds above the 5 s disconnect() wait (the default of 30 s is fine).
Replicas and shared state
Every replica of your service opens its own connection, and the gateway spreads calls across them, so tools scale like the rest of your service. A replica that dies drops out immediately, and its in-flight calls fail fast rather than hang.
Because calls are spread across replicas, tool state must not live in the process. Two replicas holding their own in-memory copy diverge in a way that looks like a bug in the service but is not: a write lands in one replica, the next read is answered by the other, and ids generated from a local count collide across both. Read and write shared storage (the database you already have), or run one replica while developing.
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. A project can hold up to 32 connections on the Free plan; see Limits.
Health checks
The SDK does not expose a "connected" flag. Use these instead:
- Process health. The agent is a normal long-running process; use your orchestrator's process check, or an HTTP health route your service already has.
- Connection health. The project's Agents page shows each connected agent, and the project page shows whether an agent is connected. Attach
@spacefix/telemetryto see its memory, CPU and event loop there too. - In your own backend. With OpenTelemetry the gauge
spacefix.agent.connectedis1while the agent holds a connection, andspacefix.agent.reconnectscounts reconnects.
Do not restart a healthy process because the gateway is briefly unreachable: the agent reconnects by itself.
Telemetry
@spacefix/telemetry reports your process's health to the dashboard: memory, CPU, event loop delay, and how many calls it handled. It is optional; without it the agent reports nothing and carries no sampling code.
npm install @spacefix/telemetry
import { telemetry } from '@spacefix/telemetry';
agent.use(telemetry());
The samples ride the same key and connection as your tools. They appear on the Agents page (tiles for heap, RSS, CPU and event loop) and on the dashboard.
agent.use(telemetry({ intervalMs: 10_000, report: metrics => console.log(metrics.calls), otel: true }));
| Option | Default | Meaning |
|---|---|---|
intervalMs | 10000 | How often to sample and report |
report | the agent's own connection | A function that receives each sample. Supplying it replaces the dashboard transport; send the sample on yourself if you want both |
otel | true | Also publish the measurements through OpenTelemetry (see below). Pass false to opt out |
Each sample describes the window since the previous one:
| Field | Unit | Meaning |
|---|---|---|
uptime | seconds | How long the process has been up |
rss, heapUsed, heapTotal | bytes | Resident set size and heap |
cpu | 0 to 1 | Share of one core used in the window |
eventLoopDelay | ms | Mean event loop delay: the number that predicts latency |
eventLoopUtilization | 0 to 1 | Fraction of the window the loop was busy |
tools | count | Tools the agent exposes |
calls, errors | counts | Calls handled in the window and how many threw |
inFlight, peakInFlight | counts | Calls running at the instant of sampling, and the most at once in the window. Fast tools finish between samples, so read peakInFlight for concurrency |
avgMs | ms | Mean handler duration |
A failed post is ignored: monitoring never breaks a tool.
OpenTelemetry
The SDK can send traces, metrics and logs for every call into your own observability backend (Datadog, New Relic, Grafana, anything that accepts OTLP). It uses only the OpenTelemetry API, so it adds no exporter and needs no configuration of its own.
@opentelemetry/apinot installed: the bridge switches itself off. Nothing breaks.- Installed, but no OpenTelemetry SDK registered in your app: every instrument is a no-op. There is no overhead and nothing is sent.
- An OpenTelemetry SDK registered: spans, metrics and logs leave through your exporters.
You never give the gateway an endpoint or credentials for your backend.
Set it up
Install the API packages (add
@opentelemetry/api-logsfor logs):npm install @opentelemetry/api @opentelemetry/api-logs
Register an OpenTelemetry SDK in your app, before the agent connects. If you already export traces from this service, you are done. Otherwise, a minimal setup:
npm install @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-httpimport { NodeSDK } from '@opentelemetry/sdk-node'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; export const sdk = new NodeSDK({ serviceName: 'orders-agent', traceExporter: new OTLPTraceExporter() // reads OTEL_EXPORTER_OTLP_ENDPOINT }); sdk.start();
Import it first, then create the agent:
import { sdk } from './tracing.js'; import { Agent } from '@spacefix/core'; import { telemetry } from '@spacefix/telemetry'; const agent = new Agent({ apiKey: process.env.SPACEFIX_API_KEY! }); agent.use(telemetry()); // also publishes process metrics through OpenTelemetry void agent.connect(); async function shutdown() { await agent.disconnect(); await sdk.shutdown(); // flush buffered telemetry process.exit(0); } process.on('SIGTERM', shutdown);
The agent needs no OpenTelemetry options. Two exist: otel: { argKeys: true } also records argument names (never values) as spacefix.arg_keys, and otel: false turns the bridge off.
What is emitted
Each call that reaches your agent produces one server span, named tools/call <name>, resources/read <uri> or prompts/get <name>, from the instrumentation scope @spacefix/core. The span continues the MCP client's trace (the gateway forwards the caller's traceparent; if the client sent none, the trace starts at the gateway). Your handler runs inside the span, so database and HTTP spans your code creates nest under it.
| Attribute | On | Meaning |
|---|---|---|
mcp.method.name | all | tools/call, resources/read or prompts/get |
gen_ai.tool.name, gen_ai.operation.name | tools | The tool name, and execute_tool |
agent.resource.uri | resources | The resource uri |
gen_ai.prompt.name | prompts | The prompt name |
spacefix.request_id | all, when sent | The request id shown on the gateway's Logs page |
spacefix.gateway.queue_ms | all, when sent | Milliseconds between the gateway sending the call and your process starting it |
spacefix.arg_keys | opt-in | Up to 20 argument names, never values |
error.type | failures | The error's class name |
Metrics (scope @spacefix/core): mcp.server.operation.duration (histogram, seconds), spacefix.agent.reconnects (counter) and spacefix.agent.connected (gauge). With @spacefix/telemetry attached and otel left on, process gauges follow: process.uptime, process.memory.usage, process.cpu.utilization, v8js.memory.heap.used, v8js.memory.heap.limit, nodejs.eventloop.delay.mean, nodejs.eventloop.utilization, spacefix.agent.tools and mcp.server.active_requests. Logs (needs @opentelemetry/api-logs and a logger provider): Connected to the gateway, Disconnected from the gateway: <reason>, Reconnecting to the gateway in <ms>ms: <reason>, and <method> <name> failed: <message> for a failed handler.
Argument values and results are never recorded. Error messages are, because you need them to debug, so do not put secrets in them. The full list of spans, metrics, backend recipes and troubleshooting is in OpenTelemetry.
Testing your tools
The SDK does not ship a fake gateway. What is verified, and how to test:
Handlers are plain functions.
defineToolreturns your definition unchanged, so import it and call itshandlerdirectly in your test runner, with whatever arguments you want. This needs no gateway and no network:import { getOrder } from './tools/get-order.js'; const order = await getOrder.handler({ id: 'o-1042' });
- Against a real gateway. Create a throwaway project, run your agent with its key and
SPACEFIX_SERVICE_URL, and call your tools throughPOST /mcp/<project-id>with a client key as in the quick start. The dashboard's Playground does the same from the UI.
Logging
The SDK writes to the console with the [spacefix] prefix (see Connection lifecycle). How much it writes is the log level:
| Level | Writes | Console method |
|---|---|---|
silent | nothing | none |
error | failures that need attention: a result that could not be returned, a rejected result, giving up on a fatalStatuses status | console.error |
warn | adds connection failures that are being retried (<reason>; retrying in <ms>ms) | console.warn |
info (default) | adds the connection line (connected with 2 tool(s)) | console.log |
debug | adds one line per call: call "get_order" received (call <id>) and ... finished in 42ms (call <id>) | console.debug |
Set it in code or in the environment:
const agent = new Agent({ apiKey: process.env.SPACEFIX_API_KEY!, logLevel: 'warn' });
SPACEFIX_LOG_LEVEL=silent node agent.mjs # turn the SDK's console output off
SPACEFIX_LOG_LEVEL=debug node agent.mjs # one line per call
The logLevel option wins over SPACEFIX_LOG_LEVEL. The variable is case-insensitive, and off, none, false, no and 0 also mean silent. An unknown logLevel throws when you construct the agent; an unknown SPACEFIX_LOG_LEVEL is ignored with one warning and the default is used.
To send the messages to your own logger instead of the console, pass logger with any of debug, info, warn and error (each takes the message, without the prefix). logLevel still decides which are sent, and a logger that throws is ignored:
import pino from 'pino';
const log = pino();
const agent = new Agent({
apiKey: process.env.SPACEFIX_API_KEY!,
logger: { info: m => log.info(m), warn: m => log.warn(m), error: m => log.error(m) }
});
The OpenTelemetry bridge's logs are separate: they go to your OpenTelemetry setup, not the console, and are not affected by logLevel.
Limits
| Limit | Value | When exceeded |
|---|---|---|
| Tool and prompt name | ^[a-zA-Z0-9_-]{1,64}$ | 400 Invalid tool name "...". at connect |
| Tools per agent | 256 on Free | 400 Too many tools: limit is 256. |
| Resources per agent | 1,000 | 400 Too many resources: limit is 1000. |
| Schema size per tool | 65,536 bytes of JSON on Free | 400 Schema for "<name>" exceeds 65536 bytes. |
| Registration body | 512 kB | 413 |
| Result body | 4 MB | The SDK sends an error result instead |
| Call timeout | 30 s (the project owner can lower it) | The caller sees Request for tool "<name>" timed out after 30000ms. |
| Live connections per project | 32 on Free | 429 Too many agent connections: the Free plan allows 32. |
| Connection attempts | 60 a minute per project on Free | 429 with Retry-After |
Paid plans raise the plan-based limits; see Limits.
Limits of the SDK model itself:
- Tools do not know who called them. Arguments only.
- 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.
pattern,patternPropertiesandformatare advertised, not enforced. Enforce them in your handler.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Error: apiKey is required. | SPACEFIX_API_KEY is empty | Export the key; create one under the project's keys |
API key was rejected (401). Check SPACEFIX_API_KEY; retrying in ... | The key is wrong, revoked, rotated or expired | Create a new agent key and restart. Set fatalStatuses: [401, 403] to exit instead of retrying |
API key was rejected (403) | A client key was used as an agent key, or the project is suspended | Use an agent or full key |
Registration was rejected (400): ... | The gateway refused your tools: a bad name, a schema that is too large or not valid JSON Schema, too many tools | Fix the message's cause; the gateway rejects the whole registration |
Connect failed: 429 ... | More replicas than the plan allows, or connection attempts over the per-minute limit (often a crash loop) | Run fewer replicas, fix the crash loop, or upgrade. The SDK waits for Retry-After |
Connect failed: 503 ... | The gateway is restarting | Nothing; the agent reconnects |
fetch failed; retrying in ... | The gateway is unreachable, or SPACEFIX_SERVICE_URL is wrong | Check the URL and network. In Docker, localhost is the container |
| Connected, but the client sees no tools | The client uses a key with a tool allow-list, the client is read-only and no tool sets readOnlyHint: true, or the oldest replica registered a different tool set | Check the key; set readOnlyHint: true on tools that only read; give every replica the same tools |
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 |
Client: Agent disconnected before returning a result. | The connection closed during the call (restart, network) | Retry; make state-changing tools idempotent |
Client: Agent is shutting down. | The call reached an agent that had begun disconnect() | Retry; with other replicas up it will likely land on one of them |
Client: Input validation error: ... | The arguments did not match your schema | Check the schema; make the field optional if it should be |
Client: Tool failed. | A handler threw something that has no message (for example throw undefined) | Throw Error objects with a message |
Process exits on unhandledRejection | Another part of your code, not the SDK, rejected | The SDK catches handler errors and bad results itself; see your own stack trace |
For wire-level problems, see Troubleshooting.
Upgrading
@spacefix/core follows semantic versioning; the current version is 1.0.0. Changes you may notice if you wrote an agent against an earlier build:
- Reconnects are jittered and honour
Retry-After, where they used to follow a fixed schedule. - Failed result posts are retried (network errors and 5xx), and an unserialisable or oversized result becomes an error result instead of ending the process or timing out.
disconnect()waits up to 5 s for running calls and returns a promise. Code that called it withoutawaitstill stops the reconnect loop immediately.- A thrown string or plain object now reaches the caller as text instead of
Tool failed. fatalStatusesis new, and the SDK identifies itself in theUser-Agentheader (spacefix-sdk-node/<version> node/<version>), which the Agents page shows. TheappInfooption appends your own application name and version to it.
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