Rink API reference.

This is the implementation-level reference for code running inside a Rink site. It explains what the runtime is, how calls are authenticated and scoped, and the complete public contract of every rink.* namespace.

The deployed environment is the final source of truth. Read it at runtime with rink.platform.info(); this page documents API version 1.7.0.

Runtime model → Documents → SQLite → Public shares → BigQuery → Data snapshots → AI → HTTP & secrets → Automation → MCP → Limits → OpenAPI spec ↗

What the Rink API is

/rink.js is a small browser SDK served by every internal Rink site. It creates one global object, window.rink. The SDK talks to same-origin /_rink/* routes, so there are no API keys, base URLs or CORS configuration in site code.

<script src="/rink.js"></script>
<script>
  const me = await rink.identity.me();
  const info = await rink.platform.info();
  console.log(me.email, info.apiVersion);
</script>

Promise and error contract

Networked SDK methods return promises. On a non-2xx response the promise rejects with an Error; error.message contains the server message and error.status contains the HTTP status. Handle at least 400, 401, 402, 403, 404, 413, 429 and 5xx.

try {
  await rink.db.collection('problems').update(id, values);
} catch (error) {
  if (error.status === 403) show('You cannot edit this problem');
  else throw error;
}

rink.files.url(), rink.db.collection() and rink.live.channel() are synchronous builders. Subscriptions return an unsubscribe function or a chainable channel object.

rink.platform

Feature detection for the Rink environment that is actually serving the site.

await rink.platform.info()
What it does — Returns the live release, API version, SDK path and capability manifest.
Who can call it — Any authenticated viewer who can open the site.
Returns — A PlatformInfo object with name, release, apiVersion, sdk, clients and capabilities. Current capabilities describe deploy, AI input, identity, directory, data policies and GitHub issue lookup.
Behavior and limits — The response is not cached. Use capability fields instead of assuming that a stale example or local SDK version matches production.
const info = await rink.platform.info();
if (info.capabilities.data.documentOwnerRules.operations.includes('update')) {
  enableContributorEditing();
}
console.log(info.release, info.capabilities.aiChat.maxOutputTokens);

rink.identity

The current SSO identity, enriched with safe professional fields from the SeenThis directory.

await rink.identity.me()
What it does — Reads the current viewer. No app-specific login flow is needed.
Who can call it — Any authenticated viewer who can open the site.
Returns — { email, user, name, slack, jobTitle, department, team, site, manager }. Email and user identify the SSO viewer; all professional fields may be null. Manager is { email, name } or null.
Behavior and limits — Never derive authorization from name, department or team. Compare normalized email addresses, and enforce the rule again on the server through data policies.
const me = await rink.identity.me();
const isUiAdmin = ['admin@seenthis.se'].includes(me.email.toLowerCase());

rink.directory

Safe colleague profiles for people pickers, attribution and team views.

await rink.directory.list()
What it does — Lists active colleagues available in Rink's cached directory.
Who can call it — Any authenticated viewer who can open the site.
Returns — An array of Person objects. Person has email plus nullable name, slack, jobTitle, department, team, site and manager.
Behavior and limits — The directory is a professional directory, not an authorization database. The verified team field may be null and must not be synthesized from department.
const people = await rink.directory.list();
const studio = people.filter((person) => person.team === 'Studio');
await rink.directory.lookup(email)
What it does — Looks up one colleague by exact email address.
Who can call it — Any authenticated viewer who can open the site.
Parameters — email — a SeenThis colleague email string.
Returns — A Person object, or null when the directory has no matching active profile.
const owner = await rink.directory.lookup('billy@seenthis.se');
ownerName.textContent = owner?.name ?? owner?.email ?? 'Unknown';

rink.db

A schemaless JSON document store scoped to the current site. Create a collection handle once and reuse it. Rink owns document IDs and audit fields.

const problems = rink.db.collection('problems');

// Every returned document has this envelope:
// {
//   id: 'uuid',
//   data: { ...your JSON... },
//   created_by: 'person@seenthis.se',
//   created_at: 'ISO timestamp',
//   updated_at: 'ISO timestamp'
// }

Collection names are 1–64 characters and may contain letters, digits, underscore and hyphen. Document IDs are UUIDs. Updates replace the complete data value; they do not merge fields.

rink.db.collection(name)
What it does — Creates a client handle for one collection in the current site.
Who can call it — Synchronous; no server call is made until a handle method is used.
Parameters — name — 1–64 letters, digits, underscores or hyphens; the first character must be alphanumeric.
Returns — A Collection handle with list, get, create, update, remove, permissions and subscribe.
const problems = rink.db.collection('problems');
await collection.list({ limit?, sort? })
What it does — Lists documents ordered by creation time.
Who can call it — Any viewer who can open the site. Read access follows site visibility; mutation policies do not hide records.
Parameters — limit defaults to 100 and is capped at 200. sort is 'asc' or 'desc' and defaults to descending.
Returns — Doc[]. There is currently no filtering, cursor pagination or server-side query language.
const newest = await problems.list({ limit: 50, sort: 'desc' });
await collection.get(id)
What it does — Reads one document by UUID.
Who can call it — Any viewer who can open the site.
Parameters — id — a document UUID returned by create or list.
Returns — A Doc. Rejects with status 404 when it does not exist.
const problem = await problems.get(problemId);
console.log(problem.data.title);
await collection.create(data)
What it does — Stores one JSON-compatible value and records the caller as its immutable creator.
Who can call it — The collection's server-enforced create policy.
Parameters — data — any JSON-compatible value. Undefined is stored as an empty object.
Returns — The created Doc, including server-generated id, created_by and timestamps.
Behavior and limits — Maximum 256 KB after JSON serialization; maximum 50,000 documents per site. Rejects with 403 for policy denial and 413 for quota violations.
const problem = await problems.create({
  title: 'Campaign links lose attribution',
  status: 'proposed',
  persona: 'Activator'
});
await collection.update(id, data)
What it does — Replaces a document's complete data value. Audit envelope fields remain server-owned.
Who can call it — The collection's update policy, including the document owner rule when enabled.
Parameters — id — document UUID; data — the complete replacement JSON value.
Returns — The updated Doc. Rejects with 404 if missing, 403 if forbidden and 413 if over 256 KB.
const current = await problems.get(id);
await problems.update(id, { ...current.data, status: 'validated' });
await collection.remove(id)
What it does — Permanently deletes one document.
Who can call it — The collection's delete policy, including the document owner rule when enabled.
Parameters — id — document UUID.
Returns — true. Rejects with 404 if missing or 403 if forbidden.
if ((await problems.permissions(id)).delete) {
  await problems.remove(id);
}
await collection.permissions(id?)
What it does — Asks the server which mutations the current viewer may perform.
Who can call it — Any viewer who can open the site.
Parameters — Optional id. Supply it to include that document's created_by owner rule; omit it for collection-wide permissions.
Returns — { create: boolean, update: boolean, delete: boolean }.
Behavior and limits — Use this to render controls, but still handle 403: policies and documents can change after the check.
const collectionPermissions = await problems.permissions();
const documentPermissions = await problems.permissions(id);
editButton.hidden = !documentPermissions.update;
collection.subscribe({ onCreate?, onUpdate?, onDelete? })
What it does — Subscribes to document changes in this collection over Rink realtime.
Who can call it — Any viewer who can open the site.
Parameters — Callbacks receive the created/updated Doc or the deleted document ID.
Returns — A synchronous unsubscribe function.
Behavior and limits — The SDK reconnects and resubscribes automatically. Re-list after reconnect when exact convergence matters.
const stop = problems.subscribe({
  onCreate: (doc) => addRow(doc),
  onUpdate: (doc) => replaceRow(doc),
  onDelete: (id) => removeRow(id)
});

// Later:
stop();

Mutation policies and document owners

Policies are independent for create, update and delete. Each operation has one base access mode: viewers, editors, emails or none. Update and delete may additionally set owner: true. Owner access is additive: editors or allowlisted admins can manage every record while the person in immutable created_by can manage their own.

{
  "create": { "access": "viewers", "emails": [], "owner": false },
  "update": {
    "access": "emails",
    "emails": ["admin@seenthis.se"],
    "owner": true
  },
  "delete": {
    "access": "emails",
    "emails": ["admin@seenthis.se"],
    "owner": true
  }
}

Site owners and editors set policies through PUT /api/sites/{site}/db-policies/{collection} or MCP rink_db_set_policy. The default is viewer access for all three mutations. A malformed stored policy fails closed. Owner rules cannot be set on create, and each email list accepts at most 100 addresses.

Policy checks live at the shared data-write boundary. Direct SDK and HTTP writes, MCP creates, form submissions, workflow db.create steps and scheduled AI output all use the same policy. Only Rink-owned workflow_defs and workflow_runs bookkeeping has a narrow system bypass.

rink.shares

Reusable public file and URL shares with server-owned identity, publication review, per-share passwords, revocation and delivery counters. Keep internal drafts in rink.sql and store the returned share ID in your record.

// Site editors configure the app-admin allowlist once:
await rink.shares.configure({ enabled: true, admins: ['admin@seenthis.se'] });
const share = await rink.shares.create({
  title: 'Presentation', slug: 'presentation', destination: 'https://example.com/deck'
});
await rink.shares.submit(share.id);
const current = await rink.shares.get(share.id);
// Only state === 'published' means publicly available.
const counts = await rink.shares.stats(share.id);
await rink.shares.update(share.id, { password: 'a long password' });
await rink.shares.update(share.id, { password: null });
await rink.shares.revoke(share.id);

Use fileId instead of destination for an existing rink.files upload. Optional fields are expiresAt and password. Title/slug/expiry edits return a share to draft for review. list(), get(id) and policy() include effective permissions. Only creators, site editors and configured app admins manage shares and read statistics; app admins do not gain site-editor or review privileges.

Management HTTP routes: GET/POST /_rink/shares, GET/PATCH /_rink/shares/:id, POST /:id/submit and /:id/revoke, GET /:id/stats, GET/PUT /policy. All require internal site authentication. Errors use the SDK's status/message contract; inaccessible shares return 404, conflicts 409, quota 413 and throttling 429.

Public routes are only /slug, /slug/open and POST /slug/unlock, on approved public hosts. Files are immutable safe attachments. No SDK, SQL, file listing or URL proxy is public. Initial sharing configuration requires a site without public static history. Existing ordinary public sites keep their static isolation.

Limits: 500 shares/site, 30 review submissions/site/hour, the current upload byte limit, 8–256-character passwords, 20 password attempts/share/15 minutes, 15-minute secure sessions, and 90-day aggregate counters. HEAD, denied and known prefetch requests do not count; Range requests return 416. Opens/retries count once per share session. A download request does not prove a completed transfer or read. Delivery uses no-store caching and current server-side state on every request.

rink.sql

A site-scoped SQLite database for apps that need schemas, indexes, joins, foreign keys, migrations, and transactional batches. Add rink.sql.json to the deployed project. Rink validates and applies append-only migrations before activation, removes the manifest from served static files, and exposes only its named statements to browser code.

Migrations are prevalidated before any SQL executes. They may use main-database CREATE TABLE/INDEX, bounded ALTER TABLE, DROP TABLE/INDEX, and INSERT/REPLACE/UPDATE/DELETE. Rink rejects pragmas, attachments, temporary or virtual objects, views, triggers, extension/file functions, transaction control, and qualified database targets.

// rink.sql.json
{
  "version": 1,
  "migrations": [{
    "id": "0001_tasks",
    "sql": "CREATE TABLE tasks (id TEXT PRIMARY KEY, title TEXT NOT NULL, created_by TEXT NOT NULL)"
  }],
  "statements": {
    "tasks.list": {
      "sql": "SELECT id, title FROM tasks ORDER BY title",
      "mode": "read",
      "access": "viewers"
    },
    "tasks.create": {
      "sql": "INSERT INTO tasks (id, title, created_by) VALUES (:id, :title, :_rink_actor_email) RETURNING *",
      "mode": "write",
      "access": "viewers"
    }
  }
}

Statement access is enforced server-side with viewers, editors, emails, or none. Reserved bindings :_rink_actor_email, :_rink_site_id, and :_rink_now are injected by Rink and cannot be supplied by the browser. Express row ownership in reviewed SQL using those bindings.

await rink.sql.statement(name).bind(params).all()
What it does — Executes one named statement and returns all rows with D1-style metadata.
Who can call it — The active deploy's per-statement access policy.
Parameters — name — exact manifest statement name. bind accepts one named object or positional values. Supported values are string, finite number, boolean, null, bigint, ArrayBuffer, and typed arrays.
Returns — { results, success, meta }. Meta includes duration, changes, last row ID, rows read/written, and generation.
Behavior and limits — Raw browser SQL is never accepted. Results are capped at 1,000 rows and 1 MB. Large integers return as strings and blobs as byte arrays.
const result = await rink.sql
  .statement('tasks.list')
  .bind({ status: 'open' })
  .all();
render(result.results);
await statement.first(column?) · await statement.run() · await statement.raw({ columnNames? })
What it does — Chooses first-row, mutation-metadata, or array-row output for a bound named statement.
Who can call it — The statement's server-enforced policy.
Returns — first returns a row, one column, or null. run returns a result envelope. raw returns row arrays and can prefix column names.
const task = await rink.sql.statement('tasks.byId').bind({ id }).first();
await rink.sql.statement('tasks.complete').bind({ id }).run();
await rink.sql.batch(statements)
What it does — Runs up to 25 prepared named statements sequentially in one SQLite transaction when any item writes.
Who can call it — Every item must independently pass its statement policy; one denial or SQL error aborts the complete batch.
Returns — An array of result envelopes in input order.
await rink.sql.batch([
  rink.sql.statement('tasks.complete').bind({ id }),
  rink.sql.statement('events.add').bind({ taskId: id })
]);
rink.sql.subscribe(handler)
What it does — Subscribes to committed database generation invalidations.
Who can call it — Any viewer who can open the site.
Returns — A synchronous unsubscribe function.
Behavior and limits — Events contain generation and statement names, not changed row contents. Refetch named queries to converge.
const stop = rink.sql.subscribe(event => {
  if (event.event === 'commit') refresh();
});

Database images are immutable S3 snapshots plus an ordered PostgreSQL binary-patch journal. Pods use disposable in-memory SQLite only. Initial limits are 25 MB per database, 2 MB changed bytes per transaction, 100 bindings per statement, and a two-second isolated execution deadline. See docs/rink-sql.md for the manifest and storage contract.

rink.github

Read-only snapshots of private GitHub issues without exposing the GitHub App credential to site code.

await rink.github.getIssue(url)
What it does — Fetches metadata for one allowed GitHub issue URL.
Who can call it — Any viewer who can open the site; organization and repository allowlists are enforced server-side.
Parameters — url — a canonical https://github.com/{owner}/{repo}/issues/{number} URL. Pull request URLs and arbitrary GitHub/API URLs are rejected.
Returns — { url, repo, number, title, state, assignees, updatedAt }. State is 'open' or 'closed'; assignees are { login, name|null }.
Behavior and limits — Read-only. Snapshots are cached for 60 seconds; concurrent misses collapse. Each site/viewer may cause 10 uncached lookups per minute. Display-name enrichment is capped at 10 assignees and cached separately.
const snapshot = await rink.github.getIssue(
  'https://github.com/seenthis-ab/component-library/issues/412'
);

await problems.update(problemId, {
  ...problem.data,
  githubIssues: [...problem.data.githubIssues, snapshot]
});

Rink does not poll or install a webhook for your app. Store the snapshot and refresh on an explicit user action. A closed GitHub issue never changes your app's status automatically.

rink.bq

Read-only shared data from the SeenThis Insights DWH (BigQuery, seenthis-insights). A dashboard shows the same numbers to everyone who can open the site, whether or not they have BigQuery access themselves. Queries run as Rink's read-only service account and are billed to the seenthis-insights billing account.

Access is per site and per dataset. An editor requests a catalog dataset in Settings → BigQuery data (or with the MCP tool rink_bq_request_access); the data owner or a Rink admin approves it after seeing who can open the site. Only serving datasets (dwh, dwh_{domain}) can be granted. Restricted datasets require the site to be “Only people you invite” and are suspended automatically if the site later opens to everyone at SeenThis.

await rink.bq.query(sql, { params?, types?, cacheTtl?, maxRows? })
What it does — Runs one read-only SELECT on the site's granted datasets.
Who can call it — Every viewer by default; editors only when the site's BigQuery policy is set to editors.
Parameters — sql — BigQuery Standard SQL with fully qualified tables. params — named @name values (never interpolate). types — e.g. { from: 'DATE' }, needed for nulls and empty arrays. cacheTtl — seconds to reuse the shared result (default 300, max 86,400, 0 disables). maxRows — rows per page (max 10,000).
Returns — { schema, rows, totalRows, bytesBilled, bytesProcessed, costUsd, cacheHit, cache, jobId, pageToken }. INT64 beyond 253, NUMERIC and BIGNUMERIC are strings; TIMESTAMP is ISO 8601.
Behavior and limits — Every query is dry-run first: it must be a single SELECT and every referenced table must be in an active grant (DML, DDL, scripts, EXPORT DATA and external connections are rejected). The estimate must fit the per-query maximum (default 100 GB, status 402). Each job then reserves max(1 GB, 1.25 × estimate), up to that maximum, against the site's and viewer's daily budgets (status 429, reset at midnight UTC) and runs with maximumBytesBilled equal to its reservation, so the budgets are a hard bound; a query that needs more than it reserved stops with status 402. Jobs have a 60-second timeout and billing labels for the site, a hashed viewer and the source. Pages are capped at 10,000 rows / 10 MB.
const { rows, schema } = await rink.bq.query(
  'select publisher, sum(impressions) as imps ' +
  'from `seenthis-insights.dwh.daily` ' +
  'where day >= @from group by 1 order by 2 desc limit 50',
  { params: { from: '2026-09-01' }, cacheTtl: 600 }
);
await rink.bq.nextPage(result)
What it does — Fetches the next page of a previous result.
Who can call it — Same as rink.bq.query.
Returns — The same shape as query(), or null when result.pageToken is null.
Behavior and limits — Page tokens only work on the site whose query created them, and only while its grants are still active. Further pages are not billed again.
await rink.bq.estimate(sql, { params?, types? })
What it does — Dry-runs a query without charge.
Who can call it — Same as rink.bq.query.
Returns — { statementType, bytesProcessed, costUsd, datasets, location, caps, siteBytesToday, viewerBytesToday, allowed, reason }.
await rink.bq.datasets()
What it does — Lists the datasets actively granted to this site.
Who can call it — Any viewer who can open the site.
Returns — [{ dataset, title, description, notes, owner, sensitivity, location }]. notes are curated by admins: business meaning, units and the partition column.

Shared data vs your own access

By default rink.bq reads shared data: the site's approved datasets, as Rink's service account, and everyone who can open the site sees the same result. For data only you may read (for example dwh_finance), connect your own Google account once in My account → Connected accounts → BigQuery and pass { as: "me" }. The query then runs with exactly your own BigQuery permissions: no dataset grant is needed, and Google decides what you can read. Disconnecting revokes the connection at Google.

await rink.bq.query(sql, { as: "me", params?, types?, maxRows?, location? })
What it does — Runs one read-only SELECT with your own connected Google account.
Who can call it — The site's owner and editors, signed in on the internal site host. Never guests, the public host or admin view-as.
Parameters — as — "me". location — the dataset location when no granted dataset names it (default EU). Also accepted by estimate and nextPage (a result remembers its as).
Returns — The same shape as the shared query.
Behavior and limits — The same dry run (one SELECT; no DML, scripts or external connections), maximumBytesBilled reservation, per-query, site and personal daily caps and usage ledger apply; jobs carry the label rink_identity: user and run in the seenthis-insights job project. Results are never cached by Rink. Errors: 409 with err.connectUrl when you have not connected, 401 when the connection was revoked or expired (reconnect), 403 with err.denied naming the table Google refused.
try {
  const { rows } = await rink.bq.query(
    'select month, sum(revenue_usd) as revenue from `seenthis-insights.dwh_finance.revenue_monthly` group by 1',
    { as: 'me' }
  );
} catch (err) {
  if (err.connectUrl) showConnectLink(err.connectUrl);
}

To show such data to colleagues or guests, use a data snapshot with "runAs": "owner" (below): the owner approves the exact query and audience once, and scheduled refreshes run with the owner's access. There is no mode where site code queries with another viewer's access.

Schema discovery

Build queries from the real schema instead of guessing. Discovery only ever shows datasets granted to this site and is cached for an hour.

await rink.bq.tables(dataset)
What it does — Lists tables and views in a granted dataset.
Who can call it — Same as rink.bq.query.
Returns — [{ table, name, type, description, rowCount, sizeBytes, partitioning, clustering, notes }].
Behavior and limits — Always filter on the partitioning column(s); clustering columns make filters cheaper.
await rink.bq.schema('dataset.table')
What it does — Columns of one table or view with type, mode, description and nested fields.
Who can call it — Same as rink.bq.query.
Returns — { table, type, description, columns, partitioning, clustering, notes, datasetNotes }.
await rink.bq.sample('dataset.table', { limit? })
What it does — A few example rows (default 20, max 100) using TABLESAMPLE.
Who can call it — Same as rink.bq.query.
Returns — The query result shape plus { table, method }.
Behavior and limits — Tables only; views would scan everything behind them. Validated, capped and counted like any query.

Dashboards with AI

Claude or Codex with the Rink MCP connector, and the Locker Room AI, can list granted datasets, read schemas, estimate, query and deploy a dashboard in one conversation. Their queries use the same validation, caps and job labels as browser code. Create site → Dashboard from BigQuery creates a site from a template (KPI tiles, time series, breakdown table, date range, CSV export) wired to rink.bq.query, requests the chosen datasets and opens Locker Room with the BigQuery dashboards skill pinned. Once a dataset is approved, its tables, partition columns and notes are added to the Locker Room AI's context automatically.

Scheduled snapshots

Heavy dashboards should not query BigQuery on every view. Refresh a snapshot nightly and read the latest document instead:

await rink.schedule.add({
  name: 'Nightly publisher snapshot',
  schedule: '0 6 * * *',
  action: {
    type: 'bq.query',
    sql: 'select publisher, sum(impressions) as imps from `seenthis-insights.dwh.daily` ' +
         'where day >= date_sub(current_date(), interval 30 day) group by 1 order by 2 desc',
    then: { collection: 'publisher_snapshot' }
  }
});

const [latest] = await rink.db.collection('publisher_snapshot').list({ limit: 1 });
render(latest.data.rows, latest.data.at);

In the starter template every panel (each KPI, the time series and the breakdown) can set its own snapshotCollection in dashboard.config.js; add one nightly bq.query schedule per snapshot panel. Snapshot panels show the window their nightly query used rather than the date picker.

For heavy dashboards, refresh a snapshot on a schedule with the bq.query cron action or workflow step (see Automation) and read it with rink.db. Results are cached per site, so many viewers opening the same dashboard cost one query.

rink.snapshots

Guest data snapshots: fixed data outputs that a site declares once, in rink.snapshots.json next to index.html, and that Rink refreshes on a schedule without a new deploy or review. Viewers of the internal site and, when you allow it, external guests you invited read the same JSON at /_rink/snapshots/{name}.json, so one static site build serves both. This is how a client dashboard stays fresh without a laptop job or a daily approval.

{
  "version": 1,
  "snapshots": {
    "revenue": {
      "source": { "bq": "select month, agency, revenue_usd from `seenthis-insights.dwh_geekout_dentsu.revenue_monthly` where month >= date_sub(current_date(), interval 24 month)" },
      "schedule": "0 6 * * 1-5",
      "audience": ["guests", "viewers"]
    },
    "orders": {
      "source": { "collection": "orders_snapshot", "latest": true },
      "schedule": { "every": "daily", "hour": 7 },
      "audience": ["guests", "viewers"]
    }
  }
}
await rink.snapshots.get(name)
What it does — Reads the current version of a snapshot declared for viewers in the live version of the site.
Who can call it — Any viewer who can open the site, when the active deploy declares the snapshot with viewers.
Returns — { name, version, generatedAt, source, schema, rows, rowCount }; the response also carries X-Rink-Snapshot-Version and X-Rink-Snapshot-Generated-At.
Behavior and limits — Same as fetch('/_rink/snapshots/' + name + '.json'), which is what guests use on the public host (the SDK is not served there). Query parameters are rejected; everyone gets the same bytes. Responses are never cached (private, no-store). 404 until the first successful refresh.
const res = await fetch('/_rink/snapshots/revenue.json');
if (!res.ok) return showWaitingState();
const { rows, generatedAt } = await res.json();
render(rows, generatedAt);

Guests and approval

Guests read definitions from the approved public version only: the manifest is part of the artifact Gunvald (and, for client-facing sites, a human approver) reviews, and later data refreshes need no new approval. A newer, unapproved deploy can neither add a guest snapshot nor change the data guests receive: each version is tied to the exact source that produced it. Guests need a valid, unexpired, unrevoked invite cookie for the site; revocation applies to the next request. Every guest read is logged with the invite and email (one entry per guest and snapshot per 10 minutes, no IP addresses). Per-guest row filtering is not supported: every guest sees the same snapshot, so use one site per client.

Running as the owner

A BigQuery snapshot may declare "runAs": "owner". It then refreshes with the site owner's own connected Google account (My account → Connected accounts) instead of the site's dataset grants, so the owner can share data only they may read. The owner approves each definition in Settings → Data snapshots: “This query runs as owner and its result is visible to audience”. The approval is tied to the exact SQL and audience; a changed query or audience, or a new owner, needs a new approval, and until then the snapshot does not refresh. If a refresh fails (connection revoked or expired, access lost, owner left SeenThis) the last good version keeps serving, the snapshot is marked stale and the owner gets a Slack DM and an email with a reconnect link. Every owner-run refresh is audited with who it ran as, the SQL fingerprint, bytes and job id.

{
  "version": 1,
  "snapshots": {
    "revenue": {
      "source": { "bq": "select month, agency, revenue_usd from `seenthis-insights.dwh_finance.revenue_monthly`" },
      "runAs": "owner",
      "schedule": "0 6 * * 1-5",
      "audience": ["guests", "viewers"]
    }
  }
}

Refresh, limits and failures

Each refresh stores a new immutable version (at most 10,000 rows and 5 MB of JSON); the last 10 are kept. A failed refresh keeps serving the previous version and sends the owner a Slack DM on the first failure. A BigQuery snapshot stops being served as soon as a dataset it read is no longer granted to the site. Editors see every snapshot, its schedule, current version, last error, a preview and Refresh now in Settings → Data snapshots, or use the MCP tools rink_snapshot_list and rink_snapshot_refresh.

rink.files

Per-site binary storage. File bytes live in S3 and metadata is scoped to the current site.

await rink.files.upload(file, { name?, type? })
What it does — Uploads a File or Blob as raw bytes.
Who can call it — Any viewer who can open the site.
Parameters — file — File or Blob. name defaults to file.name or 'file'. MIME type defaults to file.type, opts.type or application/octet-stream.
Returns — { id, name, content_type, size, created_by, created_at, url }.
Behavior and limits — Maximum 100 MB per file and 500 files per site. Empty files are rejected. Names and content types are sanitized and capped at 200 characters.
const file = await rink.files.upload(fileInput.files[0]);
preview.src = file.url;
await rink.files.list()
What it does — Lists stored file metadata, newest first.
Who can call it — Any viewer who can open the site.
Returns — Up to 1,000 FileMeta objects.
await rink.files.get(id)
What it does — Reads metadata for one file without downloading its bytes.
Who can call it — Any viewer who can open the site.
Parameters — id — file UUID.
Returns — FileMeta, or a rejected promise with status 404.
rink.files.url(id)
What it does — Builds the same-origin URL that serves a file's bytes.
Who can call it — Synchronous. Opening the URL still requires access to the site.
Parameters — id — file UUID.
Returns — A string such as /_rink/files/{id}, suitable for img.src, video.src or an anchor href.
Behavior and limits — Responses are private and cached for one hour. The public static host cannot serve this URL.
await rink.files.remove(id)
What it does — Deletes file metadata and its stored bytes.
Who can call it — Any viewer who can open the site. There is currently no per-file owner policy.
Parameters — id — file UUID.
Returns — true, or a rejected promise with status 404.

rink.ai

Server-held model credentials with per-person credit and usage attribution. AI calls share a 30-request-per-minute viewer limit.

await rink.ai.chat(messagesOrText, options?)
What it does — Generates text with Claude by default or an allowed Gemini model.
Who can call it — Any viewer who can open the site and has positive personal AI credit.
Parameters — messagesOrText is a prompt string or 1–100 { role, content } messages. Options: system, model, max_tokens, images.
Returns — The generated text string. The SDK intentionally unwraps provider metadata.
Behavior and limits — Default model is Claude Sonnet and default output is 1,024 tokens; max is 16,384. Aliases: sonnet, haiku, opus, gemini, gemini-flash, gemini-pro. Images work with Claude only: JPEG/PNG/GIF/WebP, max 4, max 7 MB each and 10 MB total.
const summary = await rink.ai.chat(notes, {
  system: 'Summarise for a product manager.',
  model: 'sonnet',
  max_tokens: 2000
});

const diagram = await rink.ai.chat(
  'Return only a Mermaid flowchart for this screenshot.',
  { images: [fileInput.files[0]], max_tokens: 12000 }
);
await rink.ai.image(prompt, { model? })
What it does — Generates an image and stores it automatically in rink.files.
Who can call it — Any viewer who can open the site and has positive personal AI credit.
Parameters — prompt — non-empty string. Optional allowed Nano Banana/Gemini image model.
Returns — The stored FileMeta; assign file.url directly to an image.
Behavior and limits — Uses Gemini image generation. The selected model is allowlisted server-side; an unknown image alias falls back to the default image model.
const image = await rink.ai.image('A flat-vector hockey puck logo');
logo.src = image.url;
await rink.ai.video(prompt, { model?, onProgress? })
What it does — Starts a Veo generation job, polls it, stores the completed video and resolves with the file.
Who can call it — Any viewer who can open the site and has positive personal AI credit.
Parameters — prompt — non-empty string. onProgress(status) receives pending poll responses. model selects an allowed Veo model.
Returns — A promise resolving to stored FileMeta when the job completes.
Behavior and limits — Polls every 5 seconds, for at most 120 polls (about 10 minutes). The SDK retries transient polling failures and rejects on terminal job failure or timeout.
const video = await rink.ai.video('Slow pan over a frozen lake', {
  onProgress: ({ status }) => showStatus(status)
});
player.src = video.url;

No credit returns status 402; rate limiting returns 429; an unavailable provider returns 503. Do not make paid calls on every render or keystroke.

rink.location

Precise browser geolocation, approximate request location and SeenThis office helpers.

await rink.location.current(options?)
What it does — Asks the browser for the viewer's current coordinates. currentPosition is an alias.
Who can call it — Browser permission is required. The coordinates are not sent to or stored by Rink unless your app passes them to another method or stores them.
Parameters — enableHighAccuracy defaults false; timeout defaults 10,000 ms; maximumAge defaults 60,000 ms.
Returns — A browser location object with latitude, longitude, accuracy, altitude, altitudeAccuracy, heading, speed and timestamp.
Behavior and limits — Rejects locally when geolocation is unavailable or denied. The rejection includes the browser geolocation error code when available.
const here = await rink.location.current({ enableHighAccuracy: true });
await rink.location.approximate()
What it does — Returns coarse location inferred from edge headers or the viewer IP without prompting.
Who can call it — Any viewer who can open the site.
Returns — { accuracy:'approximate', source, latitude?, longitude?, city, region, country, countryCode, timezone } or null.
Behavior and limits — Coordinates may be absent and must never be treated as precise position.
await rink.location.offices()
What it does — Lists SeenThis offices and their location metadata.
Who can call it — Any viewer who can open the site.
Returns — SeenThisOffice[] with id, name, city, country, countryCode, timezone, address, contact, coordinates and approximate.
await rink.location.nearestOffice(locationOrOptions?)
What it does — Finds the closest SeenThis office to coordinates.
Who can call it — Any viewer who can open the site; browser permission is required when coordinates are omitted.
Parameters — Pass { latitude, longitude } or browser geolocation options. Passing an approximate location without coordinates rejects.
Returns — { office, distanceKm, location, source }.
const here = await rink.location.current();
const nearest = await rink.location.nearestOffice(here);

rink.weather

Current weather and forecasts proxied through Rink, with no provider key in browser code.

await rink.weather.current(input)
What it does — Reads current conditions for coordinates or a SeenThis office.
Who can call it — Any viewer who can open the site.
Parameters — { office } or { latitude, longitude }. Office accepts an office id, name, city or country after normalization.
Returns — { source:'open-meteo', location, current, units }.
const stockholm = await rink.weather.current({ office: 'stockholm' });
const local = await rink.weather.current({ latitude: 59.33, longitude: 18.07 });
await rink.weather.forecast({ office?|latitude?, longitude?, days? })
What it does — Reads a daily forecast.
Who can call it — Any viewer who can open the site.
Parameters — Same location input as current. days defaults to 5 and is clamped to 1–14.
Returns — { source:'open-meteo', location, daily, units }.
Behavior and limits — The upstream weather request times out after 8 seconds. Invalid locations reject with status 400.
const week = await rink.weather.forecast({ office: 'london', days: 7 });

rink.forms

Named form submissions stored as normal rink.db documents in form_{name}. Form names are 1–59 letters, digits, underscores or hyphens.

await rink.forms.submit(name, values)
What it does — Creates one submission and triggers enabled form.submit workflows.
Who can call it — Any viewer allowed by the create policy on form_{name}.
Parameters — name — form name; values — any JSON-compatible value.
Returns — The submission Doc. Its data is { form, values, submittedAt }.
Behavior and limits — Workflow failures do not roll back a valid submission. The browser SDK returns only the submission; workflow runs and workflowError exist on the underlying HTTP response.
const submission = await rink.forms.submit('problem', {
  title: titleInput.value,
  details: detailsInput.value
});
await rink.forms.list(name, { limit?, sort? })
What it does — Lists submissions for one form.
Who can call it — Site owner and editors only.
Parameters — Same limit and sort behavior as rink.db list: default 100, max 200, newest first.
Returns — Doc[].
await rink.forms.get(name, id)
What it does — Reads one submission.
Who can call it — Site owner and editors only.
Returns — A submission Doc, or status 404.
await rink.forms.remove(name, id)
What it does — Deletes one submission.
Who can call it — Site owner and editors, and the form collection's delete policy.
Returns — true, or status 404/403.

rink.email

Transactional internal email from a Rink site through the shared mail service.

await rink.email.send({ to, subject, text?, html? })
What it does — Sends one email and attributes usage to the current site and viewer.
Who can call it — Any viewer who can open the site.
Parameters — to — one @seenthis address; subject — required and capped to 200 characters; at least text or html is required.
Returns — true after the mail service accepts the message.
Behavior and limits — 20 sends per minute per viewer. Reply-To is always the authenticated viewer; site code cannot impersonate another reply address or send to external recipients.
await rink.email.send({
  to: 'team@seenthis.se',
  subject: 'New proposed problem',
  text: 'Open Persona Portal to review it.'
});

HTTP and secrets

Rink Secrets keeps third-party API keys (HubSpot, Stripe, OpenAI, internal SaaS) on the server so they never appear in site files or browser code. Anyone at SeenThis can manage them in Rink; nobody needs AWS, KMS or SOPS access. Rink never shows a secret again after you save it.

There is no SDK method that reads a secret. Site code references it by name with rink.http.fetch, and the server substitutes the value into an outbound request to an allowed host.

await rink.http.fetch(url, { method?, headers?, body?, bodyEncoding?, followRedirects? })
What it does — Calls a third-party HTTPS API through Rink, filling in {{secret:NAME}} (site secret) and {{secret:me.NAME}} (your personal secret) on the server.
Who can call it — Any viewer who can open the site, for site secrets whose use policy is viewers; owner and editors for editors secrets. Personal secrets only for their owner's own requests.
Parameters — url — absolute https:// URL; placeholders only in the query. method — GET, HEAD, POST, PUT, PATCH or DELETE (default GET). headers — object of strings; values may contain placeholders. body — string (objects are sent as JSON); text bodies may contain placeholders. bodyEncoding — text or base64. followRedirects — follow up to 3 redirects, re-checking each hop (default: return the 3xx).
Returns — { status, headers, body, bodyEncoding: 'text' | 'base64' }. Upstream errors arrive as a normal status. Occurrences of the secret in common encodings are replaced by [redacted]. When a secret is status-only (or the upstream sent a compressed archive), body is empty and bodyWithheld says why.
Behavior and limits — At least one placeholder is required. Errors: 400 invalid request or unknown placeholder, 403 policy, host, port or private destination, 413 request over 1 MB or response over 5 MB, 429 over 60/min per person or 600/min per site, 502 upstream failure or timeout (15 s, wall clock), 503 secrets not configured.
const res = await rink.http.fetch('https://api.hubapi.com/crm/v3/objects/deals?limit=10', {
  headers: { Authorization: 'Bearer {{secret:HUBSPOT_TOKEN}}' },
});
if (res.status !== 200) throw new Error('HubSpot said ' + res.status);
const deals = JSON.parse(res.body).results;

Why it works this way

Manage secrets

# Control plane API (SSO or CLI bearer token). Values are write-only.
GET    /api/sites/{site}/secrets
PUT    /api/sites/{site}/secrets/HUBSPOT_TOKEN   { "value": "…", "allowedHosts": ["api.hubapi.com"], "usePolicy": "viewers", "responseAccess": "status-only" }
PUT    /api/sites/{site}/secrets/HUBSPOT_TOKEN   { "allowedHosts": ["api.hubapi.com", "*.hubspot.com"] }   # metadata only
DELETE /api/sites/{site}/secrets/HUBSPOT_TOKEN
GET|PUT|DELETE /api/me/secrets/{NAME}            # personal secrets, same shape

MCP offers rink_secret_list, rink_secret_set and rink_secret_delete. A value passed through MCP travels through the AI conversation, so add production keys in the Rink UI or the CLI and use MCP only for names, hosts and policies. Values are encrypted with AES-256-GCM under server-held keys bound to the site, owner and name; every change is in the audit log and site secret changes are announced in the Rink security channel — never with the value.

rink.schedule and rink.workflow

Server-side automation for work that must run after the browser closes. Management methods require the site owner or an editor.

Scheduled jobs

await rink.schedule.add({ schedule, action, name? })
What it does — Creates a cron job. rink.cron is an exact alias of rink.schedule.
Who can call it — Site owner and editors only.
Parameters — schedule — five-field cron expression evaluated in Europe/Stockholm. action — webhook, ai, email, workflow or bq.query. name is optional.
Returns — { id }.
Behavior and limits — Maximum 10 jobs per site. Webhooks block private/link-local destinations and time out after 10 seconds. AI output is written through the target collection's create policy. Email recipients must be @seenthis.
await rink.schedule.add({
  name: 'Weekday digest',
  schedule: '0 9 * * 1-5',
  action: {
    type: 'ai',
    prompt: 'Summarise yesterday’s notes',
    collection: 'daily_digest'
  }
});
await rink.schedule.list()
What it does — Lists scheduled jobs and their latest execution state.
Who can call it — Site owner and editors only.
Returns — Job[] with id, name, schedule, action, enabled, created_by, next_run, last_run, last_status and last_detail.
await rink.schedule.cancel(id)
What it does — Permanently deletes one scheduled job.
Who can call it — Site owner and editors only.
Returns — true, or status 404.

Workflow definitions

A workflow has one trigger and 1–10 ordered steps. Supported triggers are { type:'schedule', cron } and { type:'form.submit', form }. Supported steps are weather.current, db.create, email.send, webhook.call and bq.query.

A bq.query cron action stores one result snapshot in a collection or emails it as CSV: { type:'bq.query', sql, params?, maxRows?, then: { collection } | { email, subject? } }. The bq.query workflow step passes { schema, rows, totalRows, bytesBilled, jobId } to later steps as previous; its params may use placeholders but the SQL never does. Both run with the site's grants and daily budget and are labelled as cron or workflow jobs.

Secrets in schedules and workflows

Cron webhook actions and workflow webhook.call steps accept the same {{secret:NAME}} placeholders as rink.http.fetch (URL query, header values, body) and then follow the same rules: HTTPS to each secret's allowed hosts, private destinations refused, the same response redaction and response access in run history. Secrets are checked when the job or workflow is created and on every run as its creator, whoever triggers the run.

await rink.workflow.create({ name?, trigger, steps, enabled? })
What it does — Creates a workflow definition and its schedule job when applicable.
Who can call it — Site owner and editors only.
Returns — A workflow Doc in the Rink-owned workflow_defs collection.
Behavior and limits — A workflow has at most 10 steps. db.create obeys the target collection's create policy; workflow bookkeeping uses narrow system-owned collections.
await rink.workflow.create({
  name: 'Notify on problem submission',
  trigger: { type: 'form.submit', form: 'problem' },
  steps: [
    {
      type: 'email.send',
      to: 'team@seenthis.se',
      subject: 'New problem: {{field.title}}',
      text: '{{field.details}}'
    }
  ]
});
await rink.workflow.list() / get(id)
What it does — Lists up to 20 workflow Docs or reads one workflow by ID.
Who can call it — Site owner and editors only.
Returns — Doc[] from list; one Doc from get; get rejects with status 404 when missing.
await rink.workflow.run(id)
What it does — Runs a workflow immediately with a manual event, even when automatic triggering is disabled.
Who can call it — Site owner and editors only.
Returns — A workflow run Doc containing status, event, each step result/error, result, startedAt and finishedAt.
Behavior and limits — Steps execute sequentially. The first failed step stops execution; the returned run record has status 'error' instead of rejecting for an ordinary step failure.
await rink.workflow.runs(id)
What it does — Lists recent run records for one workflow.
Who can call it — Site owner and editors only.
Returns — Up to 100 matching run Docs, newest first.
await rink.workflow.enable(id) / disable(id)
What it does — Enables or disables automatic triggering and its backing schedule job.
Who can call it — Site owner and editors only.
Returns — The updated workflow Doc, or status 404.
await rink.workflow.remove(id)
What it does — Deletes the workflow and its backing schedule job.
Who can call it — Site owner and editors only.
Returns — true, or status 404.

Workflow templates

String values in workflow steps may interpolate {{event}}, {{previous}}, {{form}} and {{field.name}}. previous is the preceding step's result. Unknown placeholders become an empty string. Webhook response bodies are stored only up to 2,000 characters.

rink.live

Site-scoped realtime over one shared WebSocket per browser page.

rink.live.channel(name)
What it does — Creates a raw pub/sub channel handle with on, send and close.
Who can call it — Any viewer who can open the site. Channels never cross site boundaries.
Parameters — name — 1–65 letters, digits, colon, underscore or hyphen; first character alphanumeric. Names beginning db: cannot be published by site code.
Returns — A chainable Channel object. on(callback), send(data) and close() each return the same object.
Behavior and limits — The SDK reconnects after 1.5 seconds and resubscribes. Server limits: 50 messages/second/connection and 50 subscribed channels/connection. Realtime is ephemeral, not durable storage.
const presence = rink.live.channel('presence');
presence.on((message) => renderPresence(message));
presence.send({ state: 'online', at: Date.now() });

window.addEventListener('beforeunload', () => presence.close());

Site access and access requests

Every internal site is either open to everyone at SeenThis or limited to people you invite (the owner, editors and viewers). Owners and editors change this on the site's Access tab, with rink_set_access, or with salming rink share.

Colleagues who open a private site they are not invited to can request access from the page they see, with an optional short message. The owner gets a Slack message and an email (the editors too when the owner has left SeenThis) and approves or denies it on the Access tab, or with rink_access_requests_list and rink_access_request_decide. Approving adds the requester as a viewer and tells them in Slack and by email. One open request per person and site; a person can ask the same site again after 24 hours and send at most 10 requests a day. Requests are never offered on public links.

OpenAPI specification

The same HTTP contract behind rink.* is published as an OpenAPI 3.1 document. Use it to generate typed clients, give an AI assistant the exact request and response shapes, or browse it in any OpenAPI viewer. Prefer the SDK in site code: it adds realtime, retries and the error contract.

https://rink.staging-aws.seenthis.io/openapi.json

Endpoints live on each internal site host (https://{site}.rink.staging-aws.seenthis.io/_rink/…) and are authenticated by the viewer's SeenThis login, so calls only work from browser code on that site.

MCP connector

The MCP endpoint lets an AI assistant operate Rink as the authenticated SeenThis user. Add this URL as a custom connector in Claude or ChatGPT:

https://mcp-rink.staging-aws.seenthis.io/mcp

OAuth opens the SeenThis login. Every tool call uses the same site access rules as the control plane; connecting MCP does not grant access to somebody else's site.

Tool families

For agents such as Iris, prefer the narrow data tools and a restrictive collection create policy. MCP data reads follow site visibility; creates are attributed to the MCP user's email and enforced at the same write boundary as the browser SDK.

Command line

Rink commands are part of Salming CLI 1.7.0 or newer. Login once, then run commands from the folder containing the site. Built output is detected in dist, dist/client, build or out.

npm install -g @seenthis-ab/salming@latest
salming --version
salming rink login

salming rink deploy
salming rink deploy --site q3-launch
salming rink deploys
salming rink rollback
salming rink list
salming rink share a@seenthis.se

Deploys are immutable versions. Rollback flips the active pointer; it does not rebuild or overwrite an older artifact. See Salming when a workload genuinely needs its own runtime, infrastructure or CI/CD.

Limits and security model

All browser endpoints require a valid viewer and site access. Secrets stay server-side. Collection policies protect writes, not reads. Public Rink sites are reviewed static artifacts and cannot call rink.*.

Questions or a needed quota bump? Email infra@seenthis.co or ask in #product-team-infrastructure.