Build a tool-calling agent in Python (SDK)
What you'll build: a data analyst agent that answers plain-English questions
about a store by writing SQL, running it against a local SQLite file, and
explaining the result — for example, "Which product generated the most revenue?"
→ "The Aeron Chair, with $4,185.00." You set up the model, the tool, and the
prompt by clicking through the dashboard, then the run is a few lines of
async Python using the SDK's run_tool_loop. The whole run lands in one
trace.
A tool is a function you let the model call. AcruxCore has two kinds. 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 stores only the tool's JSON
schema, and when the model calls it, your code does the work. Querying a local
database is the textbook case for a client tool — the gateway can't reach your
SQLite file, but your Python can. The SDK's run_tool_loop handles the back and
forth: it calls the model, hands each tool call to a function you supply, feeds
the result back, and repeats until the model has an answer.
This guide splits the work the way you'd split it in real life. Everything that's a one-time setup — the model, the tool schema, the prompt — you do once in the dashboard by clicking. Everything that's code — running the agent — you do with the SDK.
sql_analyst_agent.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 built step by step with the
dashboard values beside the code, a live read of the trace the run produced, and four ways
to get it wrong triggered on purpose so you can read the real error. It renders on GitHub
with its saved output, so you can read it through before running anything.
An Anthropic API key and Python 3.9+. Install the SDK
with pip install acruxcore. This page runs the agent on Claude Haiku, but nothing in it is
Anthropic-specific: the gateway takes an OpenAI, Gemini or OpenAI-compatible credential the
same way, and your code only ever sends the public name you choose.
1. Seed a local SQLite database
The agent needs something to query. This script builds a small store.db with two
tables — products and orders — filled with fixed rows so your answers match
this guide exactly. Save it and run it once.
The core of it is one executescript call to create the two tables, then two
executemany calls to fill them:
conn.executescript("""
CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, category TEXT, price REAL, stock INTEGER);
CREATE TABLE orders (id INTEGER PRIMARY KEY, product_id INTEGER REFERENCES products(id),
quantity INTEGER, order_date TEXT, customer TEXT);
""")
conn.executemany("INSERT INTO products VALUES (?, ?, ?, ?, ?)", PRODUCTS)
conn.executemany("INSERT INTO orders VALUES (?, ?, ?, ?, ?)", ORDERS)
See seed_db.py on GitHub
for the complete script — it drops and recreates the products and orders
tables, then inserts the same fixed rows this guide's answers are based on.
python seed_db.py
# Seeded store.db: 8 products, 15 orders.
2. Register a model
A credential is per provider. The no-SDK version of this tutorial stores an OpenRouter one; this page uses a direct Anthropic one, so add a second credential — Gateway → Credentials → New credential, provider Anthropic, your key. Everything after that is identical, which is the point: the provider is a row in the dashboard, not a change in your code.
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
claude-haiku, select the Anthropic credential, and set the upstream model to
claude-haiku-4-5-20251001 — Anthropic's own id for it. Leave prices blank (they
auto-fill for known models).
The public name is yours to choose. Your code sends claude-haiku and never learns which
provider answered, so swapping the model later is a dashboard edit, not a deploy.

Click Register model, then hit Test on the new row to fire a 1-token completion and confirm the key works.
3. Create the query_database tool
Open Gateway → Tools, click New tool, and name it query_database. The
description is what the model reads to decide when to call the tool, so make it
clear.

Click Create tool. A tool is versioned like a prompt — the shell holds no logic yet. Click New version to define its parameters and executor:
- Parameters: add one row —
sql, typestring, marked required, described as the SQL SELECT the model should run. - Executor: leave it on Client — the caller's app runs it. This is the key choice: the platform stores the schema, but your Python executes the query. (The other option, HTTP, is for tools the gateway calls itself.)

Click Commit version. The first version automatically gets production and
staging aliases.
4. Connect the tool to a prompt
The prompt holds the system instructions (including the database schema, so the model knows what it can query) and the bound tool. The user's actual question isn't stored here — your code adds it at runtime, so one prompt answers any question.
Open Prompts → New prompt, name it sql-analyst-agent, and create it. On the
Editor tab, set Default model to claude-haiku and write one system
message:
You are a data analyst for an online store. Answer questions about products and
sales by querying a SQLite database with the query_database tool. Never guess —
always query.
Schema:
CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, category TEXT, price REAL, stock INTEGER);
CREATE TABLE orders (id INTEGER PRIMARY KEY, product_id INTEGER REFERENCES products(id), quantity INTEGER, order_date TEXT, customer TEXT);
Write a single read-only SQLite SELECT, call query_database with it, then answer
in one or two sentences using only the rows it returns. Prices are in USD;
revenue = quantity * price; order_date is YYYY-MM-DD.

Click Commit version — the model is part of the version, so committing bakes it
in, and because it's the first commit production points at v1 automatically.
Then switch to the Tools tab and use + Connect a tool from the catalog to
pick query_database. It saves straight away, in the default column that every
alias of the prompt inherits.

Now one hub.prompts.render("sql-analyst-agent", "production") call returns the system
message and the query_database schema together.
5. Create a personal API key
Your Python needs a key to authenticate. Open Account & keys → New key, name
it sql-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:
export ACRUXCORE_API_KEY=<your personal api key>
export ACRUXCORE_BASE_URL=https://api.acruxcore.com/api/v1
6. Run the agent in Python
Everything set up in the dashboard, the run is code. The tool's body can reach the
loop two ways — as a decorated function, or as a client_tools entry keyed by
the catalog tool name. Both do the same thing, and both thread one trace
automatically. The difference is who owns the tool's definition.
Option A — define the tool in code (@acrux.tool)
The @acrux.tool decorator derives the schema from the function signature and
docstring, syncs it to the catalog, and dispatches calls automatically:
@acrux.tool
async def query_database(sql: str) -> list[dict]:
"""Run a read-only SQL SELECT against the store database."""
conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
...
result = await hub.gateway.run_tool_loop(rendered.model, messages, tools=[query_database])
See
sql_agent_decorator_tool.py on GitHub for the complete source (named sql_agent.py in this page's prose, sql_agent_decorator_tool.py in the repo, to distinguish it from Option B below).
The tool still appears in the dashboard after the run — you define it in code, not in the UI, but both places end up with the same schema.
Option B — dashboard-authored tool (client_tools)
Same result, different wiring — and the better fit when the tool was authored in the dashboard, as it was in step 3. The catalog keeps the schema and deliberately no body, so your app supplies the body alone, keyed by the tool's name:
result = await hub.gateway.run_prompt_with_tools(
rendered,
messages=messages,
client_tools={'query_database': run_query_database},
)
Nothing is written back to the catalog, unlike Option A, and the model, the bound tool
and the prompt version id all come from rendered. The function's parameters are the
schema's own field names — here sql — because that is how it is called.
See
sql_agent_client_tools.py on GitHub for the complete source (named sql_agent.py in this page's prose, sql_agent_client_tools.py in the repo, to distinguish it from Option A above).
Both options produce the same trace. Don't do both for the same tool — a decorated tool's spec overwrites the dashboard's on every run. See Build and attach a tool for the full decorator reference.
Notice there's no hardcoded model in either option. hub.prompts.render returns the
model you bound to the prompt in the dashboard as rendered.model, and you hand it
straight to run_tool_loop. Change the model in the dashboard and your code follows —
no code edit.
Run it:
python sql_agent.py
The model writes the SQL, your code runs it and prints it, and the model turns the rows into an answer:
Q: Which product generated the most total revenue, and how much?
→ query_database: SELECT products.name, SUM(orders.quantity * products.price) AS total_revenue
FROM orders JOIN products ON orders.product_id = products.id
GROUP BY products.id ORDER BY total_revenue DESC LIMIT 1;
(trace d6b0b097-e2b8-4f00-9c40-a7828d0b034e)
A: The product that generated the most total revenue is the "Aeron Chair," with a revenue of $4,185.00.
Q: How many total units were ordered in June 2026?
→ query_database: SELECT SUM(quantity) AS total_units FROM orders
WHERE order_date BETWEEN '2026-06-01' AND '2026-06-30';
(trace 0dfc16f8-7951-4b80-aaa4-24b9f85e864c)
A: A total of 93 units were ordered in June 2026.
Each question wrote its own SQL and got its own trace. Because the question is appended in code rather than stored as a template variable, one prompt answers any number of different questions.
run_tool_loop gets its model from rendered.model — the default model you set
on the prompt version in the dashboard. Switch a prompt to a cheaper or newer
model in the UI and every caller picks it up on the next render, with no code
change or redeploy. (The render API returns it too, so a plain curl client can
read model from the render response and pass it on — see below.)
The model chooses the SQL, so treat it as untrusted. Opening the connection with
mode=ro and rejecting anything but a single SELECT (as above) means a stray or
malicious query can't modify your data. Never point a tool like this at a
writable production database.
7. Stream a reply
When you want tokens as they're generated instead of waiting for the whole answer,
pass stream=True to hub.gateway.stream and iterate the result. Streaming yields text deltas
and does not auto-run tools (that's what the loop in step 6 is for), so use it
for plain, readable answers:
stream = await hub.gateway.stream(
"claude-haiku",
[{"role": "user", "content": "In one sentence, what makes a good data analyst?"}],
)
async for chunk in stream:
print(chunk.delta.get("content", "") or "", end="", flush=True)
See stream_demo.py on GitHub
for the complete script — it opens a streamed chat call and prints each text
delta as it arrives.
python stream_demo.py
A good data analyst combines strong analytical skills, attention to detail, and
effective communication abilities to derive insights from data and translate them
into actionable recommendations.
The answer prints as it's generated. Reach for streaming when you want a live answer; reach for the loop in step 6 when the model needs to call tools.
8. Inspect the trace
Every run landed in one trace, because run_tool_loop threads the same trace
across each model turn. Open Observability → Traces and click the newest
sql-analyst-agent run: the LLM turn that asked for the tool, the query_database
tool span your Python reported, and the final LLM turn — three spans, in order.

Click the query_database span to see the payloads the loop captured — the SQL the model wrote as input, and the rows your code returned as output:

The gateway records the LLM spans; the SDK adds the tool span from your process. 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). Turn it off in Observability → Settings if you'd rather not store request bodies.
9. Group the runs into a session
The trace={"session_id": "sql-agent-demo"} you passed to run_tool_loop tags
every run with that session — a caller-supplied id that groups related runs
(one user's conversation, one job, one test run). Open Observability →
Sessions and click sql-agent-demo: both questions are there, each its own
trace, with their real token counts.

Use one session id per logical grouping — reuse it across many run_tool_loop
calls and they all collect under the same session, which is how you follow a
multi-turn agent conversation end to end.
Doing this over the API
The dashboard steps for the tool and prompt have REST equivalents — handy for
provisioning a new environment from a script, or checking your tool definitions in
with your code. Set ACRUXCORE_BASE_URL to https://api.acruxcore.com/api/v1 and
use a Bearer API key. All responses below are real.
Create the tool shell, then commit a client version — same shape the dashboard form produces:
- curl
- Python (SDK)
- Node (SDK)
# 1. Tool shell
curl -X POST "$ACRUXCORE_BASE_URL/tools" \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" \
-d '{"name":"query_database","description":"Run a single read-only SQL SELECT against the store database and return the matching rows."}'
# 2. Client version — the parameters schema + a client executor (use the id from step 1)
curl -X POST "$ACRUXCORE_BASE_URL/tools/<tool-id>/versions" \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" \
-d '{
"parametersSchema": {"type":"object","properties":{"sql":{"type":"string","description":"A single read-only SQLite SELECT statement."}},"required":["sql"]},
"executor": {"type":"client"}
}'
{
"id": "518cebb9-9d25-43b4-b34d-d876f2ce124b",
"toolId": "214f2c44-c41b-431b-b99a-d4a3dfb65f57",
"versionNumber": 1,
"executor": { "type": "client" },
"aliases": [
{ "alias": "production", "versionNumber": 1 },
{ "alias": "staging", "versionNumber": 1 }
]
}
from acruxcore import AcruxCore
async with AcruxCore() as hub:
tool = await hub.tools.create(
name="query_database",
description="Run a single read-only SQL SELECT against the store database and return the matching rows.",
)
version = await hub.tools.commit_version(
tool.id,
parameters_schema={
"type": "object",
"properties": {"sql": {"type": "string", "description": "A single read-only SQLite SELECT statement."}},
"required": ["sql"],
},
executor={"type": "client"},
)
print(f"tool_id={tool.id} v{version.version_number}")
import { acruxcore } from '@acruxcoreai/sdk';
const hub = acruxcore({ apiKey: process.env.ACRUXCORE_API_KEY });
const tool = await hub.tools.create({
name: 'query_database',
description: 'Run a single read-only SQL SELECT against the store database and return the matching rows.',
});
const version = await hub.tools.commitVersion(tool.id, {
parametersSchema: {
type: 'object',
properties: { sql: { type: 'string', description: 'A single read-only SQLite SELECT statement.' } },
required: ['sql'],
},
executor: { type: 'client' },
});
console.log(`tool_id=${tool.id} v${version.versionNumber}`);
Then the prompt shell, a version with the messages, and a binding that connects the tool:
- curl
- Python (SDK)
- Node (SDK)
# 3. Prompt shell
curl -X POST "$ACRUXCORE_BASE_URL/prompts" \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" \
-d '{"name":"sql-analyst-agent","description":"Text-to-SQL data analyst."}'
# 4. Version — system message and 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 data analyst. Use query_database to answer."}],
"model": "claude-haiku"
}'
# 5. Bind the tool to the prompt — inherited by every alias
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"}'
{
"id": "4b6b2c08-f9cc-4e33-a9bd-cb5d8b120801",
"promptId": "9983c0fb-c971-411a-b04b-a2ea82c3e402",
"versionNumber": 1,
"model": "claude-haiku",
"aliases": [
{ "alias": "production", "versionNumber": 1 },
{ "alias": "staging", "versionNumber": 1 }
]
}
from acruxcore import AcruxCore
async with AcruxCore() as hub:
prompt = await hub.prompts.create(
name="sql-analyst-agent",
description="Text-to-SQL data analyst.",
)
version = await hub.prompts.commit_version(
prompt.id,
messages=[{"role": "system", "content": "You are a data analyst. Use query_database to answer."}],
model="claude-haiku",
)
await hub.prompts.set_tool_binding(prompt.id, "<tool-id>", tool_alias="production")
print(f"prompt_id={prompt.id} v{version.version_number}")
Full script: setup_prompt.py.
import { acruxcore } from '@acruxcoreai/sdk';
const hub = acruxcore({ apiKey: process.env.ACRUXCORE_API_KEY });
const prompt = await hub.prompts.create({
name: 'sql-analyst-agent',
description: 'Text-to-SQL data analyst.',
});
const version = await hub.prompts.commitVersion(prompt.id, {
messages: [{ role: 'system', content: 'You are a data analyst. Use query_database to answer.' }],
model: 'claude-haiku',
});
await hub.prompts.setToolBinding(prompt.id, '<tool-id>', { toolAlias: 'production' });
console.log(`prompt_id=${prompt.id} v${version.versionNumber}`);
Whether you clicked or curled, render returns the same thing the SDK fetches —
the messages, the bound tool schema (already in OpenAI shape), and the bound
model. A plain curl client reads model straight from here and passes it to
/gateway/chat/completions, so it runs on the prompt's bound model too — no
hardcoding, exactly like the SDK's rendered.model:
- curl
- Python (SDK)
- Node (SDK)
curl -X POST "$ACRUXCORE_BASE_URL/prompts/sql-analyst-agent/production/render" \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" \
-d '{}'
{
"messages": [{ "role": "system", "content": "You are a data analyst for an online store. ..." }],
"tools": [
{
"type": "function",
"function": {
"name": "query_database",
"description": "Read-only SQL over the store database; the client executes it.",
"parameters": {
"type": "object",
"required": ["sql"],
"properties": { "sql": { "type": "string", "description": "A single read-only SQLite SELECT statement to run against the store database." } }
}
}
}
],
"model": "claude-haiku"
}
from acruxcore import AcruxCore
async with AcruxCore() as hub:
rendered = await hub.prompts.render("sql-analyst-agent", "production")
print(rendered.model) # "claude-haiku"
print(rendered.messages) # [{"role": "system", "content": "..."}]
print(rendered.tools) # [{"type": "function", "function": {...}}]
import { acruxcore } from '@acruxcoreai/sdk';
const hub = acruxcore({ apiKey: process.env.ACRUXCORE_API_KEY });
const rendered = await hub.prompts.render('sql-analyst-agent', 'production');
console.log(rendered.model); // "claude-haiku"
console.log(rendered.messages); // [{ role: "system", content: "..." }]
console.log(rendered.tools); // [{ type: "function", function: {...} }]
What's next
- Build a tool-calling agent in Python (no SDK)
— the same loop written by hand over plain REST, so you can see what
run_tool_loopdoes for you. - 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.