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

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

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

Bash
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"
    }
  }'
JSON
{
  "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 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 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-After header. 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_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.
  • 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, or An unexpected error occurred (a problem on our side; retry with a backoff).
  • HTTP 429 with extensions.code RATE_LIMITED: wait for Retry-After seconds and retry.
  • extensions.code ORG_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).

  1. Install the Infinity plugin and add an Infinity data source.
  2. 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.
  3. Under Allowed hosts, add https://bff.resim.ai and https://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, 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, type GraphQL, URL https://bff.resim.ai/graphql, root selector data.runQuery.records, with this query 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:
GraphQL
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 columns label as __text and job_id as __value:
GraphQL
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:

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

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

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 runQuery, 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.