SQL Bridge
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": {}
}
| Field | Required | Meaning |
|---|---|---|
type | Yes | Always "dashboard:request". |
id | Yes | Any unique string your page picks. The host echoes it on the response. |
op | Yes | Operation name (see below). |
payload | Depends | Op-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": "..." }
}
| Field | Meaning |
|---|---|
type | Always "dashboard:response". |
id | Same string as the request. |
ok | true with result, or false with error. |
result | Present when ok is true. Shape depends on op. |
error | Present 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
| Op | Payload | Result on success |
|---|---|---|
sql.list | None / {} | { 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 }
// }
| Field | Required | Meaning |
|---|---|---|
query_id | Yes | Id of a saved query from sql.list (or known from your profile). |
params | No | Array 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:
| Field | Meaning |
|---|---|
columns | Column names as strings, in order. |
rows | Array 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
| Code | Meaning |
|---|---|
not_found | Query id unknown or not on this profile. |
forbidden | The API denied permission to run the query (HTTP 403 on the host's execute call). |
bad_params | Other client-side rejections of the run request (for example bad or missing parameter values). |
api_error | The host could not complete the underlying API call for this session. |
unknown_op | op is not a supported operation. |
bad_request | Malformed 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.
Related
- Authoring HTML (ready, theme, style)
- Custom Dashboard
- TagoSQL queries
- Executing queries
- HTML shell