Text-to-SQL Agent in Python: LLM Tool Calling Tutorial AcruxCore published a Python tutorial showing how to build a text-to-SQL agent that separates each tool's model-facing definition from its implementation code, letting prompt engineers edit tool descriptions in a dashboard without a deploy. The walkthrough uses Python 3.9 or newer, an Anthropic API key, and a free AcruxCore account, seeding a local SQLite store.db with 8 products and 15 orders; the agent answers "Which product generated the most total revenue?" with "The Aeron Chair, with $4,185.00." The author discloses working on AcruxCore, the platform used in the tutorial. 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 https://console.anthropic.com/ API key, and a free AcruxCore https://acruxcore.com 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 https://github.com/AcruxCore/AcruxCore/blob/main/scripts/tutorials/tool-calling-agent-in-python-sdk/python/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. python 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. php @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. php 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=