Skip to main content

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.

Prefer a notebook?

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.

What you'll need

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

Already set up a credential?

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.

New model dialog with public name claude-haiku, credential Anthropic, and upstream model claude-haiku-4-5-20251001

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.

New tool dialog with the name query_database and a description about running a read-only SQL SELECT

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, type string, 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.)

New version form showing a required sql string parameter and the executor set to Client — the caller's app runs it

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.

The sql-analyst-agent prompt Editor tab showing default model claude-haiku, a system message with the database schema, and production pointing at v1

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.

The prompt Tools tab showing query_database connected

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.

Create API key dialog with the name sql-agent

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).

tip

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.

The model lives with the prompt

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.)

Keep the tool read-only

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.

Trace named sql-analyst-agent with three spans — an LLM span, a query_database tool span, and a final LLM span — all marked OK

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 expanded query_database span showing an Input with the SELECT statement and an Output with a single row for Aeron Chair, total_revenue 4185

The gateway records the LLM spans; the SDK adds the tool span from your process. Together they tell the whole story of the run.

Payload capture

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.

The sql-agent-demo session showing two sql-analyst-agent traces, each with 3 spans and a token count

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:

# 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 }
]
}

Then the prompt shell, a version with the messages, and a binding that connects the tool:

# 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 }
]
}

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 -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"
}

What's next