Module 1: Why SQL and LLMs — the Text-to-SQL Problem, Introduced

What is a SQL agent and the `run_sql` tool

Description

With the dangers already introduced, this lesson shifts gears and builds the landscape. You'll distinguish two ways of using a model with SQL. The first, plain text-to-SQL: you hand it the question, it returns the SQL, and that's where its job ends —you run it—. The second, a SQL agent: the model doesn't just generate SQL, it can run it itself by invoking a tool, see the result (or the error), and decide the next step.

The hinge of that second form is a tool with a name you'll see throughout the guide: run_sql. You'll learn its contract —what it receives, what it returns—, see it run for real against Reservo, and understand how the model "invokes" it through the Messages API (conceptual, with claude-sonnet-5). This lesson doesn't implement the full agent loop —that's Module 6— but it gives you the piece that loop is built on.

Connection to the module

Lessons 02-04 showed the problem and its dangers. This one gives you the vocabulary to talk about the solution: "agent," "tool," "tool-calling," "run_sql." When lesson 06 assembles the end-to-end flow and Module 6 turns it into a loop, you'll already know what each piece is.


Analogy: the assistant with access to the warehouse

Think of two ways to ask an assistant to get you a piece of inventory data.

Way 1 — they dictate the instructions. You ask "how many boxes of coffee are left?", and they answer: "go to the warehouse, aisle 3, shelf B, and count". They gave you the perfect instructions. But you have to go, count, and come back. If their instructions were wrong —the coffee is in aisle 4— you only find out once you get there, and they're no longer around to correct it.

Way 2 — they have the warehouse key. You ask the same thing, and they go, count, and come back with the number. If aisle 3 was empty, they see it, go to aisle 4, and bring you the correct data without you lifting a finger. They can make several trips if needed.

Way 1 is plain text-to-SQL: the model gives you the SQL, you run it. Way 2 is a SQL agent: the model has "the warehouse key" —a tool, run_sql, that runs queries on its behalf— and can correct itself on the fly. The key, of course, has to be handed over carefully: it's exactly the key that opens the door to the destructive dangers from the previous lesson. That's why run_sql isn't raw access to the database, but a controlled gate.


Plain text-to-SQL vs. SQL agent

Let's put the difference in a table:

Plain text-to-SQLSQL agent
What the model producesThe SQL, and that's itThe SQL, and it runs it itself
Who runs the SQLYou, in your codeThe agent, via the run_sql tool
Can it correct itself?No — it's a one-shotYes — it sees the error and retries
How many steps?OneSeveral (explore the schema, query, correct...)
AnalogyDictates the instructionsHas the warehouse key

A SQL agent is, in essence, text-to-SQL inside a loop with tools: question → generate SQL → run it with run_sql → observe the result or the error → decide (answer? correct and retry?) → answer. That complete loop is Module 6. Here we focus on the piece that makes it possible: the tool.


The run_sql tool: its contract

A tool (or tool), in the vocabulary of language models, is a function the model can ask to have executed. The model doesn't execute code; what it does is say "I want to call run_sql with this query," and your program —not the model— runs the function and returns the result to it. The model describes the action; you execute it.

For the model to know run_sql exists and how to use it, you describe it with a schema —name, description, and the parameters it accepts, in JSON Schema format—:

run_sql_tool = {
    "name": "run_sql",
    "description": (
        "Runs a READ-ONLY SQL query against the Reservo database "
        "(SQLite) and returns the resulting rows. Only use SELECT. "
        "The tables are: rooms, members, bookings, payments."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "query": {
                "type": "string",
                "description": "The SQL SELECT query to run.",
            }
        },
        "required": ["query"],
    },
}

That dictionary is the contract: it tells the model there's a tool called run_sql, what it's for (a description the model reads to decide when to use it —including the list of tables, a preview of Module 2—), and that it takes a single parameter query, of type text. The model, seeing this contract, knows it can ask to "run run_sql with query = 'SELECT ...'".

On the other side of the contract is the implementation —the actual function that runs the SQL and returns the result—. This part is our own code, and it does run:

import sqlite3

con = sqlite3.connect("reservo.db")

def run_sql(query: str) -> dict:
    """Runs a read-only query and returns a dict with the result
    or the error. It's the agent's ONLY gate to the database."""
    sql = query.strip().rstrip(";").strip()
    # Minimal guardrail (Module 5 hardens it): SELECT/WITH only.
    if sql.split()[0].upper() not in ("SELECT", "WITH"):
        return {"ok": False, "error": "only SELECT queries are allowed"}
    try:
        cur = con.execute(sql)
        return {
            "ok": True,
            "columns": [d[0] for d in cur.description],
            "rows": cur.fetchall(),
        }
    except sqlite3.Error as e:
        return {"ok": False, "error": f"{type(e).__name__}: {e}"}

Notice the shape of what it returns: a dictionary with ok (did it work?), and then either columns + rows (if it worked), or error (if it didn't). This uniform format is key: the agent always knows how to read the response, whether it's a success or a failure. And the failure carries the error message —the same no such column from lesson 03— that the agent can later use to correct itself.


Worked example: run_sql in action

Let's watch the tool run for real, with three queries you already know: a good one, one with a hallucinated column, and a destructive one that gets blocked.

import json

# (con and run_sql defined as above)

# 1) Good query
print(json.dumps(run_sql(
    "SELECT name, capacity FROM rooms WHERE capacity >= 4 ORDER BY capacity"
), ensure_ascii=False))

# 2) Hallucinated column
print(json.dumps(run_sql("SELECT SUM(b.total) FROM bookings b"), ensure_ascii=False))

# 3) Destructive, blocked by the minimal guardrail
print(json.dumps(run_sql("DELETE FROM rooms"), ensure_ascii=False))

What to expect:

{"ok": true, "columns": ["name", "capacity"], "rows": [["Studio", 4], ["Lounge", 6], ["Boardroom", 10]]}
{"ok": false, "error": "OperationalError: no such column: b.total"}
{"ok": false, "error": "only SELECT queries are allowed"}

Three responses, three shapes of the same contract:

  1. The good query returns ok: true with the columns and rows —Studio, Lounge, Boardroom—. The agent can format this as an answer.
  2. The hallucinated column returns ok: false with the engine's error. The agent knows it failed and why —something it will use in Module 6 to ask the model for corrected SQL—.
  3. The destructive one doesn't even reach the engine: the minimal guardrail rejects it beforehand, with ok: false. The warehouse key opens for querying, not for destroying.

That's the agent's central piece. run_sql is the only gate between the model and the database, and that's exactly why it's the natural place to put the guardrails (M5): if every query passes through here, hardening this function hardens the entire assistant.


How the model invokes the tool (conceptual)

What's left is seeing how the model "asks" to run run_sql. This happens through Claude's Messages API, with its tool use mechanism. Remember the hard rule: this call does not run; I'm showing you the shape as a concept.

Your program sends the model the user's question together with the tool's contract (conceptual):

# Conceptual — does NOT run in this guide (no network/API in this environment)
import anthropic

client = anthropic.Anthropic()
response = client.messages.create(
    model="claude-sonnet-5",
    max_tokens=1024,
    tools=[run_sql_tool],   # the contract from above
    messages=[
        {"role": "user", "content": "Which rooms fit 4 or more people?"}
    ],
)

The model, seeing the question and the available tool, doesn't respond with text: it responds with a request to use the tool. The response would carry a tool_use block shaped like this (realistic example of what claude-sonnet-5 would return):

{
  "type": "tool_use",
  "id": "toolu_01A...",
  "name": "run_sql",
  "input": {
    "query": "SELECT name, capacity FROM rooms WHERE capacity >= 4 ORDER BY capacity"
  }
}

Read that block as an instruction: the model wants you to call run_sql with that query. Your program then:

  1. Runs run_sql(query) —this does run— and gets the result dictionary.
  2. Returns that result to the model in a tool_result-type message, referencing the id of the request.
  3. The model, now holding the rows, drafts the natural-language answer for the user.

That back-and-forth —model asks for a tool, program runs it, model reads the result— is the heartbeat of an agent. If the result had been an error (ok: false), the model could ask for run_sql again with corrected SQL, and there's the loop. But the complete loop, with its logic for when to retry and when to stop, is Module 6.

Boundary with AI Engineering: the general mechanism of tool use —how a model invokes tools, how an agent with several tools is orchestrated, how the conversation is managed— belongs to the AI Engineering ecosystem, and it's covered in depth there. Here we stay in the SQL slice: the specific run_sql tool, its read-only contract, and its role as the sole gate to the database.


Common mistakes

  1. Believing the model executes the SQL. It doesn't. The model asks to run run_sql; your program runs it. That separation is what lets you put in guardrails: the power to run SQL lives in your function, not in the model.

  2. Giving raw access to the database instead of a controlled tool. If run_sql were just con.execute(query) without the read-only check, it would be the key to destruction from lesson 04. The point of routing everything through one function is that there's a single place to harden.

  3. Confusing "agent" with "magic." A SQL agent is a mechanical loop: ask for SQL, run it, observe, decide, repeat. There's nothing mystical about it; it's orchestration engineering (M6) on top of a well-defined tool (this lesson).

  4. Forgetting the tool_use's id. When you return the result to the model, it must reference the request's id so the model knows which call it corresponds to. It's a protocol detail, but skipping it breaks the back-and-forth.


Exercises

Exercise 1: Read the contract (Easy)

Given the lesson's run_sql_tool, answer without running anything: how many parameters does the tool accept, what's its name and type, and is it required? What schema information does the description include?

See solution

A single parameter: query, of type string, and it's required (it appears in "required": ["query"]). The description includes three hints for the model: that the query must be read-only (SELECT only), that the database is SQLite, and the list of tables (rooms, members, bookings, payments). That list of tables is a preview of Module 2 —giving the model the schema as context— tucked directly into the tool's description, to reduce the naming hallucinations you saw in lesson 03.

Exercise 2: Test the response format (Medium)

Use the run_sql implementation to run SELECT COUNT(*) AS n FROM bookings and show the dictionary it returns. Identify each key.

See solution
import sqlite3, json
con = sqlite3.connect("reservo.db")

def run_sql(query):
    sql = query.strip().rstrip(";").strip()
    if sql.split()[0].upper() not in ("SELECT", "WITH"):
        return {"ok": False, "error": "only SELECT queries are allowed"}
    try:
        cur = con.execute(sql)
        return {"ok": True, "columns": [d[0] for d in cur.description], "rows": cur.fetchall()}
    except sqlite3.Error as e:
        return {"ok": False, "error": f"{type(e).__name__}: {e}"}

print(json.dumps(run_sql("SELECT COUNT(*) AS n FROM bookings"), ensure_ascii=False))
con.close()

Expected output:

{"ok": true, "columns": ["n"], "rows": [[23]]}

Explanation: ok: true signals success; columns lists the result's column names (["n"], the alias we gave it); rows is the list of rows (a single one, [[23]]). The agent reads ok first to know whether to format the answer or handle an error.

Exercise 3: The guardrail at the gate (Hard)

Modify run_sql so that, in addition to rejecting anything that doesn't start with SELECT/WITH, it rejects any query containing an embedded ; (a second statement). Test it with a normal SELECT and with the "SELECT 1; DELETE FROM payments;" from the previous lesson.

See solution
import sqlite3, json
con = sqlite3.connect("reservo.db")

def run_sql(query):
    sql = query.strip().rstrip(";").strip()
    if sql.split()[0].upper() not in ("SELECT", "WITH"):
        return {"ok": False, "error": "only SELECT queries are allowed"}
    if ";" in sql:   # after stripping the trailing ';', any remaining ';' is a 2nd statement
        return {"ok": False, "error": "only a single statement is allowed"}
    try:
        cur = con.execute(sql)
        return {"ok": True, "columns": [d[0] for d in cur.description], "rows": cur.fetchall()}
    except sqlite3.Error as e:
        return {"ok": False, "error": f"{type(e).__name__}: {e}"}

print(json.dumps(run_sql("SELECT COUNT(*) FROM rooms"), ensure_ascii=False))
print(json.dumps(run_sql("SELECT 1; DELETE FROM payments;"), ensure_ascii=False))
con.close()

Expected output:

{"ok": true, "columns": ["COUNT(*)"], "rows": [[5]]}
{"ok": false, "error": "only a single statement is allowed"}

Explanation: After rstrip(";") we remove the legitimate trailing ;; if a ; is still left, it's because there's a second statement, and we reject it. Now the two-statement attack that fooled the minimal guard in lesson 04 gets blocked at the gate. This is the kind of hardening Module 5 builds out in earnest; here we just glimpse why run_sql is the right place to put it: one gate, one place to harden.


Summary and next step

  • Plain text-to-SQL: the model returns the SQL and you run it. SQL agent: the model runs the SQL itself, via the run_sql tool, and can correct itself on the fly.
  • A tool is a function the model can ask to have executed. It's described with a contract (name, description, input_schema); the implementation —the code that actually runs— is yours.
  • run_sql takes a query and returns a uniform dictionary: ok: true with columns/rows, or ok: false with error. It's the agent's only gate to the database, and that's why it's the natural place for guardrails.
  • The model invokes the tool via the Messages API (tool_use block, conceptual with claude-sonnet-5); your program runs it and returns a tool_result. That back-and-forth is the agent's heartbeat; the complete loop is Module 6.

Next lesson: The end-to-end flow — We put everything we've seen together into a minimal assistant: question → the model generates SQL → validate → run → answer in natural language, run against Reservo.


Additional resources

  1. Claude — Tool use (function calling) — How a model invokes tools: the input_schema, the tool_use block, the tool_result.
  2. Claude — Messages API — The shape of the call where you declare tools and receive the model's requests.
  3. Python — sqlite3.Cursor.description — Where run_sql gets the result's column names from.
  4. JSON Schema — The format used to describe a tool's input_schema.