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.
- Up to the first 100 rows — and at most
Gateway:Data:ModelMaxCharsof row JSON — go back to the model so it can answer in prose. All rows, up to the caller cap, go to the caller inrelay_dataset. - If the query is rejected, the reason goes back to the model as the tool result and it tries again. The whole tool loop is bounded by
Gateway:Data:MaxIterationsmodel calls; if the budget runs out on a rejected query, the call fails with422 sql_execution_failed.
#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 (otherwise503 dataset_backend_unconfigured).- The API key used to call the agent must hold the
data.queryscope (otherwise403 insufficient_scope). - 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 (400 user_header_required without it). X-User-Id is accepted as a fallback, and the primary header name is configurable as Gateway:Data:UserIdHeader. The value is at most 200 characters of letters, digits and @ . _ + - \; anything else is refused as malformed. 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 query the model submitted appears in queries, including ones rejected by the pre-check or the data app — when an answer comes out wrong, the retry trail is what explains it. (A tool call whose arguments carried no usable sql is answered with an error and not recorded.) 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, and must not grow into one. Row filters now name the table they govern (a legacy filter with no table name still applies to every table that has the column), and Relay renders them into the prompt so the model knows the data is scoped — but the data app remains the authority: it rewrites each referenced table into a secured relation before running the query, and it has enforcement shapes (source-native policies, a verified rewrite) that Relay cannot reproduce. 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:ModelMaxChars | 20000 | Characters of row JSON shown to the model |
Gateway:Data:CallerMaxRows | 1000 | Rows requested from the data app, i.e. what you receive (max 1000, never below ModelMaxRows) |
Gateway:Data:MaxIterations | 6 | Tool-loop iterations (model calls) per turn, including describe-table calls in index mode |
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 |
The remaining Gateway:Data:* keys (timeouts, response cap, negative cache, index preview width, header name, picker identity) are in the Configuration reference.
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 (those calls spend the same iteration budget). 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, so nothing tells the model how tables relate. Single-table questions are reliable; multi-table ones depend on names being self-explanatory. Documenting the dataset in the data app is the highest-leverage way to improve results — table descriptions, column descriptions, semantic types and units all reach the prompt when they are filled in.
Workflows cannot run a dataset agent. The workflow Agent node refuses one, because a run has no acting end user to resolve grants for.
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.