autario.js: the data SDK for apps
Any HTML page can become an app on autario, including one that Claude, ChatGPT or Lovable wrote for you a minute ago. The page asks autario.js for data, autario answers as the person looking at the page, and the page never holds a key, a token or a network connection of its own. This page is the whole reference.
Use it in one line
Add the script tag, or leave it out: autario inlines autario.js into every app it serves, so window.autario always exists inside an app on autario. Outside autario the calls reject with the reason not_in_autario, because there is nobody to answer them.
<script src="https://autario.com/autario.js"></script>
<script>
(async () => {
const { app, user } = await autario.ready();
const [first] = await autario.datasets();
const rows = await autario.query(first.id, { limit: 10 });
console.log(app.name, user.role, rows);
})();
</script>
The fastest way to try it is the Build page: paste the HTML, pick the datasets the app may read, press Run it, and the app opens inside autario on your own data. Publish puts it into My apps.
The calls
- autario.ready() resolves with { app: { id, name }, user: { id, role } }. role is owner, admin, editor, reader or visitor. user.id is an anonymous id: the same on every visit to this app, a different one in every other app, never the email or the account. Only you, the owner, in your own app also get user.name and user.email. It is cached, so call it wherever you need it.
- autario.datasets() resolves with the datasets this app may read for this viewer: the owner's datasets the app declares, each with id, title, kind, connector, rows, updated_at and columns. A visitor gets an empty list.
- autario.query(datasetId, { columns, where, orderBy, limit, offset }) resolves with an array of row objects. columns is a list of names, where is { column: value } or a list of [column, op, value] with op one of eq, neq, gt, lt, gte, lte, like, orderBy is 'column' or 'column desc', limit defaults to 100. The array carries rows.meta with total, returned, limit and offset.
- autario.csvUrl(datasetId) returns the download URL of the whole dataset as CSV, for Excel, Power BI or curl with your API key. The app itself cannot download it, because it has no network.
- autario.artifacts.get(key) and autario.artifacts.set(key, value) keep the app's saved state, one JSON value of at most 256 KB per key, shared by everyone who opens the app. get needs the reader role, set needs editor or higher.
- autario.artifacts.get(key, { scope: 'viewer' }) and autario.artifacts.set(key, value, { scope: 'viewer' }) keep one value per person looking at the app: a game's progress, a draft, a setting. Anyone who can open the app reads and writes their own value, a signed-out visitor included, and nobody can read another person's. At most 50 keys of 64 KB each per person. A signed-out visitor is remembered by this browser; a signed-in person on every device.
- autario.files.put(fileOrDataUrl, { name }) stores an image the viewer adds and resolves with { id, name, mime, bytes, created_at }. It takes a File from an input type=file, any Blob, or a data URL. PNG, JPEG, WebP and GIF only, decided by the bytes and not by the name, at most 2 MB each and 100 per person. autario.files.get(id) resolves with the image as a data URL for an img src, autario.files.list() lists the viewer's own images and autario.files.remove(id) deletes one. Each person sees only their own images, and that includes you as the owner: there is no view of everyone's images. The images count toward your storage, and deleting the app deletes them.
- autario.publicDatasets.search(q) and autario.publicDatasets.query(id, options) read the open catalogue, World Bank, FRED, Eurostat, OECD and the rest, with no sign-in and no declaration.
The same row limits, filters and sort apply as on GET /api/v1/datasets/<id>/query, because both run the same query builder.
Who sees what
Every call runs as the person looking at the app, never as the app. The app's owner decides which of his datasets the app may read, and his team decides who may look.
- The owner reads the datasets the app declares. The declaration is the dataset picker on the Build page, or the datasets list on create_app, or consumed_datasets entries of the form { "op": "datasets-data", "dataset": "<id>" }.
- A team member invited on Account > Team with a role on the app reads the same declared datasets of the owner. reader reads, editor also saves artifacts, admin also manages the app's people.
- Anyone else is a visitor: public catalogue data only, even on a public app.
- An undeclared private dataset answers not_found for a member and dataset_not_declared for the owner, with the fix in the message.
Write data
An app can keep its own datasets: a CRM's contacts, a tracker's entries, each viewer's notes. Declare them in the manifest as datasets_owned (create_app and update_app_manifest take it, so does the manifest API). Each one has a key, a scope and typed columns. The app can write only what it declared, never a connector table, an upload or any other dataset.
datasets_owned: [
{ key: 'contacts', title: 'Contacts', scope: 'app',
columns: [ { name: 'email', type: 'text', required: true },
{ name: 'deals', type: 'integer' },
{ name: 'met_on', type: 'date' } ] },
{ key: 'notes', scope: 'viewer',
columns: [ { name: 'text', type: 'text', required: true } ] }
]
- scope app: one table of the app, kept by the app owner like any of their datasets (My Data, the API, CSV, Power BI). Reading needs the reader role on the app, writing the editor role or higher.
- scope viewer: one table per person looking at the app, a signed-out visitor included. Only that person reads or changes it. The app owner has no view of these rows.
- Types: text, number, integer, boolean, date (YYYY-MM-DD) and timestamp (ISO with a time zone). A value is never converted: "3" is not a number.
- Every row is checked against the declared columns. An unknown column, a wrong type or a missing required value refuses the whole call with reason bad_rows, and nothing is written.
- A declared column keeps its type and a key keeps its scope. Add a new column or key instead.
- Limits: 500 rows per call, 16 KB per row, 50,000 rows per scope-app dataset, 1,000 rows per viewer, 20 datasets per app.
const ids = await autario.rows.insert('contacts', [{ email: 'ana@acme.com', deals: 2 }]);
const rows = await autario.rows.select('contacts', { where: { email: 'ana@acme.com' } });
await autario.rows.update('contacts', rows[0].dd_id, { deals: 3 });
await autario.rows.delete('contacts', ids);
await autario.rows.insert('notes', { text: 'only I can see this' });
Every row carries its id as dd_id. Every write leaves one line in the access log of the account whose data it is: the app owner for scope app, the signed-in viewer for scope viewer. Deleting the app deletes its datasets and every row in them.
Let viewers check the data
Every app on autario has a Data behind this control next to its title (on the installed phone app it floats in the corner). It lists every autario.query the app made while the viewer had it open: the dataset, the columns and filters, the date window, how many rows came back, the date the data was last refreshed, what each column means, and a warning when one query pulls columns that do not add up, such as spend and clicks. The viewer can open the first 200 rows of any of them. The app needs no code for this, and the list lives only in the open page.
To put a small data button on the chart a query feeds, tag the chart with the query's id. Every result of autario.query carries it as rows.queryId. A click on the button opens the list at exactly that query.
const rows = await autario.query(id, { columns: ['date', 'spend', 'clicks'], limit: 90 });
drawChart(chartEl, rows);
chartEl.setAttribute('data-autario-query', rows.queryId);
// or: autario.explain(chartEl, rows.queryId);
// open the list at a query from your own button: autario.explain(rows.queryId);
The button is drawn by autario.js inside the app, on a layer of its own, so it never moves or restyles the tagged element. Opening the list makes no new request the app could not already make: the rows view asks the same question again under the same role rules.
Errors
Every call rejects with a plain object { error, reason, status }. error is a sentence for a person, reason is the word to branch on.
- not_found: the dataset does not exist or this viewer may not read it.
- dataset_not_declared: the owner asked for his own dataset the app does not declare.
- role_required: saving needs editor or higher, reading saved state needs reader (the viewer scope needs neither).
- value_too_large, too_many_keys: a viewer value over 64 KB, or a 51st key for one person.
- file_too_large, unsupported_type, too_many_files: an image over 2 MB, a file that is not a PNG, JPEG, WebP or GIF, or a 101st image for one person.
- rate_limited: more than 300 calls a minute for this viewer. Wait and retry.
- bad_query: a column name in columns, where or orderBy does not exist.
- not_in_autario and timeout: the page is not running inside autario, or autario did not answer.
On a phone
Every app has a second address, /a/<app id>: the app alone on the whole screen, with no autario around it and its own name and web app manifest. Open it on a phone and use Add to Home Screen, and the app sits there like an installed one. It opens for the same people as the app itself: you and your team always, everyone else once you publish it as unlisted or public. The Build page shows the link after Publish, and get_app_preview_url returns it as app_url.
Why the app never holds a key
An app runs in a sandboxed frame with a null origin and no network: it cannot fetch, open a socket or read autario cookies. autario.js posts a question to the autario page around it, that page asks autario with the viewer's own signed-in session, and only the answer travels back into the app. Sign out and every call answers as a visitor. That is why you can open a stranger's app on your own data without auditing its code.
More than one file
An app is one HTML file, or a ZIP with index.html plus your CSS, JS and images (2 MB). Drop the ZIP on the Build page, or send the files to upload_app_bundle or create_app. Every file is served from autario next to index.html, so relative paths work: <script src="app.js">, <link href="css/style.css">, <img src="img/logo.png"> and links between your HTML pages. Every HTML page gets autario.js.
- index.html at the top level is the page autario opens first. A ZIP of a folder works too.
- At most 50 files, 1 MB per file, 2 MB in total.
- Allowed: html, js, mjs, css, json, svg, png, jpg, webp, woff2. Paths are relative, with letters, digits, ".", "-" and "_", and never leave the bundle (no "..").
- Still no network: nothing loads from another host, and fetch stays blocked. Put a library you use into the bundle as a file.
{
"app_id": "my-app",
"files": [
{ "path": "index.html", "content": "<!doctype html><link rel=\"stylesheet\" href=\"style.css\"><div id=\"out\"></div><script src=\"app.js\"></script>" },
{ "path": "app.js", "content": "autario.datasets().then(d => autario.query(d[0].id, { limit: 10 })).then(r => out.textContent = r.length + ' rows')" },
{ "path": "style.css", "content": "body { font: 14px system-ui }" },
{ "path": "img/logo.png", "content": "<base64>", "encoding": "base64" }
]
}
A complete example
Forty lines: pick one of the app's datasets and show its top ten rows. Paste it on the Build page, or ask Claude to start from it.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Top rows</title>
<script src="https://autario.com/autario.js"></script>
<style>
body { font: 14px system-ui, sans-serif; margin: 20px; color: #111827; }
select { font: inherit; padding: 6px 8px; }
table { border-collapse: collapse; margin-top: 14px; width: 100%; }
th, td { border-bottom: 1px solid #e5e7eb; padding: 6px 8px; text-align: left; }
th { background: #f9fafb; }
.muted { color: #6b7280; }
</style>
</head>
<body>
<h1 id="title">Top rows</h1>
<p class="muted" id="who">Loading...</p>
<select id="pick" aria-label="Dataset"></select>
<table id="rows"></table>
<script>
const esc = (v) => String(v == null ? '' : v).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
async function show(id) {
const rows = await autario.query(id, { limit: 10 });
const cols = rows.length ? Object.keys(rows[0]).filter((c) => !c.startsWith('autario_') && c !== 'dd_id') : [];
document.getElementById('rows').innerHTML =
'<tr>' + cols.map((c) => '<th>' + esc(c) + '</th>').join('') + '</tr>' +
rows.map((r) => '<tr>' + cols.map((c) => '<td>' + esc(r[c]) + '</td>').join('') + '</tr>').join('');
}
(async () => {
const { app, user } = await autario.ready();
document.getElementById('title').textContent = app.name;
document.getElementById('who').textContent = 'Signed in as ' + (user.name || user.email || 'a ' + user.role) + ' (' + user.role + ')';
const list = await autario.datasets();
if (!list.length) { document.getElementById('who').textContent += '. This app declares no dataset you can read.'; return; }
const pick = document.getElementById('pick');
pick.innerHTML = list.map((d) => '<option value="' + esc(d.id) + '">' + esc(d.title) + '</option>').join('');
pick.onchange = () => show(pick.value).catch((e) => { document.getElementById('who').textContent = e.error; });
await show(list[0].id);
})().catch((e) => { document.getElementById('who').textContent = e.error || String(e); });
</script>
</body>
</html>