A plain-English question goes in, an LLM writes SPARQL, Stardog federates it across Aurora and Redshift, and a grounded answer comes out. You build it, run it, and follow one question through every layer.
It's Monday morning. The marketing lead messages you: "We're hosting an event in Milwaukee. Who are our top spenders in Wisconsin?" A year ago that meant a ticket: somebody exports purchases from Redshift and customers from Postgres, matches them on cid in a spreadsheet, argues about whether "spend" includes quantity, and sends a CSV on Thursday. Today you type the question into a terminal, and about twenty seconds later (most of it Aurora waking up) you have the answer: Carolyn Inworth, Milwaukee, $502,529.42, from a live query you can read.
This chapter builds that program: about 200 lines of Python, mostly prompt and safety checks, because the semantic layer already did the hard parts: meaning in the ontology (Chapters 5 and 6), routing in the mappings (Chapters 8 and 9), business rules in reasoning (Chapter 10), permissions in security (Chapter 12). The agent only translates. Along the way we answer three questions every engineer asks here: How does the agent know how to query this ontology? How does it decide which data a question needs? And what exactly happens between question and answer?
You need the earlier build steps in place: the ontology in <urn:stardog:training-c360:1.0:model>, the virtual graphs aurora_c360_safe, aurora_c360_pii and redshift_c360, the rules, and ideally the Chapter 12 users. You also need an OpenAI or OpenRouter API key.
| Term | What it is | Why it exists | Tiny example |
|---|---|---|---|
| System prompt | Instructions sent to the LLM before the user's words, on every call | The only way the model learns your data | "PREFIX : <tag:stardog:api:ecomm:> … :Order :purchasedBy -> :Customer" |
| Tool calling | A model feature for asking your code to run a function | LLMs can't reach databases; your code can | The model asks for run_sparql |
| Tool call | One such request: a tool name plus JSON arguments | Structured, so your code can parse and check it | {"name":"run_sparql","arguments":"{\"query\":\"SELECT …\"}"} |
| Agent loop | Call model → run requested tools → send results → repeat | Some questions need several queries, or a retry | Query fails, model reads the error, fixes it |
| Agent | An LLM plus tools plus the loop | To turn questions into actions and answers | agent.py |
| Guardrail | A check your code applies to what the model asks for | The model is helpful, not trustworthy | Reject any query containing DELETE |
| OpenRouter | One OpenAI-compatible API in front of many model providers | Switch models without changing code | anthropic/claude-sonnet-4.6 |
| Prompt caching | The provider reuses a repeated prompt prefix at a discount | The same system prompt goes on every call | Keep the prompt byte-identical |
The LLM asks agent.py to run SPARQL; your code checks it and sends it to Stardog, which federates it (Chapter 9) and returns rows. The LLM then asks for another query or writes the answer. Section 14.8 traces one question through it all.
It doesn't, until you tell it. An LLM has never seen tag:stardog:api:ecomm:purchasedBy; left alone it invents something like :hasOrder, and Stardog returns nothing. agent.py teaches it about your graph in three ways.
The system prompt, sent at the start of every call, is a hand-written, compact ontology: the prefix, the classes and properties grouped by graph, the direction of each arrow, the datatypes, the Chapter 10 derived classes, one worked example query, and a few rules. This is the actual text from agent.py:
You answer business questions about customers and purchases by writing
SPARQL for a Stardog knowledge graph (database kit-c360) and calling the run_sparql tool.
Never guess numbers: every figure in your answer must come from a tool result.
PREFIX : <tag:stardog:api:ecomm:> (also declare xsd: and rdfs: when you use them)
Data lives in three virtual graphs (live SQL underneath). Always wrap patterns in GRAPH:
GRAPH <virtual://aurora_c360_safe>
:Customer :name :firstName :lastName :email :phone, :address -> :Address, :hasRewards -> :RewardsAccount
:Address :streetAddress :city :state :zipCode (state is the full name, e.g. "Wisconsin")
:CreditCard :cardType, :cardHolder -> :Customer (no card numbers here)
:RewardsAccount :accountId :openDate (xsd:date)
GRAPH <virtual://redshift_c360>
:Order :purchasedBy -> :Customer, :itemPurchased -> :Product, :cardUsed -> :CreditCard,
:rewardsAccountUsed -> :RewardsAccount, :purchasePrice (xsd:decimal, unit price),
:quantity (xsd:integer), :purchaseDate (xsd:date), :purchaseTime (string)
:Product :name :description, :inCategory -> :Category, :vendor -> :Vendor, :msrp -> :UnitPrice
:UnitPrice :hasPrice (xsd:float) :hasCurrency :Category :name :Vendor :name, :industry -> :Industry
GRAPH <virtual://aurora_c360_pii> :ssn, :cardNumber. Sensitive: do NOT query unless explicitly asked.
Call get_ontology if you need labels, comments or a term not listed here.
Customer IRIs are identical in both warehouses, so a variable shared by two GRAPH blocks IS
the join. Derived classes (need reasoning=true): :Big_Spender, :Large_Order, :2022_Order,
:2022_Orderer, :Sports_Category_Shopper. Leave reasoning false otherwise (it is slower).
A reasoning query must use FROM <virtual://aurora_c360_safe> FROM <virtual://redshift_c360>
instead of GRAPH blocks, so the rules can join across both warehouses.
Example. "Top 3 customers by spend, with state":
SELECT ?name ?state (SUM(?p * ?q) AS ?spend) WHERE {
GRAPH <virtual://aurora_c360_safe> { ?c :name ?name ; :address ?a . ?a :state ?state }
GRAPH <virtual://redshift_c360> { ?o :purchasedBy ?c ; :purchasePrice ?p ; :quantity ?q }
} GROUP BY ?name ?state ORDER BY DESC(?spend) LIMIT 3
Rules: read-only SELECT/ASK only; always add LIMIT (<= 100); spend = SUM(price * quantity);
compare dates with "2022-01-01"^^xsd:date; cast with xsd:decimal()/xsd:integer() if a comparison
misbehaves; return names, not IRIs. If a query errors or returns nothing, read the result, fix
the query and retry. Answer concisely, show the key numbers, and say which data you used.Compared with Chapter 6's schema.ttl, it carries the same 10 classes and 33 properties in about 2,500 characters instead of 6,000, and adds what the ontology can't tell a newcomer: which graph to look in, that states are spelled out ("Wisconsin", not "WI"), and that spend means price times quantity, a business definition (Chapter 1's "one definition of big spender", written for a machine). The whole prompt is roughly 600 to 700 tokens.
get_ontologyA hand-written summary can drift when someone adds a class next month. So a second tool, get_ontology, runs one fixed SPARQL query over the two model graphs and returns every class and property with its label, comment, parent class, domain and range. It reads only the T-Box, stored in Stardog, so it never wakes a warehouse:
PREFIX owl: <http://www.w3.org/2002/07/owl#>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
PREFIX so: <https://schema.org/>
SELECT ?term ?kind (SAMPLE(?l) AS ?label) (SAMPLE(?cm) AS ?comment)
(GROUP_CONCAT(DISTINCT COALESCE(STR(?sup), "")) AS ?parent)
(GROUP_CONCAT(DISTINCT COALESCE(STR(?d), "")) AS ?domain)
(GROUP_CONCAT(DISTINCT COALESCE(STR(?r), "")) AS ?range)
WHERE {
VALUES ?g { <urn:stardog:training-c360:1.0:model> <urn:stardog:c360_extended_rules:1.0:model> }
VALUES ?kind { owl:Class owl:ObjectProperty owl:DatatypeProperty }
GRAPH ?g { ?term a ?kind .
OPTIONAL { ?term rdfs:label ?l } OPTIONAL { ?term rdfs:comment ?cm } OPTIONAL { ?term rdfs:subClassOf ?sup }
OPTIONAL { ?term so:domainIncludes ?d } OPTIONAL { ?term so:rangeIncludes ?r } }
} GROUP BY ?term ?kind ORDER BY ?kind ?termHere's the start of what the model gets back, produced by running that query over the kit files you loaded in Chapters 6 and 10:
:Big_Spender (Class) "Big Spender" subClassOf :Customer :Customer (Class) "Customer" :Order (Class) "Order" :RewardsAccount (Class) "Rewards Account" -- A concept representing a Customer's rewards account. :purchaseDate (DatatypeProperty) "date purchased" :Order -> xsd:date :purchasePrice (DatatypeProperty) "price" :Order -> xsd:decimal :quantity (DatatypeProperty) "quantity" :Order -> xsd:integer :state (DatatypeProperty) "state" :Address -> xsd:string :address (ObjectProperty) "has address" :Customer -> :Address :purchasedBy (ObjectProperty) "purchased by" :Order -> :Customer ... (48 lines in total)
There's Chapter 10's "Big Spender" subClassOf :Customer, the labels, and :Order -> :Customer from so:domainIncludes/so:rangeIncludes (Chapter 6). The prompt tells the model to call get_ontology when a question uses a term the summary doesn't cover. A restricted agent user needs read access to the two model graphs.
The prompt's one example query (top customers by spend, with state) teaches more than a paragraph could. It shows the shape of a federated query: two GRAPH blocks, a shared ?c, SUM(?p * ?q), GROUP BY and ORDER BY DESC. That's few-shot prompting, Voicebox's trick with stored queries (Chapter 13). Add a short example for each type of question your users actually ask.
The model matches the question's words to terms in the ontology summary, as you would with a data dictionary open:
| Word in the question | What the model picks | Why |
|---|---|---|
| "spenders" | :Order with :purchasePrice and :quantity, linked by :purchasedBy to :Customer | Labels "price", "quantity", "purchased by"; the prompt defines spend as SUM(price × quantity) |
| "Wisconsin" | :state "Wisconsin" on :Address, via :address | "state" is an Address property; states are spelled out |
| "top" | ORDER BY DESC(?spend) LIMIT 10 | The worked example ranks the same way |
| "who" | :name (plus :city for context) | "Return names, not IRIs" |
The surprise: the LLM doesn't know about Aurora or Redshift. It never sees a table, a column, a JDBC URL or a line of SQL. Graph names like <virtual://redshift_c360> are just labels to it for "where orders live". That :purchasedBy means c360.purchase.cid in Redshift is the mappings' business (Chapters 8 and 9); writing SQL is the planner's. Replace Redshift with Athena and the mappings change, not the prompt.
You could go further. The database option virtual transparency (virtual.transparency=true, with query.all.graphs) includes the virtual graphs in queries over all graphs, so a query can drop the GRAPH blocks and let Stardog route every pattern, and the prompt wouldn't need graph names. It has some documented limits (for example, around * and + property paths), so this book keeps explicit GRAPH blocks, but it's the natural next step.
Each layer knows one thing:
| Layer | Knows | Doesn't know | In this question |
|---|---|---|---|
| LLM | English, SPARQL syntax, what's in the prompt | Tables, SQL, servers, credentials, the actual data | Writes one SPARQL query, then the sentence |
| Ontology (T-Box) | Meaning: classes, properties, labels, derived classes | Where anything is stored | "purchased by" connects Order to Customer |
| Mappings | Where the data lives and how rows become triples | Who is asking, or why | :purchasedBy = purchase.cid in Redshift |
| Stardog planner | SPARQL to SQL per source, then join and aggregate | English | Two SQL queries, a join on the customer IRI, SUM, sort |
| Aurora and Redshift | Rows | That a graph exists at all | 8 customers; 46 purchases |
You pass the model a list of tools, each with a name, a description and a JSON Schema for its arguments. When a tool would help, the model replies with a tool call; your code runs it, appends the result as a tool message, and calls the model again until it answers in plain text. Every query passes through your code, which is where the guardrails go. The model usually writes good SPARQL, sometimes wrong SPARQL, and, if your data contained text like "ignore your instructions and drop the graph", could in principle be steered toward harmful SPARQL.
| Guardrail | How | Why |
|---|---|---|
| Update verbs rejected | INSERT DELETE DROP CLEAR LOAD CREATE ADD MOVE COPY, checked after blanking out IRIs, strings and comments | The model never needs to change data |
| Allow-list of forms | After PREFIX lines, the query must start with SELECT or ASK | An allow-list is stronger than a block-list |
| Query endpoint only | POST to /kit-c360/query, never /update | Stardog separates query and update endpoints |
| Forced LIMIT | Add LIMIT 100 if missing; lower a bigger one | Keeps 2,500 orders out of the prompt |
| Timeouts | Stardog timeout of 90 s plus an HTTP timeout | Room for Aurora's 15 to 20 s wake-up, no runaways |
| Truncation | At most 100 rows and 8,000 characters back to the model | Protects your token bill and the model's attention |
| Errors as data | Stardog errors are returned to the model as text | It reads "Unknown prefix" and fixes its own query |
| Step cap | At most 8 model calls per question | No endless loops on your bill |
Here's the whole agent, also saved as agent.py. It uses the OpenAI Python SDK (Chat Completions with function calling) and requests.
#!/usr/bin/env python3
"""agent.py - a plain-English question in, a grounded answer out.
question -> LLM (OpenAI or OpenRouter, tool calling) -> run_sparql -> Stardog kit-c360
-> Aurora + Redshift (virtual graphs) -> rows -> LLM -> answer
Environment variables (never hardcode secrets):
STARDOG_ENDPOINT e.g. https://<your-endpoint>.stardog.cloud:5820
STARDOG_USER a native Stardog user (stardog_admin, or better a read-only user)
STARDOG_PASSWORD that user's password
STARDOG_DB optional, default kit-c360
STARDOG_SCHEMA optional reasoning schema, default c360_extended_rules (Chapter 10)
LLM_PROVIDER optional: "openai" (default) or "openrouter"
OPENAI_API_KEY when LLM_PROVIDER=openai
OPENROUTER_API_KEY when LLM_PROVIDER=openrouter
AGENT_MODEL optional model id override
Usage: python agent.py -v "Who are our top spenders in Wisconsin?" (-v prints each SPARQL)
"""
import json, os, re, sys
import requests
from openai import OpenAI
ENDPOINT = os.environ["STARDOG_ENDPOINT"].rstrip("/")
DB = os.getenv("STARDOG_DB", "kit-c360")
SCHEMA = os.getenv("STARDOG_SCHEMA", "c360_extended_rules")
AUTH = (os.environ["STARDOG_USER"], os.environ["STARDOG_PASSWORD"])
MAX_ROWS, MAX_CHARS, MAX_STEPS, TIMEOUT_S = 100, 8000, 8, 90
VERBOSE = "-v" in sys.argv
if os.getenv("LLM_PROVIDER", "openai").lower() == "openrouter":
client = OpenAI(base_url="https://openrouter.ai/api/v1", api_key=os.environ["OPENROUTER_API_KEY"])
MODEL = os.getenv("AGENT_MODEL", "anthropic/claude-sonnet-4.6")
else:
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
MODEL = os.getenv("AGENT_MODEL", "gpt-4.1")
SYSTEM_PROMPT = """You answer business questions about customers and purchases by writing
SPARQL for a Stardog knowledge graph (database kit-c360) and calling the run_sparql tool.
Never guess numbers: every figure in your answer must come from a tool result.
PREFIX : <tag:stardog:api:ecomm:> (also declare xsd: and rdfs: when you use them)
Data lives in three virtual graphs (live SQL underneath). Always wrap patterns in GRAPH:
GRAPH <virtual://aurora_c360_safe>
:Customer :name :firstName :lastName :email :phone, :address -> :Address, :hasRewards -> :RewardsAccount
:Address :streetAddress :city :state :zipCode (state is the full name, e.g. "Wisconsin")
:CreditCard :cardType, :cardHolder -> :Customer (no card numbers here)
:RewardsAccount :accountId :openDate (xsd:date)
GRAPH <virtual://redshift_c360>
:Order :purchasedBy -> :Customer, :itemPurchased -> :Product, :cardUsed -> :CreditCard,
:rewardsAccountUsed -> :RewardsAccount, :purchasePrice (xsd:decimal, unit price),
:quantity (xsd:integer), :purchaseDate (xsd:date), :purchaseTime (string)
:Product :name :description, :inCategory -> :Category, :vendor -> :Vendor, :msrp -> :UnitPrice
:UnitPrice :hasPrice (xsd:float) :hasCurrency :Category :name :Vendor :name, :industry -> :Industry
GRAPH <virtual://aurora_c360_pii> :ssn, :cardNumber. Sensitive: do NOT query unless explicitly asked.
Call get_ontology if you need labels, comments or a term not listed here.
Customer IRIs are identical in both warehouses, so a variable shared by two GRAPH blocks IS
the join. Derived classes (need reasoning=true): :Big_Spender, :Large_Order, :2022_Order,
:2022_Orderer, :Sports_Category_Shopper. Leave reasoning false otherwise (it is slower).
A reasoning query must use FROM <virtual://aurora_c360_safe> FROM <virtual://redshift_c360>
instead of GRAPH blocks, so the rules can join across both warehouses.
Example. "Top 3 customers by spend, with state":
SELECT ?name ?state (SUM(?p * ?q) AS ?spend) WHERE {
GRAPH <virtual://aurora_c360_safe> { ?c :name ?name ; :address ?a . ?a :state ?state }
GRAPH <virtual://redshift_c360> { ?o :purchasedBy ?c ; :purchasePrice ?p ; :quantity ?q }
} GROUP BY ?name ?state ORDER BY DESC(?spend) LIMIT 3
Rules: read-only SELECT/ASK only; always add LIMIT (<= 100); spend = SUM(price * quantity);
compare dates with "2022-01-01"^^xsd:date; cast with xsd:decimal()/xsd:integer() if a comparison
misbehaves; return names, not IRIs. If a query errors or returns nothing, read the result, fix
the query and retry. Answer concisely, show the key numbers, and say which data you used."""
TOOLS = [
{"type": "function", "function": {
"name": "run_sparql",
"description": "Run one read-only SPARQL SELECT or ASK query against the c360 knowledge graph.",
"parameters": {"type": "object", "required": ["query"], "properties": {
"query": {"type": "string", "description": "A complete SPARQL query with PREFIX lines."},
"reasoning": {"type": "boolean", "description": "true only for derived classes such as :Big_Spender."}}}}},
{"type": "function", "function": {
"name": "get_ontology",
"description": "Return every class and property in the c360 ontology with its label, comment, domain and range.",
"parameters": {"type": "object", "properties": {}}}},
]
ONTOLOGY_QUERY = """PREFIX owl: <http://www.w3.org/2002/07/owl#>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
PREFIX so: <https://schema.org/>
SELECT ?term ?kind (SAMPLE(?l) AS ?label) (SAMPLE(?cm) AS ?comment)
(GROUP_CONCAT(DISTINCT COALESCE(STR(?sup), "")) AS ?parent)
(GROUP_CONCAT(DISTINCT COALESCE(STR(?d), "")) AS ?domain)
(GROUP_CONCAT(DISTINCT COALESCE(STR(?r), "")) AS ?range)
WHERE {
VALUES ?g { <urn:stardog:training-c360:1.0:model> <urn:stardog:c360_extended_rules:1.0:model> }
VALUES ?kind { owl:Class owl:ObjectProperty owl:DatatypeProperty }
GRAPH ?g { ?term a ?kind .
OPTIONAL { ?term rdfs:label ?l } OPTIONAL { ?term rdfs:comment ?cm } OPTIONAL { ?term rdfs:subClassOf ?sup }
OPTIONAL { ?term so:domainIncludes ?d } OPTIONAL { ?term so:rangeIncludes ?r } }
} GROUP BY ?term ?kind ORDER BY ?kind ?term"""
FORBIDDEN = re.compile(r"\b(INSERT|DELETE|DROP|CLEAR|LOAD|CREATE|ADD|MOVE|COPY)\b", re.I)
READ_FORM = re.compile(r"^\s*((PREFIX|BASE)\s[^\n]*\n\s*)*(SELECT|ASK)\b", re.I)
TRAILING_LIMIT = re.compile(r"\bLIMIT\s+(\d+)\s*(OFFSET\s+\d+\s*)?$", re.I)
def guard(query: str) -> str:
"""Reject anything that is not a plain read, and make sure a SELECT has a sane LIMIT."""
body = re.sub(r"(?m)^\s*#.*$", "", query).strip() # drop whole-line comments
# Check a copy with IRIs, string literals and inline comments blanked out, so that
# <...#> or "Create date" can't confuse the keyword test.
check = re.sub(r"#[^\n]*", " ", re.sub(r"<[^<>\s]*>|\"[^\"]*\"|'[^']*'", " ", body))
if FORBIDDEN.search(check):
raise ValueError("Rejected: only read-only SELECT/ASK queries are allowed.")
if not READ_FORM.match(check):
raise ValueError("Rejected: the query must be a SELECT or ASK (after PREFIX lines).")
if re.search(r"\bSELECT\b", check, re.I):
m = TRAILING_LIMIT.search(check.rstrip())
if not m:
body += f"\nLIMIT {MAX_ROWS}"
elif int(m.group(1)) > MAX_ROWS: # cap the last (outermost) LIMIT
body = re.sub(r"(?is)(.*\bLIMIT\s+)\d+", rf"\g<1>{MAX_ROWS}", body, count=1)
return body
def stardog(query: str, reasoning: bool = False) -> dict:
"""POST one query to the read-only query endpoint and return the SPARQL JSON results."""
data = {"query": query, "reasoning": str(reasoning).lower(), "timeout": TIMEOUT_S * 1000}
if reasoning:
data["schema"] = SCHEMA # the Chapter 10 rules; the default schema is empty
r = requests.post(
f"{ENDPOINT}/{DB}/query", data=data,
headers={"Accept": "application/sparql-results+json"},
auth=AUTH, timeout=(10, TIMEOUT_S + 10))
if r.status_code != 200:
raise RuntimeError(f"Stardog error {r.status_code}: {r.text[:1500]}")
return r.json()
def run_sparql(query: str, reasoning: bool = False) -> str:
"""Tool 1. Guard, run, flatten, truncate. Errors come back as text the model can react to."""
try:
q = guard(query)
if VERBOSE:
print(f"\n--- SPARQL (reasoning={reasoning}) ---\n{q}", file=sys.stderr)
res = stardog(q, reasoning)
except (ValueError, RuntimeError, requests.RequestException) as e:
return str(e)
if "boolean" in res:
return json.dumps({"ask": res["boolean"]})
rows = [{k: v["value"] for k, v in b.items()} for b in res["results"]["bindings"]]
if VERBOSE:
print(f"--- {len(rows)} row(s) ---", file=sys.stderr)
text = json.dumps({"columns": res["head"]["vars"], "rows": rows[:MAX_ROWS], "row_count": len(rows)})
return text if len(text) <= MAX_CHARS else text[:MAX_CHARS] + " ... [truncated]"
def get_ontology() -> str:
"""Tool 2. Read the T-Box from the model graphs (no warehouse involved) as compact lines."""
try:
res = stardog(ONTOLOGY_QUERY)
except (RuntimeError, requests.RequestException) as e:
return str(e)
short = lambda s: (s.replace("tag:stardog:api:ecomm:", ":").replace("http://www.w3.org/2001/XMLSchema#", "xsd:")
.replace("http://www.w3.org/2002/07/owl#", ""))
lines = []
for b in res["results"]["bindings"]:
v = {k: short(x["value"]).strip() for k, x in b.items()}
line = f'{v["term"]} ({v["kind"]}) "{v.get("label", "")}"'
if v.get("parent"):
line += f' subClassOf {v["parent"]}'
if v.get("domain"):
line += f' {v["domain"]} -> {v.get("range", "")}'
if v.get("comment"):
line += f' -- {v["comment"]}'
lines.append(line)
return "\n".join(lines)
def ask(question: str) -> str:
"""The agent loop: call the model, run the tools it asks for, repeat until it answers."""
messages = [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": question}]
for _ in range(MAX_STEPS):
msg = client.chat.completions.create(model=MODEL, messages=messages, tools=TOOLS).choices[0].message
if not msg.tool_calls:
return msg.content or "(no answer)"
messages.append(msg.model_dump(exclude_none=True))
for call in msg.tool_calls:
try:
args = json.loads(call.function.arguments or "{}")
except json.JSONDecodeError:
args = None
if args is None:
result = "Error: tool arguments were not valid JSON."
elif call.function.name == "get_ontology":
result = get_ontology()
else:
result = run_sparql(args.get("query", ""), bool(args.get("reasoning", False)))
messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
return "Stopped: too many tool calls without a final answer. Try rephrasing the question."
if __name__ == "__main__":
words = [a for a in sys.argv[1:] if a != "-v"]
if words:
print(ask(" ".join(words)))
else:
print(f"c360 agent ({MODEL}). Ask a question; press Enter on an empty line to quit.")
while q := input("\n> ").strip():
print(ask(q))A reading guide, top to bottom:
TOOLS is all the model sees of your code: two names, descriptions and JSON Schemas. The description "true only for derived classes such as :Big_Spender" tells the model when to turn reasoning on.guard() tests a copy of the query with IRIs, strings and comments blanked out, so <http://www.w3.org/2001/XMLSchema#> or "Drop Cloth" can't trip it, but sends the real query.run_sparql() flattens each row to {"name": "Carolyn Inworth", …}; raw SPARQL JSON wraps every value in {"type": …, "value": …}, wasting tokens.ask() is the agent loop; the model's tool-call message is appended before the tool results, as the API requires.python3 -m venv .venv && source .venv/bin/activate pip install openai requests export STARDOG_ENDPOINT="https://<your-endpoint>.stardog.cloud:5820" export STARDOG_USER="stardog_admin" # or a read-only user from Chapter 12 # optional: export STARDOG_SCHEMA="c360_extended_rules" (the default; Chapter 10's schema) read -s -p "Stardog password: " STARDOG_PASSWORD; echo; export STARDOG_PASSWORD read -s -p "OpenAI API key: " OPENAI_API_KEY; echo; export OPENAI_API_KEY python agent.py -v "Who are our top spenders in Wisconsin?"
read -s keeps secrets out of your shell history. -v prints every SPARQL query and its row count; keep it on while learning. With no question on the command line, you get an interactive prompt.
To use Claude, or hundreds of other models, through OpenRouter, change one variable. OpenRouter speaks the OpenAI protocol, so the code only swaps the base_url to https://openrouter.ai/api/v1 and the key:
export LLM_PROVIDER=openrouter read -s -p "OpenRouter API key: " OPENROUTER_API_KEY; echo; export OPENROUTER_API_KEY python agent.py -v "Who are our top spenders in Wisconsin?" # optional: another model (OpenRouter ids look like provider/model) export AGENT_MODEL="anthropic/claude-sonnet-4.6" # the default; the blog's model family # export AGENT_MODEL="~anthropic/claude-sonnet-latest" # alias that tracks the newest Sonnet
Model ids change often; check openrouter.ai/models and choose one that supports tool calling. The code's defaults (gpt-4.1 for OpenAI, anthropic/claude-sonnet-4.6 for OpenRouter) were both listed when this was written.
Press Play to watch "Who are our top spenders in Wisconsin?" travel through every component. The numbers are real, checked with plain SQL. The SQL is illustrative: Stardog's generated SQL is wordier and depends on its planner, but this is its gist.
① The request. ask() sends the system prompt (Section 14.3), the two tool definitions, and the user message: roughly 800 tokens in.
② The tool call. The model does Section 14.4's word-matching and replies with just a tool call:
{"id": "call_1", "type": "function",
"function": {"name": "run_sparql",
"arguments": "{\"query\": \"PREFIX : <tag:stardog:api:ecomm:>\\nSELECT ?name ...\", \"reasoning\": false}"}}Unpacked, the query is:
PREFIX : <tag:stardog:api:ecomm:>
SELECT ?name ?city (SUM(?price * ?qty) AS ?totalSpend) (COUNT(?order) AS ?orders)
WHERE {
GRAPH <virtual://aurora_c360_safe> {
?c a :Customer ; :name ?name ; :address ?a .
?a :state "Wisconsin" ; :city ?city .
}
GRAPH <virtual://redshift_c360> {
?order :purchasedBy ?c ; :purchasePrice ?price ; :quantity ?qty .
}
}
GROUP BY ?name ?city
ORDER BY DESC(?totalSpend)
LIMIT 10The shared ?c in both GRAPH blocks is the entire cross-warehouse join. You can paste this into Studio as-is.
③ The guard and the POST. guard() finds no update verbs, a SELECT, and LIMIT 10 under the cap of 100, so the query passes unchanged. stardog() POSTs it to /kit-c360/query with reasoning=false.
④ SQL for Aurora. Stardog's query planner sees two GRAPH blocks bound to two virtual graphs and runs each block's mapping in reverse (Stardog has described building this SQL generation on Apache Calcite, which is how one engine speaks both PostgreSQL and Redshift dialects). The customer and address patterns are both in aurora_c360_safe, so it pushes them down as one SQL join, filter included:
-- illustrative: what Stardog asks Aurora (PostgreSQL) SELECT c.cid, c.full_name, a.city FROM c360.customer c JOIN c360.address a ON a.id = c.location WHERE a.state = 'Wisconsin'
⑤ Aurora returns 8 rows, not 500, thanks to pushdown. If Aurora was paused, the 15 to 20 seconds go here.
⑥ SQL for Redshift. Stardog needs cid, price and quantity from c360.purchase; how it gets them is the planner's choice. With a bind join it sends the 8 customer keys it already has:
-- illustrative: what Stardog asks Redshift (bind-join style) SELECT cid, price, quantity FROM c360.purchase WHERE cid IN (28, 38, 97, 225, 268, 389, 434, 471)
Or it can fetch all 2,500 purchase rows and do a hash join itself: same answer, different cost. Studio's query plan view shows which it picked.
⑦ Redshift returns 46 purchase rows for those 8 customers.
⑧ Join and aggregate inside Stardog. Both sides' rows become the same customer IRIs through the same template (Chapter 9's IRI trick). Stardog joins the 46 purchases to the 8 customers on that IRI, computes SUM(price × quantity) per customer, sorts, and applies LIMIT 10. It must do the GROUP BY itself, because the grouping key (name) comes from one engine and the numbers from the other.
⑨ Rows back to the model. run_sparql flattens Stardog's SPARQL JSON into the tool result (decimals may carry more trailing zeros in practice):
{"columns": ["name", "city", "totalSpend", "orders"],
"rows": [{"name": "Carolyn Inworth", "city": "Milwaukee", "totalSpend": "502529.42", "orders": "15"},
{"name": "Renaud Brosio", "city": "Milwaukee", "totalSpend": "207537.22", "orders": "6"},
{"name": "Sephira McKleod", "city": "Milwaukee", "totalSpend": "76339.43", "orders": "7"},
{"name": "Corbin Mandel", "city": "Green Bay", "totalSpend": "25560.00", "orders": "2"},
{"name": "Christine Ricardon", "city": "Madison", "totalSpend": "12602.50", "orders": "3"},
{"name": "Euphemia Nyles", "city": "Milwaukee", "totalSpend": "8927.89", "orders": "6"},
{"name": "Talia Roullier", "city": "Milwaukee", "totalSpend": "3441.78", "orders": "5"},
{"name": "Jade Grichukhin", "city": "Milwaukee", "totalSpend": "256.99", "orders": "2"}],
"row_count": 8}⑩ The answer. The model has enough and writes (wording varies by run):
Wisconsin's top spender is Carolyn Inworth (Milwaukee): $502,529.42 across 15 orders, well ahead of Renaud Brosio ($207,537.22, 6 orders) and Sephira McKleod ($76,339.43, 7 orders), both also in Milwaukee. Eight Wisconsin customers have purchases in total. Spend = price × quantity, from Redshift purchases joined to Aurora customer addresses.
Two model calls, one SPARQL query, two SQL queries on two engines. Carolyn is customer 225 from Chapter 2 and the national top spender from Chapter 9. Had the model written :state "WI", it would have seen "row_count": 0 and, told to "fix the query and retry", would usually try "Wisconsin". That self-correction is what the loop is for.
What you built is the blog's Path A, on your laptop with a different model provider; the blog's version is its production deployment. Strands Agents is AWS's open-source Python agent framework: it provides the loop, and you mark a Python function as a tool. pystardog is Stardog's Python client for the same HTTP API you call with requests. Amazon Bedrock AgentCore is AWS's managed service for running agents.
| Concern | agent.py | Blog Path A on AWS |
|---|---|---|
| Model | OpenAI or OpenRouter | Claude Sonnet 4.6 on Amazon Bedrock |
| Loop | ask(), about 20 lines | Strands Agents |
| SPARQL tool | run_sparql with requests | A Strands tool wrapping pystardog |
| Ontology context | SYSTEM_PROMPT + get_ontology | An ontology summary in the system prompt |
| Hosting | Your terminal | AgentCore Runtime: hosts the agent, concurrency, sessions |
| Who may call it | Whoever has your laptop | AgentCore Gateway: validates the caller's JWT, routes calls |
| Stardog credentials | Environment variables | AgentCore Identity: a credential provider; tokens never live in agent code |
Porting agent.py is mostly mechanical: the prompt and guardrails carry over, the loop becomes Strands, and credentials move into AgentCore Identity. Path A works with any Stardog reachable over HTTPS, Free tier included.
In Path B, your agent writes no SPARQL. AgentCore Gateway registers the Stardog Cloud MCP server (Chapter 13) as a tool target, so the agent's tools are the three voicebox_* tools, and Voicebox does the English-to-SPARQL step, returning provenance with each answer. It's beta and needs gated Voicebox API access.
| Path A: your SPARQL tool | Path B: Voicebox via MCP | |
|---|---|---|
| Who writes SPARQL | Your LLM, guided by your prompt | Voicebox, guided by ontology and stored examples |
| Access needed | Any Stardog over HTTPS (Free tier OK) | A Voicebox API token (gated, beta) |
| Control | Full: model, prompt, guardrails, logging | Voicebox's model and settings |
| Run an exact, verified query | Yes | No |
| Choose it when | You want control or a specific model, or are on the Free tier | You have Voicebox access and want Stardog to own NL-to-SPARQL quality |
Tokens. Every call resends the system prompt, the question and earlier tool results. A typical question is 2 or 3 calls of 1,000 to 3,000 input tokens: a fraction of a cent to a few cents. Prompt caching helps, since every call starts the same way. OpenAI caches automatically past about 1,024 tokens; Anthropic models need explicit cache breakpoints, which OpenRouter passes through (check its docs). Either way, put the stable parts first and never change them. The blog uses Bedrock prompt caching for the same reason.
Warehouses (details in Chapter 9). Aurora costs $0.12 per ACU-hour awake, $0 paused. Redshift Serverless costs $0.375 per RPU-hour with a 60-second minimum per wake-up, about $0.025 per one-minute burst at 4 RPU, so the daily cap of 1 RPU-hour allows roughly fifteen separate wake-ups (questions asked close together share one). get_ontology wakes neither. When you're done, aws/99_teardown.sh removes the AWS side, including the two public IPv4 addresses at $0.005 per hour each.
What goes wrong with an LLM writing SPARQL.
<virtual://aurora_c360_pii> (Chapter 12) can't leak SSNs, however it's prompted.Two questions remain before this goes anywhere real: what the agent does when the ontology is far too big to summarize in a prompt, and how you know an answer didn't quietly miss a source. Chapter 16 takes both. First, Chapter 15 puts this agent in a chat window that other people can log in to.
From us: the system prompt's summary says :purchasedBy -> :Customer under Order, and get_ontology can return :purchasedBy "purchased by" :Order -> :Customer from the model graph (via so:domainIncludes/so:rangeIncludes). The model's training data knows nothing about your ontology.
No. It sees graph names only as labels. The mappings define which source and table each triple comes from, and Stardog's planner writes the SQL. With virtual transparency on, it wouldn't even need the graph names.
A code filter can have gaps; Stardog enforces permissions on every query. A read-only user limits prompt injection to a wrong answer, and without the PII graph no query can send SSNs to the LLM provider.
export LLM_PROVIDER=openrouter (with OPENROUTER_API_KEY set). The code then uses base_url="https://openrouter.ai/api/v1" and the default model anthropic/claude-sonnet-4.6 (override with AGENT_MODEL).
No. Grouping by product name merges several different products called "Pliers" (165 units); grouping by product IRI keeps them apart, and Markers (160 units) is the top single product. The two queries ask different questions, which is why you read the query.
run_sparql, get_ontology) and a loop. The model asks; your code checks and runs.LLM_PROVIDER=openrouter switches providers. Path A is this agent on AgentCore with Strands and pystardog; Path B hands SPARQL to Voicebox through MCP.
Paste it to Claude with what you saw (the counts or results), so we can confirm it matches before the next build step relies on it.