Build a tool-calling agent in Python (no SDK)
What you'll build: a weather assistant you run from a single Python file
using only requests — no SDK. Python calls
the gateway for the model, runs the tool itself when the model asks for one,
feeds the result back, and the whole run lands in one trace you can open in
the dashboard. You'll do it twice: once waiting for the full answer, and once
streaming the reply token by token.
There are two ways a tool can run in AcruxCore. An HTTP tool runs on the gateway — you describe a request once and the platform makes it (see Build a tool-calling agent in the dashboard). A client tool runs in your process: the platform only stores the tool's JSON schema, and when the model calls it, your code executes the work. This guide uses a client tool, so the tool logic lives in your Python — which is the realistic shape when the tool touches your database, an internal service, or anything the gateway can't reach.
The setup is a one-time thing you do in the dashboard: a credential (your provider key), a model (a callable name pointing at that key), a prompt with the tool attached, and a personal API key so Python can authenticate. Then the whole run is Python.
weather_agent_rest.ipynb
is this whole page as one runnable notebook, written for a first-timer: a preflight cell
that checks a fresh account is ready, the tool and prompt created over REST with the
dashboard values beside each call, the trace read back from the API, and four ways to get
it wrong triggered on purpose — including the quiet one, where forgetting to thread the
trace id splits one run into two traces and nothing errors. It renders on GitHub with its
saved output, so you can read it through before running anything.
An OpenRouter API key (any provider works — OpenAI,
Anthropic, Gemini; OpenRouter just gives one key for many models) and Python 3.9+
with requests installed (pip install requests).
1. Store your provider key as a credential
A credential is a provider API key, encrypted at rest. Open Gateway → Credentials and click New credential. OpenRouter speaks the OpenAI protocol, so pick OpenAI-compatible as the provider — that reveals a Base URL field. Fill it in as below and paste your OpenRouter key (the key box is masked and is never shown again after you save).

Click Create credential. The gateway can now call OpenRouter on your behalf, but nothing is callable yet — a credential needs a model pointed at it.
2. Register a model
A model is a public name your code sends as "model", mapped to an upstream
model on one of your credentials. Open Gateway → Models → New model. Name it
llama-3.3-70b, keep the OpenRouter credential selected, and set the upstream
model to meta-llama/llama-3.3-70b-instruct — that's OpenRouter's id for it. The public
name is yours to choose and your code never learns which provider answered, so an
open-weights model here changes nothing below. Leave prices blank
(they auto-fill for known models).

Click Register model. Hit Test on the new row to fire a 1-token completion and confirm the key really works before you go further.
3. Create a personal API key
Your Python needs a key to authenticate. Open Account & keys → New key, name
it python-agent, and create it.

Copy the key the moment it's shown — this is the only time the full value appears. Then set it and the API base URL as environment variables so every script below can read them:
export ACRUXCORE_API_KEY=<your personal api key>
export ACRUXCORE_BASE_URL=https://api.acruxcore.com/api/v1
4. Create the prompt and tool over REST
Both a prompt and a tool are created in two steps: a shell (name +
description) and then an immutable version. For the tool, the version carries
the JSON schema the model reads and an executor of { "type": "client" } —
"my own app runs this." For the prompt, the version carries the templated messages
and a default model. One more call binds the tool to the prompt, so a single
render returns messages and tool schema together.
- curl
- Python (SDK)
- Node (SDK)
# 4a. Tool shell
curl -X POST "$ACRUXCORE_BASE_URL/tools" \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" \
-d '{"name":"get_weather","description":"Get the current weather for a city."}'
# 4b. Tool version — client executor + the parameters schema (use the id from 4a)
curl -X POST "$ACRUXCORE_BASE_URL/tools/<tool-id>/versions" \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" \
-d '{
"description": "Get the current weather for a city.",
"parametersSchema": {"type":"object","properties":{"city":{"type":"string","description":"City name, e.g. \"Tokyo\"."}},"required":["city"]},
"executor": {"type":"client"}
}'
# 4c. Prompt shell
curl -X POST "$ACRUXCORE_BASE_URL/prompts" \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" \
-d '{"name":"py-weather-agent","description":"Weather assistant driven by a plain-Python REST tool loop."}'
# 4d. Prompt version — the templated messages and a default model
curl -X POST "$ACRUXCORE_BASE_URL/prompts/<prompt-id>/versions" \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" \
-d '{
"messages": [
{"role":"system","content":"You are a weather assistant. Use the get_weather tool to look up conditions before answering. Never guess."},
{"role":"user","content":"What is the weather in {{ city }} right now?"}
],
"model": "llama-3.3-70b"
}'
# 4e. Bind the tool to the prompt — every alias inherits this default binding
curl -X PUT "$ACRUXCORE_BASE_URL/prompts/<prompt-id>/tools/<tool-id>" \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" \
-d '{"tool_alias":"production"}'
from acruxcore import AcruxCore
async with AcruxCore() as hub:
tool = await hub.tools.create(
name="get_weather",
description="Get the current weather for a city.",
)
await hub.tools.commit_version(
tool.id,
parameters_schema={
"type": "object",
"properties": {"city": {"type": "string", "description": 'City name, e.g. "Tokyo".'}},
"required": ["city"],
},
executor={"type": "client"},
)
prompt = await hub.prompts.create(
name="py-weather-agent",
description="Weather assistant driven by a plain-Python REST tool loop.",
)
await hub.prompts.commit_version(
prompt.id,
messages=[
{"role": "system", "content": "You are a weather assistant. Use the get_weather tool to look up conditions before answering. Never guess."},
{"role": "user", "content": "What is the weather in {{ city }} right now?"},
],
model="llama-3.3-70b",
)
await hub.prompts.set_tool_binding(prompt.id, tool.id, tool_alias="production")
Full script: setup_prompt.py.
import { acruxcore } from '@acruxcoreai/sdk';
const hub = acruxcore({ apiKey: process.env.ACRUXCORE_API_KEY });
const tool = await hub.tools.create({
name: 'get_weather',
description: 'Get the current weather for a city.',
});
await hub.tools.commitVersion(tool.id, {
parametersSchema: {
type: 'object',
properties: { city: { type: 'string', description: 'City name, e.g. "Tokyo".' } },
required: ['city'],
},
executor: { type: 'client' },
});
const prompt = await hub.prompts.create({
name: 'py-weather-agent',
description: 'Weather assistant driven by a plain-Python REST tool loop.',
});
await hub.prompts.commitVersion(prompt.id, {
messages: [
{ role: 'system', content: 'You are a weather assistant. Use the get_weather tool to look up conditions before answering. Never guess.' },
{ role: 'user', content: 'What is the weather in {{ city }} right now?' },
],
model: 'llama-3.3-70b',
});
await hub.prompts.setToolBinding(prompt.id, tool.id, { toolAlias: 'production' });
The first version of each auto-creates production and staging aliases. Open
the prompt in the dashboard to confirm: the Editor tab shows the two messages,
the bound default model, and PRODUCTION → v1.

The Tools tab confirms get_weather is connected, in the default column
that every alias of the prompt inherits:

You can also create the prompt and tool by clicking — see Build a tool-calling agent in the dashboard. The UI and the REST API write to the same catalog.
5. Run the agent in Python
Now the run path — plain requests, no SDK. Two REST endpoints do the work:
POST /prompts/{name}/{alias}/renderreturns the renderedmessagesand the boundtools, already in OpenAI shape. Neither is hard-coded in your file.POST /gateway/chat/completionsis the OpenAI-compatible completion. Send the messages and tools; the gateway calls OpenRouter and records the LLM span itself.
The trick to getting one trace for the whole loop is a header. On the first
call send x-trace-name to open a trace; the response comes back with an
x-gateway-trace-id header. On every later call send that id back as x-trace-id,
so each model turn attaches to the same trace. When the model asks for a tool, you
run it locally, add a tool span to that trace with POST /traces, append the
result, and loop.
The one-trace trick lives entirely in the headers sent to /gateway/chat/completions:
def complete(model, messages, tools, trace_id):
headers = dict(HEADERS)
if trace_id:
headers["x-trace-id"] = trace_id # attach to the existing trace
else:
headers["x-trace-name"] = "py-weather-agent" # open a new trace
r = requests.post(f"{BASE_URL}/gateway/chat/completions", headers=headers,
json={"model": model, "messages": messages, "tools": tools})
trace_id = r.headers["x-gateway-trace-id"]
The full script wires all this together — rendering the prompt, opening the trace on the first completion and threading it on every later one, running the tool locally, posting its span, and looping until the model stops asking for tools — see weather_agent.py on GitHub for the complete source.
Run it:
python weather_agent.py
The model asks for get_weather, your code runs it and prints the call, the model
gets the result back, and the second turn is the final answer:
Fetched 2 message(s) + 1 tool(s) [get_weather]
→ get_weather({'city': 'Tokyo'}) = {'city': 'Tokyo', 'conditions': '22°C, light rain'}
Assistant: The current weather in Tokyo is 22°C with light rain.
(2 model turn(s), trace 02bd6182-5dc0-4be2-a1ec-15361b85c5d6)
6. Stream the reply
Sometimes you want tokens as they're generated instead of waiting for the whole
answer. Set "stream": true in the body and read the gateway's
Server-Sent Events
stream: one data: frame per chunk, each carrying a delta.content string,
ending with data: [DONE].
Streaming yields text deltas, so this example does not forward the tools: if
the model decided to call one, the first turn would stream tool-call fragments
instead of readable prose, and streaming does not auto-run tools (that's what the
loop in step 5 is for). Omitting tools keeps the streamed output clean.
Setting stream: true and reading the SSE frames is the one AcruxCore-specific part:
resp = requests.post(
f"{BASE_URL}/gateway/chat/completions",
headers=HEADERS,
json={"model": "llama-3.3-70b", "messages": rendered["messages"], "stream": True},
stream=True,
)
for line in resp.iter_lines(decode_unicode=True):
piece = json.loads(line[len("data: "):])["choices"][0]["delta"].get("content", "")
The full script renders the same stored prompt, then sets "stream": true and reads the
gateway's SSE frames as they arrive, printing each delta.content piece live — see
weather_stream.py on GitHub
for the complete source.
Run it and the answer prints as it's generated:
python weather_stream.py
I need to check the current weather conditions in Paris. Please hold on for a moment.
(streamed 85 characters)
Without the tool, the model can only say it would check — which is exactly why the tool-driven loop in step 5 returns concrete conditions. Reach for streaming when you want a readable answer to appear live; reach for the loop in step 5 when the model needs to call tools.
7. Inspect the trace
Every run above landed in one trace, because each completion threaded the same
x-trace-id. Open Observability → Traces and click the newest
py-weather-agent run: the LLM turn that asked for the tool, the get_weather
tool span your Python added, and the final LLM turn — three spans, all in order.

Click the get_weather span to see the payloads your loop captured — the city
argument the model chose as input, and the conditions your code returned as
output:

The LLM spans were recorded by the gateway; the tool span is the one you posted from Python — together they tell the whole story of the run.
Inputs and outputs are stored only when payload capture is on for your team
(it's on by default). The weather_agent.py loop also passes
"capturePayloads": true when it posts the tool span. Turn capture off in
Observability → Settings if you'd rather not store request bodies.
Scripting the setup over the API
Steps 1–3 are dashboard actions, but the credential and model have REST equivalents too — handy for provisioning a new environment from a script. (The first personal API key is bootstrapped in the dashboard, since a REST call needs a key to authenticate in the first place.) Both responses below are real:
- curl
- Python (requests)
- Node (fetch)
# Credential (OpenRouter is OpenAI-compatible, so config.base_url is required)
curl -X POST "$ACRUXCORE_BASE_URL/gateway/connections" \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" \
-d '{"provider":"openai_compatible","label":"OpenRouter","apiKey":"<your openrouter key>","config":{"base_url":"https://openrouter.ai/api/v1"}}'
{
"id": "a2d765cc-8a97-4596-8d2e-bf4bd0418441",
"provider": "openai_compatible",
"label": "OpenRouter",
"keyLastFour": "c2b9",
"config": { "base_url": "https://openrouter.ai/api/v1" },
"createdAt": "2026-07-14T14:33:50.374Z"
}
# Model — point a callable name at an upstream model on that credential
curl -X POST "$ACRUXCORE_BASE_URL/gateway/models" \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" \
-d '{"publicName":"llama-3.3-70b","upstreamModel":"meta-llama/llama-3.3-70b-instruct","credentialId":"<credential-id>"}'
{
"id": "89141307-9c4b-460e-b813-f40886ba6e5e",
"publicName": "llama-3.3-70b",
"upstreamModel": "meta-llama/llama-3.3-70b-instruct",
"credentialId": "a2d765cc-8a97-4596-8d2e-bf4bd0418441",
"credentialLabel": "OpenRouter",
"provider": "openai_compatible",
"inputPricePerM": null,
"outputPricePerM": null,
"fallbacks": []
}
Credentials and models are platform configuration, so neither SDK wraps them — call the two endpoints directly, the same ones the curl tab shows.
import os, requests
BASE_URL = os.environ["ACRUXCORE_BASE_URL"]
headers = {"Authorization": f"Bearer {os.environ['ACRUXCORE_API_KEY']}",
"Content-Type": "application/json"}
credential = requests.post(f"{BASE_URL}/gateway/connections", headers=headers, json={
"provider": "openai_compatible",
"label": "OpenRouter",
"apiKey": "<your openrouter key>",
"config": {"base_url": "https://openrouter.ai/api/v1"},
}).json()
model = requests.post(f"{BASE_URL}/gateway/models", headers=headers, json={
"publicName": "llama-3.3-70b",
"upstreamModel": "meta-llama/llama-3.3-70b-instruct",
"credentialId": credential["id"],
}).json()
print(f"credential={credential['id']} model={model['id']}")
const BASE_URL = process.env.ACRUXCORE_BASE_URL!;
const headers = {
Authorization: `Bearer ${process.env.ACRUXCORE_API_KEY}`,
'Content-Type': 'application/json',
};
const credential = await fetch(`${BASE_URL}/gateway/connections`, {
method: 'POST',
headers,
body: JSON.stringify({
provider: 'openai_compatible',
label: 'OpenRouter',
apiKey: '<your openrouter key>',
config: { base_url: 'https://openrouter.ai/api/v1' },
}),
}).then((r) => r.json());
const model = await fetch(`${BASE_URL}/gateway/models`, {
method: 'POST',
headers,
body: JSON.stringify({
publicName: 'llama-3.3-70b',
upstreamModel: 'meta-llama/llama-3.3-70b-instruct',
credentialId: credential.id,
}),
}).then((r) => r.json());
console.log(`credential=${credential.id} model=${model.id}`);
What's next
- Store prompts and tools via the API — the
same store → fetch → run flow using the TypeScript SDK, which wraps the loop in
one
runToolLoopcall. - Build a tool-calling agent in the dashboard — the no-code path, with HTTP tools the gateway runs for you.
- Using sessions and traces — group related runs and dig into what happened.
- API details: see Gateway, Prompts, Tools, and Traces in the API Reference.