Skip to main content

SQL Bridge

warning

Experimental. This feature may change or be removed.

Your Custom Dashboard HTML talks to TagoIO only through window.postMessage with its parent (Admin). Use that channel to list and run saved TagoSQL queries on the profile. There is no free-form SQL from the embed, and the host does not inject query results until your page requests them. The shell supplies params values itself. The host does not inject dashboard or user identity (no host-context binding); use TagoSQL session functions for identity inside SQL where supported.

Admin fulfills each request with the signed-in user's session against existing APIs. Custom Dashboard is Admin only: not available in TagoRUN, and public share and distribute are not supported.

Host messages for ready, theme, and style stay on the shell side. See Authoring HTML for dashboard:ready, dashboard:theme, and dashboard:style. This page covers the SQL request/response bridge only.

Envelope

Embed to host (request)

{
"type": "dashboard:request",
"id": "unique-string",
"op": "sql.list",
"payload": {}
}
FieldRequiredMeaning
typeYesAlways "dashboard:request".
idYesAny unique string your page picks. The host echoes it on the response.
opYesOperation name (see below).
payloadDependsOp-specific body. Use {} or omit when the op takes no fields.

A malformed request without id is silently dropped. When id is present but the request is otherwise malformed, the host responds with bad_request.

Host to embed (response)

{
"type": "dashboard:response",
"id": "unique-string",
"ok": true,
"result": {}
}

On failure:

{
"type": "dashboard:response",
"id": "unique-string",
"ok": false,
"error": { "code": "not_found", "message": "..." }
}
FieldMeaning
typeAlways "dashboard:response".
idSame string as the request.
oktrue with result, or false with error.
resultPresent when ok is true. Shape depends on op.
errorPresent when ok is false. Includes code and a human-readable message.

Match responses to in-flight calls by id. Ignore responses whose id you did not send.

Operations

OpPayloadResult on success
sql.listNone / {}{ queries: [{ id, name }] } for the profile's saved queries.
sql.run{ query_id, params? }{ columns: string[], rows: object[], meta: { row_count, execution_ms, served_from_cache } } (bridge shape).

Only saved queries are available. The embed cannot send a SQL string to run.

sql.list

Lists saved TagoSQL queries on the profile.

const { queries } = await request("sql.list");
// queries: [{ id: "...", name: "..." }, ...]

sql.run

Runs one saved query by id. Optional params override the query's saved default parameters for this run.

const result = await request("sql.run", {
query_id: "...",
params: [{ key: "$1", value: "30" }],
});
// result: {
// columns: string[],
// rows: object[], // each row is an object keyed by column name
// meta: { row_count, execution_ms, served_from_cache }
// }
FieldRequiredMeaning
query_idYesId of a saved query from sql.list (or known from your profile).
paramsNoArray of { key, value } pairs. Keys match placeholders such as $1.

When you omit params, the query runs with its stored defaults. See TagoSQL parameters and Executing queries for parameter shapes. The bridge forwards only query_id and params. Other execute body fields such as after_device and test are not available.

On success, the bridge returns a normalized result:

FieldMeaning
columnsColumn names as strings, in order.
rowsArray of row objects; each row is keyed by column name.
meta{ row_count, execution_ms, served_from_cache } for this run.

This shape differs from calling POST /sql/{id}/execute directly. Raw execute returns typed { name, type } columns and flat metadata; the bridge normalizes columns to name strings and nests meta.

Errors

CodeMeaning
not_foundQuery id unknown or not on this profile.
forbiddenThe API denied permission to run the query (HTTP 403 on the host's execute call).
bad_paramsOther client-side rejections of the run request (for example bad or missing parameter values).
api_errorThe host could not complete the underlying API call for this session.
unknown_opop is not a supported operation.
bad_requestMalformed bridge payload (for example missing query_id or wrong types); rejected before any API call.

Treat error.message as human-readable detail; branch on error.code in code.

Example

Core request/response helper and a first-query run:

<script>
const pending = new Map();
window.addEventListener("message", (event) => {
const msg = event.data;
if (msg?.type !== "dashboard:response" || !pending.has(msg.id)) return;
const { resolve, reject } = pending.get(msg.id);
pending.delete(msg.id);
msg.ok ? resolve(msg.result) : reject(new Error(`${msg.error.code}: ${msg.error.message}`));
});

function request(op, payload) {
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
window.parent.postMessage({ type: "dashboard:request", id, op, payload }, "*");
});
}

async function main() {
const { queries } = await request("sql.list");
if (!queries.length) return;
const result = await request("sql.run", { query_id: queries[0].id });
console.log(result);
}
main();
</script>

Full page that also signals ready, applies theme, and renders rows into a table. The request/pending pattern matches the script above:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Custom Dashboard</title>
<style>
:root {
color-scheme: light dark;
}
body {
font-family: system-ui, sans-serif;
margin: 1.5rem;
}
table {
border-collapse: collapse;
width: 100%;
}
th,
td {
border: 1px solid #ccc;
padding: 0.4rem 0.6rem;
text-align: left;
}
#status {
color: #666;
}
</style>
</head>
<body>
<h1>SQL Bridge example</h1>
<p id="status">Loading...</p>
<div id="table"></div>
<script>
const statusEl = document.getElementById("status");
const tableEl = document.getElementById("table");
const pending = new Map();

window.addEventListener("message", (event) => {
// Prefer checking event.origin against the Admin origin in production.
const msg = event.data;
if (!msg || typeof msg !== "object") return;

if (msg.type === "dashboard:theme") {
document.documentElement.dataset.theme = msg.theme;
return;
}

if (msg.type === "dashboard:style") {
Object.assign(document.body.style, msg.style ?? {});
return;
}

if (msg.type !== "dashboard:response" || !pending.has(msg.id)) return;
const { resolve, reject } = pending.get(msg.id);
pending.delete(msg.id);
msg.ok ? resolve(msg.result) : reject(new Error(`${msg.error.code}: ${msg.error.message}`));
});

function request(op, payload) {
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
window.parent.postMessage({ type: "dashboard:request", id, op, payload }, "*");
});
}

function renderTable(result) {
const columns = result.columns ?? [];
const rows = result.rows ?? [];
tableEl.replaceChildren();
if (!columns.length && !rows.length) {
tableEl.textContent = JSON.stringify(result, null, 2);
return;
}
const table = document.createElement("table");
const thead = document.createElement("thead");
const headerRow = document.createElement("tr");
for (const key of columns) {
const th = document.createElement("th");
th.textContent = key == null ? "" : String(key);
headerRow.appendChild(th);
}
thead.appendChild(headerRow);
table.appendChild(thead);
const tbody = document.createElement("tbody");
for (const row of rows) {
const tr = document.createElement("tr");
for (const key of columns) {
const td = document.createElement("td");
const value = row[key];
td.textContent = value == null ? "" : String(value);
tr.appendChild(td);
}
tbody.appendChild(tr);
}
table.appendChild(tbody);
tableEl.appendChild(table);
}

async function main() {
try {
const { queries } = await request("sql.list");
if (!queries.length) {
statusEl.textContent = "No saved queries on this profile.";
return;
}
statusEl.textContent = `Running: ${queries[0].name}`;
const result = await request("sql.run", { query_id: queries[0].id });
statusEl.textContent = queries[0].name;
renderTable(result);
} catch (err) {
statusEl.textContent = String(err.message ?? err);
}
}

parent.postMessage({ type: "dashboard:ready" }, "*");
main();
</script>
</body>
</html>

Register the response listener before you call request. Prefer allowlisting the Admin origin on inbound messages; see Authoring HTML.