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:
ronja login # sign in through the browserronja context # print everything needed to call the APITwo 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.
There are four more exceptions, and they are the same idea four 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 in one, and ronja automation keeps a feature’s automations 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 four folders: 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.
Install
Section titled “Install”The CLI is a single binary with nothing to configure. On a Mac:
brew install ronjatech/tap/ronjaOn Linux, or anywhere you already have Go:
go install github.com/ronjatech/ronja-cli/cmd/ronja@latestbrew 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:
export PATH="$PATH:$(go env GOPATH)/bin"Add that line to your shell profile (~/.zshrc or ~/.bashrc) to make it stick. Check it worked:
ronja --helpronja --versionKeep it up to date
Section titled “Keep it up to date”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:
ronja updateIf 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:
go install github.com/ronjatech/ronja-cli/cmd/ronja@latestIf 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:
ronja update --checkThe 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.
Sign in
Section titled “Sign in”ronja loginThe 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:
ronja login --no-browserand open the printed URL somewhere you are signed in. The code stays valid for 10 minutes and works only once.
Point it at the right place
Section titled “Point it at the right place”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:
ronja loginronja login --url http://localhost:8080Each 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:
ronja profile listronja profile use acme-retailronja 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:
ronja profile rename app-2 northwindCheck who you are
Section titled “Check who you are”ronja whoamiThis 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.
Hand over to the API
Section titled “Hand over to the API”ronja contextThis prints which instance you are on and as whom, 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.
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:
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.
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:
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.
What the API tells an agent about itself
Section titled “What the API tells an agent about itself”Everything ronja context points at is served without authentication, so any HTTP client can read it:
| 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.
Send a request without handling the token
Section titled “Send a request without handling the token”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.
ronja api /api/v2/authentication/meThis 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:
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.
Uploading a file
Section titled “Uploading a file”ronja api -X POST /api/v2/file/upload/quarterly.pdf -F file=@./quarterly.pdfThe 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.
Reading one value out of a reply
Section titled “Reading one value out of a reply”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.
What a jq expression can print
Section titled “What a jq expression can print”--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:
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-untilloop.
Both limits apply to --jq on ronja query in the same way.
Waiting for something to finish
Section titled “Waiting for something to finish”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:
ronja api "/api/v2/workflow/run/$runID/head" \ --wait-until '.status == "done" or .status == "error"' --jq '.status' -rWait 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).
Query your data
Section titled “Query your data”ronja query runs read-only SQL against your Tables and gives you CSV back.
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.
Real SQL is rarely one line, so it can come from a file or a pipe instead:
ronja query --file monthly-report.sqlcat monthly-report.sql | ronja queryGive 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.
Work with a managed database
Section titled “Work with a managed database”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.
Run SQL
Section titled “Run SQL”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.
Pass values into a statement rather than building them into it:
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,ALTERandDROPare refused. Schema changes go throughronja 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 doesronja apiorcurlif you call the endpoint yourself.
A new database has no connection roles at all, so your first statement fails until one exists. Create one:
ronja api -X POST /api/v2/database/<database ID>/user \ -d '{"access":"write","featureID":"<feature ID>"}'Apply migrations
Section titled “Apply migrations”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.
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.
Try changes on a dev copy first
Section titled “Try changes on a dev copy first”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:
ronja db migrate status --env devronja db migrate push --env devronja db sql <database ID> "SELECT count(*) FROM leads" --env devYou 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, refreshing and discarding a copy is not a terminal job: it happens on the Databases page in the admin area. Promoting is the half that is scriptable.
When the copy is where you want it, promote its migrations onto production:
ronja db promote <database ID> --dry-runronja 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:
ronja api -X POST /api/v2/database -d '{"name":"crm"}'ronja api /api/v2/database/queryWork on a workflow from your editor
Section titled “Work on a workflow from your editor”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.
Start a folder
Section titled “Start a folder”From a workflow that already exists:
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:
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.
The loop
Section titled “The loop”ronja wf status # what changed locally, and in Ronjaronja wf validate # check the folder, save nothingronja wf push # send it to your draftronja wf test --param month=2026-07 # run the draft and wait for itronja wf publish # take it live, or ask for reviewronja wf run --param month=2026-07 # 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 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 — 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. |
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.
Run the live workflow
Section titled “Run the live workflow”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 testis 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
.ronjafolder 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. Useronja wf cloneinto a fresh folder. - The folder’s record is of a different version. This is the shared-workflow path:
ronja wf publishsubmitted 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 — andronja wf statusshows the comparison. - The workflow is in a shared feature. Running the live version needs an Admin, so you are told to ask one — or to let the automation that runs this workflow trigger it.
- 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.
What is in the folder
Section titled “What is in the folder”| 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), 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:
ronja wf push --stack prodA 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:
ronja wf push --stack dev # names this organization's entry "dev", onceThe 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 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. If two of your tables are called orders, the folder picks one, says which in bind, and stops caring what anything is called. 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.
ronja bind --stack prodFor 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:
ronja bind --stack prod --feature col-… # declare prod, then bind its namesronja wf push --stack prodWhere 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.
Parameters
Section titled “Parameters”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).
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.
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.
The runtime
Section titled “The runtime”"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": 3Leave 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.
The reporting timezone
Section titled “The reporting timezone”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.
Work on an app from your editor
Section titled “Work on an app from your editor”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.
ronja app status # what changed locally, and in Ronjaronja app clone <data-app-id> # or: ronja app init --feature <feature-id>$EDITOR App.tsxronja app push # sends your files, then checks that they buildronja app validate # run the build check on its ownronja app test # open the app in a browser for you and report what it sawronja app publish # publish it, or ask an admin toronja 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. |
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:
ronja app testronja app test --route '#/orders' --viewport mobileIt 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.
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.
Start a folder
Section titled “Start a folder”From a feature that already has derived tables:
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. 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. Only Derived tables are copied: Foundation, Integration and Dynamic tables have no SQL to hold, and metrics are not handled by this command yet. The CLI says which ones it left out.
Or start an empty folder in the directory you are already in:
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.
The loop
Section titled “The loop”ronja pipeline status # what changed locally, and in Ronjaronja pipeline push [file.sql ...] # send each changed file to your draft and build itronja pipeline publish [file.sql ...] # commit your drafts, or ask an admin toronja pipeline discard [file.sql ...] # throw your drafts awaypush, 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. 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. Name files to push only those. --force overwrites a table whose SQL changed in Ronja since your last push. |
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. --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. |
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;
- 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 three cases that are easy to misread as problems:
- a file you changed only on your machine. Local edits are not the gate; the gate is what changed in Ronja. Pushing is how you resolve them, and
statuslists them either way. - a file with no table in Ronja yet — a fresh
initwith nothing pushed. There is nothing on the server to have moved. - 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.statussays there is nothing to check yet and points you atronja pipeline pushto establish one.pushtreats 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 SQL each table last held is recorded in ronja.lock.json, which is committed — so even with no .ronja/ of your own, status still compares every bound table 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.
What is in the folder
Section titled “What is in the folder”| File | Keep it in version control? |
|---|---|
Your .sql files, one per table |
Yes |
ronja.json — the title, and which organization each of its stacks deploys to |
Yes |
ronja.lock.json — which table each file built in each stack, and the SQL that table last held. 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 |
Only .sql files are sent, and 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.sqlandstaging/orders.sqlboth answer toorders, 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 sameorders.sqlin one file is refused, because Ronja stores one form for both and your file would then read as changed on everystatusfor 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.
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.
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.
ronja automation init --feature <feature ID> --cron "0 2 * * *" --workflow nightlyronja automation status # what a push would change, and what moved in Ronjaronja automation push # make each automation match its fileThere 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.
What goes in the file
Section titled “What goes in the file”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 * * *", "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.
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.
Names, not IDs
Section titled “Names, not IDs”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.
What a push refuses
Section titled “What a push refuses”| 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 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.
What is committed
Section titled “What is committed”| 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.
Check a whole repository at once
Section titled “Check a whole repository at once”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:
ronja sync statusronja sync status --dir apps --stack prodIt 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. Both sync commands read, and nothing more, but they read different things depending on what your repository holds — and a permission you have not granted comes back as “could not be checked” (exit 2), not 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 |
|---|---|---|
| workflow folders | automation:read |
automation:read |
| app folders | analytics:read |
— |
| pipeline folders | data:read |
data:read |
| automation folders | automation:read |
— |
| any folder whose code names agents | — | agents:read |
| any folder whose code names credentials | — | secrets:read |
| any folder that names notes | — | 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. Grant only the rows that match what the repository actually holds.
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
.sqlfile 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.ronjainstead, so it has nothing committed to compare against — name one with--stackon 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
.ronjahere 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.
ronja sync checkronja sync check --dir apps --stack prodIt 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
dependenciesthat this stack binds nothing to, or a{{ ref('orders') }}where no file calledorders.sqlsits 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, or belong to an organization this credential does not reach: Ronja answers the same way for all three, 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,.jsxor.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.
What the login actually creates
Section titled “What the login actually creates”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
privatescope is your private feature.
The token expires 90 days after you sign in — ronja whoami shows the date, and signing in again renews it. 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:
ronja logoutThat 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.
For scripts, agents and CI
Section titled “For scripts, agents and CI”Two environment variables override everything stored on disk, so an automated job never depends on who last signed in on that machine — and never writes a credential to disk:
export RONJA_URL="https://cloud.ronja.tech"export RONJA_TOKEN="<a token>"ronja whoami --jsonFor CI, mint a token in the web UI (Account → Access tokens) and put it in your secret store. You can also pipe an existing token in instead of opening a browser:
echo "$RONJA_TOKEN" | ronja login --url "$RONJA_URL" --with-tokenCommands 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.
A few actions still need you in a browser
Section titled “A few actions still need you in a browser”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.
Related
Section titled “Related”- Build a feature with the API — what to do once you are signed in.
- Send data via the API — pushing data in, and the scoped tokens an Admin creates in Ronja for narrower jobs.
- API reference — scopes, tokens, and the actions that need a person.