Build a feature with the API
Most people build a Feature and its resources by asking Ronja in a chat. You can also build them straight over HTTP with a scoped API token — deterministic, scriptable, and with no AI spend. This guide walks the ordered sequence: create a Feature, add a Workflow, then a App.
The machine-readable entry point is /llms.txt — a lean index that links to task recipes and the full endpoint reference at /docs/api/endpoints.md (every endpoint with the scope it needs) plus the OpenAPI schema at /docs/api/openapi.json. The interactive reference is served at /docs/api. This guide is the tutorial; those are the contract.
Before you start: mint a token
Section titled “Before you start: mint a token”No token can come from the API — creating one is an Admin-only action that only a person signed in to Ronja can take, and an Admin can only ever create a token at a role below their own — a token at their own level or higher is refused, so only a Super Admin can hand out Admin or Super Admin. So every token starts in the web UI.
- Follow Send data via the API to open API Tokens and create a token. Name it (e.g.
feature builder) and pick a Role that bounds what it can do. - Keep Limited to scopes and grant only what your script needs:
- Structure → Write — create the Feature.
- Automation → Write — create and publish the Workflow.
- Analytics → Write — create and commit the App.
- Data → Write — create and fill tables (see Send data via the API).
- Secrets → Write / Agents → Write — only if the Feature binds secrets or saved agents.
- Copy the secret from the Token created modal — it is shown exactly once.
Send it on every request in the Authorization header as a Bearer token:
export RONJA_BASE_URL="https://<your-org>.ronja.tech"export RONJA_API_TOKEN="<paste your token>"alias rapi='curl -sS -H "Authorization: Bearer $RONJA_API_TOKEN"'There is no scripted shortcut for the tokens after the first one either. Creating a token is one of the actions that only a person signed in to Ronja can take: POST /api/v2/authentication/token refuses every automated caller — an API token, the CLI, a Workflow, a Saved Agent — with "code": "human_required". So a script cannot mint a narrower token to hand to a sub-task; an Admin creates each one in the web UI. See Actions the API can’t take.
The recipe
Section titled “The recipe”1. Create the Feature
Section titled “1. Create the Feature”scope is either private (the default) or organization to share it; organization needs Admin. Only you can find a private Feature: an Admin can’t browse it, but can open a specific item in it when handed that item directly. See Scopes.
rapi -X POST "$RONJA_BASE_URL/api/v2/feature" \ -d '{"name":"Weekly sales report","scope":"private"}'# -> 200 {"id":"<feature-id>","name":"Weekly sales report", ...}Save the returned Feature id — every resource below is created inside it.
2. Get data in (optional)
Section titled “2. Get data in (optional)”A Workflow that reads a table needs that table to exist first. Creating a table, pushing Parquet, and building it is already covered in Send data via the API — follow it, then come back with your table ids (table-...).
3. Create and publish the Workflow
Section titled “3. Create and publish the Workflow”A Workflow is created as a draft, filled with code, then published to go live.
# Create the draft — featureID is required.rapi -X POST "$RONJA_BASE_URL/api/v2/workflow" \ -d '{"featureID":"<feature-id>","title":"Roll up weekly sales"}'# -> 200 {"id":"workflow_...", "lifecycle":"draft", ...}
# Push the code. Markers in the body bind the workflow's data dependencies.# The body is a heredoc so the SQL keeps its own quotes.rapi -X PUT "$RONJA_BASE_URL/api/v2/workflow/workflow_.../files/main.py" \ --data-binary @- <<'JSON'{"content":"weekly = tools.query(\"SELECT week, SUM(amount) AS amount FROM {{ ref('table-8f21c4') }} GROUP BY week\")\n{{ write(\"weekly_sales\") }} = weekly\n"}JSON
# Publish the draft to make it live.rapi -X POST "$RONJA_BASE_URL/api/v2/workflow/workflow_.../publish"{{ ref('table-...') }} (an input table), {{ write(...) }} (an output table), and {{ secret('secret-...') }} are read out of the code you send and bound on the server — you send code, not a separate list of dependencies. Give each marker the resource’s own id. A {{ ref }} may also name its table instead of iding it — {{ ref('Sales.orders') }}, or a bare {{ ref('orders') }} for a table in this Workflow’s own feature — which Ronja turns into the id when you push the file and stores that way, so renaming the table later cannot break the Workflow. Either way the table has to exist already: a {{ ref }} on something that is neither one of your table ids nor a name one of your tables answers to is rejected when you push, and the rejection lists the names that would have worked. A {{ secret }} names a Secret the same way, but it fails more gently: a Secret id you cannot reach is dropped with a warning rather than refusing the push, and the workflow then fails on its first run when it asks for the credential. Read the warnings the push returns. The one exception is {{ write }}, which also accepts a plain new table name (weekly_sales above) and creates that table for you.
A {{ ref }} belongs inside the SQL you hand to tools.query(...), as above. That is how a Workflow reads a Ronja table: the query runs on the server, and the code gets the rows back. It is not a value you can assign to a variable of its own (df = {{ ref('table-8f21c4') }}), and it is not a name a query the Workflow runs for itself can read — a Workflow reaches a table only through tools.query. Written either of those ways the code saves and publishes cleanly, and then fails on its first run. Publishing is required too: a freshly created Workflow stays a draft until you publish it.
4. Create and publish the App
Section titled “4. Create and publish the App”An App is created empty and unpublished — a draft only you can see — gets its source files, then a required validate step, then commit to publish it. There is no one-shot publish.
# Create the app. Declare every table/secret/workflow/agent it uses up front.rapi -X POST "$RONJA_BASE_URL/api/v2/dataapp" \ -d '{"featureID":"<feature-id>","name":"Weekly sales","allowedTableIDs":["table_..."]}'# -> 200 {"id":"dataapp_...", "specVersion":2, ...}# specVersion is the app's semantics generation, and a new app gets 2: Ronja# installs the component kit's theme and the Tailwind runtime into the bundle,# so App.tsx needs no styling imports. Send "specVersion": 1 only when you are# porting an app whose styling is hand-written.
# Push the entry file. It must match the app's entry point — App.tsx by default.rapi -X PUT "$RONJA_BASE_URL/api/v2/dataapp/dataapp_.../files/App.tsx" \ -d '{"content":"import { createRoot } from \"react-dom/client\";\nfunction App() { return <h1>Weekly sales</h1> }\ncreateRoot(document.getElementById(\"app\")).render(<App />);"}'# -> 200 {"dataAppID":"dataapp_...", "path":"App.tsx", ...}# dataAppID is the row the write landed on. On a new app that is the id you# just created — same id, still unpublished.
# Validate + compile the draft. Required before commit.rapi -X POST "$RONJA_BASE_URL/api/v2/dataapp/dataapp_.../validate"
# Commit to publish the app. The id does not change.rapi -X POST "$RONJA_BASE_URL/api/v2/dataapp/dataapp_.../commit"You can also have Ronja render the app headlessly and look at what it did — POST /api/v2/dataapp/<id>/preview hands back a screenshot, the errors it hit and the queries it ran, as an observation and never a pass/fail verdict; it sits on the Data scope because it runs the app’s real queries, and the app-development guide linked from /llms.txt covers it in full.
Bringing an older app up to date. An app created before Ronja styled apps itself is "specVersion": 1, where the entrypoint has to import @/styles/theme and @tailwindcss/browser on its own. Move it with PUT /api/v2/dataapp/:id -d '{"specVersion": 2}', which needs no source edit — the two import lines stay harmless. It is one-way: 2 back to 1 is a 400, because a spec-2 app has no styling imports of its own and taking the install away leaves the page with no styling at all and nothing failing.
Only migrate an app that already renders with Tailwind — one using the component kit, or writing Tailwind classes by hand. An app built from the SDK’s own primitives with no Tailwind anywhere would be restyled by the move: preflight resets its native controls and shifts its layout. Nothing will suggest that migration, and you should not make it by hand either.
The restore-and-publish endpoint re-publishes an already-committed version — it is not the fresh-app path, so don’t reach for it here.
5. Share it
Section titled “5. Share it”Publishing makes the Workflow and the App live. It does not let anyone else open them. Who can open anything in a Feature is decided by the Feature, not by each thing in it: its scope, and for an organization Feature, which access groups it is attached to. Only you can find a private Feature: an Admin can’t browse it, but can open a specific item in it when handed that item directly (Scopes). If anyone else will use what you built, share the Feature. To do it in the web app instead, see Share and promote.
- Use a Feature of your own, never My Private Feature. A Saved Agent you create without naming a Feature lands in My Private Feature, your personal one. It cannot be shared — Ronja refuses a scope change on it, because it is where everything you never filed lands. Create a dedicated Feature and move things into it.
- Build in a top-level Feature. A sub-feature is a Feature nested inside another Feature. Once shared, it is visible to admins only until it is attached to an access group (step 3).
-
Preview the change. It changes nothing:
Terminal window rapi -X POST "$RONJA_BASE_URL/api/v2/feature/<feature-id>/scope-change-preview" \-d '{"requestedScope":"organization"}'# -> 200 {"violations":[], "canSelfApprove":true, "strandingWarnings":[], ...}Each entry in
violationslists itsblockers— usually a table or secret in another private Feature. Move them into this Feature and preview again. -
If
canSelfApproveistrue, share it now withPOST /api/v2/feature/<feature-id>/promotion-directand the same body. Otherwise,POST /api/v2/feature/<feature-id>/promotion-requestwith{"requestedScope":"organization","note":"…"}stages a handover, and nothing changes until an Admin approves it. If your organization has turned off self-approval, that has to be another Admin, even when you are one. -
If the preview listed
strandingWarnings, the Feature is visible to admins only until it is attached to an access group. The API can’t make that attachment; an Admin does it in Ronja, or a member with edit access when Allow members to manage this group is enabled for that access group.
Check the result with GET /api/v2/feature/<feature-id>: scope reads organization, and if step 3 applied, the Feature is attached to an access group. Don’t check it with the App’s commit response: its audience describes the Feature as it was at that commit, so an App committed in step 4 still says author.
Gotchas
Section titled “Gotchas”- Check a Workflow before you create it.
POST /api/v2/workflow/validatedry-runs a whole candidate Workflow — Feature id, main file, and file contents — and returns every problem it finds, each attributed to the file it came from: unresolved{{ ref }}/{{ write }}/{{ secret }}markers, a missing main file, a main file that is not a.pypath, invalid parameters. It takes no Workflow id and saves nothing, so you can iterate until it comes back clean before anything is created. Send theruntimeVersionyou are creating for —3for the example above, since that is what a create with noruntimeVersionproduces. Leave it out and the dry run checks nothing that depends on the runtime, so it passes code the real save would refuse. It is optional, and it does not check whether you are allowed to write into a shared Feature — that is still decided when you save. - An App publishes in three steps. Push files, then
validate, thencommit. Skipping validate makes commit fail — a draft must have compiled cleanly since its last file edit. - Declare bindings first. Unlike building in a chat, the API does not scan your source for referenced tables, secrets, workflows, or agents. Anything your Workflow or App uses must be declared — an App’s
allowedTableIDs/allowedSecretIDs/allowedWorkflowIDs/allowedAgentIDsin the create body or viaPOST /api/v2/dataapp/:id/checkout, a Workflow’s dependencies through its{{ ref }}/{{ write }}/{{ secret }}/{{ agent }}/{{ workflow }}/{{ file }}/{{ codex }}/{{ mailbox }}markers. For an App, secret references are the only binding checked when it compiles. An app whose SQL reads a table that is not inallowedTableIDscompiles clean, validates clean and publishes, and then every query it makes fails once someone opens it. Nothing warns you at any step, so check the allowlists yourself: an emptyallowedTableIDsis the single most common reason a published app draws its layout and then shows no data. - You can only attach what you can already reach. Attaching a table, secret, workflow, or Saved Agent you cannot reach returns 400 (
table "…" is not accessible — you can only bind what you can already reach). An App’s viewers inherit whatever it is wired to — potentially across Features — so this is a real access boundary. What you reach depends on your role: an Admin reaches every live resource in the organization, including one in another member’s private Feature, so an Admin can attach anything by id; anyone else reaches what their access groups reach — share the resource into a Feature you both reach, or move it, and attach it then. - An App’s entry file must mount itself. It has to end with
createRoot(document.getElementById("app")).render(<App />). A file that only defines and exports a component is refused — nothing calls it, so it would build and then show a blank page, and the compile says so rather than letting you publish it. That check catches source that never mounts; it is not a promise that a build that passes renders. A component that mounts and returns nothing, or fails on first render, still publishes. Open the app and look. - A name your source never imports or defines is refused too. A hook left off the
reactimport line, a misspelt component, animport typeused as a value — each of these used to compile clean and then throw in the viewer’s browser. The compile now names the file, the line and the identifier, and adds the import to write when it recognises the name — though a very large app, more than about 4 MB of source in all, is not checked for unbound names at all. Two things still pass, because a type-checker would accept them too: a global a<script>tag provides, reached aswindow.X, and one introduced withdeclare const X: SomeType. Types are not checked, so a mistyped prop still publishes. - A new App starts as a draft.
POST /api/v2/dataappcreates it unpublished and visible only to you, the same as a Workflow. The firstcommitpublishes that same row — the id never changes — so an app you start and abandon leaves nothing behind in the Feature. - Live versus draft. The first file edit to an already-published App forks a private draft. The response’s
dataAppIDis the row your edit landed on — if it differs from the id you sent, your change is on a draft and is not live until you validate and commit. Read the field to know where you are. - In a shared Feature, creating is Admin-only — the review gate is for edits. If you are not an Admin,
POST /api/v2/dataappin a shared Feature is refused outright with a 400 (admin required to create a data app in a shared feature). There is no review lane for a brand-new app, so there is nothing to submit and nothing to retry — post it toPOST /api/v2/dataapp/proposeinstead, which creates a proposal you author files against while an Admin approves or rejects it. A brand-new Workflow is refused one step later: the draft is created, andPOST /api/v2/workflow/:id/publishis the call that fails, withPOST /api/v2/workflow/proposeas its review lane. Both propose endpoints sit on the Admin scope, so a token limited to Analytics and Automation cannot reach them. Editing an already-published Workflow or App is the case that lands as a draft: your edits fork one automatically,commitis refused, and you callPOST /api/v2/dataapp/:id/request-review(orPOST /api/v2/workflow/draft/:id/request-review) to put it in front of an Admin. On your own private Feature everything commits directly. - You author as the token’s user. A token acts on behalf of the user who minted it: a
privateFeature it creates is that user’s private Feature, and the token can create, edit and delete its own work inside it. Inside that user’s own private Feature the two sides meet: everything in a private Feature belongs to the Feature’s owner, so the token can read and edit what its user wrote in the browser, and the user can pick up the Workflows, Apps, notes and secrets the token made. Automations are the exception: an automation is owned by the credential that created it, not by the person behind it, so one a token created can be run by hand only by that same token or by an Admin. That stops at the Feature boundary — a resource sitting in someone else’s private Feature is that person’s, whoever wrote it, so move or share it into a Feature the token reaches. The Workflows, Apps, notes, secrets and automations it creates record the token as their creator, and the Activity log records the token as the actor, so automated actions stay distinguishable from that person’s own work — you cannot post work attributed to Ronja or to someone else. Watch the scope of a shared team token: anything it creates inprivatescope lands in its minting Admin’s private Feature — invisible to whoever else operates the token. For automation that a team shares, preferorganizationscope so the resources are visible to everyone who should see them; reserveprivatescope for a token you alone use. - Partial failures leave orphans, and a retry now hits
409 name_taken. No single request spans Feature to Workflow to App, so a failure mid-chain can leave an orphan Feature with nothing in it. A Feature’s name is unique in the organization and a Table’s is unique in its Feature, so re-sending the create that already succeeded is refused with 409 and a body carrying"code": "name_taken"and a message naming the id of the row that holds the name. Retrying the same create is therefore pointless — it will refuse identically forever. Keep the ids each create call returns; on a 409, read the holder’s id out of the message and continue the chain with it rather than creating again. (Delete the orphan by id if you’d rather start clean.)
Find the exact endpoints
Section titled “Find the exact endpoints”This guide covers the golden path. Start from the machine-readable index at <your-org>.ronja.tech/llms.txt (also served at /.well-known/llms.txt): it links to the full endpoint list at /docs/api/endpoints.md (every endpoint with its required scope) and the raw OpenAPI spec at /docs/api/openapi.json. The interactive reference (try calls, read every field) is served at /docs/api.
Using Claude Code
Section titled “Using Claude Code”Drop this into your project’s CLAUDE.md so an AI coding assistant builds against the API correctly. It teaches only the shape and defers the detail to the live index, so it won’t go stale:
## Ronja API
Base URL: https://<your-org>.ronja.tech — every call sends `Authorization: Bearer $RONJA_API_TOKEN`.
Discover the API from the live index first: fetch `<base>/llms.txt`. It's a lean index —it links to task recipes and the full endpoint list at `<base>/docs/api/endpoints.md`(every endpoint with its required scope).Don't guess request bodies — read the OpenAPI spec at `<base>/docs/api/openapi.json` for exact shapes.
Golden path to build a Feature with a Workflow + App, then share it:0. GET /api/v2/policy/org -> the organization policy: read `content` and follow it as rules for HOW to build. Read-only for this token: changing it is an Admin's job, in the app or with a personal access token from `ronja login` — a token from the API Tokens page cannot write it.1. POST /api/v2/feature {name, scope} -> Feature id2. (optional) create + fill tables — see the "Send data" guide3. POST /api/v2/workflow/validate {featureID, entrypoint, files} (optional dry-run: no id, saves nothing, findings per file) POST /api/v2/workflow {featureID, title} -> draft PUT /api/v2/workflow/:id/files/main.py {content} (markers resolved server-side) POST /api/v2/workflow/:id/publish -> live4. POST /api/v2/dataapp {featureID, name, allowedTableIDs} -> empty + unpublished draft (specVersion defaults to 2: Ronja styles the app; send 1 only to port a hand-styled one) PUT /api/v2/dataapp/:id/files/App.tsx {content} (entry file = app entry point; must end with createRoot(...) .render(...) or it is refused) POST /api/v2/dataapp/:id/validate (REQUIRED before commit) POST /api/v2/dataapp/:id/commit -> published (same id)5. POST /api/v2/feature/:id/scope-change-preview {requestedScope} (read-only: violations, canSelfApprove, strandingWarnings) POST /api/v2/feature/:id/promotion-direct {requestedScope} -> shared now (canSelfApprove) POST /api/v2/feature/:id/promotion-request {requestedScope, note} -> staged until an Admin approves
Checklist:- App = push files -> validate -> commit. There is no one-shot publish.- Published is NOT shared. Only you can find a private Feature, so nobody else can open what you built until step 5 shares the Feature. Report to the person WHO can open it, never just "published". strandingWarnings = an Admin must attach the Feature to an access group in Ronja; the API can't.- A new App is a DRAFT visible only to you until the first commit publishes it. The id never changes, and an app you abandon leaves nothing behind.- Declare bindings first: allowedTableIDs / allowedSecretIDs / allowedWorkflowIDs / allowedAgentIDs (create body or POST :id/checkout). The API does NOT auto-scan source.- Of the BINDINGS, only SECRET references are compile-checked. An undeclared table/workflow/agent compiles, validates and publishes clean, then fails at run time once someone opens the app. Nothing warns you — verify the allowlists yourself.- The compile DOES refuse a name the source uses but never imports or defines (a hook off the react import line, a misspelt component, an `import type` used as a value), naming the file, line and identifier. `window.X` and `declare const X` both pass. Source totalling over about 4 MB is not checked for these at all. Types are not checked, so a mistyped prop still publishes.- A clean file write may carry `warnings` [{file, line, column, message}]: findings about the whole app as of that write (a chart title the styling drops, two styling libraries loaded at once). Never a reason validate or commit refuses. Print them; do not fail on them. One line in that list is NOT a defect: on a specVersion 1 app that already renders with Tailwind, a note that it can be moved to specVersion 2. The app works as it is. An app this recipe CREATES is specVersion 2, so it never sees that line — nor the unstyled-page finding, which describes a spec-1 app missing its two styling imports.- Only attach resources this token's user reaches, else 400. An Admin reaches every live resource in the organization; anyone else reaches what their access groups reach.- Editing an already-published App forks a draft; the response `dataAppID` is where the edit landed — if it differs from the id you sent, it's a draft, not live.- Shared Feature + non-admin — three different outcomes, do not conflate them: * CREATE an App -> 400 "admin required to create a data app in a shared feature". There is NO review lane for a new app, so do not retry and do not try to submit it for review. Use POST /api/v2/dataapp/propose (needs the admin scope) and author files against the returned proposal; an Admin approves it live. * CREATE a Workflow -> the draft is created fine, but POST /api/v2/workflow/:id/publish is refused. Use POST /api/v2/workflow/propose (needs the admin scope) instead. * EDIT a published Workflow / App -> the edit forks a draft and commit is refused; POST /api/v2/dataapp/:id/request-review (or /api/v2/workflow/draft/:id/request-review) puts it in front of an Admin.- No transaction spans the chain — a mid-chain failure leaves an orphan Feature; use idempotent names and clean up the orphan.