Query Data from External Tools
Everything your tests emit lands in SignalFlag's datalake, the same data that powers metrics and dashboards. If you want that data somewhere else, for example a Grafana dashboard your team already uses, a BI tool, a notebook or a scheduled job, you can query it directly instead of copying it into another database.
You send one SQL query over HTTPS and get the rows back in the same response.
API addresses
The APIs keep their original resim.ai addresses (api.resim.ai, bff.resim.ai).
Use them exactly as shown on this page.
Before you start
You need three things:
- A read-only API key. An org admin can create one in the web app. It can read your organization's data and run queries, but cannot change anything. Keep it on the server side of whatever tool uses it, and don't put it in public repositories or public-facing interfaces: anyone who finds it can use it until it expires or is revoked.
- A project ID.
- A branch ID. Every query reads exactly one branch: that branch's metrics config decides which topics and columns exist, and only that branch's data is returned. To compare branches, run one query per branch.
You can find project and branch IDs in the web app, with the CLI
(signalflag projects list, signalflag branches list), or through the API with the same
key:
curl -s https://api.resim.ai/v1/projects/<project-id>/branches \
-H "Authorization: Bearer $SIGNALFLAG_API_KEY"
Write and test your SQL first in the web app's metrics editor or with your agent (see Build dashboards with an agent). The query language and the available topics are the same ones described in Metrics and Dashboards.
Run a query
Send a POST request to https://bff.resim.ai/api/v1/query with the API key as a bearer
token and the query as JSON:
curl -s https://bff.resim.ai/api/v1/query \
-H "Authorization: Bearer $SIGNALFLAG_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"projectId": "<project-id>",
"branchId": "<branch-id>",
"sql": "SELECT batch_name, avg(speeds) AS avg_speed FROM drone_speed GROUP BY batch_name",
"limit": 1000
}'
{
"queryId": "4b0a6c1e-0f53-4f0c-9d0e-2a51c1f7e9b2",
"status": "SUCCESS",
"error": null,
"columns": ["batch_name", "avg_speed"],
"records": [{ "batch_name": "nightly-1042", "avg_speed": "8.93404" }],
"rowCount": 1,
"truncated": false
}
Request fields
| Field | Required | Description |
|---|---|---|
projectId |
Yes | The project to query. |
branchId |
Yes | The branch whose metrics config and data the query reads. |
sql |
Yes | One SELECT or WITH statement, without a trailing semicolon. |
batchId |
No | Only rows from this batch. An empty value means no filter. |
jobId |
No | Only rows from this job (test) of a batch. An empty value means no filter. |
parameters |
No | Values for ? placeholders in the SQL. |
limit |
No | Maximum rows to return, from 1 to 10,000. Defaults to 1,000. |
Response
| Field | Description |
|---|---|
status |
SUCCESS, FAILED or CANCELLED. |
error |
The reason a query failed, when status is FAILED. |
columns |
Column names, in order. |
records |
One object per row, keyed by column name. |
rowCount |
Number of rows returned. |
truncated |
true when more rows exist than limit allowed. |
queryId |
Identifier of the query, useful when contacting support. |
Values in records arrive as strings, as the query engine returns them. Set the column type
(number, time) in the tool that reads them.
Give every selected column a unique name (SELECT a.x AS a_x, b.x AS b_x): records is
keyed by column name, so two columns with the same name collapse into one.
Limits
- Rows: up to 10,000 per request. If
truncatedistrue, aggregate in SQL rather than pulling raw rows. - Time: a request waits about 55 seconds. A query still running after that is stopped and
the request fails with
Query timeout. Filter by batch or time range, and aggregate, to keep queries short. - First run: the first time a query runs after new data arrives it reads from storage and can take tens of seconds. Repeating the same query is usually fast until more data lands.
- Rate: 120 requests per minute per API key, and 600 per minute for your whole
organization, shared with your agents' tool calls. A request over the limit gets HTTP 429
with a
Retry-Afterheader. Every panel of a dashboard is one request per load, so a 60-panel dashboard can load twice a minute on one key. Refresh dashboards every few minutes, not every few seconds. - Cost: queries use your organization's credits. If the organization runs out, requests
fail with
ORG_SUSPENDEDuntil credits are added.
Errors
- A result with
status: FAILEDmeans the query ran and failed.errorhas the reason, usually a SQL problem such as an unknown column. - Any other error is a non-200 response with a body like
{"error": {"code": "invalid_argument", "message": "sql must start with SELECT or WITH"}}:
| Status | code |
Meaning |
|---|---|---|
| 400 | invalid_argument |
The request was rejected before running. The message says why, for example a missing field, SQL that is not a SELECT or WITH, or Query timeout. |
| 401 | unauthorized |
The API key is wrong, has expired or was revoked. Create a new one. |
| 402 | ORG_SUSPENDED |
The organization is out of credits. |
| 404 | not_found |
The project or branch does not exist in your organization. |
| 429 | RATE_LIMITED |
Wait for the Retry-After header's seconds and retry. |
| 5xx | internal |
A problem on our side. Retry with a backoff. |
Example: Grafana
Grafana reaches the API through the Infinity data source plugin. Install the Grafana Image Renderer as well if you want to export panels as images or let an agent read them through the Grafana MCP server.
To start from a working dashboard instead of an empty one, import one of the dashboard templates once the data source below is set up.
Data source
Create a read-only API key first (see Before you start), and name it after the dashboard or Grafana instance that uses it.
- Install the Infinity plugin and add an Infinity data source.
- Under Authentication, choose Bearer Token and paste the key.
- Under Allowed hosts, add
https://bff.resim.aiandhttps://api.resim.ai. Grafana refuses to send the key anywhere else.
Every query below uses the same settings: type JSON, parser Backend, method POST,
URL https://bff.resim.ai/api/v1/query, root selector records, and the request as a raw
JSON body.
Variables
Add these dashboard variables so every panel follows the same branch and batch:
project: a Constant with your project ID.branch: a Query variable on the Infinity data source, type JSON, URLhttps://api.resim.ai/v1/projects/${project}/branches, root selectorbranches, with columnsnameas__textandbranchIDas__value.batch(optional): a Query variable on the same data source, with this body and columnslabelas__textandbatch_idas__value. Batch names are not unique (every run of the same test suite can share one), so the label adds the start of the batch ID, and the newest batch comes first:
{
"projectId": "${project}",
"branchId": "${branch}",
"sql": "SELECT concat(batch_name, ' (', substr(batch_id, 1, 8), ')') AS label, batch_id FROM metadata GROUP BY batch_name, batch_id ORDER BY min(time) DESC"
}
job(optional): the same kind of variable for the tests of the selected batch, with columnslabelas__textandjob_idas__value:
{
"projectId": "${project}",
"branchId": "${branch}",
"batchId": "${batch}",
"sql": "SELECT experience_name AS label, job_id FROM metadata GROUP BY experience_name, job_id ORDER BY experience_name"
}
Panels pass these as "batchId": "${batch}" and "jobId": "${job}". When a variable has no
value, Grafana sends an empty string, which the API treats as no filter: the panel reads
the whole branch.
Panels
For each panel, use the Infinity data source with the settings above and a body such as:
{
"projectId": "${project}",
"branchId": "${branch}",
"batchId": "${batch}",
"sql": "SELECT experience_name, avg(speeds) AS avg_speed FROM drone_speed GROUP BY experience_name"
}
Then, under Columns, add each column with its type (String, Number, Timestamp).
Without types, Grafana treats every value as text.
The backend parser returns columns in alphabetical order, not in the order of your SQL. To set the order of a table or stat panel, add an Organize fields by name transformation and arrange the columns there.
For a trajectory or any other plot of one column against another, use the XY chart
panel with automatic series mapping, and select just the two columns, for example
SELECT x, y FROM ego_position ORDER BY timestamp with "jobId": "${job}".
To follow the dashboard's time range, filter on the time column in the SQL, wrapping
Grafana's variables as epoch seconds:
WHERE time >= TIMESTAMP '1970-01-01 00:00:00' + INTERVAL '${__from:date:seconds}' SECOND
AND time < TIMESTAMP '1970-01-01 00:00:00' + INTERVAL '${__to:date:seconds}' SECOND
The time column is in UTC. Grafana formats ${__from:date:<pattern>} in the viewer's
browser timezone, so a formatted date such as ${__from:date:YYYY-MM-DD HH:mm:ss} shifts the
range by the viewer's UTC offset; epoch seconds do not.
Use this form rather than functions such as from_unixtime or from_iso8601_timestamp:
repeated queries are answered from a cache that does not support every query-engine
function, and a query the cache cannot run is slower.
When a panel shows "no results found"
Infinity does not always display the API's error message on the panel. If a panel is
empty, open the panel's Query inspector and look at the raw response: a SQL error
appears in error, and a rejected request in the error object of the body.
Build dashboards with an agent
A coding agent connected to two MCP servers can do most of this for you: the SignalFlag MCP server to explore your data and test SQL, and the Grafana MCP server to create the dashboards.
Connect both servers
Using Claude Code as the example:
# SignalFlag: sign in through your browser on first use
claude mcp add --transport http signalflag https://bff.resim.ai/mcp
# Grafana: a service account token with the Editor role
claude mcp add grafana \
-e GRAFANA_URL=https://grafana.example.com \
-e GRAFANA_SERVICE_ACCOUNT_TOKEN=<token> \
-- docker run --rm -i -e GRAFANA_URL -e GRAFANA_SERVICE_ACCOUNT_TOKEN mcp/grafana -t stdio
Set up the Infinity data source yourself first, as described above. The Grafana MCP server cannot store data source secrets, and an Editor service account cannot change data sources.
Ask for a dashboard
Describe what you want to see, and name the project and the data source:
Using the SignalFlag MCP, look at the topics on the main branch of project
"drone-nav" and draft SQL for: average and max speed per batch, speed over the
run for a selected batch, and a per-experience table. Test each query with
query_emissions. Then use the Grafana MCP to build a dashboard on the
"SignalFlag datalake" Infinity data source, with project, branch and batch
variables as described in docs.signalflag.ai/guides/metrics/external-access.
The agent typically:
- Lists your topics and columns with
list_topics. - Writes the SQL and checks each query against real data with
query_emissions. - Reads the
export-dataskill, which explains the query endpoint, its limits and the Grafana setup. - Creates the dashboard, its variables and its panels through the Grafana MCP, and runs each panel's query to confirm it returns data.
Review the dashboard before you share it. In particular, check that refresh intervals are reasonable, since every panel refresh is a query that uses your organization's rate limit and credits.