Skip to main content

Build and attach a tool

What you'll build: a get_weather tool, declared in your own code, attached to your support-reply prompt, and driven by the SDK's tool-calling loop — so the model can ask for live data and your function supplies it.

You will not open the dashboard until step 3. The catalog entry, its first version, and its production alias are all created by the first run.

This is the code-first path, where your function owns the tool's definition. The other option is to define the tool in the catalog and give your code only the body to run — Define a tool in code or in the catalog compares the two and says when each is right.

1. Declare the tool in code

acrux.tool puts the four things a tool needs in one place: its name, the description the model reads, the arguments it takes, and the code that runs.

import AcruxCore, { acrux } from '@acruxcoreai/sdk';
import { z } from 'zod/v4';

const getWeather = acrux.tool(
{
name: 'get_weather',
description: 'Get the current weather for a city.',
parameters: z.object({ city: z.string().describe("City name, e.g. 'London'") }),
},
// wttr.in needs no API key, so this body runs as written.
async ({ city }) => {
const res = await fetch(`https://wttr.in/${encodeURIComponent(city)}?format=j1`);
if (!res.ok) throw new Error(`wttr.in returned ${res.status}`);
const data = (await res.json()) as {
current_condition: { temp_C: string; weatherDesc: { value: string }[] }[];
};
const current = data.current_condition[0];
return { city, tempC: Number(current.temp_C), summary: current.weatherDesc[0].value };
},
);

city inside the handler is a string, not unknown — the type comes from the schema. Rename the field in the z.object and the handler stops compiling, which is the drift this shape is there to prevent.

parameters also accepts a plain JSON Schema object, so zod is optional.

2. Run it — the catalog fills itself in

Pass the declared tool to the loop. Nothing else is needed.

const hub = new AcruxCore();

const result = await hub.gateway.runToolLoop({
model: 'support-model',
messages: [{ role: 'user', content: 'What is the weather in London right now?' }],
tools: [getWeather],
});

console.log(result.content); // the model's answer, based on what wttr.in returned
console.log(result.iterations); // 2 — one tool round-trip, then the final answer
console.log(result.traceId); // one trace covering both model calls and the tool

On its first call the loop syncs the tool: it creates the catalog entry because the name is new, commits version 1, and points production at it. It then names the tool to the gateway as a catalog reference rather than sending the schema inline, so the model is served the version the catalog holds.

Run it a second time and nothing is committed — the spec is unchanged, so the sync is a no-op. Change the description or add a parameter and the next run commits version 2 and moves production to it.

3. Inspect it in the dashboard

Now open Gateway → Tools → get_weather. The tool your code created is there, with a Defined in code badge and a version whose source is code.

The get_weather tool after a sync, showing the Defined in code badge

warning

Editing a version here is superseded by your next deploy. The sync compares the spec your code sends against the live version, so a hand edit to a code-owned tool stops being live the next time the loop runs. The version you edited is not lost — it stays in the version list and can be promoted back — but treat the dashboard as read-only for tools your code owns.

One exception is worth knowing: a function with no docstring (Python) or no description (Node) sends no description at all, which hands the model-facing wording to whoever writes it in the dashboard. Supply a description in code and code owns it. Pick per tool which side owns the words.

4. Connect it to a prompt

Open your support-reply prompt and go to the Tools tab, then choose + Connect a tool from the catalog and pick get_weather. It saves straight away — no prompt version to commit — and lands in the default column, which every alias of the prompt inherits.

Connecting get_weather to the prompt

Now hub.prompts.render returns the tool alongside the messages:

const { messages, tools } = await hub.prompts.render('support-reply', 'production', {
company: 'Acme',
customer_message: 'What is the weather in London?',
});
// tools now contains the get_weather function definition

You already have the tool declared in code, so feed the rendered messages to the same loop as before and keep passing tools: [getWeather]:

const result = await hub.gateway.runToolLoop({ model: 'support-model', messages, tools: [getWeather] });

The binding still matters for callers that have no declared tool — the raw gateway endpoint, or another service reading the prompt. In that case hub.prompts.render's tools are raw OpenAI-shaped definitions rather than declared tools, so they go to the loop as toolDefs with a dispatch to run them:

const result = await hub.gateway.runToolLoop({
model: 'support-model',
messages,
toolDefs: tools,
dispatch: async (name, args) => {
if (name === 'get_weather') return fetchWeather(args.city as string);
throw new Error(`Unknown tool: ${name}`);
},
});

5. Where the tool runs

Three cases, and the loop decides between them before it calls the model:

What you passWho runs the tool
tools: [getWeather] — a declared toolYour process, via the declared handler
toolRefs: [{ name }] resolving to an http executorThe platform, which calls the URL and writes the tool span itself
toolRefs: [{ name }] resolving to a client executorA declared tool of that name, else its clientTools entry, else dispatch
toolDefs: [rawDefinition]dispatch

clientTools is the usual answer for the third row: clientTools: { get_weather: fetchWeather } runs the tool without letting your code take over its catalog definition, which tools: [declared] would. See Call a prompt's tools from the SDK.

A client tool with no runner at all fails immediately with MISSING_DISPATCH, before the first model call, so the mistake costs no tokens.

A catalog tool with an http executor needs no local code at all:

const result = await hub.gateway.runToolLoop({
model: 'support-model',
messages,
toolRefs: [{ name: 'search_orders' }],
});

The endpoints behind this are documented in the API reference: POST /tools/sync and POST /tools/resolve.

What's next