Skip to content

Use the Ronja CLI

The ronja CLI signs your terminal in to Ronja. It exists mainly so scripts and AI coding agents can work against the API without anyone pasting credentials around by hand.

It is deliberately not a wrapper around the API. It does the one thing a script cannot do for itself — an interactive browser sign-in — and then hands over everything needed to call the API directly over HTTP. Two commands:

Terminal window
ronja login # sign in through the browser
ronja context # print everything needed to call the API

Two more make those HTTP calls easier without wrapping anything: ronja api sends a request to any path with your credentials attached — bar the handful of actions that still need you in a browser — and ronja query runs read-only SQL and gives you CSV. Both are further down this page.

Three more handle the other thing a script should not do by itself — take a credential from a person — and never print one: ronja secret create stores an API key or database login, ronja secret connect connects a service by signing in to it, and ronja token create opens the form where you create a token for an app.

There are five more exceptions, and they are the same idea five times: ronja wf keeps a workflow’s Python in a folder on your machine, ronja app keeps an app’s source (TSX) in one, ronja pipeline keeps a feature’s derived-table SQL and its metric definitions in one, ronja automation keeps a feature’s automations in one, and ronja module keeps a shared Python package in one. Either way you write it in your own editor and keep it in your own version control, then validate, push and publish from the terminal.

One more command works inside any of those folders (a module folder aside — a module declares no dependencies): ronja bind gives the things your code reads names of its own, so the same folder can deploy to more than one organization. And when a repository holds many folders, ronja sync status checks all of them at once — with ronja sync check alongside it, for everything those folders point at, and ronja sync apply to deploy the lot.

The CLI is a single binary with nothing to configure. On a Mac:

Terminal window
brew install ronjatech/tap/ronja

On Linux, or anywhere you already have Go:

Terminal window
go install github.com/ronjatech/ronja-cli/cmd/ronja@latest

If brew says Cask 'ronja' is unreadable, your Homebrew is too old for the package. Run brew update, then install again.

brew is macOS-only — Homebrew does not install this kind of package on Linux. If you would rather not use either, download a binary for your platform from the releases page, unpack it, and put ronja somewhere on your PATH.

If the command is not found after go install, your Go bin directory is not on your PATH:

Terminal window
export PATH="$PATH:$(go env GOPATH)/bin"

Add that line to your shell profile (~/.zshrc or ~/.bashrc) to make it stick. Check it worked:

Terminal window
ronja --help
ronja --version

The CLI tells you when it is behind. Once a day, when a newer version exists, it prints a single line in your terminal: which version you have, which one is available, and the command to run. It never updates itself.

On macOS and Linux, install the newer version with:

Terminal window
ronja update

If you installed with Homebrew, the file belongs to Homebrew — ronja update will say so and tell you to run brew upgrade ronja instead.

On Windows there is no release file to install, so ronja update refuses. Run the install command from Install again instead — it fetches the newest version:

Terminal window
go install github.com/ronjatech/ronja-cli/cmd/ronja@latest

If ronja update reports that it cannot write to the directory the ronja file sits in — /usr/local/bin is the usual one — nothing was downloaded and nothing changed. Re-run it with sudo, or install the newer version the way you installed the first one. The message names the reason it could not write: (permission denied), or (read-only file system).

To see what an update would do without changing anything:

Terminal window
ronja update --check

The check itself is a single unauthenticated request to api.github.com, made at most once a day, and it carries none of your Ronja credentials. To switch the daily message off, set RONJA_NO_UPDATE_CHECK=1 in your shell profile — that stops the request as well as the message. It stays silent whenever the output is not going to a terminal — piped into another command, written to a file, or run from a script or a build job — whenever you pass --json, and whenever CI is set.

Terminal window
ronja login

The CLI prints a short code and opens your browser. Check that the code on screen matches the one in your terminal, confirm you recognise the machine listed as Requested by, then choose Authorize. Your terminal picks up the result within a few seconds.

If the machine has no browser — a server over SSH, a container — use:

Terminal window
ronja login --no-browser

and open the printed URL somewhere you are signed in. The code stays valid for 10 minutes and works only once.

With no --url, the CLI signs you in to https://cloud.ronja.tech. Pass --url only when your Ronja lives somewhere else — a self-hosted deployment, or a development server:

Terminal window
ronja login
ronja login --url http://localhost:8080

Each login is stored as a named profile: one instance, one organization, and the token that reaches it. Several can be signed in at the same time, and the most recent login becomes the current profile. To see them all, switch, or target one for a single command:

Terminal window
ronja profile list
ronja profile use acme-retail
ronja query --profile local "SELECT 1"

Run ronja profile use with no name to pick from a list instead — arrow keys to move, Enter to choose, q or Esc to cancel. It starts on the profile you are already using, so Enter alone changes nothing.

Any command also takes --url to pick by instance instead of by name.

If you belong to more than one organization

Section titled “If you belong to more than one organization”

An access token belongs to one organization — the one you are signed in to in the browser at the moment you approve. The approval screen names it, so check it there.

To add a second: switch organization in the web app, then run ronja login again. The new token is stored as its own profile beside the first, never on top of it, and each is named after its organization.

Because two profiles can then share one instance, --url alone is no longer enough to tell them apart. When it is ambiguous the CLI names both and asks you to pick with --profile rather than guessing — the difference between them is which organization you are about to write to.

ronja wf init, ronja app init and ronja pipeline init print the organization they bind to and refuse a Feature they cannot reach there — with the folder left exactly as they found it, so a refusal never leaves a half-made folder behind. ronja bind --feature makes the same check when you add a second organization to a folder you already have.

If the check cannot be made — the instance is unreachable, or your token is not allowed to read Features — you get a note saying so and the command carries on. It needs a credential, not a live connection.

Profiles on your own machine are named by port — local-8082 — so several development servers running at once stay tellable apart.

Rename anything you dislike; the name is only a local handle:

Terminal window
ronja profile rename app-2 northwind
Terminal window
ronja whoami

This asks the server rather than reading the file on disk, so a revoked token is reported as revoked. It prints the user, organization and role the CLI is acting as. Add --json for a machine-readable answer — every command supports it.

Terminal window
ronja context

This prints which instance you are on and as whom, your organization’s policy, how to authenticate, and then the instance’s own API index — the guides, what you can build, and where the endpoint reference, schemas, and built-in skills live. It reads that index live from the server, so it always matches the API in front of you.

The organization policy is the short document of standing rules your admins wrote for how work in your organization is done — the same rules Ronja follows in every conversation. It is printed under Organization policy right after the sign-in line, so a coding agent reads it before it builds anything; if none has been written yet, or your token cannot read it, one line says so. If you are an admin, the section also includes the ronja api -X PUT /api/v2/policy/org command that changes it, carrying the version you just read — see Policies. Pass --no-docs to skip the API index and keep the rest — the connection details and the policy.

If you run it inside a workflow, app, pipeline or automation folder — or in a folder holding Python — it ends by saying so and listing the commands for that kind of folder.

From there you work over plain HTTP. Load your stored login into the environment:

Terminal window
eval "$(ronja env)"

That sets RONJA_URL and RONJA_TOKEN. Because eval consumes the command’s output, the token goes straight into your environment without ever being displayed — so it stays out of your terminal history, your logs, and, if you are working with an AI agent, its transcript.

Terminal window
curl -H "Authorization: Bearer $RONJA_TOKEN" "$RONJA_URL/api/v2/authentication/me"

Environment variables do not survive between separate commands. If something runs each command in a fresh shell — most AI agent tools do — combine the two steps:

Terminal window
eval "$(ronja env)" && curl -H "Authorization: Bearer $RONJA_TOKEN" "$RONJA_URL/api/v2/feature/query"

If RONJA_TOKEN is already set, that value wins and there is nothing to load.

Every documentation path ronja context points at is served without authentication, so any HTTP client can read it (the organization policy it prints is the exception — that is read with your token):

Path What it is
/llms.txt The index: task guides, what you can build, and links to everything below
/docs/api/endpoints.md Every endpoint with the token scope it requires
/docs/api/skills.md Ronja’s own agent expertise, as an index
/docs/api/openapi.json Request and response schemas

/docs/api/skills.md is worth knowing about: it lists the same built-in skills Ronja’s own agent uses — how to design a feature, author a workflow, define a metric, build an app. It is an index, one line per skill; fetch the full text of the one you need from /docs/api/skills/<slug>.md. Read the relevant skill before building that kind of resource and you will get the shape right the first time.

ronja secret create stores an API key or a database login as a secret without the value ever appearing on your screen, in your shell history or in an AI agent’s transcript. You never pass the value as an argument: you type it at a hidden prompt, pipe it in, or point at a file.

An API key names the hosts it may be sent to — they become the secret’s Allowed URLs. Give the host name only, such as api.stripe.com or *.example.com, not a full URL:

Terminal window
ronja secret create --feature <feature ID> --name Stripe --host api.stripe.com

In a terminal this asks for the value with typing hidden. To pipe it in instead, for example from your clipboard, use pbpaste | ronja secret create ...; one trailing newline is removed. A key with more than one value names each with --field and takes a JSON object such as {"apiKey": "...", "apiSecret": "..."} from a pipe or --from-file. Every field is listed under Agent-visible fields, so a workflow can read it. Without --field the one field is called apiKey.

A database login names its database with --dialect and takes its fields as a JSON object from a file or a pipe:

Terminal window
ronja secret create --feature <feature ID> --name Warehouse --dialect postgres --from-file warehouse.json

Ronja checks that the login has every field the database needs to connect, such as the host, database and username, and names any that are missing.

The command prints the new secret’s ID, its fields and how long each value is, never the value itself. A length that looks wrong is how you spot a paste that was cut short:

secret-abc123 Stripe (api_key, feature <feature ID>, 1 field: apiKey — 107 chars)

It refuses:

  • a value written into a flag, such as --field apiKey=sk-..., because flags end up in your shell history;
  • an empty value, including nothing on stdin;
  • --dialect in a terminal without --from-file or a pipe.

When the value must not pass through whatever runs the command — an AI coding agent, a script — add --in-browser. The CLI then reads no value at all. It creates the secret without one and opens its page in Ronja, where you enter the value yourself:

Terminal window
ronja secret create --feature <feature ID> --name Stripe --host api.stripe.com --in-browser

The secret’s page opens on a short form: Connect and the secret’s name, one line saying which company Ronja will use the value with (“Ronja will use this to connect to stripe.com.”), the fields and Save. An address that is not what it looks like is flagged underneath in plain words: one with characters that may imitate another site is written out, an entry that is not a web address Ronja can use is named as one nothing will be sent to, and a wildcard says it covers every address ending in its domain. For a database login, the line says Ronja will use the login to connect to the host you enter. The last line says when the secret was requested and by whom — by you, by Ronja’s agent in a chat, through your access token, by name (the CLI’s is named after your computer), or by an app or automation — and to close the page without entering anything if you were not expecting it. Every API-key field requested by the CLI is required: fill them all before clicking Save. Leaving one blank keeps the secret waiting for its value. If the secret changed after you opened the page, saving is refused, what you typed is cleared and the page shows the secret as it is now, so check it again. Only you see the form: it shows only to the person the secret belongs to, and only while it is waiting for a value. Secret settings opens the secret’s full page.

The value never passes through the command, its arguments, your shell history or an agent’s transcript. That is what --in-browser guarantees, and all it guarantees: whoever can edit the secret or the feature’s workflows, including whatever ran the command as you, can still use the value and change where it goes.

The command waits up to 15 minutes for the value to be saved, then prints the same line as above, with “value entered in Ronja” where the lengths would be. If it stops waiting, the secret stays waiting for a value. Finish it on its page, or run the same command again to pick up the same secret. If you run it again with different hosts or fields while the first one is still waiting, the command stops and names what differs.

  • --no-browser prints the link instead of opening it.
  • It needs you signed in as yourself (ronja login). With an API token it is refused, since there is no person for the value to come from: start the secret in Ronja instead.
  • If you can already reach a secret pointing at the same host or database, the command lists it first. It does not stop you creating another.
  • A wildcard over a domain other people control, such as *.com or *.github.io, is refused for a value entered this way, and so is a host with characters other than a–z, 0–9, - and .: give an internationalised name in its xn-- form. The refusal is a floor, not a full list of hosts other people control, so still read the hosts on the card.

To see your secrets, rotate a value or delete one, use Manage secrets in Ronja or ronja api.

ronja secret connect connects a service Ronja signs you in to, such as Gmail, Google Sheets or HubSpot, from the terminal. It opens a page in Ronja titled Connect and the service’s name, which says what Ronja is asking to do (for example “Ronja is asking to read your Google Calendar.”). For a new connection it says who asked; for one you already have, it asks you to continue only if you started this yourself. With --scope, it lists the permissions asked for and says they replace what the connection can do now. Nothing opens at the provider until you click Continue to Google (or the provider’s name): its sign-in then opens in a new window, you approve there, and the connection is stored as a secret exactly as if you had signed in from the chat.

Terminal window
ronja secret connect gmail

Name the service as Ronja does, such as gmail, google_sheets or hubspot. If you mistype it, the command lists the services you can connect.

While you sign in, the terminal waits. When you are done, the sign-in window closes, the page says the service is connected, and the command prints the connection and the permissions it now holds:

connected: secret-abc123 (Google OAuth, scopes: https://www.googleapis.com/auth/gmail.readonly)

You have one connection per provider. Connecting a second Google service, such as Google Sheets after Gmail, adds its permissions to your Google connection. If the connection already holds everything the service needs, the command says already connected and opens nothing. --feature <feature ID> places a new connection in that feature; a connection you already have stays where it is, and the command tells you which feature that is.

On a service that lets you choose permissions, --scope picks them, once per permission. The connection is then asked for exactly those, replacing what it held, the same as unticking boxes under Permissions to request. Today Fortnox is the only service that offers a choice.

If the provider’s sign-in page let you untick a permission and you did, the command names what was not granted and exits with an error: anything needing it will fail. Run it again and leave those ticked. When you already had a connection and untick only the new permissions, nothing about it changes, so the command keeps waiting: press Ctrl-C and run it again. The command waits up to 15 minutes. If it stops waiting, you can still connect from the page in Ronja, or run the command again to wait again.

--no-browser prints the link to the page instead of opening it. The page lets only you connect, signed in to Ronja.

To reconnect a connection whose sign-in has expired, run the same command, or use Reconnect on the secret’s page in Ronja.

ronja token create gets an app, a script or a server its own personal access token, limited to the scopes it needs. The token is created by you, in Ronja, and the terminal never sees it: the command opens the token form already filled in, you click Create token, and you copy the token from the page into your app.

Terminal window
ronja token create --name "Sales dashboard" --scope data:read

Give each scope as <scope>:read or <scope>:write, once per scope, from data, structure, analytics, automation, agents, secrets and admin. At least one is required. A token with Full access is something you choose on the form yourself; the command cannot ask for one.

The browser opens Account → Access tokens with the Create personal access token form showing “Pre-filled from the Ronja CLI for your organization — review before you create.” The banner names the organization the token will be created in: if you are signed in to a different one, Ronja first offers to switch. The terminal prints the organization it asked for, so you can compare the two. Check the name, the scopes and the expiry, then click Create token. Ronja shows the token once — copy it then. Nothing is created until you click.

The token never expires unless you ask: --expires 90d sets a number of days (up to 3650), counted from when you open the form, and --expires 2026-12-31 a date, both in your browser’s timezone. If the date in the link is not after today there, the form shows it in red and Create token stays off until you pick a date or choose to let the token never expire.

While you are on the form, the terminal waits. Once you have created the token it prints the token’s ID, name, scopes and expiry — never the token itself:

created: api_token-abc123 Sales dashboard data:read expires never

Add --json for machine-readable output: a result with status: "created" and a tokens list containing each matching token’s ID, name, scopes and expiry. With --no-wait --json, the result is status: "pending" and an empty tokens list, because the command has not observed a token being created. Neither result contains a token value.

An expiry prints as a date and time with its offset, such as 2026-12-31T00:00:00+01:00. The command recognizes your token by its name, or by its scopes if you renamed it on the form; a different token created meanwhile, such as a sign-in from another terminal, is mentioned but not reported as yours.

--no-browser prints the link instead of opening it, and --no-wait returns as soon as the link is open. If nothing appears within 10 minutes the command stops waiting; a token you created is still listed under Account → Access tokens, where you can also revoke it.

Only an Admin can create a token, so for anyone else the command stops with “Only an Admin can create a personal access token.” A login limited to scopes needs the Admin scope; without it, create the token in Ronja instead.

ronja api makes a request to your instance with your credentials already attached. You give it a path; it adds the base URL and the authorization header.

Terminal window
ronja api /api/v2/authentication/me

This is the safer form of the eval "$(ronja env)" recipe above: your token is loaded inside the command and never reaches a shell variable, a command line, or an agent’s transcript.

It knows nothing about individual endpoints — it is a way to send a request, not a menu of things to send. Find out what to call from ronja context and the reference it points at.

To send a body, use -d:

Terminal window
ronja api -X POST /api/v2/feature -d '{"name":"Sales","scope":"private"}'
Flag What it does
-X, --method The HTTP method. Defaults to GET, or POST when you pass a body.
-d, --data The request body. -d @file.json reads it from a file, -d @- reads it from a pipe — which is easier than escaping JSON on a command line. A file is sent from disk at whatever size your Ronja accepts; a body from a pipe is held in memory and capped at 16 MiB, and so is a path that is not a plain file (a pipe, a device). A folder is refused before anything is sent, so point at a file inside it. --timeout covers the upload as well as the answer, so raise it — or pass --timeout 0 — for a large file.
-F, --form Send a file. -F file=@report.pdf attaches a file from your machine, -F note=draft adds a plain field. Repeat the flag for more than one. Use this for upload endpoints, which do not accept -d. A file is sent from disk at whatever size your Ronja accepts; -F file=@- reads from a pipe instead and is capped at 16 MiB, as is a path that is not a plain file (a pipe, a device). A folder is refused before anything is sent, so point at a file inside it. --timeout covers the upload as well as the answer, so raise it — or pass --timeout 0 — for a large file.
-H, --header An extra header, written as "Name: value". Repeat the flag for more than one. A body is sent as application/json unless you override it here.
-q, --jq Pull one part out of the response with a jq expression, so you do not need a second tool to read it. It prints every value it matched, or nothing at all — see What a jq expression can print.
-r, --raw With --jq, print text results without quotes — the form you want when the result goes straight into another command.
-o, --out Write the response to a file instead of printing it, readable only by your own user account.
--timeout How long to wait for one request. Two minutes by default; 0 waits for as long as the request takes, which paired with --retry leaves the command no time limit at all — it warns when you ask for both.
--retry Retry this many times if the instance is busy or briefly unavailable. Off by default.
--fail-on-error Treat a successful response that carries an error message as a failure. See the caution under Query your data.

The path must start with / — the instance address is added for you, so pass /api/v2/... rather than a full URL. It is sent exactly as you write it, query string included, so escape anything that needs escaping: a space in a search term has to be written %20, or the request will be rejected.

The response is printed exactly as the server sent it, so you can pipe it straight into a file. That includes error responses: if a request fails, the body still prints, a single line naming the status and path goes to the error stream, and the command exits non-zero — so a script stops rather than continuing on a failure.

If you ask for a path that does not exist, the reply suggests the closest real ones — most usefully when the path is right but the method is not.

Terminal window
ronja api -X POST /api/v2/file/upload/quarterly.pdf -F file=@./quarterly.pdf

The reply carries both an id and a key for the uploaded file. Keep both: different parts of the API ask for one or the other, and they are not interchangeable.

Terminal window
ronja api /api/v2/feature/query --jq '.result[].name'
featureID=$(ronja api -X POST /api/v2/feature -d @feature.json --jq '.id' -r)

Put the expression straight after --jq and any other flags after it. --jq takes a value, so --jq -r '.id' reads -r as the expression and the command refuses it, telling you the working form.

--jq prints every value the expression matched, or nothing at all. There is no limit on the count and you never get part of an answer, so what a script captures is either the whole list or an empty result beside a non-zero exit:

Terminal window
ids=$(ronja api /api/v2/feature/query --jq '.result[].id' -r)

However long the reply, that captures every id in it: there is no cut-off past which you would quietly get a prefix instead. If the expression fails partway — a row that is not the shape it expected — nothing is printed at all, rather than the good lines up to that point followed by a failure.

Two limits can stop an expression, and both end the command with a message rather than with part of the answer. An expression that reads a reply rather than building one will not meet either:

  • How much work and memory one expression may use, measured against the size of the reply. An expression that manufactures values instead of picking them out — range, recurse, repeat, a piece of text multiplied by a number — is stopped, and told to select from the reply it was given. A perfectly good expression over a very large reply is stopped too, and there the message says to ask the endpoint for less rather than to rewrite the expression: most listings accept ?limit=, and a smaller page you can filter beats a larger one that is refused.
  • Ten seconds per reply. This is separate from --timeout, which bounds the request, and from --wait-timeout, which bounds a --wait-until loop.

Both limits apply to --jq on ronja query in the same way.

Some work — a workflow run, a table build, a data sync — starts immediately and finishes later. --wait-until keeps asking until a condition is true, so you do not have to write a loop:

Terminal window
ronja api "/api/v2/workflow/run/$runID/head" \
--wait-until '.status == "done" or .status == "error"' --jq '.status' -r

Wait for the statuses that mean finished, rather than for “not running any more” — a Durable workflow can pause partway through at Waiting, and a condition like .status != "running" would treat that pause as the end and hand you a run with no result.

Ask for the run with /head on the end, as above, and a pause is handled for you: when a Waiting run’s wait ends, the work carries on in a newer run entry and the one you started is marked Continued, so a wait that watched the run you started would never see it finish. /head answers with whichever run currently carries the work, and you keep sending the id you started with. For a run that never pauses it answers exactly what the plain address does.

It asks every three seconds and gives up after five minutes; --wait-interval and --wait-timeout change both. It only works on requests that read, never on ones that create something — otherwise waiting would make one thing per attempt.

A condition is judged on every value it produces, and a condition that produces none is not satisfied — an expression that names a field the reply does not have would otherwise end the wait the moment the first reply arrived. Write one that comes down to a single value, such as [.result[] | select(.done)] | length == (.result | length).

Waiting for a table to finish building takes more than the verdict, because the request that starts the build answers before the build begins:

Terminal window
snap=$(ronja api "/api/v2/feature/model/$tableID" --jq '[.buildVerdict, .lastBuiltAt, .lastBuildError.occurredAt] | map(. // "-") | join("|")' -r)
# start the build, then:
ronja api "/api/v2/feature/model/$tableID" \
--wait-until "(.buildVerdict | IN(\"building\",\"pending\",\"invalidated\") | not) and ([.buildVerdict, .lastBuiltAt, .lastBuildError.occurredAt] | map(. // \"-\") | join(\"|\")) != \"$snap\"" \
--jq '.buildVerdict' -r

Take the snapshot before you start the build, and wait for it to change. The request that starts a build answers before the table has even left the state it was in, so a condition that names verdicts alone can be satisfied by the build before yours. The three fields in the snapshot are the ones only a finished build changes: a build that lands new data sets lastBuiltAt, a build that fails normally leaves lastBuiltAt untouched and stamps lastBuildError.occurredAt instead (a build that fails after its data has already landed moves both), and the verdict on its own cannot tell a fresh failure from the one that was already there. Because the wait ends only once one of them moves, the verdict it hands you is your own build’s — read lastBuildError beside it when that verdict is a failure.

That matters most when you are retrying: a table that already failed carries a failure verdict before your new build has done anything, so waiting on the verdict alone would report the old failure and its old message. On a table that has never built, the two clocks simply read -, and the same two lines work unchanged.

To ask whether a table’s updates are failing right now rather than whether your build did, read buildFailing instead: it is present only while failures keep coming (since is when they began) and disappears once a build publishes, whereas lastBuildError stays behind after a recovery.

invalidated is named alongside building and pending because it means a rebuild is queued, not a result. The API reference lists all seven verdicts and what each one means.

This is also the one place where waiting for not one of the in-flight values is the safe form, and only because a table’s in-flight values can be named in full: a build is queued, running, or over. (A table with several inputs can hold off on purpose until the rest of them have caught up — but it does so at Ready, which is not an in-flight value, so the rule above still holds.)

Your organization’s settings — the ones an Admin sets on the Approvals page — can be read and changed with ronja api too. Any member can read them; changing them needs the Admin role. There is no special command: they are ordinary requests.

They come in four groups, and each group is changed with its own request:

Group Holds
reporting The reporting currency, the number format, and the reporting timezone
models The default AI models and reasoning effort, and where requests may be processed
governance How many saved Agents may run at once, whether people may approve their own requests, and whether User Read-Only members can use Ronja’s AI
mail Who Ronja may email outside your organization, whether its email is plain text, your organization’s address for email-triggered automations, and whether an email-triggered automation may be opened to senders that are not on its allowlist

Read all four at once:

Terminal window
ronja api /api/v2/organization/settings

Change one or more settings in a group by sending only the ones you are changing. Anything you leave out stays as it is:

Terminal window
ronja api -X PUT /api/v2/organization/settings/reporting \
-d '{"reportingCurrency":"SEK","reportingLocale":"sv-SE"}'

The reply is the group as it was saved, so you can check what landed. In the rare case the change is saved but cannot be read back, the reply is 204 with no body: the change landed — read the group again to see it.

Opening email triggers is a mail setting. openEmailTriggersAllowed is what lets an individual email-triggered automation accept senders that are not on its allowlist — see Accept mail from anyone, which covers everything it costs:

Terminal window
ronja api -X PUT /api/v2/organization/settings/mail \
-d '{"openEmailTriggersAllowed":true}'

It is refused while agentEmailExternalMode is anyone — narrow who Ronja may email outside your organization first — and turning it back off closes every automation that was opened, immediately.

The currency and the number format are two settings. reportingCurrency is what your amounts are, and reportingLocale is how numbers are written — the separators, and which side the symbol goes on. Setting one never sets the other, so a Swedish organization reporting in kronor usually sends both, as above.

To see which values a setting accepts, ask for the list rather than guessing:

Terminal window
ronja api /api/v2/organization/settings/options --jq '.locales[].tag' -r

Three things are refused rather than quietly ignored, and each message says what to do instead:

  • A setting sent to the wrong group. The message names the request that does change it.
  • A setting sent as null. Leave it out to keep it as it is, or send "" to clear it.
  • "" on a setting that cannot be empty. Some always hold a value — the default effort, for example, and your organization’s email address for automations, which can be changed but not removed. The first time you set that address turns email-triggered automations on.

Every change is recorded in the organization’s history with its old and new value, and a request that changes nothing records nothing. An Admin can read that history:

Terminal window
org=$(ronja api /api/v2/tenant --jq '.id' -r)
ronja api "/api/v2/audit?targetType=tenant&targetID=$org"

Billing is not among these settings. Buying credits, cards, top-ups and your plan are changed only by the Super Admin, signed in to Ronja in a browser — see A few actions still need you in a browser.

ronja query runs read-only SQL against your Tables and gives you CSV back.

Terminal window
ronja query "SELECT * FROM {{ ref('tableID') }} LIMIT 10"

Tables are referenced by ID with {{ ref('tableID') }}, not by name — the ID appears in the table’s URL in the app. A table name such as Sales.orders is resolved when SQL or code is saved; this command runs a query instead of saving one, so it reads the marker straight through to the table’s data and a name there is not a table.

Real SQL is rarely one line, so it can come from a file or a pipe instead:

Terminal window
ronja query --file monthly-report.sql
cat monthly-report.sql | ronja query

Give the SQL in exactly one of those three ways. If you give none, the command tells you so rather than waiting.

Flag What it does
--out Write the CSV to a file instead of printing it. Nothing goes to the output stream, so this is the one to use for a result too big to read on screen — except with --json, where the file gets the CSV and the JSON object still prints. The file is readable only by your own user account.
--json Print the whole response — the CSV, the row count, whether it was cut short, and the reporting timezone — as a single JSON object.
--jq Pull one value out of that response — the row count, whether it was cut short, or the reporting timezone. Not the rows themselves: those are CSV, which jq does not read. Cannot be combined with --json. The same rule and limits apply as on ronja api: What a jq expression can print.
-r, --raw With --jq, print text results without quotes.
--max-rows Ask for at most this many rows. Without it you get the server’s own limit.
--timeout How long to wait. Two minutes by default; 0 waits for as long as the query takes.

A query over a lot of data is sent to larger compute automatically, and that can take minutes. If a heavy query stops with a timeout, it was not rejected — raise --timeout and run it again.

If your result hits the row limit, the command says so on the error stream and still exits successfully — the rows you got are real, there are simply more of them. Raise --max-rows, add --out to write them to a file, or aggregate in SQL.

Every successful query also prints Reporting timezone on the error stream, and carries it as zoneUsed in the --json response. It is there because the CLI sends no timezone of its own, so your query reads in UTC, while the same query in the app reads in your own timezone. Anything grouped by day, week or month therefore falls on different boundaries in the two places — the numbers differ and both are right. When a figure from the terminal disagrees with the same figure in the app, check this first.

A backslash in your SQL that probably does not mean what you intended — a '\\d' that now matches a literal backslash rather than a digit, say — earns an advice: line on the error stream, and an entry under advice in the --json response. Advice is never a failure: the query ran exactly as written. Backslashes in SQL says why.

ronja db works with a Managed database — the Ronja-hosted Postgres you build a system on, as opposed to the Tables ronja query reads. Everything here needs the Admin role.

Terminal window
ronja db sql <database ID> "SELECT * FROM leads LIMIT 10"

The SQL comes from an argument, a file, or a pipe — exactly as it does for ronja query — and the result is CSV, with the same --out, --json, --max-rows and --timeout flags.

One thing to know about numbers in that CSV: a decimal column with decimal places is written as the shortest number that reads back the same, so 1234.50 prints as 1234.5, and a value with more than about fifteen significant digits is rounded. When you need the digits exactly as the database stores them, ask for the text: CAST(amount AS TEXT).

Two values stop the statement instead of coming back wrong. A whole number too large for 64 bits in a column that has no decimal places — a big numeric used as an id — fails rather than being rounded into something that is no longer the id. So does a date holding the value infinity: a date column has nowhere to put it. Both messages name the column, and both are fixed the same way — CAST(the column AS TEXT) and read it as text.

Pass values into a statement rather than building them into it:

Terminal window
ronja db sql <database ID> "INSERT INTO leads (email, score) VALUES (\$1, \$2)" \
--params '["a@b.c", 42]'

Two things differ from ronja query, and both are worth knowing:

  • It cannot change the schema. This runs as the database’s write role, so CREATE, ALTER and DROP are refused. Schema changes go through ronja db migrate, which records them in the database’s own history.
  • A statement that fails is a plain failure. Unlike ronja query, there is no successful-response-carrying-an-error to watch for: the message prints, the command exits non-zero, and so does ronja api or curl if you call the endpoint yourself.

A write has no CSV to print, so it reports on the error stream instead: 2 rows affected, or 0 rows affected when the statement ran and matched nothing — that is an answer, not a failure. The count is for the statement’s outer command, so a change written inside a WITH clause is not counted in it. When Ronja cannot honestly say, the line is absent rather than zero: when the statement returned rows, when you sent more than one statement in a single call (only the last one’s count comes back, so no honest number exists for the body), and for a command that reports no count at all (DO, CALL, SET, TRUNCATE, COMMENT). --json carries the same answer as rowsAffected, null for that unknown case. The key is always there, so test for null — jq '.rowsAffected != null', or obj["rowsAffected"] is not None — never for the key’s presence, which is true even in the unknown case and so tells you nothing. (Call POST /api/v2/database/<database ID>/sql yourself and the field is left out instead of being null; there, and only there, the key’s absence is the test.) Either way, a script that treats the unknown case as 0 reports a large write as a no-op.

A new database has no connection roles at all, so your first statement fails until one exists. Create one:

Terminal window
ronja api -X POST /api/v2/database/<database ID>/user \
-d '{"access":"write","featureID":"<feature ID>"}'

Ask for each login once and reuse it — do not put this call in a job that runs repeatedly. Asking for a read, write or admin login that already exists replaces its password rather than creating a second one, so a scheduled job that “makes sure the write login exists” changes the password on every run. Everything inside Ronja that is already using the login keeps working, but anything holding a copy of the password outside Ronja stops. See Managed databases.

Keep your schema changes as .sql files in a migrations/ folder. Each file is one migration, named after the file, and they apply in filename order — so zero-pad the numbers, because 10_x.sql sorts before 9_x.sql.

Terminal window
ronja db migrate status --database <database ID>
ronja db migrate push --database <database ID>

status reports what is applied, pending or drifted and changes nothing. It exits non-zero when anything has drifted, so it works as a check in an automated job without anyone parsing its output. push applies everything not yet applied, all in one transaction: if the last one fails, none of them applied.

Both send the whole folder every time and let the database work out what is new, so running push twice is safe — the second run reports everything as already applied. After the first successful push the database is remembered, and --database is no longer needed in that folder.

If the database has a dev copy — a second database carrying the same structure, with either none of its rows or all of them — add --env dev to send a command to the copy instead of production:

Terminal window
ronja db migrate status --env dev
ronja db migrate push --env dev
ronja db sql <database ID> "SELECT count(*) FROM leads" --env dev

You always name the production database. Ronja resolves the copy for you, and the same migrations/ folder applies to both — which is the point of trying it on the copy first. If the database has no dev copy, --env dev fails and says so rather than quietly running against production.

Making a structure-only copy is a terminal job too, so a pipeline can stand its own sandbox up rather than depending on one somebody made by hand:

Terminal window
ronja api -X POST /api/v2/database/<database ID>/twin -d '{"mode":"schema"}'

That answers 202 with the copy at provisioning — poll GET /api/v2/database/<database ID> until its twin reports ready before migrating it. Rebuilding a structure-only copy from production’s current structure is the same shape, with no body — so name the method, because ronja api sends a GET when there is nothing to send:

Terminal window
ronja api -X POST /api/v2/database/<database ID>/twin/refresh

A refresh is refused while the copy is still being made, so wait for ready rather than retrying into it.

Two things stay out of a script. A copy with the production data is refused, because taking one disconnects the production database while it runs, and a script cannot be shown that and agree to it — make that one on the Databases page or ask Ronja in a chat. And discarding a copy stays on the page. Promoting is scriptable, and always was.

When the copy is where you want it, promote its migrations onto production:

Terminal window
ronja db promote <database ID> --dry-run
ronja db promote <database ID> --yes

--dry-run reports what would be promoted and changes nothing. The real promote asks you to confirm before anything is applied; --yes skips that prompt, which is what an automated job needs. Either way the migrations the copy has and production does not are applied in one transaction, and the command exits non-zero when the two histories have drifted apart — so a pipeline stops there instead of carrying on.

Creating, listing and deleting databases stay on ronja api:

Terminal window
ronja api -X POST /api/v2/database -d '{"name":"operations"}'
ronja api /api/v2/database/query

ronja wf — short for ronja workflow — keeps one Workflow’s Python in a folder on your machine. You edit it in your own editor, keep it in your own version control, and push it to Ronja when it is ready. Everything you push goes to your own draft of the workflow, never straight to the live one.

Use this rather than ronja api whenever the code is in files. It sends the whole folder in one command, keeps the workflow’s parameters in the folder alongside the code, and runs and waits for a test run for you — none of which you get from sending files one at a time.

From a workflow that already exists:

Terminal window
ronja wf clone <workflow ID>

The workflow ID appears in the workflow’s URL in the app. If you already have a draft of it — from the app, or from an earlier push — those are the files you get, because they are your newest version.

Or from a Python script you already have:

Terminal window
ronja wf init --from scripts/monthly_report.py --feature <feature ID>

The feature ID appears in the feature’s URL. Unlike clone, which copies the workflow down into a new folder, init sets up the directory you are already in — so you can run it inside an existing repository. Nothing is created in Ronja yet: the workflow comes into existence on your first push, so you can get the script working first and only then have it appear in the app.

Terminal window
ronja wf status # what changed locally, and in Ronja
ronja wf validate # check the folder, save nothing
ronja wf push # send it to your draft
ronja wf test --param month=2026-07-01 # run the draft and wait for it
ronja wf publish # take it live, or ask for review
ronja wf run --param month=2026-07-01 # run what is now live, and wait for it
Command What it does
status Files you changed since your last push, whether you have a draft, and anything that changed in Ronja since — the app edits the same draft, so this is worth checking before you push.
validate Sends the folder to Ronja and reports what a save would reject: a table, agent, Codex or workflow reference that does not resolve, a missing main file, a file name Ronja cannot store. A secret it cannot reach is reported as a warning instead — the save succeeds and that credential is simply not bound. A main file Ronja will not accept as a path — one that is not a .py file (the .py is lowercase, so Main.PY is not one), or a name it cannot store — is a warning too: setting the main file to such a name is refused, but a workflow that has carried one since before that rule keeps working, and validate cannot tell the two apart — so it reports it without failing the check. Read that warning before you push: validate does not block the push, but the push is refused when it would change the main file to that name — whichever of the two reasons the warning gave. On a first push — the one that creates the workflow — the refusal comes before anything exists, so nothing at all is written and there is no half-made workflow to go looking for. On a push to a workflow that already exists, a hand-edited ronja.json naming such a file is refused after your files have gone up, because the settings are written last. When the push only restates the name the workflow already has, it goes through. A main file with nothing to run in it is a warning as well: every line in it is blank or a comment, which is what you get when the workflow points at a file your code is not in. The folder still saves, and a run of it is refused before it starts. A main file that wraps its code in if __name__ == "__main__": is a warning too, and nothing later refuses it: Ronja does not guarantee the main file runs as __main__, so on standard compute the block is skipped and a run can finish done having done nothing, while on dedicated compute — which Ronja also picks on its own for a large or memory-heavy run — the block runs. Move the block’s code to the top level of the file, or define main() and call it at the end of the file with no if around it — on a Durable workflow, end with result = main() and then a last line holding just result, so the run keeps what main() returned. A workflow you run that is not published yet is a warning too: the reference saves, and every run of that call is refused until you publish it. Parameters you declared are checked the same way: a parameter with no name is an error, while a questionable declaration — an option list on a parameter that is not a dropdown, a default value that does not match the parameter’s type, a description longer than Ronja is shown — is a warning you can push past. Nothing is saved, and the workflow does not have to exist yet. It also reports code the folder’s runtime cannot run, and a folder that names no runtime is checked against 3 — the runtime a new workflow is created on — so these are the first errors to expect when you bring older code across: a call to tools.getData(...) or tools.getAllData(...), a table read straight out of Ronja’s own storage by s3:// URI, and a single tools.query naming more than 50 tables. It also checks the folder’s own dependency names before anything is sent — a name no stack answers, or one used under the wrong kind of marker — and those fail the check, because push refuses them. If Ronja does not answer, validate says so rather than reporting a problem: nothing was checked, and that is not a verdict on your files.
push Validates first, then copies the folder into your draft — creating the draft, or the workflow itself on a first push. Add --no-validate to skip the check, --force to overwrite a draft that changed in the app since your last push. If a single file changed in Ronja since you last synced, push stops on that file and names it rather than overwriting it. If the save had to drop a secret it could not reach, the files still land but the command exits non-zero and lists what was dropped — the workflow would fail the moment a run touched that credential. Add --allow-dropped-bindings when you mean to connect it later. If Ronja does not answer, push says so and names what it had sent by then — nothing further was pushed, and that is not a verdict on your files.
test Runs your draft and waits for it, then prints the status, log and outputs. Pass each value as --param name=value. Add --stale-ok to run the draft as it stands when the folder has changes you have not pushed. If the draft is Durable and pauses, test stops waiting there and reports the status as waiting with what ran so far — it does not sit out the pause. It exits zero, because nothing failed: the run resumes on its own, so check it in Ronja for the rest. Add --follow to keep waiting through pauses until it finishes (raise --timeout, or pass --timeout 0, for long pauses). If your draft runs another workflow that is not published yet, the run reaches that call and refuses, naming the workflow to publish — a test run is no exception to the rule that only a published workflow can be run. A run can also fail before your code ever starts, while Ronja is still setting it up; test says nothing in your code ran and names the reason, so you run it again rather than going looking through your files for a bug. One refusal comes even earlier, before there is a run at all: if the file the workflow runs holds nothing but blank lines and comments, test is refused outright and there is no run to open in Ronja. The message names that file — put your code in it, or point the workflow at the file that already holds your code.
publish Publishes a workflow that has never been live, commits your draft onto one that has, or — on a shared workflow only an admin can commit — submits it for review and tells you so. If somebody else published a change while you were working, publish refuses and tells you what moved; add --overwrite-remote to commit yours anyway and replace theirs.
run Runs the live workflow — the one your automations and colleagues use — and waits for it, then prints the same report as test. Pass values as --param name=value. It runs the workflow this folder is for and takes no workflow ID; to run some other workflow, send POST /api/v2/workflow/<workflow ID>/run with ronja api.
discard Throws your draft away. The live workflow and your local files are untouched. Asks first; pass --yes when there is no terminal to ask on. A workflow that has never been published is its draft, so discarding it would delete the workflow — that needs --delete-workflow. It is a soft delete: the workflow goes to the trash for 30 days, your local files are kept, and the folder is unbound so your next push creates a fresh workflow.

status, push and publish also print a URL: line — a link to the workflow in Ronja, so you can open it — which is simply left out, and never reported as a failure, when the command didn’t reach Ronja or your instance has no web address configured.

Every command takes --json, and each one exits non-zero when it fails, so an automated job stops rather than continuing on a broken workflow.

A handful of older workflows carry a legacy Pre-run approval gate, which requires approval before every run. Those cannot be run from the terminal at all — the approval can only be given in a chat, so run or test that kind from a chat in the app, or ask Ronja to convert the workflow to a mid-run approval. A mid-run approval is fine here: the run starts normally, and if it reaches the step that asks it waits for someone to decide on the run’s page.

ronja wf run is the step after ronja wf publish: it runs the version everyone else uses, rather than your draft. It runs the workflow this folder is for, so it takes no workflow ID at all — to run a different one, send the request yourself with ronja api.

It refuses when your folder holds anything the live workflow does not, or when it cannot tell — and it always says why it stopped:

  • You have a draft open. Your folder is tracking that draft, and ronja wf test is what runs it. Publish it or discard it first.
  • The folder has changes that are not live. The run would not be the code you are looking at. Push and publish them, or test them as a draft.
  • The folder has no record of a sync. The .ronja folder that records what you last synced stays out of version control, so a copy taken from Git has nothing to compare the live version against — there is no way to tell whether it holds the code that would run. Use ronja wf clone into a fresh folder.
  • The folder’s record is of a different version. This is the shared-workflow path: ronja wf publish submitted your draft for review, and when an Admin approved it your folder was never told, so the record it kept still points at the draft. Your files are usually exactly what went live — what is missing is the record that says so — and ronja wf status shows the comparison.
  • Your account or your token may not start runs. Starting one needs the User role and a token carrying the automation write scope, so a User Read-Only account is turned away. Where the workflow lives makes no difference: read access to the workflow is enough, in a shared feature as much as in your own.
  • Your credential can no longer see the workflow. A not found straight after a successful push usually means your access to it changed while you were working. Re-run ronja wf status to see what your credential still reaches, and ask an Admin if the workflow is gone.
  • A run is already going and the workflow skips overlapping runs. Nothing starts, and you are usually told which run is in the way — the workflow is set to Skip if a run is in progress; see Concurrency.

The other direction is a note, not a refusal: if somebody published over the workflow since your last sync, the run goes ahead and tells you so.

If the request to start the run times out, run does not tell you it failed — a run that Ronja has already recorded keeps going whether or not the request came back, and starting a second one would do the work twice. It looks for the run that started and follows that one instead. When it cannot find it, it says the run may still be running and how to check, so the decision to start another is yours rather than an accident.

A workflow that writes to a table writes to the real table here as well — that is what publishing it meant, so there is no flag to confirm it, unlike test.

If the workflow is Durable and pauses partway through, run stops waiting and says clearly that the run has not finished: nothing failed, but this is not a completed check. It resumes on its own, so look at the run in Ronja for the rest. Add --follow to keep waiting through pauses until it finishes (raise --timeout, or pass --timeout 0, for long pauses) — the report then describes the run that carried the work to the end, which after a pause is a later run than the one that started.

A run can also fail before your code ever starts — while Ronja is still setting the run up. Both run and test say nothing in your code ran and name the reason, so the next step is to run it again rather than to go debugging: it is not a report about your files. The one reason that is about your folder is a bad entry under pipPackages in ronja.json, and the message says so.

File Keep it in version control?
Your Python, including any files it imports Yes
ronja.json — the title, the file Ronja runs first (main.py unless you change it — it has to be a .py file in the folder, and the .py is lowercase: Main.PY is a different file, not another way of writing main.py), the workflow’s parameters, its runtime, its reporting timezone, and which organization each of its stacks deploys to Yes
ronja.lock.json — which workflow each stack’s pushes created. Written only once the folder names stacks Yes
.ronja/ — the record of what you last pushed No; it is ignored for you automatically
Anything else beginning with a dot — .env, .git/, .venv/ Your choice; Ronja never syncs them

Hidden files are never sent. Anything whose name starts with a dot, at any level of the folder, stays local — so a .env beside your code is not uploaded, and a .git/ directory is not walked. status, validate and push each say which files they left out and why, so nothing disappears quietly. A workflow that already has a file with a hidden name cannot be cloned into a folder at all: the file would be written once and then invisible to every later command. Rename it in the app first.

The mapping in ronja.json records one entry per instance and organization, so the same folder can target a test organization and your production one — even on the same instance — without either overwriting the other. Use --profile or --url to choose which one a command talks to.

Stacks: naming the places a folder deploys to

Section titled “Stacks: naming the places a folder deploys to”

A folder can give each of those places a name — dev, staging, prod — and then say which one it means:

Terminal window
ronja wf push --stack prod

A name is easier to type and to read in a diff than “the entry for https://… in organization ten-…”, and it lives in the repository, so prod means the same thing to everybody on the team. It is not a login name: --stack says where you are deploying, --profile says which credential gets you there.

Naming them splits the folder’s bookkeeping in two, and both halves belong in version control:

File Holds Changes when
ronja.json the stacks — where each one points and which feature it uses you decide something
ronja.lock.json what each stack’s deploys created every deploy

That is the point of the second file. It keeps the file people review stable, and it gives an automated job something committed to compare against, so a job running from a fresh checkout can tell whether somebody changed things in Ronja since the last deploy.

A fresh checkout can push safely. The folder’s sync record — which files you last synced, and your draft — is deliberately not committed: your draft is yours, and a colleague inheriting a pointer to it would be pointed at something they cannot open. So an automated job, and anybody who has just cloned the repository, starts with nothing to compare against. ronja.lock.json is what fills that gap. For a table it records the SQL the table held when the folder last agreed with it; for a workflow and an app it records which published version the folder was taken from. A push from a fresh checkout of a folder that names stacks reads where things stand now and either proceeds, because nothing has been published since, or stops and names the version somebody else published. Either way you do not need --force, and you should not use it there: it would replace their work rather than showing it to you. This works from the first push onwards — a workflow or app you have created but not yet published has no published version behind it to protect, so a fresh checkout pushes to it just the same. If you do use --force to push past a version somebody else published, the record of which version you were taken from is cleared rather than moved forward: their work is not in your folder, so nothing here may vouch for it, and the next fresh checkout asks for --force again rather than proceeding quietly. discard leaves the record alone for the same reason — it throws your draft away and leaves your files exactly as they are.

A folder created before stacks has nowhere to keep that record, so it still asks for --force from a fresh checkout. Naming a stack is what changes it.

{
"formatVersion": 2,
"kind": "workflow",
"title": "Region Report",
"entrypoint": "main.py",
"stacks": {
"dev": {"url": "http://localhost:8098", "tenantID": "development", "featureID": "col-…"},
"prod": {"url": "https://app.ronja.tech", "tenantID": "ten-…", "featureID": "col-…"}
}
}

You do not have to name anything. A folder created before stacks keeps working exactly as it did, and push leaves its ronja.json unchanged. It moves the first time you name a stack:

Terminal window
ronja wf push --stack dev # names this organization's entry "dev", once

The CLI moves that entry into stacks, writes ronja.lock.json, and says so. From then on the folder still works with no flag at all — the stack that matches your credential is chosen for you — and --stack is needed only when more than one could apply. If you are bound to two organizations, name them one at a time; the ones you have not named yet keep working the old way.

Once a folder names stacks, everybody working on it needs a ronja new enough to read the newer file format — check before you name one. A build from this release on will refuse the file and tell you to upgrade. An older build will not: the version marker is new in this release, so a build that predates it drops the part of the file it does not recognise, reads a deployed folder as though it had never been pushed, and creates a second set of resources on its next push. That is the reason to name a stack only once everyone on the folder has upgraded.

A few things are refused on purpose, because each one would otherwise create a second copy of your work without saying so: a --stack whose organization your credential cannot reach, a name that is a near-miss for one you already have (the message names the one you meant), naming a second stack for an organization this folder already names, and a new name that differs from an existing one only in capitalisation.

One more is refused with no flag involved at all. If ronja.lock.json records a stack that ronja.json does not name — the half-written state a push that failed between the two files leaves behind — a command run without --stack cannot see the ids under it and would create a second set beside them. It stops instead, and names the stacks the lock knows: naming one with --stack adopts what is already there and repairs the pair.

If a folder does end up with two stacks pointing at the same organization — a merge is the usual way — commands still run: --stack picks one, and a command run without the flag lists both and asks you to name one. What the two cannot have is separate records of what you last pushed. That record is kept per organization rather than per stack name, so both stacks compare your files against the same one, and the folder starts reporting changes you did not make. The messages say so when they list the two. One folder tracks one deployment per organization; use a second folder for the second deployment.

ronja wf status reports which stack it is looking at, once the folder’s files name one. clone and init take --stack too, if you want a new folder to start out named; init --stack writes ronja.lock.json only once your first push has created something to record.

Everyone sharing the folder does not have to be on the same ronja version. A setting a newer version added — at the top level of ronja.json or ronja.lock.json, or inside one of their entries — is left exactly as it was when your version rewrites the file, rather than being dropped. (A new field nested inside parameters or access is not preserved.) If the file was written in a format your version cannot read at all, the command says so and asks you to upgrade rather than rewriting it. ronja --version reports what you are running.

Dependencies: naming the things your code reads

Section titled “Dependencies: naming the things your code reads”

This works the same way in all four kinds of folder — a workflow’s, an app’s, a pipeline’s and an automation’s — and it is described here once.

Your code refers to things it does not own — a table it reads, a secret it authenticates with. Written as an id, {{ ref('table-abc') }}, that folder can only ever be pushed to the organization the id came from: an id belongs to one organization, and in the next one it means nothing.

So a folder can give those things names of its own instead, and let each stack say which of its organization’s rows each name refers to:

{
"formatVersion": 3,
"dependencies": {
"orders": {"kind": "table"},
"hubspot": {"kind": "secret"}
},
"stacks": {
"dev": {"url": "http://localhost:8098", "tenantID": "development", "featureID": "col-…",
"bind": {"orders": "table-…", "hubspot": "secret-…"}},
"prod": {"url": "https://app.ronja.tech", "tenantID": "ten-…", "featureID": "col-…",
"bind": {"orders": "table-…", "hubspot": "secret-…"}}
}
}

Your code then says {{ ref('orders') }} everywhere, and the CLI substitutes the right id for whichever stack you are pushing to. kind is one of table, secret, agent, codex, mailbox, workflow, module or note. note is the one that never appears in code — nothing in a workflow’s Python or an app’s TSX points at a note — so a note name is only ever written in an automation file’s references.

"formatVersion": 3 belongs with them, and ronja writes it for you the moment you add a name — so you only have to think about it if you write dependencies into ronja.json by hand. It is what stops an older ronja opening the folder: names mean nothing to it, so it would send orders to Ronja as though it were an ID, and for a secret Ronja answers that with a warning rather than a refusal — a push that looks like it worked, deploying a workflow with nothing connected. A folder that declares names and claims an older format is refused when you push it, and the message names the line to add.

The names belong to the folder, not to your organization, and that is the point of them. A table name is unique inside its feature, but two features may each have an orders — the folder picks the one it means, says which in bind, and stops caring what anything is called anywhere else. A name may contain a full stop, so "Sales.orders" is a perfectly good thing to call a dependency; it is still resolved on your machine, and the ID is what goes to Ronja. Both halves live in ronja.json, where a reviewer can see them: what the code needs, and which row answers it here.

An app’s access block takes these names too. An app’s permissions are not worked out from its code — you list them (see Work on an app from your editor) — so a folder that used names in its code and IDs in its permissions would still only deploy to one organization. Write the name in the list instead, and each stack’s bind answers it:

"dependencies": {"orders": {"kind": "table"}},
"access": {"allowedTableIDs": ["orders"]}

A name in allowedMetricIDs is declared as a table, because a metric is a kind of table and shares its IDs. Anything in one of these lists that is neither a name you declared nor an ID is sent on as you wrote it, and Ronja tells you it cannot find it.

One thing the CLI will not let you do: write an ID that one of your own names already answers to. ronja app push refuses it and tells you which name to use instead. Nothing else would ever catch it — an ID matches itself, so no command would report anything wrong, and the app would simply be granted the first organization’s row everywhere you pushed it. You would find out when somebody opened it somewhere else and it showed nothing.

Filling in the second half is what ronja bind does.

Terminal window
ronja bind --stack prod

For every name with no answer on that stack it looks for a resource of that kind called exactly that, shows you what it found, and writes the ones you accept. It works in a workflow, app, pipeline or automation folder alike. --yes skips the confirmation, which is what an automated job wants; it exits non-zero while any declared name is still unbound, so a job can use it as a check.

Two things it deliberately will not do:

  • Only exact name matches are offered. If nothing is called that, or several things are, it says which of the two happened and leaves the name alone — including under --yes. A near match is a guess, and a wrong answer here points your code at somebody else’s table without anything failing. It also stops short when the search comes back full, even if only one thing in it matched: a full result list is not a complete one, so it names what it found and asks you to confirm it by hand rather than acting on a list it could not see the end of.
  • It cannot offer a Codex. The search behind it does not cover Codexes, so it says so rather than reporting “nothing found”; set those by hand in ronja.json.

Promoting a folder to a second organization is what all of this is for, and it is one command. Deploying somewhere new needs a stack for it, so --feature declares one as it binds:

Terminal window
ronja bind --stack prod --feature col-… # declare prod, then bind its names
ronja wf push --stack prod

Where it points and which organization it belongs to come from the credential you are signed in with. --feature says which Feature this organization’s new resources are created in — the one thing the CLI cannot work out for you, and the same choice ronja wf init --feature asks for.

Leave --feature off and a stack the folder does not have is refused rather than half-written, because a stack with no Feature is a decision that only looks like it has been made. It will not move an existing stack to a different Feature either: that changes where everything is created from then on, so it is an edit to ronja.json and a review, not a flag. Naming the Feature a stack already uses is fine and does nothing, so an automated job can run the same line every time.

The code does not change between the two deploys, and no id from either organization ends up in a file anybody edits by hand. What a reviewer sees is one stack and one bind map — the decisions, written down once.

A workflow’s parameters — the values you fill in when you run it, and the ones you pass as --param name=value — are declared in ronja.json, so they live in version control next to the code that reads them. Your Python reads each one with tools.getVariable("name").

"parameters": [
{ "name": "month", "label": "Month", "type": "date", "required": true },
{ "name": "upto", "label": "Up to", "type": "number", "defaultValue": 10 }
]

type is string, number, date or select. Add description to explain the value, defaultValue to prefill it, required to insist on it, and for select, either options (a fixed list) or optionsQuery (SQL that produces the list).

description is read by Ronja too, not only by the person filling in the parameter inputs. It is the one place the declaration can say what a value means — that a text parameter wants comma-separated addresses, or an ISO week rather than a calendar one — and a type cannot. Leave it out and Ronja picks something plausible: the run succeeds, and the answer is wrong. Keep each one to a single short sentence, because the copy Ronja is shown is cut at 140 bytes. validate warns you when one is longer than that.

defaultValue does more than fill the box in the run form: a run that leaves the parameter out is given the default, so the script receives it exactly as if it had been passed. That is why a parameter with a default is never reported as missing, even when it is required.

Write the default in the form the parameter’s own type accepts, because a default the type cannot hold is not applied — the run goes ahead and your script gets nothing for that parameter. A date default has to be YYYY-MM-DD: "2026-01-02", never "01/02/2026", which could mean either day and so is refused. That one is worth checking by hand — a date in the wrong form is still a perfectly good string, so nothing flags it. ronja wf validate reports a default of the wrong kind (a number where a date belongs), but not a date string it cannot read, and the run itself says nothing at all.

ronja wf push makes the workflow match what you declared — adding, changing and removing parameters. ronja wf status shows what you have declared and tells you when it differs from the workflow. ronja wf test --param accepts only the names you declared, so a typo is caught before anything runs — and a required parameter without a default left empty (--param month=) is refused the same way.

If ronja.json has no parameters at all — which is how folders created before this worked look — the CLI leaves the workflow’s parameters alone, and you can keep editing them in the app. Add the key (use [] if there are none yet) to take ownership of them from the folder.

"runtime": 3 is what a new workflow is created on, and it is the one to leave alone unless you have a reason not to. It is Durable — the runtime whose steps are recorded, so a run that fails can pick up where it stopped, and a run can pause partway through: until a set time, while a Saved Agent works, or at Awaiting approval while a person decides — and it reads tables through tools.query("SELECT ... FROM {{ ref('table-id') }}") rather than opening a table’s files itself.

"runtime": 3

Leave the key out and the workflow is created on 3; the push that creates it writes the runtime it was given into ronja.json, so the folder you commit says which one your code is written for.

"runtime": 2 is Durable without that change — its code reads a table’s files directly. "runtime": 1 is the standard runtime, which records nothing and cannot resume. Name either one to pin it: ronja wf init --runtime 2 (or --runtime 1) writes the key for you, and ronja wf clone writes whichever runtime the workflow you cloned is on.

ronja wf push raises a workflow on a lower runtime to match what the folder declares, and tells you it did. That is a one-way change: a folder declaring a runtime below the one the workflow already has is refused before anything is written, because the runtime cannot be lowered. Upgrading does not rewrite your Python — change the code in the same push, or the next run fails on code written for the other runtime. A runtime this version of the CLI does not recognise is refused when it opens the folder, so it fails at the push rather than at a run.

ronja.json can also declare the workflow’s reporting timezone — the calendar its runs group days, weeks and months in. It is how you change that calendar from the terminal, and it keeps the setting in the same commit as the code it applies to.

"reportingTimezone": "Europe/Stockholm"

The key works like parameters, in the same three states:

In ronja.json What push does
Not there at all Leaves the workflow’s timezone alone
"" Sets the workflow to UTC
"Europe/Stockholm" Makes the workflow match

Leaving it out is what a folder made before this existed looks like, so it has to mean leave it alone — otherwise the first push after upgrading would quietly move every one of those workflows to UTC. ronja wf clone writes the key when the workflow has a timezone; ronja wf init leaves it out, so a brand-new workflow inherits your organization’s default.

"" means reset to UTC, not “no timezone”. There is no way to put a workflow back to having none: a workflow with no timezone is one created before workflows had the setting, or one created while your organization has no default, and it follows whoever started the run instead. ronja wf status reports a difference between the folder and the workflow, and ronja wf push refuses to overwrite a timezone somebody changed in the app since you last synced.

ronja app is the same loop for an app. Everything about the folder works the way it does for a workflow — the same ronja.json, the same record of what you last pushed, the same one-entry-per-organization mapping, the same warning when someone else has changed things since you last synced.

Terminal window
ronja app status # what changed locally, and in Ronja
ronja app clone <data-app-id> # or: ronja app init --feature <feature-id>
$EDITOR App.tsx
ronja app push # sends your files, then checks that they build
ronja app validate # run the build check on its own
ronja app test # open the app in a browser for you and report what it saw
ronja app publish # publish it, or ask an admin to
ronja app discard # throw your draft away
Command What it does
init Sets up the directory you are already in, optionally copying in a component you already have with --from. --feature is required, --title names the app. Nothing is created in Ronja yet.
clone Copies an existing app’s files down into a new folder, permissions included. Prefers your own draft over the published version, because that is your newest work. Nothing is created in Ronja.
status Files you changed since your last push, whether you have a draft, whether a clean build stands for it, which permissions differ from ronja.json, and anything that changed in Ronja since. Read-only, and the local half works signed out. It reports either that the draft builds or that no clean build stands since the last change — which is equally true of a draft that failed to build, one pushed with --no-validate, and one whose check never got an answer, because no build result is stored. Run ronja app validate to find out which; status will not tell you there is an error when there may be none.
push Creates the app if this is the first push, then sends the permissions, then the files, then checks the build. Add --no-validate to skip the check, --force to overwrite a draft that changed in the app since your last push. Like a workflow, each file it sends says which version it expects to be replacing, so a change somebody makes while the push is running stops it and names the file instead of being lost.
validate Rebuilds your draft on its own and reports what failed. Warns when the folder holds changes you have not pushed — it checks what is on the server, not what is on your disk. It also checks the folder’s own dependency names, which can fail the check even when the draft builds — push would refuse that folder.
test Opens your draft in a real browser somewhere in Ronja, waits for it to finish loading, and reports what it saw: a screenshot, anything that went wrong while it ran, and what it fetched. Writes a report.json and the screenshots into .ronja/test/, the local-only folder Ronja never syncs, and prints the path (--out-dir puts them wherever you say instead). --route '#/orders' opens a particular view; --viewport mobile renders it phone-sized.
publish Publishes the app, or — on a shared app only an admin can commit — submits it for review and tells you so. Either way it also tells you who can open the app and what they can do in it — the same sentence Ronja says in chat. Refuses a draft that does not build. --no-request-review turns the review route into a failure instead of submitting, which is what you want in an automated job that expects the change to go live directly — but check the self-approval policy first: where your organization has turned it off, a job publishing a draft its own token authored is refused, and with this flag that refusal is the job’s exit code rather than a review request.
discard Throws your draft away; the published app and your local files are untouched. Asks first; pass --yes when there is no terminal to ask on. An app that has never been published is its draft, so discarding it would delete the app — that needs --delete-app. It is a soft delete: the app goes to the trash for 30 days, your local files are kept, and the folder is unbound so your next push creates a fresh app.

status, push and publish also print a URL: line — a link to the app in Ronja, so you can open it — which is simply left out, and never reported as a failure, when the command didn’t reach Ronja or your instance has no web address configured.

Every command takes --json, and each one exits non-zero when it fails.

ronja.json says which kind of folder it is, so running a workflow command inside a data-app folder is refused rather than doing something surprising.

Like a workflow, a new app is a draft until you publish it. Your first push creates it unpublished and visible only to you — nobody else sees it in the feature, and an app you start and abandon leaves nothing behind. ronja app publish publishes that same app: the ID never changes, so a link you shared while building keeps working.

Four things work differently from a workflow, and each one is visible the first time you use it.

The first file is always App.tsx. An app builds from that file and the name cannot be changed, so the folder simply reports it.

What the app is allowed to read lives in ronja.json. A workflow’s tables and secrets are worked out from its code. An app’s are not — you say what it may reach, under access:

"access": {
"allowedTableIDs": ["table-abc"],
"allowedSecretIDs": [],
"allowedAgentIDs": [],
"allowedWorkflowIDs": [],
"allowedCodexIDs": [],
"allowedMetricIDs": [],
"capabilities": []
}

Each entry can be an ID, or one of the names your folder declared under dependencies — which is what lets the same app folder deploy to two organizations. See Dependencies.

An app pushed without this builds and displays, but has nothing to show — so fill it in before your first push. Nothing later reminds you: an app with an empty list compiles, validates and publishes exactly like a finished one, and the gap only shows when somebody opens it. Because these are permissions rather than settings, ronja app push and ronja app status list every single thing being granted or taken away, one per line, instead of just saying that something changed. You can only grant what you can already reach yourself, and for a shared feature an admin still has to approve the change.

Leave capabilities empty for an app that reads Ronja tables — listing the table is all it needs. Only four names ever belong there, one per thing an app can do beyond reading your data: ai (ask the AI for a completion), query_external (read an outside or managed database), write_external (write rows to a managed database), and upload_file (take a file upload). Declare only the ones your app uses. Anything else is a mistake, and ronja app push refuses it before it sends anything, naming what it found — a misspelled capability would otherwise publish without complaint and fail only when somebody opened the app. ronja app init --help describes the same four.

If ronja.json has no access at all — which is how folders created before this worked look — the CLI leaves the app’s permissions alone and you keep editing them in the app. Add the key to take ownership of them from the folder.

Pushing takes a few seconds per file. Ronja rebuilds the whole app on every file it receives, so a push says which file it is working on. It also means the app will not build for a moment in the middle of a push — a file that mentions another one you have not sent yet. That is expected and is not a failure; your files are saved either way. The check at the end is the one that counts, and it is reported as Compiles: yes or Compiles: NO with the errors. If the compiler itself does not answer, the push says so and does not call your files wrong.

An app that does not build cannot be published. ronja app publish refuses it and shows you the errors, and the live app keeps serving its last published version until you fix them. ronja app validate runs the same check on its own.

ronja app test looks at the app for you. Ronja opens your draft in a real browser, waits for it to finish loading, and hands back what it saw:

Terminal window
ronja app test
ronja app test --route '#/orders' --viewport mobile

It writes report.json and one screenshot-1.png, screenshot-2.png, … per picture taken, plus screenshot.png for the one that best represents the finished page, when the render produced one. They go into .ronja/test/ inside the folder — the local-only directory that is never sent to Ronja and never committed — and the path is printed on every run. Pass --out-dir to put them somewhere else; whatever you name is used exactly as given, the folder itself included. Because .ronja/test/ is a location the CLI picked rather than one you typed, it stops with an error — before rendering anything, so you have spent nothing — if a symbolic link would send those files outside the folder, which can happen in a folder someone else committed to git. Remove the link, or name your own --out-dir. Keep them out of the folder itself, though: a screenshot sitting beside App.tsx is a picture, not source, and ronja app push refuses a folder holding one — it says so, and says the files look like ronja app test output. Some renders take no pictures at all — a bundle that did not build has nothing to photograph — and running it again clears the previous run’s pictures first, so what you are looking at is always this run. On screen you get a short summary: whether the page settled, how many errors it hit, and what it fetched.

It is a report, not a verdict. There is no pass or fail, and it never blocks anything — ronja app test finishes successfully even for an app full of errors, because the errors are the point of the report. Read it in this order: look at the screenshot; check whether the page settled — either the app said it had finished everything it started, or it simply went quiet (if it did not settle, it was still working when time ran out — that is slow, not broken, and --timeout gives it longer than the 20 seconds it waits by default); look at what it fetched and how many rows came back, because a blank panel above a query that returned nothing is usually the data, not the code; and only then read the errors, which name the line in App.tsx. Pass --fail-on-errors when you want an automated job to stop on them — it stops on an app that did not build, too, which is the case that reports no errors at all because there was nothing to run. One thing the report will not show you, and that is worth knowing before you go looking for it: a chat panel cannot hold a conversation in a test run. Nobody opened the app, so its App session has no person behind it and there is nobody for a conversation to belong to. It costs the errors list nothing, though — a panel sends nothing until someone types a message, so a plain ronja app test reports no chat error at all. Only a --steps run that actually sends one produces anything, and the panel catches the refusal and shows it in the app, so it appears among the requests the app made rather than among the errors — and it does not trip --fail-on-errors. Check a chat panel by opening the app.

You can also drive the app while it renders. --steps names a file holding a JSON array of interaction steps — [{"action":"click","text":"Refresh"},{"action":"screenshot"}] — which run against the app once it has loaded, and a {"action":"screenshot"} step is how you capture what the page looked like after them. This needs the Admin role, because the steps really happen: a click runs the app’s own code with the same authority anyone opening the app has, so it can start workflows and Saved Agents, upload files and call outside systems. Nothing is mocked and nothing is rolled back — treat a step file the way you would treat clicking through the live app yourself.

Now and then it reports that Ronja’s browsers were busy. It waits and tries once more, and if they are still busy the command fails rather than reporting a clean app it never saw. That says nothing about your app — wait a moment and run it again; do not change anything in between.

node_modules, dist and build are never sent, and are listed among the files each command left out. An app holds at most 100 files, of at most 5 MiB each and 50 MiB in total — all three checked on your machine, so an oversized folder is refused before anything is sent rather than halfway through. See Limits and retention.

Work on a feature’s tables from your editor

Section titled “Work on a feature’s tables from your editor”

ronja pipeline — aliased pl — keeps a Feature’s Derived tables as .sql files in a folder on your machine. One file is one table. You edit the SQL in your own editor, keep it in your own version control, and push it to Ronja when it is ready. The same folder can carry the feature’s metrics as one JSON file each.

Everything you push goes to your own draft of each table, and pushing builds that draft rather than the live table — so a query that does not work changes nothing anyone else can see.

From a feature that already has derived tables:

Terminal window
ronja pipeline clone <feature ID>

The feature ID appears in the feature’s URL in the app. You get one .sql file per derived table, named after the table, and one metrics/<name>.json per metric — see Keep a metric’s definition in the folder. If you already have a draft of one — from the app, from a chat, or from an earlier push — that is the SQL you get, because it is your newest version. Foundation, Integration and Dynamic tables are not copied: they have no SQL a file could hold. The CLI says which ones it left out.

Or start an empty folder in the directory you are already in:

Terminal window
ronja pipeline init --feature <feature ID>

Nothing is created in Ronja yet. Each table comes into existence on the first push of the file that describes it — and unlike a new workflow or app, a new table is visible in the feature straight away, holding no data until you publish it.

Terminal window
ronja pipeline status # what changed locally, and in Ronja
ronja pipeline push [file.sql ...] # send each changed file to your draft and build it
ronja pipeline publish [file.sql ...] # commit your drafts, or ask an admin to
ronja pipeline discard [file.sql ...] # throw your drafts away

push, publish and discard all take file names, and act on the whole folder when you give none.

Command What it does
status Files you changed since your last push, files that have no table yet, each table’s build state, any draft you have open, and anything that changed in Ronja since — chats and the app edit the same draft, so this is worth checking before you push. Every metrics/*.json file gets a block of its own: the metric it defines (metric:, with the metric’s name), its trust badge (status: — unvetted, verified or retired), whether the file says something the metric does not (local:), any draft you have staged and not published (draft:), a tags: line when the file lists tags (whether they are in place, and what a push would change), and a drift: line when the definition moved in Ronja — which on a Verified metric adds this metric is VERIFIED, so --force alone will not overwrite it. A file with no metric behind it yet says so instead: metric: none yet — a push creates one called …. Read-only: it does not even open a draft.
push For each changed file: creates the table if it is new, opens or reuses your draft, writes the SQL, builds it, and reports the result — which columns the change adds or drops, which input tables it starts or stops reading (Inputs: +/-), how many rows it produced against the live table, and, for a draft under a million rows, a few sample rows. Above that the sample is skipped and the command says so — reading rows out of a table that large costs real compute, so query it directly if you want a look. Files build in dependency order, so a table is built after everything in the folder it reads from. Then, if the folder carries any, it writes the table and column descriptions — see Document your tables and their columns. Name files to push only those. Then, last, any metrics/*.json definitions — see Keep a metric’s definition in the folder. --force overwrites a table whose SQL or descriptions changed in Ronja since your last push, and a metric whose definition did — except a Verified one, which takes --force-verified-metric as well.
publish Commits each draft onto its table — or, in a shared feature only an admin can commit, submits it for review and tells you so. Refuses a draft whose build failed, one that is still building, and one that has never been built. Publishes in dependency order, so a table lands after everything in the folder it reads from. Name files to publish only those. If somebody else published a change while you were working, publish refuses that file and tells you what moved; add --overwrite-remote to commit yours anyway and replace theirs. --no-request-review turns the review route into a failure instead of submitting, which is what you want in an automated job that expects the change to go live directly — but check the self-approval policy first: where your organization has turned it off, a job publishing a draft its own token authored is refused, and with this flag that refusal is the job’s exit code rather than a review request. Publishing a metric whose definition changed says so: the metric reads “Verified · review pending” until an admin re-confirms it.
discard Throws your drafts away. The live tables and your local files are untouched, so status will show the folder as changed against them; push again to start fresh drafts. Name files to discard only those. Asks first; pass --yes when there is no terminal to ask on.

Every command takes --json, and each one exits non-zero when it fails.

status is a check you can gate on. The exit code answers one question: has anything moved in Ronja under me? It works as a check in an automated job without anyone parsing the output — the same contract ronja db migrate status has — and --json still prints the full report either way, so you can read the detail when you want it.

It exits non-zero when:

  • a table’s SQL moved in Ronja since your last sync — either the live table or your own open draft, because a chat or the app edits the same draft you do;
  • a table’s description or one of its column descriptions moved in Ronja since your last sync, for a folder that documents anything;
  • a metric’s definition moved in Ronja since your last sync, for a folder that carries metrics/*.json files — the same question asked of a recipe instead of SQL;
  • a metrics/*.json file could not be judged at all, whether or not Ronja was the reason: one that is not valid JSON, one whose recipe.source names nothing this folder can resolve, or one bound to a row that turns out to be a table rather than a metric. status prints the reason in that file’s block and counts it as unchecked;
  • a health check the folder wrote was changed in Ronja since your last push, for a folder that carries checks/*.json files;
  • a checks/*.json file could not be judged at all: one that is not valid JSON, one whose table could not be read, or one that names no .sql file in the folder;
  • it could not check at all: no working sign-in, an instance it could not reach, a table listing that failed;
  • the binding is ambiguous or broken — the folder names a table your record was not taken from, or an instance entry it cannot resolve;
  • a table it is bound to could not be read, so drift cannot be ruled out for it.

“Not checked” is a different answer from “everything is fine”, and a job that treated the two alike would report a clean folder it never looked at.

It exits zero when nothing on the server has moved, as far as this folder can tell — which includes these cases that are easy to misread as problems:

  • a file you changed only on your machine — a .sql file, or a metrics/*.json one that defines something the metric does not yet hold. Editing is not the gate; the gate is what changed in Ronja. Pushing is how you resolve them, and status lists them either way. A metric file that is broken rather than merely edited is the exception, and it is in the list above: a file status cannot read is a file whose drift it cannot rule out.
  • a file with no table in Ronja yet — a fresh init with nothing pushed, or a metrics/*.json file whose metric has not been created yet. There is nothing on the server to have moved.
  • a health check the organization does not hold yet — one the file declares that is not on the table, or one waiting for its table to be published. Pushing is how you resolve it.
  • a health check a push would refuse — one the file no longer declares (or that a deleted checks file created), which a push stops on until you pass --prune, or one whose kind the file changed. status says a push would refuse it; neither is something that moved in Ronja.
  • a fresh clone of a folder that names no stacks. .ronja/ is never committed, so a colleague who has just cloned such a folder has no record to compare against — that is the normal way somebody joins a pipeline, not a failure. status says there is nothing to check yet and points you at ronja pipeline push to establish one. push treats the same state the same way: its overwrite guard stands down when there is nothing to compare.

A fresh clone of a folder that does name stacks is not one of those cases, and a job that gates on this command should expect it to fail there. The definition each table and each metric last held is recorded in ronja.lock.json, which is committed — so even with no .ronja/ of your own, status still compares every bound row against the live one and exits non-zero if it moved. Only your own open draft reads as unknown, and the report says which half it could not judge. push reads the same record, so it too refuses a table whose live SQL has moved since the last push, on a first push from a fresh clone as much as any other.

--json is where the local picture lives: the files you changed and the ones with no table yet are in the payload even though they do not move the exit code.

File Keep it in version control?
Your .sql files, one per table Yes
metrics/<name>.json — one metric’s definition. See Keep a metric’s definition in the folder Yes
checks/<name>.json — the health checks on the table <name>.sql builds. See Keep a table’s health checks in the folder Yes
tables/<name>.json — a description of a table this folder does not build. See Document your tables and their columns Yes
ronja.json — the title, and which organization each of its stacks deploys to Yes
ronja.lock.json — which table or metric each file is in each stack, the definition that row last held, what each table’s documentation looked like at the last push, and which health checks the folder wrote. Written only once the folder names stacks Yes
.ronja/ — the record of what you last pushed No; it is ignored for you automatically
Anything else — a README, notes, test fixtures Your choice; Ronja never syncs them

The only files sent are your .sql files, any metrics/*.json definitions, any checks/*.json health checks and any tables/*.json description files; everything else is ignored without comment, so a pipeline folder can sit inside an ordinary repository. Deleting a .sql file does not delete its table: the CLI says the file is gone and leaves the table alone. Delete tables in the app.

Write each input as {{ ref('table ID') }}, the same form the app and the API use. The CLI works out each table’s inputs from the references in its file, so adding an input is editing the SQL and nothing else. A numbered reference like {{ ref('0') }} is refused — it means “the first of this table’s declared inputs”, which is not something a file on your machine can know, and pushing it would silently point the query at a different table.

A reference can also name another .sql file in the same folder, by its file name without the .sql. {{ ref('orders') }} beside an orders.sql reads the table that file builds — no ID, and it works on the first push, before that table exists. Files are still built in dependency order, so the one being read is built first. Two rules go with it:

  • Two files with the same name are refused, even in different subdirectories. raw/orders.sql and staging/orders.sql both answer to orders, so a reference to it could mean either, and picking one silently would build the wrong table with nothing to look at. Rename one, or write the table’s ID.
  • One file may not name the same neighbour both ways. Writing {{ ref('orders') }} and {{ ref('table-…') }} for the same orders.sql in one file is refused, because Ronja stores one form for both and your file would then read as changed on every status for ever, with nothing you could edit to settle it. Pick one spelling and use it throughout the file.

For a table this folder does not build — one in another feature, or one somebody else owns — give it a name under dependencies and let each stack say which table it means. That is what lets the same pipeline folder deploy to more than one organization: the SQL says {{ ref('customers') }} and no ID from either organization is ever written in it. A name declared there may not be the name of one of the folder’s own .sql files — the file always wins, so the declaration would be dead text that reads as though it were in force, and it is refused when you push rather than quietly ignored. Capitalisation does not rescue it: Orders.sql and a dependency called orders are refused as the same clash, because a name is matched without regard to case when it is bound.

A reference to a table you cannot read stops the push, and the message quotes the reference and names the table ID. The usual reason is an ID from somewhere else: table IDs belong to the organization that holds them, so SQL carrying one only ever works in that organization. Get access to the feature the table lives in, change the reference, or — if this folder is meant to deploy to more than one organization — give the table a name under dependencies instead of writing its ID.

A table may not read itself, directly or through other tables. There are two checks, and they catch different loops:

  • Files in the folder that reference each other in a loop are refused on your machine before anything is sent, by push and publish alike. The message says the files “reference each other in a loop, so there is no order that builds them” and lists them in order. Break the loop in the SQL.
  • A loop the folder cannot see by itself — a file that references its own table, or a loop that leaves the folder through a table named under dependencies and comes back — is refused by Ronja with a 400 that names the loop, on push or on publish.

A change that only removes inputs is always accepted, even for a table that is already part of such a loop. That is how you break one: remove a reference that is part of the loop from one of the tables on it, then push and publish.

Publishing rebuilds everything downstream. When a draft is committed, every table that reads the published one is rebuilt automatically — so there is nothing to run afterwards, and no run command to look for.

Starting a new table needs the Admin role, so a first push of a new file is refused otherwise. Editing an existing table is not: anyone with the User role who can read the table can push and build their own draft, and then submit it for review. A User Read-Only account can clone the folder and check its status, and nothing more.

The same folder can carry what a table and its columns mean, in version control beside the SQL, and push writes it. There are two places to put it, and they never overlap: one table has one of them.

In a .sql file, for the tables this folder builds. Open the file with a -- @table comment:

-- @table One row per invoice line, from Fortnox, refreshed nightly.
-- Amounts are in SEK; a credit note carries a negative quantity.
-- @column invoice_no: the supplier's own number, not Ronja's id
-- @column amount: line total, excluding VAT
SELECT ...

The block is read only when the file’s first comment line starts with -- @table. A leading comment that does not is ordinary commentary and nothing is read or sent — so adding this to one file changes nothing about the others. A plain -- line continues whatever came before it, so a description can run over several lines; a bare -- on its own ends the documentation, and anything below it is commentary again — a -- @column written down there is not read, and the push says so, naming how many it found. A documentation line the CLI cannot make sense of is reported and skipped, never a refusal: a comment should not be able to stop a push of SQL that is perfectly good.

In tables/<name>.json, for a table this folder does not build — an Integration, Foundation or Dynamic table, or one a workflow writes. Those are where column descriptions matter most and they have no folder of their own:

{
"description": "One row per Fortnox invoice line, synced hourly.",
"columns": {
"invoice_no": "the supplier's own number, not Ronja's id",
"amount": "line total, excluding VAT, in SEK"
}
}

The file name is a dependency name — declare it in ronja.json with kind table and let each stack say which table it means, so the same folder documents the right rows in every organization it deploys to. A table’s ID works too, and then the file only ever works in the organization that minted it. Those two keys are the whole file: anything else is refused where you wrote it, rather than silently dropped on every push forever.

What you leave out is what you do not manage. Name three of a table’s twelve columns and the other nine keep whatever they say in Ronja — the push sends nothing for them. In the JSON file an explicit "" is the opposite claim, “there is nothing to say here”, and clears what the column holds.

A name your data does not have is reported, not refused. Descriptions attach to the columns a table has actually measured. Describe a column that is not there and the push says so — and keeps what you wrote, ready to attach by itself if a later build produces that column:

revenue.sql — pushed (built)
Docs: description written; 1 column(s) documented: id; 1 name(s) the table
does not have, kept for when it does: margin

That is what makes a committed file safe: it may legitimately run ahead of data that is rebuilt nightly, renamed in staging, or not loaded yet, and your pipeline still goes green.

Documentation is written after the build. A push builds the table first and describes it second, because the descriptions attach to the columns the build produced. If the SQL lands and the documentation does not, the command says so and exits non-zero — your draft still holds the work and is publishable, and the next push tries the prose again.

status reports both carriers: a docs: line beside each .sql file, and an entry per JSON file saying which table it names and whether a push would change anything. It also stops a push whose descriptions moved in Ronja since this folder last agreed with them, and names what moved — --force is there when you have looked and mean to replace it. A folder that documents nothing never sees any of this.

Who can do it: prose in a .sql header goes to your own draft, exactly like the SQL, so anyone with the User role who can edit the table can write it and submit it for review — the admin approving the draft sees the wording before it lands. A tables/*.json file writes the live table directly and needs the Admin role.

Keep a metric’s definition in the folder

Section titled “Keep a metric’s definition in the folder”

A metric is a recipe rather than SQL, so no .sql file can hold one. It goes in metrics/<name>.json inside the same pipeline folder, one file per metric, and the same three commands handle it: push stages it in your own draft and builds it, publish commits it or asks an admin to, discard throws the draft away.

{
"recipe": {
"source": "orders",
"time": {"column": "created_at", "native_grain": "day"},
"dimensions": [{"name": "country"}],
"base_measures": [
{"name": "revenue", "agg": "sum", "column": "amount"},
{"name": "orders", "agg": "count", "column": "*"}
],
"value": "revenue / orders"
},
"description": "Average order value, per day.",
"reportingTimezone": "Europe/Stockholm"
}

Those three keys, plus an optional tags list (see Tag a metric from the folder), are the whole file. Anything else is refused where you wrote it rather than silently dropped on every push forever — the same rule a tables/*.json file follows. description and reportingTimezone are optional; recipe is the definition and is what the number means.

The file name is the metric’s name, and source names the table it reads. Both accept a dependency name declared in ronja.json, so the same committed folder can define the same number in more than one organization. A table’s ID works too, and then the file only ever works in the organization that minted it. The source may be a table this folder builds — write its file name without the .sql, exactly as a {{ ref }} does — and the metric is pushed after that table, so a new pipeline and the metric on top of it go up in one command.

A metric and a table share one namespace inside a feature. revenue.sql and metrics/Revenue.json in the same folder are refused before anything is sent, because they would ask Ronja for the same name — capitalisation does not separate them.

Creating a new metric needs the Admin role, exactly as creating a new table does. Changing an existing one does not: anyone with the User role who can read it can push a definition to their own draft and then submit it for review.

Publishing a definition change un-verifies the metric, and publish says so. A metric’s Verified badge is an admin’s statement about a specific definition, so changing the definition parks it at “Verified · review pending” until an admin re-confirms. That is not the CLI being cautious — it is the same thing that happens when the definition is changed in the app.

--force is deliberately harder here than it is for a table. push stops when the live metric’s definition moved in Ronja since your last push, and names what moved. --force prints the difference before overwriting it, so you see what you are replacing. And if the metric someone else changed is Verified, --force alone is refused outright: overwriting the company’s official definition of a number takes --force-verified-metric as well, which is a sentence you have to mean to type.

Add a tags list to a metric’s file to file it under those tags on the Metrics page:

{
"recipe": { ... },
"description": "Average order value, per day.",
"tags": ["Finance", "Sales"]
}
  • A push applies the tags to the live metric straight away. Tags are not part of the definition, so there is no draft, no build and nothing to publish for them — the push reports the change on a Tags: line (for example +Finance +Sales) and says there is nothing to publish for them. A change to the tags alone is enough for ronja sync apply to deploy the folder, and it never touches the metric’s definition or its Verified badge.
  • Names are matched without regard to case, so finance in the file is satisfied by an existing Finance tag. A name your organization has never used is created, and the push says created tag "…" so a typo is caught the first time.
  • Tags someone adds in the app are left alone. The folder only manages the names it lists: a colleague’s extra tag stays, and does not make the folder look changed.
  • A tag the file lists comes back if someone removes it in the app. The folder is the source of truth for the names it lists, so the next push puts it back. To stop that, take the name out of the file.
  • Removing a name from the file removes that tag from the metric on the next push — for everyone — if the folder has a record of it: one this folder put there, or one the metric already carried when the folder was cloned. A tag the folder has no record of is left alone, so the first push after you add a tags key only ever adds.
  • A tag an Admin renames in the app is kept, not reverted. status says renamed in the web app: Fin → Finance — update the file; change the name in the file when you are ready.
  • Leave the tags key out and the folder does not manage tags at all. "tags": [] is different: it says the metric carries none of the folder’s tags.
  • Archived or hidden metrics are skipped. Their tags are left as they are; an Admin can still change them from the metric’s own page.
  • A push refuses the file itself if it lists more than 20 names, a blank name, a name longer than 64 bytes (letters like ö count as two), or two names that differ only in case.
  • The 20-tag limit is reported once, not on every push. A change that would leave the metric with more than 20 tags, counting the ones added in the app, is the one refusal the folder remembers. status shows a tags: line saying it was refused, with the reason, until you change the file’s tags, or run ronja pipeline push --retry-tags from the folder to try again once there is room. ronja sync apply cannot retry a refused change, so run that push locally. A refused tag change never stops the definition from being pushed.
  • Any other failed tag change is retried by the next push. When your role can’t change tags, you can’t see the metric, your token doesn’t cover tags, your organization’s Ronja doesn’t have tag support yet, or Ronja doesn’t answer, the push reports Tags: NOT applied with the reason and finishes with an error, and the next push tries again on its own. In ronja sync apply the rest of the folder is still published, and the folder is reported as not fully deployed until the tags land.

A ronja pipeline clone writes each tagged metric’s current tags into its file. A metric with none gets no tags key, so the folder leaves its tags alone until you add one. An older ronja refuses a metric file that has a tags key — run ronja update (see Keep it up to date).

Keep a table’s health checks in the folder

Section titled “Keep a table’s health checks in the folder”

A table’s health checks can live beside its SQL: checks/orders.json holds the checks on the table orders.sql builds. The file’s name must match a .sql file in the folder — checks on a table built somewhere else stay in the app.

{
"checks": [
{
"name": "Every order has an id",
"expression": "count(*) filter (where order_id is null) = 0",
"severity": "fail",
"description": "An order without an id cannot be matched to its invoice."
},
{ "name": "Refreshes daily", "expectedIntervalHours": 24 }
]
}

Each check has a name and either an expression (something true or false about the table’s data) or expectedIntervalHours (a freshness check, in whole hours — 24 is daily). severity is warn or fail — fail is an Admin-protected check — and description says what a failure would mean. Leave out severity, description or enabled and the folder does not manage that field: a check someone silences on the table’s Checks tab stays silenced when you push a file that does not mention enabled. A new check whose entry says "enabled": false is created silenced: it is never live, so it never raises an alert.

Checks are written to the published table, never to your draft. ronja pipeline push writes a table’s checks once the published table holds that file’s SQL and you have no open draft of it; until then it reports them as pending, and ronja pipeline publish writes them straight after it commits the table. A push visits every checks file, so adding checks to a table whose SQL did not change is just a push. Ronja runs every new or rewritten check before saving it and refuses one it cannot evaluate, or one too expensive to evaluate on the table within the time allowed; a check that is already failing is saved, and — because a push is often a script — it raises the same alert as a check that starts failing in the background. That alert is off until an Admin turns it on; see Health checks.

A check that already exists on the table under the same name is taken over by the file, and the push says when it changed one. Checks on the table that the file does not name are left alone. Removing a check from the file does not remove it from the table: the push stops and names it, and --prune silences it — the CLI never removes a check. To remove one for good, take it out of the file and ask Ronja to remove it in a Build-mode chat; a check the file still declares is created again by the next push, as a new check with no history. Deleting the whole checks file works the same way — the push names every check that file created that is still active. Renaming a check in the file makes a new check. If someone changed a check in Ronja since your last push, the push stops and names it; --force — the same flag that overwrites a table’s SQL — overwrites — and that includes a check you are about to silence with --prune, so pruning one someone changed in Ronja takes --prune --force. ronja pipeline publish writes the checks of each table it commits but never prunes or overwrites; if it finds either, it names them and leaves them for your next push.

Creating, silencing or downgrading a fail check needs an Admin. Anyone who can edit the table can push the folder: the warn checks land, each Admin-protected (fail) check is reported refused, and the push exits non-zero until an Admin pushes it. ronja pipeline clone writes a table’s active checks into its checks/ file, leaving out a freshness check whose interval is not a whole number of hours, since a file cannot say it.

SQL reaches the query engine exactly as written, so '\s' is a whitespace class and E'\n' is a newline — the data platform explains why. A Table, App, Workflow or shared module created before that changed still reads backslashes the older way, and the App and Workflow pages, and the page of a Derived table written in SQL, show a Legacy SQL escapes chip. One text always reads the older way, whatever the chip says: the optionsQuery of a workflow’s select parameter — leave its backslashes as they are.

Pushing a change to one of these that changes its code moves it onto the current behaviour — there is nothing to declare. The push lands on your draft like any other edit, so rewrite the backslashes in that same push: a regular expression written '\\s' for the older behaviour has to become '\s'. The published version keeps the older behaviour until you publish, and your own test runs use the new one straight away. A push that changes only settings — a title, an allowlist, or column documentation in a table’s tables/<name>.json — moves nothing. A -- @table / -- @column header lives inside the .sql file, so editing a note there changes the table’s code and does move it. A module takes the new behaviour at ronja module publish, and a workflow that imports it keeps the module version it is pinned to, older behaviour included, until that workflow is published again.

status in a folder that holds one of these prints a line naming it:

Note: revenue.sql: legacy SQL escapes — publishing a change to its code moves it to the current behaviour

Work on a feature’s automations from your editor

Section titled “Work on a feature’s automations from your editor”

ronja automation keeps a Feature’s Automations as .json files in a folder on your machine. One file is one automation, and the file’s name is the automation’s name.

Terminal window
ronja automation init --feature <feature ID> --cron "0 2 * * *" --workflow nightly
ronja automation status # what a push would change, and what moved in Ronja
ronja automation push # make each automation match its file

There is no publish step and nothing to discard. An automation has no draft and no version history, so a push is the whole write — unlike a workflow, an app or a table, where you edit a private draft first.

The file describes the automation the way you would set it up in the app: what fires it, what it does, and what it may reach.

{
"triggerKind": "cron",
"cronExpr": "0 2 * * *",
"timezone": "Europe/Stockholm",
"action": { "kind": "workflow", "config": { "workflowID": "nightly" } }
}

references go with an agent action and nothing else. They are what an Automation that runs its own instructions is allowed to reach, so an Automation that runs a Workflow or a saved Agent has no use for them: a Workflow reaches what its own code declares, and an Agent reaches what the Agent itself declares. Writing references beside either action is refused, with the reason, before anything is sent — an empty list is fine.

{
"cronExpr": "0 3 * * *",
"action": { "kind": "agent" },
"prompt": "Check yesterday's orders against the returns policy.",
"references": [{ "kind": "note", "resourceID": "policy" }]
}

Leaving action out of a file does not opt out of that rule. An absent action means this folder does not manage it, so the Automation keeps the action it already has — and writing references in a file for an Automation that already runs a Workflow or a saved Agent is refused for the same reason. That one needs to read the Automation first, so it shows up when you run status (as a problem on that file) or push (as a refusal). Nothing is written either way.

prompt goes with an agent action and nothing else, which is why the example above says "kind": "agent" out loud. prompt is the instructions an Automation that runs its own instructions follows — that is what an agent action is — so it is the one field that belongs to that action rather than to the Automation around it. An Automation that runs a Workflow or a saved Agent has no use for it, and Ronja does not store what you write there: it replaces the value with a placeholder of its own. So a file declaring prompt beside either of those would push, look successful, read back as the placeholder, and report the same difference on every status from then on, with nothing you could edit to close it. It is refused instead, with the reason. For a saved Agent’s per-run seed message write action.config.prompt, which is a different field on a different object and one Ronja does keep.

That rule has the same second door references has: a file declaring prompt and no action, for an Automation that already runs a Workflow or a saved Agent, is refused too — on status as a problem on that file, on push as a refusal, and nothing is written either way.

One difference is worth knowing before you put it in a job. When the file names the action kind itself, the refusal happens as the file is read, before Ronja is contacted at all — so ronja sync status and ronja sync check over a repository containing such a file stop and name it, rather than giving that folder one of their three answers. Fix the file and they run normally again.

A field you leave out is a field this folder does not manage. push never sends it and status never mentions it, so Ronja keeps whatever is there. Adding the key is how you take a field over; setting it to "" or [] is how you clear it. That holds inside action as well: a file that names the workflow and says nothing about its parameterValues leaves the values set in the app exactly as they are.

The one key you always write when you write an action is its kind — workflow, saved_agent or agent. It decides which of the rest Ronja reads, so a push without it is refused rather than guessed at.

You can change an Automation’s action from a file, with one exception. Moving an Automation to a Workflow or to a saved Agent works from the file, and so does moving it between those two. Moving it back to agent — its own instructions — does not: a push cannot change an action away from a Workflow or a saved Agent, so it would report success and leave the Automation exactly as it was, and every later status would show the same difference with nothing you could edit to close it. A file that asks for that is refused instead, and it tells you to make the change in the app.

That rule exists for Enabled above all. If a file says "enabled": true, every push turns the automation on — including the push that runs on your next merge, hours after somebody paused a misbehaving automation at two in the morning. So ronja automation init leaves it out, and a push that would turn one back on after somebody paused it stops and tells you who paused it and when. --force is there when that is genuinely what you mean.

Some things cannot be in the file at all, and the CLI says why rather than ignoring them: the automation’s name (that is the file’s name — rename the file), its ID and Feature (those are in ronja.json and ronja.lock.json), anything Ronja computes for you (the next run, the email address of an email trigger, a webhook’s URL), and a webhook’s signing secret, which Ronja shows once when it mints it and never again.

Anything the automation points at — the workflow it runs, the Agent it runs, a note it may read, a table it watches, a mailbox it watches — is written as a name you declare under dependencies and each stack answers with its own ID. That is what lets one committed folder deploy to more than one organization.

If a name has no answer on the stack you are pushing to, the push stops and names the exact field:

references[2].resourceID: alias "policy" has no bind in stack "prod"

{{ … }} references do not work in an automation file, and writing one is refused. Ronja never reads that form out of an automation, so it would be stored as-is, point at nothing, and fail the first time the automation ran unattended — with nothing anywhere reporting a problem. Write the name as the field’s own value instead.

It stops when Because
the automation changed in Ronja since your last push there is no version history here to merge against, so a push would replace whatever moved without knowing what it was. --force replaces it
a file would turn an automation back on after somebody paused it see above
a file changes what fires the automation, the event it subscribes to, or whether it is reference-based those cannot be changed after an automation is created. Change them in the app
an action with no kind the kind decides what the rest of the action means, so Ronja will not guess at it
a file moves an Automation back to its own instructions from a Workflow or a saved Agent a push cannot change an action away from either of those, so it would report success and change nothing. Make that change in the app
a file writes prompt for an Automation that runs a Workflow or a saved Agent Ronja replaces that value with a placeholder of its own, so the file could never match what is stored and status would report the same difference for ever. prompt belongs to an agent action; a saved Agent’s seed message is action.config.prompt
a file this folder created is gone those automations are still running. Deleting them is --prune, and only that
a file uses a mailbox and you are not an Admin connecting an automation to a mailbox needs the Admin role
the feature holds more automations than one page of results Ronja cannot tell which ones are missing, so it says so instead of guessing

Deleting a file therefore never deletes an automation on its own — a bad merge must not be able to stop one silently. ronja automation push --prune does it deliberately, and it is a soft delete: the automation stops running and sits in the trash for 30 days, paused. Emptying that trash is done in the app.

--prune still stops on an automation that changed in Ronja since your last push, the same as any other write here — a lost file plus a colleague who has been editing that automation is exactly the pair you want a question about. --force deletes it anyway.

File Commit it?
Your .json files, one per automation Yes
ronja.json — the title, the names it uses, and which organization each stack deploys to Yes
ronja.lock.json — which automation each file is, and when this folder last agreed with it Yes
Anything else — a README, notes Your choice; Ronja never syncs them

There is no .ronja/ directory here, unlike the other folder kinds. Ronja gives back the whole configuration of an automation when it is read, so the comparison is between your committed file and Ronja directly — which means a fresh checkout on a build server is checked exactly as thoroughly as your own copy.

Only .json files are sent, and everything else is ignored without comment, so an automation folder can sit inside an ordinary repository.

A repository usually holds more than one folder. ronja sync status walks a directory, finds every folder in it — workflow, app, pipeline and automation folders alike — and reports which ones have drifted, without you visiting each one:

Terminal window
ronja sync status
ronja sync status --dir apps --stack prod

It reads and never writes: no draft is created and nothing in your working tree is touched, so it is safe to run on a repository you only have read access to.

What a token needs to run it. sync status and sync check read, and nothing more. ronja sync apply, below, writes — so it needs the write half of whatever its folders hold, and a token you point at a repository is a token that can deploy it. All three read different things depending on what your repository holds, and for the two reading commands a permission you have not granted comes back as “could not be checked” (exit 2) rather than as a failure, so it is worth granting the right ones up front rather than widening a token until the red goes away:

you have sync status needs sync check also needs sync apply needs
workflow folders automation:read automation:read automation:write
app folders analytics:read — analytics:write
pipeline folders data:read data:read data:write
automation folders automation:read — automation:write
any folder whose code names agents — agents:read agents:read
any folder whose code names credentials — secrets:read secrets:read
any folder that names notes — analytics:read analytics:read

sync check needs more than sync status because it follows what your code points at: an agent your workflow runs, a credential it injects, a note an automation may read. sync apply needs the write scope instead of the read one, and it still needs the read rows for anything the folders point at, because it runs the same reference checks on the way. Grant only the rows that match what the repository actually holds.

A scope is not the whole story for sync apply: the role gates in Who can do this at the top of this page still apply to every folder it deploys. Committing a change to a shared workflow or app needs an Admin or a maintainer of its feature; creating a table needs an Admin anywhere; creating an automation in a shared feature needs an Admin or that feature’s maintainer. So a token carrying nothing but :write is enough to keep a repository up to date and not always enough to stand one up.

Each folder gets one of three answers:

  • clean — Ronja holds what this folder committed, and a push would change nothing.
  • drifted — a push from here would change something. Either you hold work that was never deployed — an edited file, a new one, a folder that has never been pushed at all, a setting the folder declares and the row does not have — or something changed in Ronja since this folder last synced. The report says which of the two, because you act differently on each.
  • unknown — it could not be checked, and that is deliberately not the same answer as “fine”.

The exit code is the answer for the whole tree, so this works as a check in an automated job with nothing to parse: 0 when every folder was checked and every one is clean, 1 when something drifted, and 2 when anything could not be checked — including a directory it could not read. “Could not tell” wins over “drifted” — if one folder drifted and another could not be read, the honest answer is that not everything was verified. Finding no folders at all is also a 2, so a renamed directory cannot pass the check by being empty. --json carries the same word in a verdict field.

Folders are checked against one --stack. A folder that does not name that stack, or names it against a different organization, is reported as not checked rather than stopping the run — a repository can legitimately hold folders belonging to several organizations — and it is never counted as clean, so a mistyped stack name cannot report a whole repository healthy.

sync status compares your files and the settings the folder declares — its parameters, its reporting timezone, its runtime, an app’s access list. It does not compare anything else about the resource, so a change made in Ronja to something the folder does not describe is not drift here.

What a fresh checkout can be told, and what it cannot

Section titled “What a fresh checkout can be told, and what it cannot”

Read this before you put sync status in an automated check, because the answer is not the same for every kind of folder.

The record of what you last synced lives in .ronja, which is deliberately kept out of Git — it is yours, not the team’s. So a machine that has just cloned the repository does not have it, and the only thing left to compare against is ronja.lock.json, which is committed. What that file can vouch for differs:

  • Pipeline folders that name stacks — the lock records the SQL each table held when the folder last agreed with it. On a fresh checkout Ronja can tell you both whether the table changed in Ronja and whether the .sql file in the repository is different from what was actually deployed. That second one is the check most worth having: somebody edits a query, commits it, and never pushes. (A pipeline folder that has never named a stack keeps that record in .ronja instead, so it has nothing committed to compare against — name one with --stack on the next push.)
  • Workflow and app folders — the lock records which published version the folder was taken from, and nothing about the files. On a fresh checkout Ronja can tell you whether somebody has published since, but it cannot tell you whether the files in the repository have been deployed. It says so rather than guessing: the folder is reported as unknown, never as clean.
  • Automation folders — fully checked, and they are the only kind with nothing missing at all. There is no .ronja here to be absent: reading an automation gives back its whole configuration, so the comparison is between the committed file and Ronja directly. A fresh checkout is checked exactly as thoroughly as your own copy.

That difference is deliberate and it is not going to change. A workflow and an app are edited in your own draft, which only you can see — so a record of what their files looked like is a record of one person’s private copy, and committing it would hand a colleague a baseline for something they cannot open. A table’s record is different: it describes the live table, which is the same for everyone, so it can safely be shared.

What to do about it: on a fresh checkout, a pipeline or automation folder is fully checked. For a workflow or an app, run ronja wf push or ronja app push — those read the files in Ronja directly and do not need a local record.

A folder that has never been deployed counts as drifted, not clean. If the folder holds files and nothing of it exists in Ronja yet, the answer is that a first push would create it — reporting that as “clean” would let a check pass while an entire table or workflow sat undeployed. A folder with no files in it is clean, because there is genuinely nothing to deploy.

Two things it will tell you it could not check rather than guess at. A folder that has never been synced from this machine, and has nothing committed to compare against either (see the section above for which kinds have that), is reported as unknown — clone or push once and it can be checked. And a folder whose ronja.json sits inside another folder is reported and skipped, because the two overlap and which one owns the shared files is genuinely ambiguous; move one of them.

Check that everything still points at something

Section titled “Check that everything still points at something”

ronja sync status answers “would a push from here change anything?”. ronja sync check answers a different question: does everything these folders refer to still exist?

That is the one nothing else catches. A table renamed in one feature quietly breaks a query in a folder nobody opens for a month, and the folder still looks perfectly clean.

Terminal window
ronja sync check
ronja sync check --dir apps --stack prod

It reads what each folder’s code actually refers to — the tables it queries, the credentials it injects, the agents and workflows it runs, the things an app grants itself in ronja.json — and asks Ronja about each one. Like sync status, it reads and never writes.

Each reference gets one of four answers:

  • ok — it resolved, and this credential can read it.
  • unresolved — nothing here could turn it into a real reference at all. Usually a name in dependencies that this stack binds nothing to, or a {{ ref('orders') }} where no file called orders.sql sits beside it. This one is decided on your machine, so it is reported even when you are signed out.
  • unreachable — a real id that Ronja will not show you. It may have been deleted, moved to the trash, be in a feature that is in the Trash, or belong to an organization this credential does not reach: Ronja answers the same way for all four, on purpose, so that asking cannot reveal whether something exists.
  • not_checked — nobody could ask, and why.

The exit codes line up with sync status’s — 0 when every reference resolved, 1 when something did not, 2 when anything could not be checked, with “could not tell” winning over “did not resolve” — but the words are its own, and a script that tests for drifted here will never match. sync check reports clean, broken and unknown, in the verdict field of --json and per folder:

exit verdict meaning
0 clean every reference resolved
1 broken something is unresolved or unreachable
2 unknown something could not be checked

Credentials you have not created yet. A {{ secret }} marker naming a secret nobody has set up is reported as unreachable and fails the check — the workflow saves perfectly well, and then every run that touches that marker fails, which is the thing worth catching before a deploy. ronja wf push treats it the same way, and for the same reason. Note that ronja wf validate does not: it answers “would this save?”, and the save genuinely would succeed, so it reports the same problem and exits zero. The two commands disagreeing about one folder is deliberate.

While you are still connecting things up, ronja sync check --allow-dropped-bindings accepts them. It stops those references failing the run — anything else that does not resolve still does — and it changes only the score: the reference is still listed, still marked as a dropped binding, and the report still tells you runs that touch it will fail.

A data app gets one extra check, in both directions. An app may only reach what its access block lists, and pushing from a folder does not fill that list in for you the way building the app in Ronja does — so an app that queries a table it never listed pushes cleanly, publishes, and then fails for whoever opens it. sync check reports that, and also tells you when the access block grants something no file in the folder uses.

Four limits, stated plainly rather than glossed over:

  • An app’s queries at run time cannot be listed in advance. What is checked is what the app declares, plus the ids written directly in its own source files — a .tsx, .ts, .jsx or .js. An id that appears only in a README, a fixture or a data file is not treated as a reference, and one assembled at run time is invisible to this.
  • Codex and mailbox references cannot be checked at all. There is no way for the CLI to ask about either one, so they are reported as not checked rather than passed as fine. (A workflow’s codex references are the exception — Ronja resolves those itself when it validates the workflow.)
  • A workflow folder that has never been pushed has no feature yet, and Ronja’s own workflow check needs one. That folder is reported as not checked; push it once and it can be checked from then on.
  • A folder belonging to a different organization is reported as not checked, not as broken. Without --stack, a folder whose stacks all name an organization this credential does not reach cannot be checked at all: the names its code uses are bound per organization, so nothing here knows what they were meant to point at. The report names the stacks the folder does declare, so you can pick one with --stack.

ronja sync apply walks the same directory and, for each folder it finds, does what that folder’s own commands do: it pushes, and then it publishes. What comes out is live, not a set of drafts waiting for someone to publish them. Automation folders are the exception, and only because they have no draft to publish — as their own section explains, a push to an automation writes production directly.

Terminal window
ronja sync apply --stack prod
ronja sync apply --stack prod --dry-run

Folders are deployed in path order, and the report says so. An app folder’s push warnings — the same lines ronja app push prints under its verdict, and the same Note: lines, which are not problems — appear as note lines under that folder, and do not stop the publish.

It refuses to create anything. This is the point of the command, not a gap in it. ronja sync apply only ever updates what already exists; anything with nothing behind it yet is refused and named:

  • would_create — a .sql file with no table yet, an automation .json with no automation yet, a folder that has never been pushed. There is no delete command to undo a mistake here, and a build server that throws its checkout away between runs would create the same things again on the next run.
  • no_drift_anchor — an automation this folder is bound to but has never recorded agreeing with. Writing it would overwrite whatever changed in Ronja, and would switch the automation back on if someone had paused it.
  • manifest_rewrite — deploying this folder would rewrite its committed ronja.json, moving it onto the stack you named. That kind of migration stays a thing you do in one folder, watching it happen.

An older folder that names no stacks at all is not a rewrite and deploys normally — run ronja sync apply without --stack for those.

The refusal is per file for pipeline and automation folders, and per folder for workflow and app folders — so one new .sql file in a folder where everything else is bound stops that folder, rather than being created quietly alongside the rest.

Deploy the folder on its own once (ronja pipeline push, ronja wf push, and so on), and ronja sync apply keeps it up to date from then on.

When you do mean to create — standing up a new environment, say — --allow-create opts in:

Terminal window
ronja sync apply --stack staging --allow-create

It tells you how many rows it is about to create, in which organization, and which features they will land in, before it creates any of them:

--allow-create: this will create 30 rows in ten-8f21c4 on https://cloud.ronja.tech.
Into features sales-ops (4 rows), warehouse (26 rows).

That count is rows, not folders — thirty folders each gaining one new file is thirty new rows — because the number is the point. The features are named for the same reason: each folder’s featureID comes out of its own committed ronja.json, so an edit to one of those files sends new rows somewhere else, and a line naming only a count could not tell that run from the one you meant. --dry-run --allow-create shows you the same two lines and creates nothing.

It opts into that one refusal only. An automation with no recorded agreement, and a folder whose ronja.json would be rewritten, are still refused.

If you stop a run part way through with Ctrl-C, you still get the report: the folders it had already finished are listed with what happened to them, and the ones it never reached are listed as not attempted.

A folder with nothing left to push still gets published if it has something waiting. Those are two different states, and the difference matters: an unchanged folder is not published again, so an unchanged repository does not gain a new version of everything on every run — but a draft you staged earlier with ronja wf push, ronja app push or ronja pipeline push, or one an earlier sync apply pushed before its publish failed, is picked up and published. Otherwise a run that failed half way would report the repository deployed on every attempt after it, and nothing would ever say the folder was not live.

One kind of change is not seen this way: a draft whose only difference from the live version is a setting — a title, the file Ronja runs first, the reporting timezone — rather than the files themselves. Publish that folder from its own directory.

A table’s health checks never hold its SQL back. If a pipeline folder’s health checks cannot all be applied — a check removed from its file still needs --prune, one was changed in Ronja, or the server you deploy to cannot write them yet — ronja sync apply still publishes that folder’s tables, then reports the folder as checks_not_applied and exits non-zero, naming the tables it published and the checks it could not apply. Health checks guard the data but never block it, so they do not stop a deploy; documentation and a metric’s description do, and still stop the folder. Settle the checks with that folder’s own ronja pipeline push.

There is no --force here, and no --overwrite-remote. When apply refuses because something changed in Ronja, the way through is that folder’s own command, run by someone looking at that one folder.

Publishing never files a review request. If your credential may not publish into a shared feature, apply fails and says so rather than sending a review request from every folder in the repository — and a draft that did end up waiting for review is reported as not deployed.

One folder refusing does not stop the run: every folder is attempted and reported, and the exit code is the answer for the whole tree.

exit verdict meaning
0 applied every folder was deployed, or had nothing to deploy
1 refused something was refused, or did not land
2 unknown something could not be deployed or ruled out — including a write Ronja never answered, and the folders a Ctrl-C never reached

--dry-run prints what apply would attempt, per creation unit — the same grain as the refusals above — and writes nothing. It tells you what the command will try — not what Ronja will answer, which can change between the preview and the run.

Approving in the browser creates a personal access token bound to you. It appears under Account → Access tokens, named after the machine that requested it.

Two things follow from it being yours:

  • It carries your live role. If your role changes, the token’s permissions change with it. If you leave the organization, it stops working. You cannot use it to grant someone else more access than they already have.
  • Anything it creates belongs to you. A feature it creates in private scope is your private feature.

The token expires 90 days after you sign in — ronja whoami shows the date, and signing in again renews it (a token you pasted in with --with-token keeps the lifetime it was minted with). Revoke it sooner from Account → Access tokens when a machine no longer needs it, and immediately if a laptop is lost or a token is pasted somewhere it should not have been.

On your machine it is written to a config file readable only by your user account — ronja context prints the exact path (~/Library/Application Support/ronja/config.json on macOS, ~/.config/ronja/config.json on Linux).

To remove a stored credential:

Terminal window
ronja logout

That forgets one profile — the current one, unless --profile or --url names another. Other organizations on the same instance keep their own logins. It does not revoke the token — do that from Account → Access tokens if the token may have been exposed.

Two environment variables override everything stored on disk, so an automated job never depends on who last signed in on that machine — and those two never write a credential to disk:

Terminal window
export RONJA_URL="https://cloud.ronja.tech"
export RONJA_TOKEN="<a token>"
ronja whoami --json

For CI, mint a token in the web UI (Account → Access tokens) and put it in your secret store.

A job that publishes — ronja wf publish, ronja app publish, ronja module publish, ronja pipeline publish, ronja sync apply — commits a draft, so the self-approval policy applies to it like anyone else: the token counts as a person — a personal access token as you, an API token as the Admin who created it — so where your organization has turned self-approval off, the job cannot commit a draft that person authored. On a shared resource that means a job cannot both push the change and take it live: have a different Admin, or that feature’s maintainer, publish it, or let the job submit it for review.

Commands never prompt when they are not attached to a terminal, and exit non-zero on failure, so an automated job fails loudly rather than hanging.

A token belongs to exactly one organization, and RONJA_TOKEN does not say which — so before a folder command uses the binding recorded in ronja.json, the CLI asks Ronja which organization the token reaches. That keeps a folder that a colleague in another organization committed from being picked up as if it were yours. If the folder records several organizations for the same instance, the command stops and asks you to name one. --stack is the answer that needs no stored login at all — a named stack says which organization it belongs to, so the job reads it out of the repository, and if the token turns out to reach a different organization the command says so instead of guessing. --profile works too, since signing in with ronja login stores the organization alongside the token.

An AI coding agent on a machine with a writable home has a better option than carrying a token on every command: with $TOKEN holding a token minted in Account → Access tokens, pipe it in once, instead of opening a browser, and sign in to a directory of its own.

Terminal window
echo "$TOKEN" | RONJA_CONFIG_DIR=~/agent-ronja ronja login --url "https://cloud.ronja.tech" --with-token

That address is the default, so --url only has to be there when it names your own instance’s address instead.

RONJA_CONFIG_DIR relocates the config file the login writes, so the agent’s login never lands in, or re-points, the profile store your own commands use — ronja login makes the profile it creates the current one, so the same login without the variable would change which organization your own bare ronja api reaches. Signing in again in the same directory, for the same organization, replaces the token there; a token for a different organization lands as a second profile beside it. Every command after that names only the directory, and no credential at all:

Terminal window
RONJA_CONFIG_DIR=~/agent-ronja ronja api /api/v2/feature/query
RONJA_CONFIG_DIR=~/agent-ronja ronja query "SELECT 1"

rm -rf ~/agent-ronja forgets that credential without touching your own; like ronja logout, it does not revoke the token.

It goes on every command because most agent tools run each command in a fresh shell, and a variable set in front of one command applies to that command only — the same reason ronja env has to be combined with the call that uses it.

Signing in gives the CLI everything your role can do — with one deliberate exception. A small set of actions refuse every automated caller, the CLI included, and answer with "code": "human_required" plus a sentence saying where to do it in Ronja.

They are the ones you would not want a script to take by accident: emptying something from the Trash permanently, changing who can get into your organization, deleting the organization, and billing. Ordinary deletion still works from the CLI — it puts the item in the Trash, recoverable for 30 days.

The reasoning is worth knowing, because it will look like a missing feature the first time you hit it: you approved this credential in a browser up to 90 days ago, for the work you were doing then. See Actions the API can’t take for the full list.