A lot of people, when they hear “AI for sports stats,” assume the model just knows the answer. Ask ChatGPT who led the NFL in receiving yards in 2024 and it’ll say something. The problem is you have no real way to tell whether that something is true.

I built playcall — a natural-language NFL stats chatbot — because I wanted to ask NFL questions in plain English and get answers I could actually trust. The model doesn’t answer from memory. It writes SQL against the nflverse play-by-play parquet files, runs the query in DuckDB, and reads the resulting rows back to you. If the data doesn’t have the answer, it says so.

That distinction is the whole point of the project.


What it does Link to heading

You open the chat. You ask: “Who were the top five receivers by receiving yards in 2024?” The model picks the right columns, writes a SQL query, runs it, and answers. Every tool call shows up in the UI as an expandable card — so if the SQL is wrong, you can see it. The audit trail is always one click away.

The data is the nflverse play-by-play release — every NFL play since 1999, all 376 columns of it. About 510 MB total, sitting on disk as parquet files. DuckDB reads them directly, no ETL, no warehouse, no data pipeline to maintain.

Because nflverse re-publishes the current-season parquet nightly during football season, the frontend has a one-click sync workflow built in. There’s a small pill in the header — ↻ 2025·1m, next to the model picker — showing the latest season loaded and how stale it is, color-coded fresh / stale / missing. Click it and a dialog opens with three choices: refresh the current season, fetch any missing seasons, or pull a full re-download from the nflverse release repo. Progress streams live, and the DuckDB view hot-reloads when each download finishes — no server restart, no remembering which parquet files you have on disk. The app stays current against the upstream nflverse release without me having to think about it.

I built it for a specific reason. I like NFL stats. I’m comfortable with SQL. But sitting down to query 376 columns of play-by-play — figuring out the right filter, joining team names, remembering whether home_score is per-play or per-game (it’s per-game, repeated on every row, which is a great way to triple-count yourself into a wrong answer) — is enough friction that I rarely bothered. Most people, who aren’t comfortable with SQL at all, don’t bother ever.

The point of the app is to remove that friction without giving up the underlying truth of the numbers.


Why natural language is the right interface for NFL stats Link to heading

If you hand an average NFL fan a parquet file with 376 columns and tell them “the answer to your question is in there,” they will leave. Probably forever.

If you hand them a chat box, they ask the question they actually had.

This is the part I think is underappreciated about LLMs in analytics. The model is not the analyst. The model is the interface. It takes ambiguous natural-language input (“how did Chase do last year”) and turns it into something deterministic the database can answer. The analysis is still SQL. The aggregation is still SQL. The data is still cold parquet on disk.

That’s the whole bet of the design: the LLM is good at the part that’s hard for users (translating intent into columns and filters), and the database is good at the part that’s hard for LLMs (counting things accurately).

It’s worth being honest about what playcall actually is, then: an LLM wrapper around nflverse. “LLM wrapper” often gets used dismissively — all you did was wrap a model — but I think the framing is exactly backwards. The wrapper is the part worth building when the data underneath is already extraordinary. Years of meticulous play-by-play charting, schema decisions, and edge-case work went into the nflverse releases, done in the open by volunteers over more than a decade. Everything below sits on top of their parquet files. My job, as the wrapper, is to stay thin enough that the data shows through — to translate English into SQL without editorializing along the way.

How the text-to-SQL agent works Link to heading

The agent is a small loop. Up to 8 turns per question. Each turn, the LLM sees the conversation, a system prompt with a curated 77-column schema baked into it, and the three available tools. It either calls a tool or it answers. The whole thing runs as a single async generator on a FastAPI backend, streamed over server-sent events to a React frontend.

System overview Link to heading

flowchart TB User(("User")) FE[Frontend] subgraph Agent["Agent Loop · ≤ 8 turns"] direction LR SP[System Prompt] subgraph Tools["Tools"] direction TB T1["execute_sql
SELECT only"] T2["find_player
name → gsis_id"] T3["describe_columns
schema grep"] end end LLM["LLM
Ollama · OpenAI · Anthropic"] DB[("DuckDB · nflverse parquet")] User <--> FE FE <-->|SSE| SP SP <-->|tool_calls| LLM Tools -->|SQL| DB classDef user fill:#fef3c7,stroke:#f59e0b,color:#78350f classDef fe fill:#fef9c3,stroke:#eab308,color:#713f12 classDef agent fill:#f3e8ff,stroke:#a855f7,color:#581c87 classDef tool fill:#fce7f3,stroke:#ec4899,color:#831843 classDef llm fill:#dcfce7,stroke:#10b981,color:#14532d classDef data fill:#dbeafe,stroke:#3b82f6,color:#1e3a8a class User user class FE fe class Agent,Tools agent class T1,T2,T3 tool class LLM llm class DB data

Three tools, deliberately:

  • execute_sql — runs a single SELECT against the DuckDB view and returns the rows back to the model. Errors come back too. DuckDB parser errors aren’t raised; they’re returned as tool results, which is how the model learns to, say, quote a reserved-word column name on its next attempt. The loop self-corrects without us writing any special error-handling.
  • find_player — takes a player name and returns a stable gsis_id. Player names in the play-by-play are abbreviated (B.Smith) and ambiguous, so the model goes through this resolver first to get an unambiguous id, then filters the play-by-play on that id. Two-tool design, on purpose: the LLM does fuzzy name matching, the database does exact filtering.
  • describe_columns — fuzzy search over all 376 columns. The curated schema in the prompt covers the common ones; this tool exists for the long tail. It saves the model a turn it would have spent guessing a column name and burning a Binder Error.

That’s the whole surface area. There’s no read_news tool, no recall_player_history tool. If a question can’t be answered from the parquet, the answer is “I don’t have data for that.”

Request lifecycle Link to heading

sequenceDiagram autonumber actor U as User participant FE as Frontend participant Agent as Agent Loop participant LLM as LLM participant Tool as Tool participant DB as DuckDB U->>FE: "Who led 2024 in receiving yards?" FE->>Agent: POST /api/chat (opens SSE stream) Note over Agent,LLM: turn 1 — LLM decides: tool call or answer? Agent->>LLM: system prompt + chat history + tool schemas LLM-->>Agent: tool_call: execute_sql("SELECT ... FROM pbp WHERE season=2024 ...") Agent-->>FE: SSE: tool_call (renders as audit card) Agent->>Tool: dispatch Tool->>DB: SELECT on pbp view DB-->>Tool: rows (capped at MAX_SQL_ROWS = 200) Tool-->>Agent: result dict Note over Agent,LLM: turn 2 — LLM answers from the tool result Agent->>LLM: previous messages + tool result LLM-->>Agent: text deltas (no more tool_calls) Agent-->>FE: SSE: streaming text, then done FE-->>U: "Ja'Marr Chase — 1,708 receiving yards."

One round-trip for clarity. Real conversations usually take two or three: a find_player call to resolve the name, an execute_sql to actually run the query, and a final turn where the model reads the rows and writes the prose answer.

The entire response is server-sent events, end to end. Tool calls render inline in the chat as expandable cards, so you can click any one and see the SQL the model wrote, the rows it got back, the whole trace. That’s the audit trail. If a number looks wrong, the query that produced it is right there next to it.


Why this beats writing SQL against the parquet by hand Link to heading

I could write the SQL myself. The whole architecture quietly assumes that I either can’t, or won’t bother. Both are true at different times.

When I’m sitting down for a Sunday slate of games and I want to know “how did the Vikings rush defense look on early downs last year against teams that ran shotgun more than 60% of the time” — I could open DuckDB, DESCRIBE pbp, scroll through the column list, write the CTE, fix the typos, and get my answer in ten minutes. Or I could type the question into the chat and read the answer in fifteen seconds.

Both produce the same number. Only one of them I’ll actually do.

The other case is sharing. If you hand a friend a SQL prompt, they will not query the data. If you hand them a chat box, they will. The chat box is the lossy interface that makes the data accessible to people who don’t write SQL — which, even among NFL fans who love stats, is most people.


The other reason I built this: learning how LLM tool calling actually works Link to heading

I’ll be honest about the second motivation. I wanted to understand custom tool calling (function calling, depending on whose SDK you’re reading) — the mechanic where you hand an LLM a set of functions, it decides when to call them, and you feed the results back into the conversation. Tool calling is the foundation of every “agentic” AI product right now. I’d read about it. I’d skimmed the OpenAI and Anthropic SDK docs. I had a working mental model that, predictably, was wrong in the places that mattered most.

There’s a real gap between reading “the model decides which tool to call” and actually watching it do that — choosing find_player before execute_sql, retrying after a SQL parser error, refusing when a query returns zero rows. The interesting parts of tool calling aren’t in the docs. They’re in the questions you only ask after you start building. How does the agent loop terminate? Who controls the shape of the tool result the model sees next? What does the model do when you give it two tools that could plausibly answer the same question, and how do you steer it toward the right one without overconstraining the prompt?

Playcall ended up being the small, specific app I needed to build to internalize all of that. I built it the way I build most things now: spec first, with Claude Code. The three tools are deliberately narrow in scope so the boundaries between them stay sharp. The agent loop fits in roughly fifty lines. The provider abstraction meant I wrote the tool-call protocol translation three times — once for Ollama, once for OpenAI, once for Anthropic — which is the fastest way I know to learn what’s genuinely shared across the providers and what’s just one company’s JSON-schema branding on top of the same underlying idea.

If you’re trying to learn this yourself, I’d recommend the same shape: pick a narrow domain where the answers are verifiable (sports stats, your own email archive, a specific dataset), give the model two or three tools, and run an eval suite against it. The constraint forces you to think clearly about what each tool is for, and the eval forces you to be honest about whether the model is actually getting it right.


What’s next for playcall Link to heading

Playcall today is essentially single-table — the play-by-play view, the players table for name resolution, and a hardcoded teams table for full names. The nflverse releases also include weekly stats, rosters, depth charts, FTN charting, schedules. Each one is a parquet file. Each one can become another view in DuckDB and another section in the schema prompt.

The interesting NFL analytics questions live across multiple tables: roster-aware queries (“how did the Bills do in games their starting QB missed”), opponent context, time-series of advanced metrics. That’s the direction I’m taking it next.

Try it Link to heading

The code is at github.com/allenwalker3/playcall. It runs locally against Ollama (free, no API keys) using any tool-calling model like Qwen 3, or against OpenAI / Anthropic if you’d rather use a hosted model.

There’s a built-in eval suite of 66 ground-truth-verified test cases. Recent snapshot:

ModelProviderPass rate
qwen3.6:27bOllama66/66 · 100%
gpt-5.5OpenAI66/66 · 100%
qwen3-coder:30bOllama63/66 · 95%
gemma4:26bOllama62/66 · 94%
gpt-oss:20bOllama60/66 · 91%

Before you trust whatever model you point it at, run the suite and see which NFL query shapes it handles reliably and which it doesn’t. The deltas are mostly in the advanced category — model-derived columns like cpoe and xyac_epa where the right column isn’t obvious from the question. Smaller local models miss a chunk of those even when they score above 90% overall, which is exactly the kind of thing the eval is there to surface.

That’s the honest version of “AI for analytics”: you don’t trust the model. You trust the verifier.