Skip to content

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:

Bash
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:

Bash
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
  }'
JSON
{
  "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 truncated is true, 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-After header. 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_SUSPENDED until credits are added.

Errors

  • A result with status: FAILED means the query ran and failed. error has 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.

  1. Install the Infinity plugin and add an Infinity data source.
  2. Under Authentication, choose Bearer Token and paste the key.
  3. Under Allowed hosts, add https://bff.resim.ai and https://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, URL https://api.resim.ai/v1/projects/${project}/branches, root selector branches, with columns name as __text and branchID as __value.
  • batch (optional): a Query variable on the same data source, with this body and columns label as __text and batch_id as __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:
JSON
{
  "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 columns label as __text and job_id as __value:
JSON
{
  "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:

JSON
{
  "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:

SQL
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:

Bash
# 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:

Text
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:

  1. Lists your topics and columns with list_topics.
  2. Writes the SQL and checks each query against real data with query_emissions.
  3. Reads the export-data skill, which explains the query endpoint, its limits and the Grafana setup.
  4. 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.