Build an automated AI data analysis agent with Flights
I want to build my own data-analysis agent and run it on a schedule in MotherDuck: it discovers the entities to cover, fans out one agent each, explores my warehouse with read-only SQL, and writes a ranked "notable things" brief. Help me adapt the "Build an automated AI data analysis agent with Flights" recipe to my own data and use case, using it as a guide: https://motherduck.com/docs/cookbook/flight-analysis-agent
This Flight shows how to build your own data-analysis agent and run it on a schedule in MotherDuck. Instead of reaching for a finished framework and hoping it does the right thing, you compose an agent from parts you understand, then let a Flight trigger it on your latest data.
The shipped example briefs NYC 311 service requests by borough using the
public sample_data dataset, so a fresh deploy runs end to end with no data of
your own. Each run discovers the active boroughs in a recent window, fans out one
agent per borough, and each agent explores the 311 data, optionally enriches it
with weather, and writes a ranked "notable things" brief that is stored in a
table. Swap the discovery query, the source table, the skill, and the tools to
point it at your entities and your domain.
An agent is just a loop
An agent is a loop: send the model a prompt, let it call tools, feed the tool results back, and repeat until it stops and answers. That loop is cheap to own, and you do not hand-write it here. Pydantic AI runs it. What determines whether the answer is any good is not the loop, it is what you put around it.
Consider the classic failure: ask an agent "what is the average order value?" and
it writes SELECT avg(order_total) and returns 16,990.42. Plausible, and wrong,
because order_total is stored in cents and the real answer is 169.90. The loop
did exactly what it was told. The missing piece was context. That is the whole
game: a capable loop plus the context and tools that make it correct.
The three layers
build_agent() composes the agent from three things you can see and change:
- The model, using OpenRouter.
MODELis an OpenRouter model slug, so the agent is not locked to one provider. Swap it for any tool-capable model (Anthropic, OpenAI, Google, open-weight) without touching the rest of the code. OpenRouter speaks one OpenAI-compatible API for all of them. - A skill: your company context. The
SKILLconstant is the domain knowledge the agent needs to be correct: what counts as notable for 311, theUnspecifiedborough exclusion (the analog of internal or test accounts), how to ground every number in a query, and reference data such as the borough coordinates the weather tool needs. A Flight ships as a single file, so the skill lives inline and is passed to the agent as its instructions. EditingSKILLis how you retarget the agent to your own domain. - Tools you build yourself. Two ordinary Python functions handed to
Agent(tools=[...]):explore_warehouse(sql)runs read-only SQL againstmd:sample_data. It guards writes with DuckDB'sjson_serialize_sql(): that function only serializes a statement to an AST when it is a read, so a mutating statement (INSERT, UPDATE, DELETE, CREATE, ATTACH) comes back witherror: trueand is refused before it runs. This is the same read-only primitive the MotherDuck Wasm client and Dives use. Its description tells the agent to stick toSELECTand to explore the schema throughinformation_schema.get_weather(latitude, longitude, start_date, end_date)is a thin wrapper over the Open-Meteo historical archive API (no key required). It lets the agent explain a complaint spike with weather, for example heavy rain preceding flooding reports.
How it works
flight.py runs a fixed sequence. The parts you change are the discovery query,
SOURCE_TABLE, the SKILL, and the prompt in build_prompt():
- Anchor the window.
sample_datais a frozen snapshot, so "recent" is measured fromMAX(created_date), notnow(). The run computes an anchor and aBRIEF_WINDOW_DAYSlookback once. Against a live warehouse you would anchor tonow()instead. - Discover entities.
discover_boroughs()lists the boroughs active in the window (busiest first, excluding the geography-lessUnspecifiedbucket). TheBOROUGHSenv var overrides discovery;MAX_BOROUGHScaps the count for testing. - Build the agent once.
build_agent()composes the three layers above into a single Pydantic AIAgent, shared across the run. - Fan out, bounded. One
agent.run()per borough runs behind anasyncio.Semaphore(CONCURRENCY), so the batch stays inside the Flight runtime and under OpenRouter rate limits. The prompt fixes the borough and window and asks the agent to profile activity, decide what is notable, and check weather where it fits. - Persist and summarize. Each non-empty brief is written to
RESULTS_TABLE(flights_demo.main.borough_briefs), and the run logs anok/failedbatch summary to stderr. One borough's failure is caught so it does not abort the rest.
Discovery and persistence use a direct duckdb.connect("md:") (deterministic
infra); only the agent's exploration goes through the explore_warehouse tool,
which connects to the single md:sample_data database.
Questions to answer
Before pointing this at your own data, decide:
- What entity do you fan out on? Here it is NYC boroughs; for you it might be
customers, regions, products, or teams. This is the discovery query in
discover_boroughs()and the entity filter inbuild_prompt(). - What is the source table, and which column marks "recent"? Set
SOURCE_TABLEand the date column the window filters on (created_datehere). - Live data or a frozen snapshot? The shipped example anchors the window to
MAX(created_date)becausesample_datais frozen. Against a live table, anchor tonow()instead (see Caveats). - What is notable in your domain, and what must be excluded? This is the
SKILLconstant: the definitions, exclusions (theUnspecifiedborough is the analog of internal or test accounts), and any reference data your tools need (here, borough coordinates for the weather tool). - Which tools does the agent need? A read-only SQL tool over your warehouse is almost always one. The weather tool is domain-specific: keep it only if an external signal actually explains your data, otherwise replace it.
- Which model, and does it support tool calling? Set
MODELto a tool-capable OpenRouter slug (see Caveats). - Where do briefs land, and who may read them? Set
RESULTS_TABLEto a database the Flight can write, and apply the source data's access controls to it. - How often, and how many entities per run? Choose a schedule and use
MAX_BOROUGHSandCONCURRENCYto bound cost while you iterate. - Where does the OpenRouter key come from? A local env var for local runs, or a MotherDuck Flights secret when deployed (see "Deploy as a Flight").
Caveats
- Fan-out multiplies cost and time. One agent per entity is N independent model
runs, each making several tool calls. Keep
MAX_BOROUGHSsmall on the first run and while iterating, and only lift the cap once a run looks right.CONCURRENCYbounds parallelism to the Flight runtime and OpenRouter rate limits; it does not bound spend. - Output is non-deterministic. Two runs over the same window can rank or word findings differently. The guardrails constrain what the agent may claim (every number grounded in a query it ran), not the exact prose. This is the wrong tool when you need one stable, exact figure: compute that with plain SQL and reserve the agent for the judgment layer on top.
- The window is anchored to the data, not the clock. Because
sample_dataends in 2023, the run measures "recent" fromMAX(created_date). Point it at a live table without switching the anchor tonow()and it will keep briefing the same stale window. - The model must support tool calling. A non-tool model set as
MODELcannot callexplore_warehouseorget_weather; pick a tool-capable slug from OpenRouter.
What you'll adjust
| Knob | Where | Default | Purpose |
|---|---|---|---|
SKILL | constant in flight.py | 311 domain context | The company context that makes the agent correct. The main thing you tune. |
| Discovery query | discover_boroughs() | top boroughs by volume | The SQL that lists the entities to brief. Replace with your own partition. |
SOURCE_TABLE | top of flight.py | sample_data.nyc.service_requests | The table each agent analyzes. Point at your data. |
build_prompt() | function in flight.py | 311 "notable things" prompt | The per-entity task and how the brief is shaped. |
explore_warehouse / get_weather | functions in flight.py | SQL tool + weather tool | The tools the agent gets. Add, remove, or replace them. |
MODEL | config / env | anthropic/claude-sonnet-4.6 | OpenRouter model slug. Any tool-capable model works. |
CONCURRENCY | config / env | 3 | Max simultaneous agents. Bound by runtime and API rate limits. |
BRIEF_WINDOW_DAYS | config / env | 7 | Lookback window in days, measured from the anchor date. |
MAX_BOROUGHS | config / env | 0 (all) | Cap the entity count for a cheap test run. 0 = no cap. |
BOROUGHS | env | (unset) | Comma-separated override that skips discovery (e.g. BROOKLYN,QUEENS). |
RESULTS_TABLE | top of flight.py | flights_demo.main.borough_briefs | Where briefs are stored. Must be writable. |
OPENROUTER_API_KEY | Flight secret / env | (required) | OpenRouter API key. A local run sets it directly; a Flight injects it from a secret (see below). |
MOTHERDUCK_TOKEN | Flight-injected | (Flight-injected) | Auth for duckdb.connect("md:") (discovery, persistence, and the warehouse tool). |
Run it
You need a MotherDuck account, a MotherDuck access token, and an
OpenRouter API key. With the defaults the agents
read the public sample_data.nyc.service_requests table (no data of your own
required) and write briefs to flights_demo.main.borough_briefs.
export MOTHERDUCK_TOKEN=your_md_token_here
export OPENROUTER_API_KEY=sk-or-your_key_here
# optional: keep the first run small and cheap
export MAX_BOROUGHS=1
uv run --with-requirements requirements.txt flight.py
The run discovers boroughs, fans out the agents, prints a batch summary to stderr, and stores one brief per borough. Inspect them with:
SELECT run_ts, borough, window_days, left(brief_md, 280) AS preview
FROM flights_demo.main.borough_briefs
ORDER BY run_ts DESC, borough;
Deploy as a Flight
Store your OpenRouter key as a MotherDuck Flights secret. The simplest way is
the MotherDuck UI: open
Settings > Secrets, add a secret of
type Flights, and give it an OPENROUTER_API_KEY parameter. If you would
rather use SQL, create the same secret from a write-enabled SQL connection
(read-only connections reject CREATE SECRET):
CREATE SECRET openrouter IN motherduck (
TYPE flights,
PARAMS MAP { 'OPENROUTER_API_KEY': 'sk-or-...' }
);
A TYPE flights secret injects each param under the env var
<secret_name>_<PARAM>, not the bare param name: the param above arrives as
openrouter_OPENROUTER_API_KEY, not OPENROUTER_API_KEY. (DuckDB lowercases the
unquoted secret name into the prefix.) flight.py handles this: it reads
OPENROUTER_API_KEY for local runs and otherwise picks up any env var ending in
_OPENROUTER_API_KEY, so the secret name you choose does not matter.
Then create the Flight with the MD_CREATE_FLIGHT SQL function (adapt the
arguments to your situation), passing:
name: a Flight name, for exampleanalysis_agentsource_code: the contents offlight.pyrequirements_txt: the contents ofrequirements.txtflight_secret_names:["openrouter"]so the key is injected (asopenrouter_OPENROUTER_API_KEY;flight.pyresolves it)config(optional): overrideMODEL,CONCURRENCY,BRIEF_WINDOW_DAYS,MAX_BOROUGHSas key/value pairs
A MotherDuck token is attached to the Flight automatically and injected at run
time as MOTHERDUCK_TOKEN; no token argument is needed.
Create the Flight without a schedule first, trigger one manual run with
MD_RUN_FLIGHT(flight_id := ...) (the id is returned by MD_CREATE_FLIGHT and
listed by MD_FLIGHTS()), and confirm it succeeds and briefs land in
RESULTS_TABLE. Keep MAX_BOROUGHS small for that first run to bound cost. Then
clear the cap and add a schedule (for example 0 13 * * *, daily at 13:00 UTC)
by updating the Flight's schedule_cron with MD_UPDATE_FLIGHT. Schedule updates
are metadata-only and do not create a new Flight version.
Building agents, briefly
The takeaway is not this specific Flight, it is the shape. An agent is a loop plus
tools plus context. Pydantic AI gives you the loop and clean tool wiring;
OpenRouter gives you any model behind one API; the two functions here show how to
build a safe internal tool (a read-only warehouse query) and an external one (an
API call). To make the agent stronger you add context to the SKILL and tools to
the list. To do more work you scale out, as this Flight does by fanning out one
agent per entity, or you point a single agent at more tools.
Security
- The warehouse tool refuses writes.
explore_warehouserunsjson_serialize_sql()on every query and executes only reads; a mutating statement is refused and the error is handed back to the agent so it can correct. This is the in-code first layer. - Still scope the token. Defense in depth: give the Flight a MotherDuck token
scoped to read the source data and write only the
RESULTS_TABLEdatabase, so the guard is not the only backstop. - The weather tool only makes outbound GETs to a single fixed public host.
- Keep secrets out of code. The OpenRouter key comes from a MotherDuck secret
(or a local env var), never hard-coded or placed in Flight
config. The MotherDuck token is injected by the runtime, never checked in. - Briefs can contain real data. The stored
brief_mdquotes figures the agent pulled from your tables. Apply the same access controls toRESULTS_TABLEas to the source data.
Learn more
- Pydantic AI: overview, the
tools reference, and the
OpenRouter model page
(
OpenRouterModel+OpenRouterProvider). - OpenRouter: the models list for tool-capable
slugs to use as
MODEL. - Open-Meteo: the historical weather API.
- DuckDB:
json_serialize_sql, the read-only guard primitive used here. - Flight mechanics (creating, running, scheduling): use the MotherDuck MCP
get_flight_guidetool. Deeper MotherDuck or DuckDB questions: use theask_docs_questionMCP tool. - Files in this template:
flight.py(the single-file Flight source) andrequirements.txt(duckdbandpydantic-ai-slim[openrouter], pinned for reproducible Flight builds).