Guides

Building tools

Design tools, resources and prompts that a model uses correctly: names, descriptions, schemas, results, errors, sizes, idempotency and the controls in the dashboard.

A model sees only three things about a tool: its name, its description and its argument schema. Everything else on this page follows from that: write those well, return results the model can use, fail with messages it can act on, and remember that a call may arrive twice or not complete. In the examples, orderService stands for the service code you already have (and Order for its type in Go); a tool should be a thin adapter over it, so there is one source of truth.

This page shows the shape of the code. For every option and type, see the SDK pages.

Tools, resources and prompts

Use it whenA client does
ToolThe model should do something or look something up, with argumentsCalls it with arguments (tools/call)
ResourceThe model should read a current view of something, by URI, with no argumentsReads it (resources/read)
PromptYou want to ship message templates that a user picks from a client's menuLists it and fills in its arguments (prompts/get)

All three travel down the same connection, are routed the same way and are logged and counted the same way.

A complete example

This agent offers three tools, a resource and a prompt. The rest of the page explains each choice.

agent.mjs
import { Agent, defineTool, defineResource, definePrompt } from '@spacefix/core';
import { z } from 'zod';

const getOrder = defineTool({
  name: 'get_order',
  description:
    'Look up one order by its id. Returns the order, or an error if no order has that id. ' +
    'To find orders by customer, use search_orders instead.',
  inputSchema: z.object({ id: z.string().describe('The order id, for example "1042"') }),
  handler: async ({ id }) => {
    const order = await orderService.findById(id);
    if (!order) throw new Error(`No order with id ${id}. Ask the user to confirm the id, or use search_orders.`);
    return order;
  }
});

const searchOrders = defineTool({
  name: 'search_orders',
  description: 'List orders for a customer, newest first. Returns an empty list when nothing matches.',
  inputSchema: z.object({
    customer: z.string().describe('Customer handle, for example "ada"'),
    status: z.enum(['open', 'shipped', 'cancelled']).optional().describe('Only orders in this status'),
    limit: z.number().int().min(1).max(50).default(10).describe('How many orders to return (1 to 50)')
  }),
  handler: ({ customer, status, limit = 10 }) => orderService.search({ customer, status, limit })
});

const createOrder = defineTool({
  name: 'create_order',
  description: 'Create an order. Always pass the same requestId when retrying, so the order is created only once.',
  inputSchema: z.object({
    requestId: z.string().describe('A unique id for this attempt, for example a UUID. Reuse it when retrying.'),
    customer: z.string(),
    total: z.number().int().describe('Total in cents')
  }),
  handler: ({ requestId, ...input }) => orderService.createOnce(requestId, input)
});

const openOrders = defineResource({
  uri: 'orders://open',
  name: 'Open orders',
  description: 'Every order that has not shipped yet',
  mimeType: 'application/json',
  handler: async () => (await orderService.all()).filter(o => o.status === 'open')
});

const summariseOrder = definePrompt({
  name: 'summarise_order',
  title: 'Summarise an order',
  description: 'Write a short, polite summary of an order for a support reply.',
  argsSchema: z.object({ id: z.string().describe('The order id') }),
  handler: async ({ id }) =>
    `Summarise this order politely, in two sentences:\n${JSON.stringify(await orderService.findById(id))}`
});

const agent = new Agent({ apiKey: process.env.SPACEFIX_API_KEY });
agent.register([getOrder, searchOrders, createOrder, openOrders, summariseOrder]);
void agent.connect();

Names

  • A tool or prompt name is 1 to 64 characters from A-Z, a-z, 0-9, _ and -. Use get_order, not get order or orders.get.
  • A resource is named by its URI (orders://open), and also has a display name.
  • Names are what a model uses to choose. Prefer a verb and a noun (search_orders, create_order), and keep sibling tools easy to tell apart.
  • Registering the same name twice is an error in Go and replaces the earlier tool in Node.js.
  • A name outside the rule makes the connection fail: the gateway answers 400 with Invalid tool name "<name>"., and the SDK logs it and keeps retrying. The Go SDK refuses the name at registration instead.

Descriptions are the prompt

The name, description and schema are everything the model gets. Get order selects worse than Look up one order by its id. Returns the order, or an error if no order has that id. To find orders by customer, use search_orders instead. Say what the tool returns, what it returns when nothing is found, and which tool to use for related jobs. Naming the sibling tool is often the difference between the right call and a wrong one.

Describe each argument too: .describe(...) on a Zod field, or the desc struct tag in Go. Give an example value when the format is not obvious.

Treat descriptions as instructions to the model, not as documentation for people. You can also change a tool's description from the dashboard without redeploying (see below).

Schemas

Every tool has a JSON Schema for its arguments. In Node.js you pass plain JSON Schema or any schema library that can describe itself as JSON Schema (Zod 4, ArkType, Valibot). In Go the schema is generated from the input struct: the json tag names a field, a field without omitempty is required, and the tags desc, enum, default, min, max, minLength, maxLength, pattern, format, minItems and maxItems refine it. Add core.StrictInput() to a tool to refuse arguments the struct does not declare.

What the gateway validates

Before your handler runs, the gateway validates the arguments against the schema you registered. A call that does not match never reaches your code, is not billed, and the client gets a result with isError:

Input validation error: Invalid arguments for tool search_orders: data/limit must be <= 50
Input validation error: Invalid arguments for tool search_orders: data/status must be equal to one of the allowed values
Input validation error: Invalid arguments for tool get_order: data must have required property 'id'

Types, required fields, enums, numeric and length bounds, array sizes and additionalProperties are enforced this way.

What is only advertised

Likewise, the SDK converts your schema to JSON Schema once, when it registers the tool. It does not run your schema library again on each call's arguments, so Zod refinements, transforms and .regex() checks are not run. Validate in the handler anything the JSON Schema cannot express.

You cannot change a tool's argument schema from the dashboard. Change it in code and redeploy.

Results

What the handler returns is what the model reads.

  • Return plain data. A string is sent as text; any other value is sent as JSON text; undefined or nil becomes null. Return what the model needs and no more: a trimmed object beats a table dump.
  • Return a shaped result when you need one. A value with a content array passes through untouched, so you can return several blocks or set isError yourself. In Go, core.TextResult and core.ToolResult build one.
  • Return values that serialise to JSON. A result that cannot be serialised (a BigInt, a circular structure) fails the call with Result could not be serialised: <reason>. Convert such values yourself.

Errors

A handler that throws (or, in Go, returns an error) makes the call fail. The model reads the message as the tool's result, with isError: true, so write it for the model:

js
throw new Error(`No order with id ${id}. Ask the user to confirm the id, or use search_orders.`);
  • Say what went wrong and what to try next. No order with id 1042. Ask the user to confirm the id beats ENOENT.
  • Only the message is sent, not the stack. Throw Error objects in Node.js; in Go, err.Error() is the message, and a panic is recovered and sent as panic: ....
  • Do not leak internals or secrets. The message goes to whoever is driving the agent, and the first 300 characters are also stored in the call log.
  • Errors are never cached, and a call that fails because your handler threw is still a billable call, because it reached your agent.

Sizes and timeouts

LimitValue
Tools per agent256 on the Free plan
Schema size per tool65,536 bytes of JSON on the Free plan
Resources per agent1,000
One registration (all tools, resources and prompts)512 kB
One result4 MB. A larger result fails the call with an error that says it is too large
Call wait30 seconds by default; lower it per project in Project settings (1 to 30)

A tool that may take longer than the wait should return a job reference and let the model poll a second tool:

start_report  -> { "jobId": "r-81", "status": "running" }
get_report    -> { "jobId": "r-81", "status": "done", "url": "..." }

When the wait expires, the client gets Request for tool "<name>" timed out after <ms>ms. Your handler is not stopped in Node.js and its late result is discarded. In Go you can give each handler's context a deadline with the CallTimeout option, set just below the gateway's wait. See Concepts.

Idempotency and at-most-once delivery

The gateway delivers a call to one agent, at most once. It never retries a call itself. But the caller can: a model that sees a timeout or Agent disconnected before returning a result. will often try again, and your handler may already have done the work.

So make writes safe to repeat. Ask the caller for a request id, and do the write once per id:

js
const createOrder = defineTool({
  name: 'create_order',
  description: 'Create an order. Always pass the same requestId when retrying, so the order is created only once.',
  inputSchema: z.object({
    requestId: z.string().describe('A unique id for this attempt. Reuse it when retrying.'),
    customer: z.string(),
    total: z.number().int().describe('Total in cents')
  }),
  // createOnce stores the order under requestId in your database
  // (for example with a unique constraint) and returns the existing order on a repeat.
  handler: ({ requestId, ...input }) => orderService.createOnce(requestId, input)
});

Calling this twice with the same requestId returns the same order instead of creating a second one. Keep the record of seen request ids in shared storage such as your database, not in memory, or a repeat that lands on a different replica creates a second order (see Running replicas).

Prefer read-only tools at first, and mark every tool with annotations so a client can tell a read from a write. Set readOnlyHint: true on every tool that only reads: read-only keys, a project whose Client access is Read only, and mcp:read OAuth tokens see only those tools. A tool without it counts as a write and is hidden from them. Beyond that, annotations are hints, not enforcement: gate destructive actions behind an explicit argument as well, or leave them out until you have seen real transcripts.

Splitting across files

Past a handful of tools, keep each in its own file and collect them in one place. That one list is also the whole surface a model can reach, which makes it your review boundary.

js
// tools/get-order.mjs
import { defineTool } from '@spacefix/core';
export const getOrder = defineTool({ name: 'get_order', /* ... */ handler: ({ id }) => orderService.findById(id) });

// tools/index.mjs
export const tools = [getOrder, searchOrders, createOrder];

// agent.mjs
agent.register(tools);                              // tools only
agent.register([...tools, openOrders, summariseOrder]); // tools, resources and prompts mixed

defineTool exists so that your editor can infer a handler's argument type from the schema next to it. Register everything before you connect: the list of tools is sent when the connection starts (in Go, registering after Run has started is an error).

Resources or tools?

Use a resource 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 (a catalogue, a configuration, a snapshot). Use a tool for anything with arguments or side effects.

  • A resource handler takes no arguments. Anything parameterised belongs in a tool.
  • A string result is sent as is; anything else as JSON text. mimeType defaults to application/json.
  • A resource read is a call: it counts toward your allowance, is logged, and respects the same timeout.
  • Resources cannot be disabled or cached from the dashboard, and a key's tool allow-list does not filter them.
  • A resource that fails reaches the client as a JSON-RPC error (-32603) with your message, not as an isError result.

Prompts with arguments

A prompt is a reusable message template. 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.

  • Prompt arguments are always strings, because that is what MCP clients send. In Go the handler receives a map[string]string.
  • A required argument that is missing is refused before your handler runs: Invalid arguments for prompt summarise_order: data must have required property 'id'.
  • Return a string (sent as one user message), or { messages: [{ role, content }] } for a longer exchange. In Go, core.PromptText returns one user message, and UserMessage and AssistantMessage build longer ones.
  • Prompts are named by the same rule as tools (1 to 64 characters from A-Z a-z 0-9 _ -).

Caching and disabling tools from the dashboard

Open your project, find the Tools table and press Edit on a tool. You can change how the gateway treats it without redeploying your service:

ControlWhat it does
Description models seeReplaces the description your code registered (up to 1000 characters). Empty uses the registered one. Only the description can be changed; the schema cannot. A tool you edited shows an Edited pill
Cache results (seconds)Identical calls inside the window are answered by the gateway and never reach your service. A whole number from 0 (off) to 3600. Shows as Cached 30s
Reads or writesWhether read-only clients may see and call the tool. Follow the agent uses your code's readOnlyHint (no hint counts as a write); Read: read-only clients may call it and Write: hidden from read-only clients override it. The hint under the field says what the agent declares. An override shows as Treated as read or Treated as write
DisabledThe tool is hidden from every client and refused. Your agent is unaffected and nothing is redeployed. The row is dimmed and shows Disabled

Settings are stored on the project, per tool name, and apply to the next request.

Caching

Cache only tools that read and have no side effects, such as a lookup of slowly changing data.

  • A cached result is keyed by project, tool name and the arguments. Key order does not matter ({"a":1,"b":2} equals {"b":2,"a":1}); array order does.
  • The key does not include who is calling. One cached result is served to every client of the project, whichever key asked first. Do not cache a tool whose answer depends on the caller.
  • Only successful results are cached. Errors, timeouts, and results larger than 256 kB never are. Resources and prompts are not cached.
  • A cache hit is not a call: it does not reach your agent, appears nowhere in Logs or Usage, and is not billed. It still passes the key checks, the rate limit and argument validation.
  • The cache lives in the gateway's memory and is emptied when you save a tool's settings or when an agent connects. A write made through another tool does not invalidate it, so keep the window shorter than the staleness clients can tolerate (5 to 60 seconds suits a lookup).
  • The value is held in the gateway's memory for the window you set. If a tool returns sensitive data, think before caching it.

Disabling

Tick Disabled to pull one function from every client in seconds. The tool disappears from tools/list and a call to it fails as if it did not exist (Tool <name> not found). Untick it and save to bring it back. To stop one replica instead, disable that agent; to stop one client, use a key with a tool allow-list or revoke the key (see Projects and keys).

The Tools table lists the tools registered right now, then any tool with saved settings that no connected agent registers (marked Not registered), so you can still edit or re-enable it while its agent is offline.

Checklist

  1. Each tool does one thing, and its name says what.
  2. The description says what it returns, what it returns when nothing matches, and which sibling to use instead.
  3. Every argument has a description, and a type, enum or bounds where they apply.
  4. Anything a JSON Schema cannot enforce (pattern, format, defaults, refinements) is checked in the handler.
  5. Errors say what to do next and carry no secrets.
  6. Writes accept a request id and are safe to repeat; state lives in shared storage.
  7. Slow work returns a job reference.
  8. Read-only tools come first, and each one sets readOnlyHint: true.

Next steps