Dataset agents
A dataset agent answers questions about structured data. Pin a dataset to an agent, and when a user asks a data question the model writes one read-only SQL query, the gateway runs it against the internal data app, and the caller gets back both the SQL and the rows.
This is the structured-data counterpart to a knowledge base. A knowledge base grounds an agent on documents by retrieving passages; a dataset grounds it on tables by generating and running a query.
#How a turn works
- The caller posts to
POST /v1/agents/{id}/chat/completionswith the acting end user in theX-Relay-User-Idheader. - The gateway fetches the dataset's tables, columns and that user's row filters from the data app, and renders them into the system prompt.
- The model calls the gateway-owned tool
relay_run_dataset_sqlwith its SQL. - The gateway checks the query cheaply, then sends it to the data app, which applies the user's column and row permissions and executes it.
- The first 100 rows go back to the model so it can answer in prose. All rows, up to the caller cap, go to the caller in
relay_dataset. - If the query is rejected, the reason goes back to the model as the tool result and it tries again — up to
Gateway:Data:MaxIterationsattempts.
#Setting one up
In Agents, pick a dataset in the editor. The picker lists datasets from the data app; you can also type an id directly, which you will need to do when the dataset is visible to the caller but not to the panel's authoring identity.
Three things must be in place or the agent will not run:
Gateway:Data:BaseUrlandGateway:Data:ApiKeyconfigured on the gateway host.- The API key used to call the agent must hold the
data.queryscope. - The data app's API key must be scoped to the dataset (an API-key scope row with read access, managed in the data app).
If you have already published a version of this agent, snapshot and publish again after adding the dataset. A run prefers a published snapshot, and an older snapshot has no dataset in it — so pinning one appears to do nothing until you publish.
#Calling it
curl -s -X POST http://localhost:5300/v1/agents/AGENT_ID/chat/completions \
-H "Authorization: Bearer $RELAY_KEY" \
-H "X-Relay-User-Id: $END_USER_ID" \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"What were total sales by region last month?"}]}'
X-Relay-User-Id is required. X-User-Id is accepted as a fallback. GET /v1/agents reports "dataset": true and "requires_user_header": true so a client can tell which agents need it.
Dataset agents cannot stream. The gateway has to see the whole tool call, run the query and attach the results before it can respond, so "stream": true returns a 400 rather than being silently downgraded.
#What comes back
An ordinary chat completion, plus a relay_dataset object:
{
"choices": [ { "message": { "role": "assistant", "content": "Sales were 4.2M in EU and 1.1M in ME…" } } ],
"relay_dataset": {
"dataset_id": "7d3f…",
"dataset_name": "Retail Sales",
"dialect": "DuckDb",
"queries": [
{
"sql": "SELECT region, SUM(amount) AS total FROM orders GROUP BY region",
"explanation": "Total sales per region",
"effective_sql": "WITH \"orders\" AS (SELECT \"region\",\"amount\" FROM main.\"orders\" WHERE \"region\" IN ('EU','ME')) SELECT region, SUM(amount) AS total FROM orders GROUP BY region",
"columns": ["region", "total"],
"rows": [ { "region": "EU", "total": 4200000 } ],
"rows_returned": 2,
"rows_shown_to_model": 2,
"truncated": false,
"row_cap": 1000,
"elapsed_ms": 14,
"tables_referenced": ["orders"],
"masked_columns": ["orders.salary"],
"row_filters": ["orders.region"]
}
]
}
}
Every attempt appears in queries, including failed ones — when an answer comes out wrong, the retry trail is what explains it. rows_shown_to_model records how many rows the model actually saw, so its evidence is auditable against the rows you received.
Response headers carry the same facts in summary: X-Relay-Dataset-Id, X-Relay-Sql-Queries, X-Relay-Rls-Applied.
A model that answers without calling the tool — asking a clarifying question, or saying the dataset cannot answer this — returns a normal completion with no relay_dataset. That is a valid outcome, not an error.
#Permissions
Access is resolved per end user, by the data app, not by Relay. A user sees only the datasets, tables and columns they have been granted, and row-level security is applied inside the query itself, before any aggregation — so a COUNT(*) returns the count of rows that user may see.
Relay is deliberately not the enforcement point. It cannot be: the row-security records name a column but not a table, so only the data app — which can read the schema — can work out where a filter applies. Relay's own pre-check exists to catch a hallucinated table name a round trip earlier, nothing more.
The acting user id is caller-asserted. Relay cannot verify it. Two consequences:
- Derive it server-side from your own authenticated principal. Never pass through a value the browser supplied.
- The Relay API key is a service credential. Any key with
data.querycan read any data-app user's schema and grants by varying the header, so keep the scope off keys that do not need it.
#Limits
| Setting | Default | What it does |
|---|---|---|
Gateway:Data:ModelMaxRows | 100 | Rows shown to the model |
Gateway:Data:CallerMaxRows | 1000 | Rows requested from the data app, i.e. what you receive |
Gateway:Data:MaxIterations | 6 | Query attempts per turn |
Gateway:Data:CacheTtlSeconds | 120 | How long a dataset's schema and grants are cached per user. Set to 0 while editing grants. |
Gateway:Data:SchemaMaxChars | 24000 | Prompt budget for the schema block |
Past the schema budget the prompt switches to an index — one line per table — and the model calls relay_describe_dataset_tables for the tables it needs. That works into the low thousands of tables; beyond that the model cannot ask about a table it was never told exists, so the dataset needs narrowing on the data-app side.
#Notes and rough edges
Turn off PII redaction on a dataset agent. EnablePiiRedaction rewrites messages before the schema is even built, so "sales for john@acme.com" becomes a question the model cannot turn into a query.
Joins are guesswork. The data app has no foreign-key catalog and no table descriptions yet, so nothing tells the model how tables relate. Single-table questions are reliable; multi-table ones depend on column names being self-explanatory. Documenting columns in the data app is the highest-leverage way to improve results — descriptions, semantic types and units all reach the prompt.
Only documented tables are visible. The data catalog lists a table only once its columns are documented, and the query allow-list matches that same set. A granted-but-undocumented table reports "not present in the data catalog", which looks like a permissions problem and is not.
Row limits do not bound the source's work. No LIMIT is pushed into the model's SQL — that is not safe across dialects. A cartesian join still costs a full scan and will time out. This is why the prompt tells the model to aggregate in SQL.