Tools
Tools are how the model reaches outside the conversation. The agent backend supports two sources of tools: client-supplied definitions passed at session creation, and MCP gateway providers resolved at request time.
Where tools come from
Tools can be loaded from two places:
- Client-supplied — passed as
extensions.toolswhen creating a session. The client defines the schema and handles execution. - MCP gateway — loaded at request time from the platform's own MCP
gateway. Each model definition (or session's
extensions.mcpProviders) declares which providers it cares about, and the inference server resolves them:
GET ${MCP_GATEWAY_DOMAIN}/v1/mcp/providers/${providerName}The gateway returns tool definitions for that provider. The backend hands them to the model alongside any client-supplied tools.
MCP_GATEWAY_DOMAIN points at the platform's MCP gateway, not at an
arbitrary MCP server. The inference server only ever talks to our
gateway. The gateway itself maintains a provider registry — that's
the pluggable layer. Today NetSuite is the only registered base
provider; the registry is extensible (registerMCPPlatform /
MCPPlatformDefinition) but additional providers haven't been opened
up for general use yet.
Defining tools
Each tool definition describes what the tool does and what data it expects and returns. Definitions use JSON Schema for input and output shapes.
Schema
interface ToolDefinition {
/** Unique name — must match the function that executes it */
name: string;
/** Human-readable description — the model reads this to decide when to call */
description: string;
/** JSON Schema describing the input parameters */
requestArgs: {
properties: Record<string, JSONSchema>;
required?: string[];
};
/** JSON Schema describing the response shape */
responseShape: {
properties: Record<string, JSONSchema>;
};
}Example
const getLocations = {
name: "getLocations",
description: "Retrieves a list of warehouse locations",
requestArgs: {
properties: {
includeInactive: {
type: "boolean",
description: "Include inactive locations",
},
},
},
responseShape: {
properties: {
locations: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "number" },
name: { type: "string" },
useBins: { type: "boolean" },
},
},
},
error: { type: "string" },
},
},
};Auto-injected fields
The platform automatically injects certain fields into every tool:
sessionIdis added torequestArgs(marked required) — the model always knows which session it's operating in.stateis added toresponseShapewith enumCOMPLETE | FAILED | PENDING— every tool response must indicate its completion status.
You don't need to include these in your definitions — they're added for you.
Passing tools to a session
Supply tool definitions when creating a session. extensions.tools is a
record keyed by tool name (not an array) — the schema is
Record<string, ToolDefinition>:
await fetch(`${BASE_URL}/v1/sessions`, {
method: "POST",
headers,
body: JSON.stringify({
customizations: {
agentId: "RfsAgent",
extensions: {
tools: {
getLocations,
getBinContents,
createTransfer,
},
},
},
}),
});The model can then call any of these tools during the conversation. When it does, your client receives the call and is responsible for executing it and returning the result.
Tool naming conventions
Tool names carry meaning through prefixes. The platform and client libraries use these to determine routing and authorization:
| Prefix | Behavior | Handled by |
|---|---|---|
| (none) | Standard tool — read-only queries, no side effects | Client |
_ | Internal/server-side tool (e.g. _titleSession) | Server (automatic) |
pre_ | Requires user confirmation before execution | Client (with UI gate) |
post_ | Auto-executed after a related action | Client (automatic) |
priv_ | Private tool — requires TOTP auth via MCP Gateway | Server (MCP gateway) |
mcp_ | MCP provider tool | Server (MCP gateway) |
Your client only needs to handle tools classified as STANDARD (no
prefix) and optionally pre_ / post_ tools. Everything else is
managed by the server. Use getToolClassification() from
@rfsmart/ai-agent-types to check.
Why tool calls are async
Many tools wrap operations that take seconds or minutes. The agent backend can't block waiting for those, so the protocol is explicitly two-phase:
- The model emits a
tool_useblock in its response. - The caller picks it up, executes the tool out-of-band, sends heartbeats while it works, and finally submits the result.
- The server inserts the
tool_resultblock and the model continues on the next call.
Lifecycle
A tool call moves through four states, persisted so you can reconnect to it from anywhere:
PENDINGinitial stateThe model has emitted the tool call; nobody has picked it up yet.
PROCESSINGcaller is workingSet when the caller first sends a heartbeat. Subsequent heartbeats refresh the lock timestamp.
COMPLETEterminal — successSet when the caller submits a result. The session can now resume.
ERRORterminal — failureSet when the caller submits an error result. The session still resumes; the model gets to see and react to the error.
Heartbeats
The heartbeat protocol detects stuck callers. If a tool sits in
PROCESSING without a heartbeat for too long, the server marks it as
abandoned.
There are two heartbeat paths, and the distinction matters for disconnect behavior:
- Server-side (MCP-routed tools). When a tool is fulfilled by the platform's MCP gateway, the gateway emits heartbeats back to the inference server during execution. The client doesn't participate, so a client disconnect does not abandon the tool. Long-running MCP-routed tools survive disconnects.
- Client-side (client-supplied tools). When a tool is fulfilled by
your own client — i.e. you defined it via
extensions.tools— the heartbeat runs from the device that picked up the call (browser, mobile app, server-side worker). If that device disconnects, the heartbeat loop stops and the tool is abandoned after the timeout.
For client-side tools, send heartbeats on a timer at a regular interval while you're working. A cadence of 250ms is typical for responsive tool execution. The endpoint is cheap — it just bumps a timestamp.
const interval = setInterval(async () => {
await fetch(`${BASE_URL}/v1/tools/request/${sessionId}/${requestId}/heartbeat`, {
method: "POST",
headers,
body: JSON.stringify({ state: "PROCESSING", heartbeat: Date.now() }),
});
}, 250);Always heartbeat from a separate timer, not from inside the tool's main work loop. If your tool blocks, the heartbeat must still fire or the server will assume you crashed.
If you need true disconnect-survivability for a long-running operation, route it through an MCP provider rather than implementing it as a client-supplied tool — that's the path where heartbeats happen server-side.
See the Tools API reference for endpoint details.
Submitting results
When the tool finishes, POST /v1/tools/response/:sessionId/:requestId
with a structured result. The server validates the shape, marks the tool
COMPLETE, and the next call to the messages endpoint will resume the
conversation with the result fed back to the model.
// Success
await fetch(`${BASE_URL}/v1/tools/response/${sessionId}/${requestId}`, {
method: "POST",
headers,
body: JSON.stringify({
response: { state: "COMPLETE", locations: [...] },
}),
});
// Failure
await fetch(`${BASE_URL}/v1/tools/request/${sessionId}/${requestId}/heartbeat`, {
method: "POST",
headers,
body: JSON.stringify({
state: "ERROR",
error: "Query timed out after 30 seconds",
}),
});If the tool failed, submit an error via the heartbeat endpoint with
state: "ERROR". The model is trained to recover gracefully — it'll
often try a different approach or ask the user for help.