The Semantic Layer, From ScratchChapter 15
Chapter 15

Build your own Voicebox

Chapter 14's agent becomes a chat page your colleagues can log in to: their own Stardog permissions, their own chat history, the SPARQL behind every answer, and a model you pick. You run it, measure it, and see where each piece comes from.

Without a semantic layer, "Who are the top spenders in Wisconsin?" means two exports and a spreadsheet. With what you built up to Chapter 14, it's one line in a terminal: python agent.py -v "Who are our top spenders in Wisconsin?", and a few seconds later Carolyn Inworth, Milwaukee, $502,529.42. But only you can use it. It runs in your terminal, under one Stardog user, and forgets each question as soon as it answers. The marketing lead who asked can't open it, and if she could, she would see SSNs that Chapter 12 says she must not see.

Stardog's answer to that is Voicebox (Chapter 13): a chat page on top of your graph. This chapter builds your own version of it, called askgraph, from the same pieces as Chapter 14's agent: the same system prompt, the same run_sparql tool, the same guard. What's new is a web page with a login checked by Stardog, a chat history per user, every SPARQL query shown in a fold-out step, a settings panel to switch the model, and a line under each answer saying how many queries and tokens it took. All the code is on GitHub, and Build step 8 runs it on your laptop.

15.1Terms in this chapter

TermWhat it isWhy it existsTiny example
ChainlitAn open-source Python library that turns a chat program into a web pageSo you write the agent, not the chat window@cl.on_message runs your code for each question
StepA fold-out box under a chat message showing work done on the way to the answerSo a reader can check the query, not just the sentence"Used SPARQL", with the query and 8 rows inside
StarterA clickable example question on the empty chat pageShows new users what they can ask"Top spenders in Wisconsin"
Chat historyPast conversations, saved and listed in a sidebarCome back tomorrow and ask a follow-upOne row per chat in the threads table
Free modelAn OpenRouter model id ending in :free, costing $0 per tokenLearn without a billnvidia/nemotron-3-super-120b-a12b:free
Rate limitA cap on how many requests a provider accepts; past it, HTTP 429 "Too Many Requests"Free capacity is shared50 free requests a day without credits
Fallback modelA second model the app switches to when the first keeps failingA busy free model shouldn't stop an answeropenai/gpt-4o-mini
TokenThe unit LLM providers count and charge by, roughly four characters of EnglishCost and limits are measured in itThe Wisconsin question used 4,496

15.2Why build your own

Ask Voicebox the book's question, "Who are the top spenders in Wisconsin?", and two things happen that are worth seeing up close.

First answer: no data. A Voicebox app has a Graph setting. With it on local, Voicebox searches only the graphs stored inside Stardog. Our customers and purchases live only in the virtual graphs over Aurora and Redshift (Chapter 9), so there is nothing to find. Switch the setting to all and ask again.

Second answer: the wrong number, confidently. Voicebox now answers, but it adds up the unit price and ignores the quantity:

Wisconsin customerVoicebox: SUM(price)True spend: SUM(price × quantity), Chapter 9
Carolyn Inworth$27,991.66$502,529.42
Renaud Brosio$10,386.34$207,537.22
Sephira McKleod$4,677.43$76,339.43

Carolyn has 15 purchase lines. Her biggest is a price of 10,479.00 with a quantity of 21: that one line is 10,479.00 × 21 = 220,059.00 of real spend, and Voicebox counted it as 10,479.00. Its sentence even rounded its own table differently, saying $27,991.70 where the table said $27,991.66. The order of these three happens to survive, but across all customers it doesn't: from rank 4 down the two measures disagree, Legra Stennard (true spend $315,120, the real number 10) drops out of the price-only top ten, and Mord Pettus appears in it when he shouldn't.

Chapter 14's agent gets the same question right every time, and it's not because its model is smarter. Its system prompt says, in plain text, that :purchasePrice is the unit price, that spend = SUM(price * quantity), and it includes an example query that does exactly that. Voicebox reads your ontology, and the ontology says only that :purchasePrice is labeled "price". Nothing in the graph says what "spend" means. The definition lives in the prompt, not in the model. Whoever owns the prompt owns the answer.

Three more reasons to own the chat page:

Voicebox (Chapter 13)askgraph (this chapter)
Who writes the SPARQLVoicebox's LLM, from the ontology and stored example queriesA model you pick, from prompts/system.md and prompts/examples/
What "spend" meansWhatever the LLM guesses from the label "price"Written in the prompt: SUM(price * quantity)
Wisconsin top spenderCarolyn Inworth, $27,991.66 (after switching Graph to all)Carolyn Inworth, $502,529.42
Who the query runs asThe Voicebox app's connectionThe person who logged in (hr_user or marketing_user)
Free tierThe chat page yes; API keys and MCP noYes: needs only a Stardog user and an OpenRouter key
Where it runsStardog CloudYour laptop (port 8000)

15.3The whole app on one page

Browser localhost:8000 one login per person askgraph on your laptop: app.py (Chainlit) Login check ASK {} as that user Chat history data/askgraph.db (SQLite) Agent loop: src/askgraph/agent.py prompts/system.md + prompts/examples/*.sparql tools: run_sparql, get_ontology · guard() config/settings.toml: model, endpoint, limits Secrets, all outside the project folder chainlit.env: signs the login cookie openrouter.env: the LLM key (both in ~/.config/semantic-layer/) Stardog password: in memory only LLM via OpenRouter nemotron-3-super :free fallback: gpt-4o-mini Stardog: kit-c360 runs as the logged-in user Chapter 12 rules apply Aurora customers, SSNs Redshift purchases ❶ ❷ question ❸ ❹ ❺ SQL ❻ answer + steps ❶ log in: Stardog checks the password ❷ ask a question ❸ prompt + question to the model; it asks for run_sparql ❹ guarded SPARQL to Stardog, as you ❺ Stardog asks Aurora and Redshift in SQL ❻ rows to the model, answer to you, chat saved
Figure 15.1: askgraph. Everything inside the blue box runs on your laptop. The model only ever sees the prompt, your question and the rows Stardog returned; it never sees a password or a database.

Follow the Wisconsin question through it with real values:

  1. Log in. You type hr_user and its password. The app sends Stardog the smallest possible query, ASK {}, using those credentials. Stardog answers HTTP 200, so you're in. A wrong password gets 401 and the login page says so. The app never checks a password itself.
  2. Ask. You click the starter "Top spenders in Wisconsin".
  3. The model asks for a query. The agent loop sends the system prompt (about 3,700 characters, roughly 1,000 tokens), the two tool definitions and your question to the model. The model replies with a run_sparql tool call, not text.
  4. The query runs as you. guard() from Chapter 14 checks it is a read with a LIMIT, then the app POSTs it to /kit-c360/query with hr_user's credentials.
  5. Stardog federates. SQL to Aurora for the 8 Wisconsin customers, SQL to Redshift for their purchases, the join and SUM inside Stardog (Section 14.8). 8 rows back, in 0.4 to 0.6 seconds when the warehouses are awake.
  6. Answer. The rows go back to the model, which writes the answer: Carolyn Inworth $502,529.42, Renaud Brosio $207,537.22, Sephira McKleod $76,339.43, and five more. The page shows it with the query in a step above it, and the whole chat is saved for hr_user.

15.4A tour of the folder

The code is in the book's repository, code/askgraph on GitHub. It is Chapter 14's agent.py split into small files, plus a web page:

askgraph/
├── run.sh                    ./run.sh ui | cli | test
├── app.py                    the chat web page (Chainlit): login, history, steps, settings
├── cli.py                    the same agent in the terminal
├── chainlit.md               text of the page's Readme button
├── requirements.txt          chainlit, openai, requests, sqlalchemy, aiosqlite, pytest
├── config/settings.toml      model list, Stardog endpoint and database, users, limits
├── prompts/system.md         the system prompt (Chapter 14's, plus one line on permissions)
├── prompts/examples/         three worked examples, one .sparql file each
├── src/askgraph/
│   ├── agent.py              the loop: model call, tool calls, retry and fallback
│   ├── stardog.py            guard(), run_sparql, get_ontology, the login check
│   ├── tools.py              the two tool definitions the model sees
│   ├── llm.py                the OpenRouter (or OpenAI) client
│   ├── config.py             reads settings.toml and the secret files
│   └── history.py            the SQLite tables for chat history
└── tests/                    16 tests: guard, prompt, live Stardog answers, login, history

Open the folder in an editor to read along. In VS Code, code askgraph from a terminal (or File → Open Folder…); in Sublime Text, subl askgraph (or File → Open…). Start with prompts/system.md, then src/askgraph/agent.py (180 lines), then app.py (178 lines).

One script runs everything. It uses the project's own Python environment in .venv, so nothing installs globally:

./run.sh ui                                   # the chat page at http://localhost:8000
./run.sh cli --user hr -v "Who are the top spenders in Wisconsin?"   # one question in the terminal
./run.sh cli --user marketing "What is Jolynn Bithany's SSN?"
./run.sh test                                 # 16 tests, about 13 seconds

-v prints each SPARQL query, its row count and time, and the first rows. --user picks the Stardog user for the terminal; the web page takes it from the login instead.

15.5Chainlit: the chat page for free

Chainlit (version 2.12 here) is a Python library that draws the chat page for you. You write functions and mark them with decorators; Chainlit calls them at the right moment. Here is what it gives askgraph, and the few lines from app.py that ask for each.

A login page. Chainlit draws it; you decide who gets in. askgraph asks Stardog:

@cl.password_auth_callback
async def auth(username: str, password: str):
    username = (username or "").strip()
    ok = await cl.make_async(check_login)(load_settings(), username, password)
    if not ok:
        return None
    _CREDENTIALS[username] = password          # in memory only, never on disk
    return cl.User(identifier=username, metadata={"provider": "stardog"})

check_login (in stardog.py) accepts only the users listed in settings.toml (hr_user, marketing_user) and returns true when Stardog answers ASK {} with HTTP 200 for that username and password.

The askgraph login page with hr_user typed in the first field
Figure 15.2: The login page. The first field is labeled "Email address" by Chainlit, but it takes a Stardog username such as hr_user.

Starter questions. Three buttons on the empty chat page:

@cl.set_starters
async def starters(user=None, language=None):
    return [
        cl.Starter(label="Top spenders in Wisconsin", message="Who are the top spenders in Wisconsin?"),
        cl.Starter(label="Big spenders per state", message="How many big spenders are there per state?"),
        cl.Starter(label="Jolynn Bithany's SSN", message="What is Jolynn Bithany's SSN?"),
    ]

Steps. The agent loop (agent.py) doesn't print anything. It hands back a stream of events: tool_call, tool_result, answer, usage. The terminal (cli.py) prints them; the web page turns each query into a fold-out step. The heart of @cl.on_message, shortened:

elif ev.kind == "tool_call":
    step = cl.Step(name="SPARQL", type="tool", show_input=False, default_open=False)
    await step.__aenter__()
elif ev.kind == "tool_result":
    step.output = f"```sparql\n{ev.query}\n```\n\n**{ev.row_count} row(s)** in {ev.elapsed:.1f}s\n\n" \
                  + md_table(ev.columns, ev.rows)
    await step.__aexit__(None, None, None)

A footer under each answer, built from the usage event: model, number of queries, tokens, cost and user, for example nvidia/nemotron-3-super-120b-a12b:free · 1 query · 4,380 tokens · $0.0000 · user hr_user.

askgraph answering Who are the top spenders in Wisconsin: a table with Carolyn Inworth 502,529.42, Renaud Brosio 207,537.22, Sephira McKleod 76,339.43, Corbin Mandel 25,560.00 and more
Figure 15.3: The Wisconsin answer, logged in as hr_user. "Used SPARQL" above the table is the folded step; Section 15.8 opens it.

A settings panel (the gear icon in the message box) with two pickers. The choice applies to your next message:

The settings panel with a Model picker set to nvidia/nemotron-3-super-120b-a12b:free and a Reasoning schema picker set to c360_extended_rules
Figure 15.4: The settings panel. The model list and the schema list both come from config/settings.toml.

Chat history and the sidebar, once you give Chainlit somewhere to store it (Section 15.10). Things Chainlit gives you that askgraph doesn't use: file upload (on by default, ignored here), voice input, and its own MCP client.

15.6Where each piece comes from

Nothing about your data is hard-coded in Python. It sits in files you can open and change, and the app re-reads the prompt, the examples and settings.toml on every question, so an edit applies to the next question without a restart.

WhatWhereA real line from it
What the model knows about the graphprompts/system.md:purchasePrice (xsd:decimal, unit price) and spend = SUM(price * quantity)
Worked examplesprompts/examples/*.sparql (all three are appended to the prompt)# Q: Who are the top spenders in Wisconsin?
Model, endpoint, users, limitsconfig/settings.tomlmax_rows = 100, hr = "hr_user"
The LLM key~/.config/semantic-layer/openrouter.envSEMANTIC_LAYER_OPENROUTER_KEY=…
The login cookie secret~/.config/semantic-layer/chainlit.env, created by the first ./run.sh uiexport CHAINLIT_AUTH_SECRET='…'
Demo users' password (terminal and tests only)~/.config/semantic-layer/stardog-users.env, path set in settings.tomlSTARDOG_DEMO_USERS_PASSWORD=…

Here is the example file that fixes the Voicebox mistake. Its first line is the question; the rest is a query that answers it correctly:

# Q: Who are the top spenders in Wisconsin?
PREFIX : <tag:stardog:api:ecomm:>
SELECT ?name (SUM(?p * ?q) AS ?spend) WHERE {
  GRAPH <virtual://aurora_c360_safe> { ?c :name ?name ; :address ?a . ?a :state "Wisconsin" }
  GRAPH <virtual://redshift_c360> { ?o :purchasedBy ?c ; :purchasePrice ?p ; :quantity ?q }
} GROUP BY ?name ORDER BY DESC(?spend) LIMIT 10

And the part of settings.toml you edit to point the app at your server:

[stardog]
endpoint = "https://<your-endpoint>.stardog.cloud:5820"   # or export STARDOG_ENDPOINT, which wins
database = "kit-c360"
schema   = "c360_extended_rules"           # reasoning schema (used only when reasoning=true)
timeout_s = 120                            # the first query may wake Aurora and Redshift
max_rows  = 100                            # LIMIT cap enforced by the guard

[users]
default = "hr"
hr        = "hr_user"
marketing = "marketing_user"

The one line that matters most in this chapter is spend = SUM(price * quantity) in prompts/system.md. Delete it and the example file, and this app can make Voicebox's mistake too. That shows where the definition really sits today: in a text file that only this app reads. Voicebox, Studio users and any other agent never see it. The lasting fix is to put the business definition in the graph itself, next to :purchasePrice, so every tool that reads the ontology gets the same meaning. Chapter 16 starts doing exactly that, with a metric labeled "total spend" stored in the graph.

15.7Choosing the model

The app needs a model that does tool calling well (Chapter 14): it must reply with a clean run_sparql call whose query argument is just SPARQL. Four OpenRouter models, each given the Wisconsin question:

ModelPriceResult
nvidia/nemotron-3-super-120b-a12b:free$0Pass. A clean, correct tool call and the right answer. The default.
qwen/qwen3.8-27b:free$0Fail. Its thinking text leaked into the tool arguments, so the query argument didn't start with PREFIX or SELECT. guard() rejects that ("the query must be a SELECT or ASK"), so nothing useful reaches Stardog.
google/gemma-4-31b-it:free$0Couldn't test. HTTP 429: the free provider was busy.
openai/gpt-4o-mini$0.15 per million input tokens, $0.60 per million outputPass. Clean tool call, right answer. The paid fallback.

What a question costs. On gpt-4o-mini, the Wisconsin question used 2,996 tokens and the footer showed $0.0005: twenty questions for a cent. On the free model, $0.

What "free" means. Free models on OpenRouter allow 50 requests a day while your account has no credits, and 1,000 a day once you have bought $10 of credits. A request is one model call, not one question. The Wisconsin question takes two calls (one returns the tool call, one writes the answer), so 50 requests is about 25 questions a day. When the provider is busy you get a rate limit reply, HTTP 429. askgraph then waits 8 seconds and tries once more; if it fails again it moves to the fallback model listed in settings.toml and says so in the chat (in italics, as a "system" message). Both settings live in the [llm] block:

[llm]
provider = "openrouter"
model    = "nvidia/nemotron-3-super-120b-a12b:free"
fallback_models = ["openai/gpt-4o-mini"]
ui_models = ["nvidia/nemotron-3-super-120b-a12b:free", "openai/gpt-4o-mini"]
retry_backoff_s = 8
max_tool_turns  = 8

Privacy. Free endpoints may log your prompts or use them for training. A prompt contains your question and the rows Stardog returned: for hr_user asking about Jolynn Bithany, that includes the SSN 609-38-5573. Here that's fine, because every name and SSN in the c360 kit is made up. With real customer data, use a paid provider whose terms rule out training, or a model inside your own cloud account (the blog uses Amazon Bedrock), and keep the agent's Stardog user away from PII (Chapter 12).

To switch models: pick one in the settings panel (for this chat), change model in settings.toml (for everyone, from the next question), or pass --model on the command line. Use model ids from openrouter.ai/models that list tool support, and run the three starter questions before trusting a new one.

15.8See every query, and measure it

Open the "Used SPARQL" step under the Wisconsin answer and you see exactly what ran:

The Used SPARQL step opened: the SPARQL query with two GRAPH blocks, 8 rows in 0.4 seconds, and a table starting Carolyn Inworth 502529.420000000041909515857696533203125
Figure 15.5: The step opened. The query, "8 row(s) in 0.4s", and the first rows exactly as Stardog returned them.

Look at Carolyn's raw number: 502529.420000000041909515857696533203125. That is what Stardog sent back (the multiplication passes through a binary floating-point number, the same effect Chapter 9 showed with xsd:float). The model rounded it to $502,529.42 in the answer. Reading the step tells you which numbers came from the database and which the model wrote.

The same information is in the terminal with -v. This is a real run:

$ ./run.sh cli --user hr -v "Who are the top spenders in Wisconsin?"
askgraph: user=hr_user model=nvidia/nemotron-3-super-120b-a12b:free

--- run_sparql (reasoning=False) ---
PREFIX : <tag:stardog:api:ecomm:>
SELECT ?name (SUM(?p * ?q) AS ?spend) WHERE {
  GRAPH <virtual://aurora_c360_safe> { ?c :name ?name ; :address ?a . ?a :state "Wisconsin" }
  GRAPH <virtual://redshift_c360> { ?o :purchasedBy ?c ; :purchasePrice ?p ; :quantity ?q }
} GROUP BY ?name ORDER BY DESC(?spend) LIMIT 10
--- 8 row(s) in 0.5s
    Carolyn Inworth | 502529.420000000041909515857696533203125
    Renaud Brosio | 207537.2200000000302679836750030517578125
    Sephira McKleod | 76339.430000000007566995918750762939453125
    Corbin Mandel | 25560
    ...
(the answer: a table with all 8 customers, then "Data used")

[nvidia/nemotron-3-super-120b-a12b:free | 1 query | 4496 tokens | cost $0.0000]

Timing a few questions with the warehouses already awake gives a clear picture:

QuestionUserModelQueriesStardog timeWhole questionTokensCost
Top spenders in Wisconsinhr_usernemotron :free10.5 s6.3 s4,496$0
Top spenders in Wisconsinhr_usergpt-4o-mini10.6 s4.9 s2,996$0.0005
Big spenders per state (reasoning on)hr_usernemotron :free10.5 s5.5 s4,788$0
Jolynn Bithany's SSNhr_usernemotron :free10.5 s4.1 s3,798$0
Jolynn Bithany's SSNmarketing_usernemotron :free20.3 s + 0.3 s6.6 s6,306$0

Three things stand out:

15.9Same question, two users

Log in as hr_user and ask "What is Jolynn Bithany's SSN?". Then log out (the round avatar, top right), log in as marketing_user and ask the same thing. Same app, same model, same prompt:

As hr_user, the answer: Jolynn Bithany's SSN is 609-38-5573, with 1 query and user hr_user in the footer
hr_user: "Jolynn Bithany's SSN is 609-38-5573." One query.
As marketing_user, the answer: unable to retrieve the SSN because the PII graph returned no results for this user; 2 queries
marketing_user: "I'm unable to retrieve Jolynn Bithany's SSN…" Two queries.

Figure 15.6: The same question as two Stardog users. The SSN is fake, from the c360 kit.

Open the marketing user's steps and you can see what Stardog did:

marketing_user's first SPARQL step: the query joins aurora_c360_safe and aurora_c360_pii, and the result is 0 rows in 0.5 seconds
Figure 15.7: The first query, the same one hr_user ran, returns 0 rows. No error.
  1. The first query is identical for both users: find Jolynn in <virtual://aurora_c360_safe> and her :ssn in <virtual://aurora_c360_pii>.
  2. For hr_user, Stardog reads both graphs and returns 1 row: Jolynn Bithany | 609-38-5573.
  3. For marketing_user, Chapter 12's named-graph security quietly leaves the PII graph out. The ?c :ssn ?ssn pattern matches nothing, so the join returns 0 rows. Stardog doesn't raise an error and doesn't say a graph was hidden.
  4. The model sees 0 rows, runs a second query on the safe graph alone, finds 1 row (Jolynn exists), and answers that the SSN isn't available to this user.

The agent did the right thing here, but look at why it said "permission". prompts/system.md contains this line: "If a query on the PII graph returns no rows, the current user is probably not allowed to see it: say that plainly and do not invent a value." The model is following that guess. Stardog's reply, 0 rows, is exactly what it would also return for a customer who simply has no SSN on file. Suppose (hypothetically) Aurora had a customer with no row in the SSN table: hr_user would get the same 0 rows, and the same prompt line would make the agent say "you're not allowed", which would be wrong. From inside the agent, "doesn't exist" and "not allowed" look the same. Chapter 16 is about answers you can trust, and this gap is part of it.

15.10Login and chat history

Each chat is saved and listed in the left sidebar under "Today", newest first. Click one to reopen it: the questions, answers and SPARQL steps come back, and you can ask a follow-up ("and in Minnesota?") because the app rebuilds the agent's conversation from the saved messages. The model and schema chosen for that chat come back too.

The history sidebar listing three chats: What is Jolynn Bithany's SSN?, and two Who are the top spenders in Wisconsin? chats, with one reopened
Figure 15.8: hr_user's sidebar with three chats; the Wisconsin chat is reopened. marketing_user sees only its own chat.

15.11Keeping keys out of the project

The project folder holds no secret: it's safe to push to GitHub as it is. The secrets live in a folder of your own, readable only by you. To store the OpenRouter key without it showing on screen or landing in your shell history (zsh):

mkdir -p ~/.config/semantic-layer
read -rs "KEY?OpenRouter key: "; echo
printf 'SEMANTIC_LAYER_OPENROUTER_KEY=%q\n' "$KEY" > ~/.config/semantic-layer/openrouter.env
chmod 600 ~/.config/semantic-layer/openrouter.env; unset KEY

read -rs reads without echoing; printf %q quotes any odd characters safely; chmod 600 makes the file readable only by you. The same recipe with STARDOG_DEMO_USERS_PASSWORD and stardog-users.env stores the demo users' password for the terminal and the tests. An environment variable wins over a file (OPENROUTER_API_KEY, STARDOG_PASSWORD, STARDOG_ENDPOINT), which is handy for a one-off run. The app never uses the admin user stardog_admin: only the two users listed in settings.toml can log in.

Check yourself

Voicebox says Carolyn Inworth spent $27,991.66; askgraph says $502,529.42. Which is right, and where does the difference come from?

$502,529.42. Voicebox added up the unit price of her 15 purchase lines; askgraph multiplies each price by its quantity, because prompts/system.md says spend = SUM(price * quantity) and the example query does it. Her biggest line alone is 10,479.00 × 21 = 220,059.00.

Where does the Stardog password live while you use the web page?

Only in the app server's memory, after Stardog accepted it for ASK {}. Not in data/askgraph.db, not in a file. That's why a restarted server asks you to log in again.

marketing_user gets "not allowed" for the SSN. Did Stardog tell the agent that?

No. Stardog returned 0 rows and no error. The agent says "not allowed" because a line in the prompt tells it to read 0 rows from the PII graph that way. A customer with no SSN on file would also give 0 rows.

A free model allows 50 requests a day. About how many Wisconsin questions is that?

About 25. Each Wisconsin question makes two model calls: one that asks for the query, one that writes the answer.

You add a fourth example file to prompts/examples/. Do you need to restart the app?

No. The prompt and every example file are read again for each question, so the next question already includes it. The same goes for settings.toml.

The Wisconsin question took 6.3 seconds. Where did the time go?

About 0.5 seconds in Stardog (Aurora and Redshift included, since they were awake); the rest in the two model calls. On the first question of the day, add 15 to 60 seconds while the warehouses wake.

Chapter recap

Note what you saw (the test count, the three answers, and the marketing user's 0 rows) so you can compare it with the expected values above before Chapter 16 builds on this agent.