You are going to build a data analyst agent. Ask it “Which product generated the most total revenue?” and it writes a SQL query, runs it against a local SQLite file, and answers “The Aeron Chair, with $4,185.00.”
The interesting part is not the agent. It is where each piece of it lives. The model name, the system prompt and the database schema go in a dashboard, because they are the things you will change most often and want to change without a deploy. Only the tool bodies are code — the function that opens the database, and a second one that answers whether a column may be shown to a user at all.
Those two tools are owned differently on purpose. The first publishes its own JSON Schema to the platform, so the code is the source of truth for what the model may send. The second ships with no docstring at all, which hands its model-facing description to the dashboard, where a compliance reviewer can rewrite it without opening the repository.
Disclosure: I work on AcruxCore, the platform used here. The split described below is a general one; the walkthrough is specific to our product.
Every tool a model can call has two halves, and they do not have to live in the same place.
The definition is the object the model reads: a name, a description, and a JSON Schema for the arguments. It decides whether the model picks the tool and what shape the call takes.
The implementation is the code that runs when the model asks. It reads a file, hits an internal API, or — here — opens SQLite.
Most agent frameworks glue the two together in one Python file, so changing what the model reads means a code review and a deploy. Separating them lets a prompt engineer reword a tool description on a Tuesday afternoon while the function that runs it stays untouched. The cost is that you now have two sources of truth to keep aligned, which is the problem the rest of this article is about.
Python 3.9 or newer, an Anthropic API key, and a free AcruxCore account. Nothing here is Anthropic-specific — the platform takes an OpenAI, Gemini or OpenAI-compatible credential in exactly the same way, and your code never learns which one answered.
pip install acruxcore
The agent needs something real to read. This script builds store.db with two tables and fixed rows, so your answers match the ones printed later.
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)
The full script, with all the rows, is seed_db.py.
python seed_db.py# Seeded store.db: 8 products, 15 orders.
Eight products, fifteen orders, prices in USD. Small enough to check the agent’s arithmetic by hand, which you will want to do.
A credential is your provider key, stored once. A model is a public name you invent, mapped to an upstream model on one of those credentials.
Open Gateway → Credentials → New credential, pick Anthropic, paste your key. Then Gateway → Models → New model: name it claude-haiku, select that credential, and set the upstream model to claude-haiku-4-5-20251001.
The public name is the point. Your code sends claude-haiku and never learns who answered, so switching provider later is a dropdown, not a pull request. Hit Test on the new row to fire a one-token completion and confirm the key works before you write any Python.
The prompt holds the system instructions, and — because this agent writes SQL — the database schema. The user’s actual question is not stored here. Your code appends it at run time, so one prompt answers every question.
Open Prompts → New prompt, name it sql-analyst-agent, set Default model to claude-haiku, and write one system message:
You are a data analyst for an online store. Answer questions about products andsales 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 answerin 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 the first commit points production at v1 automatically. That matters more than it looks: rolling back a bad prompt and the model it was tuned for is one click, not two deploys.
Now the one piece that stays in your repository. @acrux.tool does not wrap the function or change how it runs — it reads the function and builds the model-facing definition out of what is already there.
from acruxcore import AcruxCore, acrux
php
@acrux.toolasync def query_database(sql: str) -> list[dict]: """Run a read-only SQL SELECT against the store database.
Args: sql: A single read-only SQLite SELECT statement. """ statement = sql.strip().rstrip(";").strip() if not statement.lower().startswith("select"): raise ValueError("Only read-only SELECT statements are allowed.") if ";" in statement: raise ValueError("Only a single statement is allowed.") conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True) conn.row_factory = sqlite3.Row try: return [dict(row) for row in conn.execute(statement).fetchall()] finally: conn.close()
Three things in that function are the security model, and none of them is optional. The model chooses the SQL, so treat it as untrusted input: mode=ro opens the connection read-only, the startswith check rejects anything that is not a SELECT, and the semicolon check stops a second statement riding along behind the first. Never point a tool like this at a writable production database.
The decorator has already turned that function into the object the model will read, and hung it on the function as query_database.acrux_tool. Print its three fields and you get this:
{ "name": "query_database", "description": "Run a read-only SQL SELECT against the store database.", "parameters": { "type": "object", "properties": { "sql": {"type": "string", "description": "A single read-only SQLite SELECT statement."} }, "required": ["sql"] }}
That block is the output of that print, not a schema written for this article. No part of it was typed: name came from the function name, description from the first line of the docstring, and the sql property from the sql: str annotation and its Args: line. Add a limit: int parameter tomorrow and the schema grows a limit on its own, because there is no second copy to remember.
Nothing has reached the network yet — the decorator only attaches metadata. Publishing is a separate, explicit call:
async with AcruxCore() as hub: await hub.tools.sync([query_database])
Run that once and the tool appears in the catalog, with a Defined in code badge and a v1 tagged code:
The badge is not decoration. It records that the version came from tools.sync, which tells the next person that editing the function — not the dashboard form — is how you change this tool.
You can skip that explicit call if you like: the run loop in step 6 publishes the definition on first use unless you pass sync=False. Doing it deliberately, in a setup script, is the habit worth keeping — it makes publishing a tool schema a thing you decide rather than a side effect of running your app.
It also matters because syncing is an overwrite. If a tool of that name already exists in the catalog, tools.sync commits a new version from your local schema and moves the alias, and a prompt that had pinned an exact version loses the pin. Nothing errors and the run succeeds, so the way this shows up is a pinned production prompt quietly following whatever is on somebody's laptop. Publishing from code means your code is the source of truth for that tool — decide that once, per tool, and do not also author it in the dashboard.
The agent needs one more capability, and this one is a policy, not a technique. The orders table has a customer column, so the model can answer "who spends the most" with a person's name — and whether it may is not an engineering decision. So the second tool is owned the other way round. The code keeps the call shape; the dashboard gets every word the model reads.
@acrux.toolasync def check_disclosure_policy(field: str) -> dict: # No docstring, on purpose. See below — the absence is the mechanism. sensitive = field.strip().lower() in {"customer", "customer_name", "email"} return { "field": field, "may_disclose": not sensitive, "guidance": ( "Do not name an individual customer. Report aggregate figures only." if sensitive else "This column may be shown to the user." ), }
@acrux.tool takes the description from the docstring's first line, so a function without a docstring has nothing to put there. Print the definition it built and the description is empty:
{ "name": "check_disclosure_policy", "description": null, "parameters": { "type": "object", "properties": {"field": {"type": "string"}}, "required": ["field"] }}
tools.sync then omits the description rather than sending null, and the catalog reads an absent description as "keep whatever is already there". Sync it and the tool arrives with a schema and no text at all: v1, tagged code, an em dash where the description would be.
Now a human writes that sentence. Open the tool, click New version, and fill in the one field the code did not supply. Leave the parameter row exactly as the dialog prefilled it — field, string, required, description empty — because that row is the code's schema, and editing it is what causes churn later. The dialog says the arrangement back to you: the code sends no description, so this wording is yours and a deploy carries it forward.
That commits v2, tagged dashboard. Committing does not promote, so the new text is not live until you open the Aliases tab and point production at it. Two deliberate clicks, for a string the model reads on every single call.
Then comes the test that decides whether any of this is real. Run the sync again, exactly as your next deploy would:
Published: ToolSyncResult(tool_id='2572965e-…', version_number=2, committed=False, alias='production', superseded_source=None)
committed=False, on version_number=2 — the version the dashboard wrote. The deploy compared the local spec against the live version, found the same schema and the same description (because the code supplied none), and committed nothing at all. Whoever owns that sentence keeps it, deploy after deploy.
Two things to know before relying on this.
The missing docstring is load-bearing. Add one to that function later and the code starts supplying a description again, which wins on the next sync and supersedes the dashboard’s version. The SDK warns when that happens, and on_conflict="error" turns the warning into a failed deploy. A tidy-up commit that "adds the missing docstrings" is the realistic way to lose a policy sentence, which is why the comment in the function is not decoration.
The parameter rows are still the code’s. If the person editing the description also fills in a parameter description, the schema stops matching what your type hints generate, and the next sync sees a real diff, commits a code-sourced version, and carries the text forward onto it. Nothing breaks and the wording stays live, but the version number moves on every deploy.
One dashboard detail falls out of this. The Defined in code badge tracks whatever production points at right now, not the tool's history — so it was there while v1 was live, and it is gone with the dashboard's v2 live. That is the right answer: the text the model now reads was written by hand.
Everything is in place, so the run is short. Render the prompt, append the question, hand both decorated functions to the loop.
async def ask(hub: AcruxCore, question: str) -> str: rendered = await hub.prompts.render("sql-analyst-agent", "production") messages = [*rendered.messages, {"role": "user", "content": question}] result = await hub.gateway.run_prompt_with_tools( rendered, messages=messages, tools=[query_database, check_disclosure_policy], trace={"name": "sql-analyst-agent", "session_id": "sql-agent-demo"}, ) print(f" (trace {result.trace_id})") return result.content
Underneath, that is a tool loop: it calls the model, hands each tool call to your function, feeds the result back, and repeats until the model stops asking. run_prompt_with_tools is a thin wrapper that reads four things off the render so you never restate them — the model, the messages, the prompt's own tool bindings, and the prompt version id.
The first of those is why there is no model name in this code at all. The last is the one that quietly matters: it is what links the trace back to the prompt version that produced it. The lower-level call, run_tool_loop, takes the same arguments but derives none of them, so writing run_tool_loop(rendered.model, messages, ...) by hand gives you an identical answer and an identical span tree with the prompt version missing. Nothing errors — the lineage is just gone.
Model calls in this build go out through AcruxCore’s gateway — the SDK sends them to your account, the platform forwards them to Anthropic with the credential you stored in step 2, and it records the request on the way through. That is why the trace in the next section exists without you writing any logging. Point the SDK at your account and run it:
export ACRUXCORE_API_KEY=<your personal api key>export ACRUXCORE_BASE_URL=https://api.acruxcore.com/api/v1python sql_agent.py
The transcript below is a real run. It is what sql_agent.py printed, with only the last answer wrapped to fit the page.
Q: Which product generated the most total revenue, and how much? (trace 606dbd38-cb34-4cc3-a1a1-ec4dc9af87b2)A: The **Aeron Chair** generated the most total revenue at **$4,185.00**.
Q: How many total units were ordered in June 2026? (trace d1ace20b-ae00-4c4d-9294-a613327e1583)A: In June 2026, a total of **93 units** were ordered.
Q: Who is our biggest customer by total spend? (trace ea57a392-9af2-41b6-bfd8-48297ee17a8c)A: Our biggest customer by total spend has spent $6,995.00. I'm unable to disclose the specific customer name due to privacy policy, but I can confirm this is our top customer by total spending.
Read the third answer again. The system prompt says nothing about a disclosure policy, the question did not ask for one, and no line of Python tells the model when to check. The only thing that could have sent it to check_disclosure_policy is the sentence a human typed into the dashboard in step 5. That is the whole argument of this article, running.
Model output varies between runs, but the underlying facts do not: seeding the database and executing those statements returns 4185.0, 93 and 6995.0 on any machine, which is worth checking yourself before you trust an agent's arithmetic on data you cannot see.
Take the first question. Both model turns and the tool call landed in one trace, because the loop threads the same trace id across the whole exchange. Open Observability → Traces and click that sql-analyst-agent run.
Three spans, in order: the turn where the model asked for the tool, the tool call itself, the turn where it wrote the answer. Click the middle one and you get the two things you actually want when an agent gives a wrong answer — the SQL the model wrote, and the rows your code handed back.
That pairing is the whole reason to route tool calls through a loop that reports spans. A wrong answer from a text-to-SQL agent has exactly two causes — the model wrote the wrong query, or the query returned something it then misread — and this view separates them in one click.
The third question’s trace is longer, for the same reason its answer was. Five spans, in order: the model asks for query_database, gets the row back, asks for check_disclosure_policy on the customer field, reads may_disclose: false and the sentence beside it, and only then writes the answer. The row it was holding said Initech, 6995.0. The answer it gave named the number and not the customer.
The gateway records the LLM spans because the calls pass through it. The tool span comes from your own process, reported by the SDK, since the platform never sees your SQLite file.
The session_id passed to the loop groups related runs. Open Observability → Sessions and every question from the run is there under sql-agent-demo, each its own trace, with real token counts.
One session id per logical grouping — a user’s conversation, a nightly job, one test run. Reuse it across many calls and they collect together, which is how you follow a multi-turn agent from first question to last.
Go back to the prompt, switch Default model from claude-haiku to something larger, and commit a new version. Run the script again with no edits. rendered.model returns the new model and the loop uses it.
The same is true of the system prompt and the schema block inside it. Adding a customers table to the agent's world is a prompt commit, not a release. So is the disclosure policy: tightening or loosening the sentence the model reads about check_disclosure_policy is a tool version, and the person who writes it never touches the repository.
Only three things in this build require a deploy: the SQL guard, the connection to your database, and the rule that decides which columns count as personal. All three should require one — they are code, and they are testable.
This build made that choice twice, in two different directions, so it is worth naming the rule behind it.
Let your code own the definition when the schema and the body change together. That is this agent. Adding a limit parameter to query_database means editing the Python whatever you do — a new argument, and a new LIMIT clause in the query it builds. If the schema were also stored in the dashboard, you would then have to open the tool form and add limit there by hand as well. The first time someone forgets that step, the model is never told limit exists, so it never sends one — the parameter you just shipped does nothing, and nothing anywhere reports an error.
Let the catalog own it when the definition has a different audience than the body. Two versions of this come up. A colleague who is not on the deploy rota needs to reword a description because the model reaches for the tool too often — that is a sentence, and it should not need a release. Or one tool name is served by three services in two languages, and there is no single function to read a schema from.
Or split it by field, which is what step 5 did. The choice is not one tool, one owner. The description is prose aimed at the model; the parameter schema is a contract with your code. Those change for different reasons and by different people, so query_database keeps its docstring and check_disclosure_policy does without one — same decorator, opposite owners, and the compliance sentence survives every deploy because the code never sends a competing one.
Switching sides does not mean rewriting the function. It keeps its body and its signature; it stops being the source of the schema. You bind the tool to the prompt in the dashboard, and pass the same function as client_tools={"query_database": query_database} instead of tools=[query_database] — the SDK then takes the definition from the catalog and calls your function to run it, writing nothing back. What you should not do is both. Author a tool in the dashboard and sync it from code and each side overwrites the other, depending on who ran last. The ownership guide builds one tool each way.
The agent you have is one prompt, two tools and a single run_prompt_with_tools call, and every dial worth turning is behind a login rather than behind a deploy.
The obvious next step is a third tool that your process does not run at all. An HTTP tool is a URL and a schema described once in the catalog; the gateway calls it, and your app does nothing. That is the last square of the grid: this build has a tool whose schema and body are both code, and a tool whose body is code and whose wording is config, and an HTTP tool is one where none of it is code. Some of an agent’s capabilities can live entirely behind a login, and you get to decide which, per tool.
The step-by-step version of this build, with the dashboard screens in full, is in the AcruxCore docs.
Github code related to this documentation can be found here
Text-to-SQL Agent in Python: LLM Tool Calling Tutorial was originally published in Towards AI on Medium, where people are continuing the conversation by highlighting and responding to this story.