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 API, the GraphQL endpoint and the sign-in service keep their original resim.ai
addresses (api.resim.ai, bff.resim.ai, resim.us.auth0.com). Use them exactly as
shown on this page.
Before you start
You need three things:
- A read-only machine-to-machine (M2M) credential (a client ID and secret) bound to your organization. You cannot create one yourself: request it from SignalFlag through your usual support channel, naming the organization it is for. It can read your organization's data and has no write permissions in the SignalFlag API. Keep the secret on the server side of whatever tool uses it.
- 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
(resim projects list, resim branches list), or through the API with the same
credential:
curl -s https://api.resim.ai/v1/projects/<project-id>/branches \
-H "Authorization: Bearer $TOKEN"
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.
Get a token
The credential uses the OAuth2 client credentials flow:
curl -s https://resim.us.auth0.com/oauth/token \
-H 'Content-Type: application/json' \
-d '{
"grant_type": "client_credentials",
"client_id": "<client-id>",
"client_secret": "<client-secret>",
"audience": "https://api.resim.ai"
}'
The response contains an access_token that is valid for 24 hours. Most tools that support
OAuth2 client credentials fetch and refresh it for you.
Run a query
Send a GraphQL request to https://bff.resim.ai/graphql with the token as a bearer header:
curl -s https://bff.resim.ai/graphql \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"query": "mutation($p: String!, $b: String!, $s: String!) { runQuery(projectId: $p, branchId: $b, sql: $s, limit: 1000) { status error columns records rowCount truncated } }",
"variables": {
"p": "<project-id>",
"b": "<branch-id>",
"s": "SELECT batch_name, avg(speeds) AS avg_speed FROM drone_speed GROUP BY batch_name"
}
}'
{
"data": {
"runQuery": {
"status": "SUCCESS",
"error": null,
"columns": ["batch_name", "avg_speed"],
"records": [{ "batch_name": "nightly-1042", "avg_speed": "8.93404" }],
"rowCount": 1,
"truncated": false
}
}
}
Arguments
| Argument | 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. Read this one. |
rows |
The same rows as arrays. Values are JSON-encoded strings, so prefer records. |
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 credential, 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 credential. 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. - A GraphQL error with no result means the request was rejected before running. The
message says why, for example
sql must start with SELECT or WITH,Query timeout, orAn unexpected error occurred(a problem on our side; retry with a backoff). - HTTP 429 with
extensions.codeRATE_LIMITED: wait forRetry-Afterseconds and retry. extensions.codeORG_SUSPENDED: the organization is out of credits.
Example: Grafana
Grafana reaches the API through the Infinity data source plugin.
Data source
Request a read-only M2M credential from SignalFlag first (see Before you start).
- Install the Infinity plugin and add an Infinity data source.
- Under Authentication, choose OAuth2, grant type Client Credentials:
- Client ID and secret: your read-only credential.
- Token URL:
https://resim.us.auth0.com/oauth/token. - Endpoint params:
audience=https://api.resim.ai.
- Under Allowed hosts, add
https://bff.resim.aiandhttps://api.resim.ai. Grafana refuses to send the credential anywhere else.
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, type GraphQL, URLhttps://bff.resim.ai/graphql, root selectordata.runQuery.records, with this query 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:
mutation {
runQuery(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") {
records
}
}
job(optional): the same kind of variable for the tests of the selected batch, with columnslabelas__textandjob_idas__value:
mutation {
runQuery(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") {
records
}
}
Panels pass these as batchId: "${batch}" and jobId: "${job}". When a variable has no
value, Grafana sends an empty string, which runQuery treats as no filter: the panel reads
the whole branch.
Panels
For each panel, use the Infinity data source with type GraphQL, URL
https://bff.resim.ai/graphql, parser Backend, root selector data.runQuery.records,
and a query such as:
mutation {
runQuery(projectId: "${project}", branchId: "${branch}", batchId: "${batch}",
sql: "SELECT experience_name, avg(speeds) AS avg_speed FROM drone_speed GROUP BY experience_name") {
status error records
}
}
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:
WHERE time >= TIMESTAMP '${__from:date:YYYY-MM-DD HH:mm:ss}'
AND time < TIMESTAMP '${__to:date:YYYY-MM-DD HH:mm:ss}'
Use a TIMESTAMP '...' literal like this one rather than functions such as
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 display GraphQL errors 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
or in the errors list. Rate limiting is the exception: it returns HTTP 429, which
Infinity shows on the panel.
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 explainsrunQuery, 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.