Skip to main content

Call a prompt's tools from the SDK

What you'll build: one prompt with a tool bound to it, called three different ways — including a streamed run where model text and tool activity arrive as separate, typed events.

You bind a tool to a prompt alias once. Every render of that alias returns the tool's resolved definition next to the templated messages, so the only step left is to run the tool. The SDK gives you three calls that do it, from a two-line shortcut to the loop written out by hand. This page is about those three calls and nothing else.

This page assumes the tool is already attached

Attaching is its own step, and none of the code here does it. You attach a tool to a prompt on the prompt's Tools tab in the dashboard, or with PUT /prompts/{promptId}/tools/{toolId} — set_tool_binding in the Python SDK, setToolBinding in Node. Connect a tool to a prompt shows both, and Create a tool covers where the tool itself came from. Which route you used changes nothing below: render() returns the same result either way.

1. Render the prompt​

Every call on this page starts with a render, because a render is what turns a prompt alias into the model, the messages and the tools. Two of its fields carry the tools: tools holds the OpenAI-shaped definitions the model reads, and toolResolutions holds one entry per tool, naming the alias or pin the binding followed and the version that resolved to.

curl -X POST "$ACRUXCORE_BASE_URL/prompts/weather-brief/production/render" \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"variables":{"city":"Lisbon"}}'
{
"messages": [
{ "role": "user", "content": "What's the weather in Lisbon right now? Answer in one short sentence." }
],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather conditions for a city.",
"parameters": {
"type": "object",
"required": ["city"],
"properties": { "city": { "type": "string", "description": "City name, e.g. Lahore." } }
}
}
}
],
"toolResolutions": [
{ "name": "get_weather", "alias": "production", "versionNumber": 3, "source": "default" }
],
"model": "gpt-4o-mini",
"versionId": "d884bfc0-2f5f-4858-b119-682ff7caebbb",
"versionNumber": 4
}

2. Three ways to call it​

Two of the three are the same mechanism. run_prompt_with_tools calls run_tool_loop and adds nothing to it. The wrapper's only job is to read five arguments off the render result and pass them down. So the question is not which of those two is better. The question is whether the prompt supplies the five arguments, or your code does.

The third call never runs a tool. chat hands back the model's tool request without acting on it, so running it is your code's job.

run_prompt_with_tools — the prompt supplies the arguments​

These are the five arguments the wrapper reads from the render result:

ArgumentRead from
the modelthe prompt version's bound model
the messagesthe render
the tool refsone ref per toolResolutions entry
the prompt version idthe resolved version
the variablesthe values the messages were rendered from

So the call itself is two lines:

const r = await hub.prompts.render('weather-brief', 'production', { city: 'Lisbon' });
const result = await hub.gateway.runPromptWithTools(r);

console.log(result.content); // The weather in Lisbon right now is 21°C and sunny.
console.log(result.iterations); // 2

Everything else you pass reaches run_tool_loop untouched, client_tools, dispatch, temperature and stream included.

The prompt version id is the reason the wrapper exists. A hand-written loop that leaves it out still returns a correct answer, and its trace carries no link back to the prompt version behind it. No error is raised, so nothing points at the gap.

An argument you pass yourself always wins over the derived one. runPromptWithTools(r, { model: 'gpt-4o' }) overrides the bound model and leaves the other four arguments alone. toolRefs: [] runs the prompt with no tools at all.

Two behaviours to know:

  • A pinned binding travels as a pin. If the binding pins tool v2 rather than following an alias, the call sends {name, version: 2}, so the prompt keeps running v2 even after that alias is promoted to a newer version.
  • A prompt with no tools bound still runs. You get a plain completion, not an error. The name fits badly in that case, and raising an error would have failed an unconfigured prompt for no good reason.

If the prompt version has no bound model and you pass none, the call raises rather than guessing:

acruxcore: this prompt version has no bound model, so there is nothing to run it
on. Either bind a default model on the prompt version, or pass model= to
run_prompt_with_tools().

run_tool_loop — you supply the arguments​

Call the loop directly when one of the five arguments should not come from the render, or when the loop itself needs changing. That covers running a subset of the bound tools, adding a tool that is not bound at all, setting a different maxIterations, and passing a responseFormat to shape the final answer.

The call below is what the wrapper builds, with maxIterations changed. Two of the five arguments are easy to leave out, and neither omission raises an error. Without the prompt version id, the trace has no link to the prompt version. Without the variables, it does not record the values the messages were rendered from.

const result = await hub.gateway.runToolLoop({
model: r.model,
messages: r.messages,
toolRefs: r.toolResolutions.map((t) => ({ name: t.name, alias: t.alias })),
promptVersionId: r.versionId, // easy to forget; costs trace lineage
variables: r.variables, // without it the run cannot seed a dataset
maxIterations: 3,
});
That map drops a pinned binding

The map above reads alias only. A binding that pins an exact version carries no alias, so it sends {name, alias: undefined}, and the tool resolves to production instead of the pinned version. Read the pin first:

toolRefs: r.toolResolutions.map((t) =>
t.pinnedVersionNumber !== undefined
? { name: t.name, version: t.pinnedVersionNumber }
: { name: t.name, alias: t.alias },
),

run_prompt_with_tools builds the refs this way already.

chat — one request, no loop​

chat sends exactly one request. Tool calls come back raw and nothing is dispatched. Reach for it when you own the loop, or when you want to look at what the model asked for before running anything.

curl -X POST "$ACRUXCORE_BASE_URL/gateway/chat/completions" \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4o-mini",
"messages": [{"role":"user","content":"What'\''s the weather in Lisbon right now? Answer in one short sentence."}],
"tool_refs": [{"name":"get_weather","alias":"production"}],
"prompt_version_id": "d884bfc0-2f5f-4858-b119-682ff7caebbb"
}'
{
"id": "chatcmpl-EEyYkncDTfsaGVXWa5faoRUjKylu1",
"model": "gpt-4o-mini-2024-07-18",
"object": "chat.completion",
"created": 1787238090,
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_mpQNFOQ4A0ZQ0O8ydYAq2ZWv",
"type": "function",
"function": { "name": "get_weather", "arguments": "{\"city\":\"Lisbon\"}" }
}
]
},
"finish_reason": "tool_calls"
}
],
"usage": { "prompt_tokens": 69, "completion_tokens": 15, "total_tokens": 84 }
}

A tool_ref picks one tool version in one of two ways: alias to follow, or version to pin. Sending both is a 400, because a ref holding an alias and a version points at two different versions:

{ "error": { "code": "VALIDATION_ERROR", "message": "a ref takes either alias or version, not both." } }

prompt_version_id on the body is what links this call's llm span back to the prompt you rendered. It must be a prompt version in your own team; anything else is a 400 rather than a silent drop, so a caller asking for lineage learns when it isn't happening.

Why name tools at call time at all?​

The question every reader arrives with: if the tools are attached to the prompt, why do run_tool_loop and chat list them again?

Because the loop belongs to the caller. Something has to tell the SDK which tools may run in your process, and the platform cannot reach into your process to find out. Naming them is how you say so. run_prompt_with_tools reads the same names off the render instead, which is the one thing it saves you.

Streaming​

Both loops stream. stream: true gives you a typed event stream rather than raw text, because a UI needs to render "running get_weather…" as its own state, not as more model text:

for await (const event of await hub.gateway.runPromptWithTools(r, { stream: true })) {
if (event.type === 'content') process.stdout.write(event.delta);
else if (event.type === 'tool_call') console.log(`\n[calling ${event.name}]`);
else if (event.type === 'tool_result') console.log(`[${event.name} done]`);
else if (event.type === 'done') console.log(`\ntrace: ${event.result.traceId}`);
}

Four event types, discriminated on type:

EventCarries
contentdelta, round
tool_callid, name, arguments (already parsed), round
tool_resultid, name, result or error, round
doneresult — the same object the unstreamed call returns

Notice neither snippet supplies any tool code. How the tool was attached to the prompt makes no difference here: a binding made in the dashboard, through the API or from the SDK produces the same render result. What decides is the executor on the tool version that binding resolved to. An http executor runs on the platform, so the call above is the whole thing. A client executor is a function inside your own process, which the platform cannot run, so you pass it yourself.

When a tool resolves to a client executor, name its function in client_tools. The streaming call takes the same argument, because the executor belongs to the tool version and not to how you called it:

events = await hub.gateway.run_prompt_with_tools(
r, stream=True, client_tools={"get_weather": get_weather}
)
# then loop over `events` exactly as above

The map holds only the tools you have to run, so a prompt with two http tools and one client tool has exactly one entry. Each function is called with the tool schema's own field names — as keywords in Python, as one object in Node — and nothing is written to the catalog, so the binding's alias or pin is untouched. Passing the same function as tools=[fn] would instead commit a new catalog version and drop a pin. Create a tool explains when that is what you want.

The key is the catalog tool's name; the value is any function you like. The key is the whole wiring — it is what the model asks for and what the SDK looks up — so it has to match the catalog exactly, including case. The function's own name takes no part, which is why the Node example above maps get_weather to getWeather without ceremony. Create a tool walks through that wiring in full.

Ask for a client-executor tool without an implementation and the loop stops before it calls the model, naming the tool and listing what you did pass:

acruxcore: tool 'get_weather' has a client executor, so something has to run it,
but no implementation was supplied. Pass it in client_tools={'get_weather': ...},
or pass dispatch=. client_tools held: ['get_wather'].

dispatch is still there, and still the right answer for one case: a tool set you only learn at runtime, where you cannot write the keys in advance.

Two details that decide how you render this:

Text arrives from every round, not only the last. Whether a round is the final one is only knowable once its finish_reason arrives, so holding a round back would withhold the final answer — the one thing streaming exists to deliver. Every content event carries round, and a mid-loop preamble ("let me check the weather") is always followed by a tool_call event on the same round. A UI that wants to hide preambles can render them as a transient state and replace them when tool_call arrives.

The trace is identical either way. One llm span per round, with each round's tool spans nested under it, and the same prompt lineage stamped on all of them — streamed or not. Streaming does not cost you observability.

If you'd rather own the loop, chat/stream also accept tools while streaming. There, tool_calls arrive as fragments and you assemble them yourself: correlate by index, take id and function.name from whichever frame carries them, and concatenate function.arguments across frames into one JSON string. Nothing is dispatched — that's your job.

const parts = new Map();
for await (const chunk of await hub.gateway.stream({ model: r.model, messages: r.messages, toolRefs })) {
for (const tc of chunk.delta.tool_calls ?? []) {
const part = parts.get(tc.index ?? 0) ?? { id: '', name: '', arguments: '' };
if (tc.id) part.id = tc.id;
if (tc.function?.name) part.name = tc.function.name;
if (tc.function?.arguments) part.arguments += tc.function.arguments;
parts.set(tc.index ?? 0, part);
}
}
// parts now holds { id, name, arguments: '{"city":"Lisbon"}' } — run it yourself.

Which call to use​

CallStreams?Dispatches tools?Tools from the binding?Needs the SDK?
run_prompt_with_tools(r)yesyesyesyes
run_tool_loop(...)yesyesonly what you passyes
chat / stream with toolsyesnoonly what you passno — plain HTTP works

Start at the top row. Move down only when you need a changed loop, or a raw completion to inspect.

Runnable scripts​

The smallest possible streamed loop — a render, a stream, a print, and nothing else. Useful as the thing you copy before you have decided how to render any of it:

export ACRUXCORE_API_KEY=acx_sk_...
export ACRUXCORE_BASE_URL=https://api.acruxcore.com/api/v1

python stream_minimal.py
ToolLoopToolCallEvent(id='call_gpBrjx…', name='get_weather', arguments={'city': 'Lisbon'}, round=0, type='tool_call')
ToolLoopToolResultEvent(id='call_gpBrjx…', name='get_weather', round=0, result={'location': 'Lisbon, Portugal', 'temperature_c': 26, …}, error=None, type='tool_result')
ToolLoopContentEvent(delta='The', round=1, type='content')
ToolLoopContentEvent(delta=' weather', round=1, type='content')
ToolLoopContentEvent(delta=' in', round=1, type='content')
…
ToolLoopDoneEvent(result=RunToolLoopResult(content='The weather in Lisbon right now is 26°C with patchy rain nearby.', iterations=2, stopped_at_limit=False, trace_id='e785caf9-…'), type='done')

Streaming, formatted — the same loop with the events rendered as a user would see them, and a client_tools entry so the client-executor alias runs too. It prints how long after the tool returned the first word of the answer appeared, which is the part a blocking loop makes you wait for:

export ACRUXCORE_API_KEY=acx_sk_...
export ACRUXCORE_BASE_URL=https://api.acruxcore.com/api/v1

python stream_prompt_tools.py
PROMPT_ALIAS=staging CITY=Karachi python stream_prompt_tools.py
prompt weather-brief@production (version 4)
model gpt-4o-mini
tools get_weather@production
────────────────────────────────────────────────────────────────────────

⚙ calling get_weather({'city': 'Lisbon'}) …
✓ get_weather → {'location': 'Lisbon, Portugal', 'temperature_c': 26, 'condition': 'Patchy rain nearby'}

The weather in Lisbon right now is 26°C with patchy rain nearby.
────────────────────────────────────────────────────────────────────────
rounds 2
trace be771f0a-ae3a-41c5-8924-a8d379a91b23
first event 1174 ms (a tool call, or text)
first token of the answer 2657 ms
… after the last tool 800 ms
whole loop 2977 ms

Every call on this page, side by side against the same prompt:

Both default to weather-brief / get_weather from Connect a tool to a prompt; point them at your own with PROMPT_NAME and PROMPT_ALIAS. Running against the production alias exercises an http executor (the platform runs the tool) and staging a client one (the function in the script runs it) — the calling code is identical either way.

What's next​