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 when | A client does | |
|---|---|---|
| Tool | The model should do something or look something up, with arguments | Calls it with arguments (tools/call) |
| Resource | The model should read a current view of something, by URI, with no arguments | Reads it (resources/read) |
| Prompt | You want to ship message templates that a user picks from a client's menu | Lists 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.
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();
type GetOrderInput struct {
ID string `json:"id" desc:"The order id, for example \"1042\""`
}
type SearchOrdersInput struct {
Customer string `json:"customer" desc:"Customer handle, for example \"ada\""`
Status string `json:"status,omitempty" desc:"Only orders in this status" enum:"open,shipped,cancelled"`
Limit int `json:"limit,omitempty" desc:"How many orders to return (1 to 50)" min:"1" max:"50" default:"10"`
}
type CreateOrderInput struct {
RequestID string `json:"requestId" desc:"A unique id for this attempt, for example a UUID. Reuse it when retrying."`
Customer string `json:"customer"`
Total int `json:"total" desc:"Total in cents"`
}
func main() {
agent, err := core.New(core.Options{APIKey: os.Getenv("SPACEFIX_API_KEY")})
if err != nil {
log.Fatal(err)
}
must := func(err error) {
if err != nil {
log.Fatal(err)
}
}
must(core.AddTool(agent, "get_order",
"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.",
func(ctx context.Context, in GetOrderInput) (Order, error) {
order, ok := orderService.FindByID(in.ID)
if !ok {
return Order{}, fmt.Errorf("no order with id %s: ask the user to confirm the id, or use search_orders", in.ID)
}
return order, nil
}))
must(core.AddTool(agent, "search_orders",
"List orders for a customer, newest first. Returns an empty list when nothing matches.",
func(ctx context.Context, in SearchOrdersInput) ([]Order, error) {
limit := in.Limit
if limit == 0 {
limit = 10 // a schema default is advertised, not applied for you
}
return orderService.Search(in.Customer, in.Status, limit), nil
}))
must(core.AddTool(agent, "create_order",
"Create an order. Always pass the same requestId when retrying, so the order is created only once.",
func(ctx context.Context, in CreateOrderInput) (Order, error) {
return orderService.CreateOnce(in.RequestID, in.Customer, in.Total), nil
}))
must(agent.Resource("orders://open", core.ResourceOptions{
Name: "Open orders",
Description: "Every order that has not shipped yet",
MimeType: "application/json",
}, func(ctx context.Context) (any, error) {
return orderService.Open(), nil
}))
must(agent.Prompt("summarise_order", core.PromptOptions{
Title: "Summarise an order",
Description: "Write a short, polite summary of an order for a support reply.",
Args: []core.PromptArg{{Name: "id", Description: "The order id", Required: true}},
}, func(ctx context.Context, args map[string]string) (core.PromptResult, error) {
order, _ := orderService.FindByID(args["id"])
b, _ := json.Marshal(order)
return core.PromptText("Summarise this order politely, in two sentences:\n" + string(b)), nil
}))
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := agent.Run(ctx); err != nil {
log.Fatal(err)
}
}
Names
- A tool or prompt name is 1 to 64 characters from
A-Z,a-z,0-9,_and-. Useget_order, notget orderororders.get. - A resource is named by its URI (
orders://open), and also has a displayname. - 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
400withInvalid 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;
undefinedornilbecomesnull. 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
contentarray passes through untouched, so you can return several blocks or setisErroryourself. In Go,core.TextResultandcore.ToolResultbuild one. - Return values that serialise to JSON. A result that cannot be serialised (a
BigInt, a circular structure) fails the call withResult 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:
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 idbeatsENOENT. - Only the message is sent, not the stack. Throw
Errorobjects in Node.js; in Go,err.Error()is the message, and a panic is recovered and sent aspanic: .... - 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
| Limit | Value |
|---|---|
| Tools per agent | 256 on the Free plan |
| Schema size per tool | 65,536 bytes of JSON on the Free plan |
| Resources per agent | 1,000 |
| One registration (all tools, resources and prompts) | 512 kB |
| One result | 4 MB. A larger result fails the call with an error that says it is too large |
| Call wait | 30 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:
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)
});
core.AddTool(agent, "create_order",
"Create an order. Always pass the same requestId when retrying, so the order is created only once.",
func(ctx context.Context, in CreateOrderInput) (Order, error) {
// CreateOnce stores the order under RequestID in your database
// (for example with a unique constraint) and returns the existing order on a repeat.
return orderService.CreateOnce(in.RequestID, in.Customer, in.Total), nil
})
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.
// 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
// orders.go
func registerOrders(agent *core.Agent) error {
return core.AddTool(agent, "get_order", "Look up one order by its id.",
func(ctx context.Context, in GetOrderInput) (Order, error) { /* ... */ })
}
// main.go
for _, register := range []func(*core.Agent) error{registerOrders, registerCustomers} {
if err := register(agent); err != nil {
log.Fatal(err)
}
}
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.
mimeTypedefaults toapplication/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 anisErrorresult.
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
usermessage), or{ messages: [{ role, content }] }for a longer exchange. In Go,core.PromptTextreturns one user message, andUserMessageandAssistantMessagebuild 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:
| Control | What it does |
|---|---|
| Description models see | Replaces 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 writes | Whether 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 |
| Disabled | The 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
- Each tool does one thing, and its name says what.
- The description says what it returns, what it returns when nothing matches, and which sibling to use instead.
- Every argument has a description, and a type,
enumor bounds where they apply. - Anything a JSON Schema cannot enforce (
pattern,format, defaults, refinements) is checked in the handler. - Errors say what to do next and carry no secrets.
- Writes accept a request id and are safe to repeat; state lives in shared storage.
- Slow work returns a job reference.
- Read-only tools come first, and each one sets
readOnlyHint: true.
Next steps
- SDKs: the full API of the Node.js and Go SDKs.
- Running replicas: why state and idempotency records must be shared.
- Connecting clients: what a client sees from your tools.