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.
/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.
*.rink.staging-aws.seenthis.io hosts and approved internal aliases, not public *.rink.staging-aws.seenthis.se hosts.<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>
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.
Feature detection for the Rink environment that is actually serving the site.
await rink.platform.info()const info = await rink.platform.info();
if (info.capabilities.data.documentOwnerRules.operations.includes('update')) {
enableContributorEditing();
}
console.log(info.release, info.capabilities.aiChat.maxOutputTokens);The current SSO identity, enriched with safe professional fields from the SeenThis directory.
await rink.identity.me(){ 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.const me = await rink.identity.me(); const isUiAdmin = ['admin@seenthis.se'].includes(me.email.toLowerCase());
Safe colleague profiles for people pickers, attribution and team views.
await rink.directory.list()const people = await rink.directory.list(); const studio = people.filter((person) => person.team === 'Studio');
await rink.directory.lookup(email)const owner = await rink.directory.lookup('billy@seenthis.se');
ownerName.textContent = owner?.name ?? owner?.email ?? 'Unknown';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)const problems = rink.db.collection('problems');await collection.list({ limit?, sort? })limit defaults to 100 and is capped at 200. sort is 'asc' or 'desc' and defaults to descending.const newest = await problems.list({ limit: 50, sort: 'desc' });await collection.get(id)const problem = await problems.get(problemId); console.log(problem.data.title);
await collection.create(data)const problem = await problems.create({
title: 'Campaign links lose attribution',
status: 'proposed',
persona: 'Activator'
});await collection.update(id, data)const current = await problems.get(id);
await problems.update(id, { ...current.data, status: 'validated' });await collection.remove(id)if ((await problems.permissions(id)).delete) {
await problems.remove(id);
}await collection.permissions(id?){ create: boolean, update: boolean, delete: boolean }.const collectionPermissions = await problems.permissions(); const documentPermissions = await problems.permissions(id); editButton.hidden = !documentPermissions.update;
collection.subscribe({ onCreate?, onUpdate?, onDelete? })const stop = problems.subscribe({
onCreate: (doc) => addRow(doc),
onUpdate: (doc) => replaceRow(doc),
onDelete: (id) => removeRow(id)
});
// Later:
stop();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.
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.
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(){ results, success, meta }. Meta includes duration, changes, last row ID, rows read/written, and generation.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? })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)await rink.sql.batch([
rink.sql.statement('tasks.complete').bind({ id }),
rink.sql.statement('events.add').bind({ taskId: id })
]);rink.sql.subscribe(handler)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.
Read-only snapshots of private GitHub issues without exposing the GitHub App credential to site code.
await rink.github.getIssue(url){ url, repo, number, title, state, assignees, updatedAt }. State is 'open' or 'closed'; assignees are { login, name|null }.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.
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? })@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).{ schema, rows, totalRows, bytesBilled, bytesProcessed, costUsd, cacheHit, cache, jobId, pageToken }. INT64 beyond 253, NUMERIC and BIGNUMERIC are strings; TIMESTAMP is ISO 8601.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)await rink.bq.estimate(sql, { params?, types? }){ statementType, bytesProcessed, costUsd, datasets, location, caps, siteBytesToday, viewerBytesToday, allowed, reason }.await rink.bq.datasets()[{ dataset, title, description, notes, owner, sensitivity, location }]. notes are curated by admins: business meaning, units and the partition column.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? })"me". location — the dataset location when no granted dataset names it (default EU). Also accepted by estimate and nextPage (a result remembers its as).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.
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)[{ table, name, type, description, rowCount, sizeBytes, partitioning, clustering, notes }].await rink.bq.schema('dataset.table'){ table, type, description, columns, partitioning, clustering, notes, datasetNotes }.await rink.bq.sample('dataset.table', { limit? }){ table, method }.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.
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.
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"]
}
}
}
bq (one read-only
SELECT, run as the site with the same dry-run validation, grants, caps and
labels as rink.bq; no parameters) or collection (a
rink.db collection: every document's data, or with
"latest": true the newest document, unwrapping the rows of a
bq.query schedule result).{ every: "hourly" | "daily" | "weekly", hour?, minute?, weekday? },
at most every 15 minutes. Without one, a snapshot refreshes when its
definition is first deployed and on Refresh now.viewers (signed-in colleagues who
can open the site) and/or guests (active guest invites).
public is not allowed.await rink.snapshots.get(name)viewers.{ name, version, generatedAt, source, schema, rows, rowCount }; the response also carries X-Rink-Snapshot-Version and X-Rink-Snapshot-Generated-At.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 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.
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"]
}
}
}
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.
Per-site binary storage. File bytes live in S3 and metadata is scoped to the current site.
await rink.files.upload(file, { name?, type? }){ id, name, content_type, size, created_by, created_at, url }.const file = await rink.files.upload(fileInput.files[0]); preview.src = file.url;
await rink.files.list()await rink.files.get(id)rink.files.url(id)await rink.files.remove(id)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?)messagesOrText is a prompt string or 1–100 { role, content } messages. Options: system, model, max_tokens, images.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? })const image = await rink.ai.image('A flat-vector hockey puck logo');
logo.src = image.url;await rink.ai.video(prompt, { model?, onProgress? })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.
Precise browser geolocation, approximate request location and SeenThis office helpers.
await rink.location.current(options?)const here = await rink.location.current({ enableHighAccuracy: true });await rink.location.approximate(){ accuracy:'approximate', source, latitude?, longitude?, city, region, country, countryCode, timezone } or null.await rink.location.offices()await rink.location.nearestOffice(locationOrOptions?){ office, distanceKm, location, source }.const here = await rink.location.current(); const nearest = await rink.location.nearestOffice(here);
Current weather and forecasts proxied through Rink, with no provider key in browser code.
await rink.weather.current(input){ office } or { latitude, longitude }. Office accepts an office id, name, city or country after normalization.{ 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? }){ source:'open-meteo', location, daily, units }.const week = await rink.weather.forecast({ office: 'london', days: 7 });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)const submission = await rink.forms.submit('problem', {
title: titleInput.value,
details: detailsInput.value
});await rink.forms.list(name, { limit?, sort? })await rink.forms.get(name, id)await rink.forms.remove(name, id)Transactional internal email from a Rink site through the shared mail service.
await rink.email.send({ to, subject, text?, html? })await rink.email.send({
to: 'team@seenthis.se',
subject: 'New proposed problem',
text: 'Open Persona Portal to review it.'
});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.
UPPER_SNAKE_CASE (2–64 characters, e.g. HUBSPOT_TOKEN). Values are write-only and at most 8 KB; Rink shows only the last four characters of long values.api.hubapi.com) or a wildcard (*.hubspot.com) the value may be sent to. Public suffixes that anyone can register under, such as *.co.uk, *.github.io or *.vercel.app, are refused (checked against the Public Suffix List). Site JavaScript runs in every viewer's browser, so the host list is what stops a viewer from sending your key somewhere else.viewers (anyone who can open the site) or editors (owner and editors only).responseAccess): full responses, or status-only. Default: status-only for site secrets usable by every viewer, full for editors-only and personal secrets. Opening an editors-only secret up to viewers falls back to status-only unless you choose otherwise.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? }){{secret:NAME}} (site secret) and {{secret:me.NAME}} (your personal secret) on the server.viewers; owner and editors for editors secrets. Personal secrets only for their owner's own requests.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).{ 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.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;rink.http.fetch with any arguments. That is why each secret is bound to its allowed hosts: Rink refuses to send it anywhere else, including after a redirect, and never lets a secret value choose the host or path.{{secret:me.NAME}} always means the signed-in person's own secret, never the site author's, and is never used for other viewers or by automations someone else runs. Automations that use one stop as soon as their creator can no longer manage the site.Rink/version (+site).rink.http.fetch returns only the status and a few safe headers. Site secrets that every viewer may use default to Status only.# 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.
Server-side automation for work that must run after the browser closes. Management methods require the site owner or an editor.
await rink.schedule.add({ schedule, action, name? }){ id }.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()await rink.schedule.cancel(id)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.
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.
{{secret:me.NAME}} works in scheduled jobs and scheduled workflows you create, which run as you. A form-submit workflow runs on whoever submits, so it cannot use personal secrets, and a run started by someone else fails instead of using yours. When you lose the right to manage the site, or hand over ownership, those automations are disabled and the change is in the audit log.await rink.workflow.create({ name?, trigger, steps, enabled? })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)await rink.workflow.run(id)await rink.workflow.runs(id)await rink.workflow.enable(id) / disable(id)await rink.workflow.remove(id)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.
Site-scoped realtime over one shared WebSocket per browser page.
rink.live.channel(name)const presence = rink.live.channel('presence');
presence.on((message) => renderPresence(message));
presence.send({ state: 'online', at: Date.now() });
window.addEventListener('beforeunload', () => presence.close());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.
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.
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.
rink_platform_info.rink_deploy for small inline files; rink_create_deploy_upload for a built tar.gz through a 10-minute, single-use upload URL.rink_list, rink_create, rink_get, rink_deploys, rink_copy, rink_rollback, rink_set_disabled, rink_delete.rink_stage_begin, rink_stage_add_files, rink_stage_finalize, rink_draft_list, rink_draft_read, rink_draft_write, rink_draft_delete, rink_draft_rename, rink_draft_publish, rink_draft_reset.rink_bq_datasets, rink_bq_tables, rink_bq_schema, rink_bq_sample, rink_bq_estimate, rink_bq_query (at most 200 rows), rink_bq_request_access. Always filter on partition columns, select only needed columns and estimate before large queries. rink_connection_status tells you whether your own Google account is connected (needed for as: "me" and owner-run snapshots) and links to connect it.rink_db_list, rink_db_get, rink_db_create, rink_db_get_policy, rink_db_set_policy. MCP intentionally exposes no document update/delete tool.rink_secret_list, rink_secret_set, rink_secret_delete. Values are write-only and never returned.rink_get_access, rink_set_access, rink_access_requests_list, rink_access_request_decide, rink_publish_public, rink_unpublish_public, guest invite/list/revoke tools and rink_request_security_review.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.
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.
rink.http.fetch calls/minute/viewer and 600/site, 1 MB request, 5 MB response, 15 s, HTTPS port 443 to allowed hosts only; secret values at most 8 KB.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.