Skip to content

API reference

This page is the lookup summary. For the end-to-end walkthrough with examples, see Send data via the API. The interactive endpoint-by-endpoint API reference is served at /docs/api on your Ronja domain. For AI agents, the machine-readable entry point is a lean index at /llms.txt (also at /docs/api/llms.txt and /.well-known/llms.txt); it links to the compact machine-readable list of every endpoint (with the scope each needs) at /docs/api/endpoints.md, alongside the full OpenAPI schema at /docs/api/openapi.json.

Tokens are created by an Admin on the API Tokens page (page title “Tokens”): click Create New.

Create field Details
Name A label, e.g. “CI pipeline”
Role Every token is bound to a role (default User); the role bounds everything the token can do. An admin cannot mint a token at or above their own role level
Access Limited to scopes (recommended default — grant at least one scope) or Full access (unrestricted within its role; a warning is shown)
  • The token secret is shown exactly once at creation (“Copy this token now — you won’t be able to see it again.”). It cannot be re-displayed — only revoked.
  • Send it as a bearer token: Authorization: Bearer <token> (the Bearer prefix is optional).
  • Tokens are bound to your organization and can be revoked at any time from the token list or detail page.

Scopes narrow a token within its role — they never widen it. Each scope is granted at None, Read, or Write. A scoped token is denied on anything outside its grants.

Scope Covers
Data Tables, files, tags, connectors & search
Structure Features & access groups
Analytics Apps, notes & knowledge
Automation Workflows, schedules & scripts
Agents Saved Agents (create, edit, run), chat & MCP
Secrets Secrets & OAuth connections
Admin Members, billing, governance & settings

A token can later be restricted to scopes, or widened back to full access (confirm-gated), from its detail page.

A personal access token (PAT) is a token that acts as you — it uses your live permissions rather than a role fixed at creation. If your role changes, the token’s access changes with it; if you lose access, the token stops working. This makes a PAT the simplest way to call the API as yourself, for example to build resources owned by you.

Because a PAT is personal, you create and manage it on your own Account page: open Account, go to the Access tokens tab, and click Create token.

Create field Details
Name A label, e.g. “My laptop”
Access Limited to scopes (recommended default — grant at least one scope) or Full access (a warning is shown). A full-access PAT carries your full live role
Expires Optional — the token stops authenticating after this date

A PAT has no Role picker — it has no role of its own; it inherits yours. Whether it is limited to scopes or full access, a PAT never exceeds what you can already do, and its access follows your live role — so it auto-limits if you’re demoted or offboarded. You can restrict a PAT to scopes, or widen it back to full access (confirm-gated), from its detail page.

  • The secret is shown exactly once at creation, cannot be re-displayed, and can be revoked any time from the Access tokens tab.
  • Send it as a bearer token: Authorization: Bearer <token> (the Bearer prefix is optional).
  • Everything a PAT does is audited as you, with the token noted as the mechanism (“via PAT <name>”) — so your activity stays complete whether you used the app or a PAT.

Creating a PAT is Admin-only for now; the token always acts as the Admin who created it. An Admin can also see and revoke any personal access token across the organization from the API Tokens page (page title “Tokens”), where each PAT is marked with a PAT badge and its owner.

A Ronja API credential is a scoped API token sealed inside a secret, pointed back at your own Ronja organization, so Ronja’s own agents and workflows can call your Ronja API for operations the built-in tools don’t cover. An Admin can create one either with the endpoint below or simply by asking Ronja in a chat (“create a Ronja API credential”), which mints it after an approval step. See Let Ronja call your API for the walkthrough.

Field Details
Endpoint POST /api/v2/secret/ronja-apiAdmin only
name A label for the secret
featureID Required — the feature the credential lives in. A private feature keeps it personal; a shared feature exposes it to that feature’s members
scopeGrants Optional map of scope → read/write (same scopes as a token). Omitted → the read-oriented default (Data, Structure, Analytics at Read; Agents and Automation excluded)
role Optional — the minted token’s role (default Admin, so the credential can reach role-gated read endpoints such as listing every table in the organization; the read-only scope default still bounds writes). Pass User for a narrower credential. Capped to the caller’s own role
expiresInDays Optional — the credential’s lifetime. Omitted → 90 days; a larger number lives longer. After it expires the credential stops authenticating and must be re-created

The response is the created secret’s metadata. The token is never returned — it is sealed in the secret and can’t be read back. An agent or workflow references the secret and Ronja injects the Authorization header automatically.

A Ronja API credential can’t create another Ronja API credential, and each organization can hold only a limited number at once — delete an unused one before creating another if you reach the limit.

Reachability caveat. A scoped credential (like any scoped token) reaches only routes that carry a scope annotation; anything outside its grants is denied. The interactive reference at /docs/api lists the set of public endpoints, which is not identical to the scope-reachable set — some listed endpoints aren’t reachable by a scoped credential, and some reachable ones aren’t listed. Treat /docs/api as a guide, not a guarantee, and grant scopes to match the routes you actually call.

Store an API key or credential from the API with POST /api/v2/secret, then read it from a Workflow. The value is write-only — it goes to secure storage and is never returned by any endpoint (reads expose metadata only). Requires a token with Secrets: Write (a User, or Data Admin when the secret is bound to a shared Feature).

Field Notes
name A label for the secret
secretType api_key (default), oauth, client_credentials, database, or url_scope
credentials Required — JSON object of field → value, e.g. {"token":"sk-..."}
agentFields The field names a Workflow is allowed to read, e.g. ["token"]. Must list every field a Workflow will read — a field absent from it is not readable at run time and the run fails with an error like Field '<name>' not available on secret
allowedURLs Egress allowlist, e.g. ["*.example.com"]. Required unless secretType is database
featureID Optional — binds the secret to a Feature. A private Feature must be one you own

To rotate or replace a secret’s value later, send the new credentials with PUT /api/v2/secret/{secretID}; that also runs an automatic health check and returns its result.

A Workflow reads a stored field with the two-argument secret marker in its source — {{ secret('secret-...', 'token') }}, where the second argument is a field name from credentials that is also listed in agentFields.

Push data files into a manual-integration tableIntegration tables fed by files instead of a connector. Requires a token whose role has Admin rights and (if scoped) Data: Write; listing files only needs read access.

Format: Parquet only. Filenames must match [a-zA-Z0-9._-]+.parquet; convert CSV to Parquet client-side first.

Step Endpoint Notes
1. Create the table (once) POST /api/v2/feature/model with {"name":"...","featureID":"...","kind":"manual_integration"} featureID goes in the body, not the path. Returns the table ID (table-…), also visible in the table’s URL
2. Upload a file PUT /api/v2/feature/model/{tableID}/file/{filename} Raw body (not multipart), max 1 GiB per file. Re-uploading the same filename replaces it; different filenames accumulate and build together
3. List files GET /api/v2/feature/model/{tableID}/files Paginated (200 per page, nextCursor)
4. Delete a file DELETE /api/v2/feature/model/{tableID}/file/{filename} 404 if the file doesn’t exist
5. Build POST /api/v2/feature/model/{tableID}/build Required — uploads alone are not queryable. Async: poll GET /api/v2/feature/model/{tableID} and branch on buildVerdict (see below)

The table’s schema is auto-detected from the Parquet files.

Polling caveat. status alone is not a success signal. A table that already holds data from an earlier build is restored to ready after a FAILED rebuild, so it stays queryable and serves its last good data — the failure surfaces as a populated lastBuildError ({message, type, occurredAt}) on the same response. A table with no previous data ends at status: "build_failed" — and so does one whose rebuild can never succeed (an input table was deleted), even though its last good data is still queryable. See Table types.

Branch on buildVerdict, not on status. The same response carries buildVerdict, which already combines the two: building and pending mean keep polling, ok means it worked, ok_partial means the table is queryable but the build skipped unreadable source files, failed_stale is the trap above (the run failed and the table is still serving its previous data, with status reading ready), failed means the run did not produce a result — not that the table is empty — and invalidated means something upstream changed and this table is due a rebuild.

A failed_stale with no lastBuildError beside it is narrower than it looks: Ronja could not read the failure record for that table, so the last run’s outcome is unknown rather than known-bad. It is reported that way deliberately — a verdict that claimed success on a record nobody could read is the one mistake an automated publish would act on.

failed is usually a first build with nothing to fall back on, but it is also where a table sits when every rebuild is bound to fail the same way — an input table was deleted, say. Such a table keeps its last good data and stays queryable while reading failed, so treat the verdict as a statement about the last run, never about whether there is data to read. lastBuildError stays the field that tells you why.

Each upload is validated as a readable Parquet file before it is accepted: the footer must parse, there must be at least one column, and the data pages must decode. A file that fails any of these is rejected with a 400 and discarded, so a bad file can never be stored. An empty file (valid Parquet, zero rows) is accepted — it is treated as “no data this window” and skipped at build time, contributing nothing to the table.

Push rows into a Dynamic table (Table types), for data your own app or scheduled script produces — as JSON, or as a Parquet file when the volume warrants it. Data in, queryable table out, with no build step: when the call returns, the rows are there.

Requires a token with write access to the table’s Feature and (if scoped) Data: Write.

Step Endpoint Notes
1. Create the table (once) POST /api/v2/feature/model with {"name":"...","featureID":"...","kind":"dynamic"} Requires Admin. Returns the table ID (table-…)
2. Write rows POST /api/v2/feature/model/{tableID}/rows with {"rows":[{...}],"writeMode":"append"} Synchronous. Max 50,000 rows and 8 MiB of JSON per request. Returns {tableID, rowsWritten, writeMode, partitionCount, warnings}
2b. Or upload Parquet POST /api/v2/feature/model/{tableID}/rows/parquet?writeMode=append, the file as the raw request body Same write, bulk envelope. Max 1 GiB, no row limit. Returns {tableID, bytesReceived, writeMode, partitionCount, warnings}
3. Query POST /api/v2/duckdb/query with {"sql":"SELECT * FROM {{ ref('table-…') }}"} No build required
Undo — empty the table POST /api/v2/feature/model/{tableID}/clear with {"reason":"..."} Same access as writing rows. Contents are snapshotted first
Undo — restore a snapshot GET /api/v2/feature/model/{tableID}/dynamic-versions, then POST /api/v2/feature/model/{tableID}/dynamic-versions/{versionID}/restore Same access as writing rows. Snapshots expire after 7 days

writeMode is append (the default) or replace, and is strict — "REPLACE" or any other value is a 400 rather than a guess. On the Parquet endpoint it is a query parameter rather than a body field, since the body is the file.

The Parquet upload must be the raw request body (curl --data-binary @data.parquet), never a form upload (curl -F): a multipart envelope is not a Parquet file and is rejected with a 400. Every upload is validated before anything is written — the footer must parse, there must be at least one column, and the data pages must decode — so a bad file is rejected and the table is untouched. Apart from the envelope and the size cap, the Parquet endpoint behaves exactly like the row push: same access, same writeMode semantics, same partition ceiling, same drift warnings, same Automation triggering, same snapshots.

append is at-least-once. A retry after a timeout can write the same batch twice. For a recurring refresh, send the whole current dataset with "writeMode":"replace" — that is the idempotent shape.

Appends accumulate. Each append adds a data file that is never merged away, and both writes and queries slow down as they pile up, so an append past a ceiling is refused with a 400 naming the two fixes. Watch partitionCount on each response; when it climbs, switch to replace or clear the table.

Schema evolution is lenient — new columns are added. A column whose type diverges from the existing table still writes, and warnings explains the drift.

Every successful write counts as an update to the table, so any Automation watching it fires, exactly as an in-app write would.

One limit worth planning around. Writing rows — and clearing or restoring, which need exactly the same access — requires write access to the table’s Feature: for a personal Feature that means its owner, or an Admin. Any other colleague who can write the table inside Ronja still gets a 403 over the API. Move the table to a shared Feature if a second person needs to push to it.

Bodies are always {"error": "<message>"}, on every endpoint. A missing or invalid token is always 401 Unauthorized.

The two data-push APIs above have separate error vocabularies — including two different meanings for 403 — so they are listed separately.

Status Message Cause
403 forbidden Role below Admin, or a scoped token without the Data: Write grant
400 invalid filename: must match [a-zA-Z0-9._-]+.parquet Bad filename
400 model is not a manual_integration table Wrong table kind
400 no rows: ... Nonexistent table ID (returned as 400, not 404)
400 uploaded file is not a valid parquet file (corrupt or missing footer): re-export the file and upload again Not a Parquet file, or a corrupt/truncated footer; the upload is discarded
400 uploaded parquet file has no columns The file declares an empty schema; the upload is discarded
400 uploaded parquet file is unreadable (…): re-export the file and upload again The footer parses but the data pages fail to decode; the upload is discarded
404 file not found DELETE on a missing file
413 file too large: max 1073741824 bytes Over 1 GiB — split the file client-side

Covers /rows, /rows/parquet, /clear, and the version-restore endpoint.

Status Message Cause
403 forbidden No write access to the table’s Feature — you are neither the owner of a personal Feature nor an Admin, or you are not an Admin on a shared one — or a scoped token without the Data: Write grant. Any role below User is refused outright
404 not found Nonexistent, or deleted, table ID
400 table … is a … table, not a dynamic one The table is not a Dynamic table
400 table … is a session-scoped scratch table and is not writable over HTTP … The table belongs to a chat session rather than a Feature; save it to a Feature first
400 no rows provided — \rows` must be a non-empty array of objects` Empty rows
400 too many rows: … exceeds the limit of 50000 per request Split the batch
400 invalid writeMode "…": must be "append" (default) or "replace" Unrecognized writeMode (case-sensitive)
400 table already has … committed partitions (limit …) Append ceiling — re-push with replace, or clear the table
409 table is busy with another write — retry shortly Another writer holds the table’s lock; nothing was written
413 request body too large: limit is 8388608 bytes /rows body over 8 MiB — split the batch, or send Parquet
400 request body is not a valid Parquet file (corrupt or missing footer). Send the file as the RAW request body … Parquet upload that is not a Parquet file — most often a curl -F form upload; the upload is discarded
400 uploaded Parquet file has no columns Parquet upload declaring an empty schema; the upload is discarded
400 uploaded Parquet file is unreadable: re-export the file and upload again Parquet upload whose footer parses but whose data pages fail to decode; the upload is discarded
413 parquet file too large: max 1073741824 bytes Parquet upload over 1 GiB — split the file client-side