Recipes
Worked examples. Each one is a real shape rather than a demonstration of a flag. The levels are everyday (one person, one machine), team (something a repository or a team shares) and operator (something you run for others); the minutes are rough, and "needs" is what to have before you start. With nothing set up yet, Morning triage and Check a machine is set up, without unlocking anything need nothing beyond rta.
Three have pages of their own. For a security team and For a developer walk one role's tasks through the chapters that explain them, and An agent in a cluster goes end to end: a profile, a ServiceAccount token the kube plugin mints, an instance holding it, and an agent connected over MCP.
Pair with an agent on a staging database, for an hour
The common case, and the one worth learning first. You want your editor's agent to help debug a slow query against staging — not production, not forever, and with a record afterwards.
Before you start, this recipe needs the pg plugin and a profile named staging: Getting the first-party ones installs the first, and Profiles defines the second. On a machine with neither, rta use staging stops with no profile named "staging", and the grant is refused because pg.query is not a capability there yet.
# 1. Take production off the table entirely, for as long as you are working.
rta use staging --for 2h
# 2. Let one named agent read that one environment.
rta grant allow pg.query --profile staging --agent claude --ttl 1h \
--rate 30/1h --note "slow query on orders"
# 3. Work. Then see what actually happened.
rta agent log --limit 50Three bounds, each doing a different job:
rta use stagingmeansrta mcp serverefuses every other profile, whatever grants exist. It cannot grant anything — only take away — so it is safe to reach for first.--agent claudekeeps the grant off your other MCP clients.--rate 30/1his the one people skip. A time bound and a use bound both fail the same way: an agent in a loop exhausts them correctly and instantly. A rate limit turns a runaway into something you can notice while it is happening.
When the hour is up, all of it lapses on its own.
Hand over one secret, once
rta grant allow kv.get deploy-token --agent claude --ttl 5m --max-uses 1One key, five minutes, one read. The clearest case in the whole model — and the record shows that kv.get deploy-token happened without showing what came back.
To make the broad form impossible for everyone on the team, put requireScope: [kv.get] in the repository's .rta-policy.yaml, as A ceiling a repository carries does: rta grant allow kv.get, which would cover the entire store, is then an error, and only a grant naming one key is accepted.
A ceiling a repository carries
Commit this next to your code and every clone inherits it:
# .rta-policy.yaml
maxTTL: 15m
never:
- pg.dump
- vault.kv.get
neverProfile:
- production
requireScope:
- kv.get
- s3.object.getNo seal, no key distribution, no trust in how it reached the machine — because it can only subtract. The worst a hostile edit achieves is making rta refuse more than you wanted.
A subdirectory may tighten it and cannot loosen it, so a service inside a monorepo can be stricter than the repository root without any mechanism for that.
Morning triage
rta sys overview
rta agent overview
rta doctorsys overview is host health grouped rather than seven commands. agent overview is the last hour of agent calls, how many were refused, and anything parked waiting on you. doctor is what rta can reach — read the info rows, not just the failures.
Once a week, the machine itself:
rta pkg managers # which managers this machine has, version and path — and which it does not
rta pkg overview # every package manager's outdated count, the OS, the kernel, a reboot owed
rta pkg outdated # the packages, with the exact upgrade command on every row
rta pkg upgrade brew # one manager at a time, never everything; apt and friends print the sudo command insteadBinaries you installed from GitHub releases have no manager; list them once under plugins: pkg: tools: as - gh=github:cli/cli and rta pkg tools compares each against its latest release, and rta pkg upgrade gh installs it in place — fetched, hashed, checked against the digest the release publishes, swapped in atomically. It takes the release's .tar.gz or bare binary for your OS and architecture and nothing else: a release that ships only a .tar.xz or a .zip is refused, and so is a download whose bytes are not a program this machine runs. Binaries from go install need no entry: they carry their module path, and appear under the go manager.
Then the detail on whatever looked wrong:
rta sys overview --detail
rta agent log --refusedCertificate expiry as a cron job
rta cert expiry example.com api.example.com -o json \
| jq -r '.rows[] | select(.[3] != "ok") | .[0] + ": " + .[2]'The fourth column is the verdict — ok, WARN <30d (the window is --warn-days) or EXPIRED — and the third is the time left as text, 83d or expired 4191d ago, not a number to compare.
The exit code says whether the check could be made, not what it found: an expired certificate exits 0, and a host that cannot be reached does not. To make a finding fail a job, ask for the verdict:
rta cert expiry example.com -o json | jq -e 'all(.rows[]; .[3] == "ok")' >/dev/null || echo "check failed" >&2Dependency review before a release
rta audit deps -o md >> release-notes.md
rta audit why some-packageaudit deps checks what you already declare against OSV, and each hit says whether you asked for that package or something else pulled it in — which is the difference between a fix you make and a fix you wait for. audit why draws the whole route from the lockfile.
Audit every repository a team owns
audit deps reads what a project already committed, and from a terminal --path also takes a repository URL — cloned shallowly in memory, never written to disk. So auditing a repository is one command, and auditing all of them is that command in a loop:
gh repo list my-org --limit 200 --json url --jq '.[].url' |
while read -r repo; do
rta audit deps "$repo" -o json | jq -c --arg r "$repo" '{repo: $r, grade: .rows[0][1], detail: .rows[0][2]}'
doneNothing is installed, resolved or built — a lockfile is a list a package manager already committed, and reading a list is not scanning. The whole tree does arrive in memory, so a very large monorepo is a very large process; --recursive is what finds the manifests in one.
It is refused over MCP, and that is not an oversight. audit deps is read-only and needs no grant, which is what puts it on an agent's tool list with nothing asked. A URL an agent composes is a request rta makes on its behalf, to a host the agent chose, with the reply landing in its context — the thing http.get carries a grant for. Point an agent at a checkout you made.
The rows grade each advisory and name the versions that fix it, from osv.dev's own records. --detail ends with the tools that answer what this cannot, with the target already substituted in — a full scan of what a lockfile reader never sees, the ecosystem's own auditor, the dependencies nothing imports, and an SBOM worth committing:
a full scan `trivy fs .` or `grype dir:.` — OS packages, vendored code, images, and every advisory graded
reachability `govulncheck ./...` — whether your code can reach the vulnerable function
unused `go mod tidy` — declared dependencies nothing imports
an sbom to keep `syft . -o cyclonedx-json` — read once here, kept thereThey are pointers, never wrappers: rta does not run them, parse them, or track their flags. What it owes is the invocation with the target already in it, so the next step is a paste and not a search. The rows fit what was actually read — a Go project is never told about knip, and a pnpm project is never told to run npm audit.
What in the cluster is out of support
rta audit kube eol # the control plane, every kubelet, every image
rta audit kube eol --namespace payments # one team's images, and the control plane
rta audit kube eol -o json | jq -r '.rows[] | select(.[0] != "overall" and .[1] == "fail") | .[0]'audit kube eol reads the versions a cluster already reports — kubectl version, the nodes' kubelets, every pod's image tags — and grades each against endoflife.date the way rta eol check grades one product: past its end of life fails, inside --warn-days of it warns, and a supported release passes with the date it stops being one. An image is recognised by the catalogue's own names and aliases, so postgres:15.4 is PostgreSQL 15 and debian:bookworm-slim is Debian 12, and a hundred replicas of one image are one row with a count. What the catalogue does not track — your own images, most operators' — is one line naming them rather than a guess, and a floating latest tag or a bare digest says that nothing can be read from it.
The same question about the machine you are sitting at is rta pkg outdated; about one product, rta eol check; about a list you wrote down once, rta eol watch.
A security review you can paste into an issue
{
rta audit web example.com -o md
rta audit mail example.com -o md
rta cert chain example.com -o md
} > review.mdEvery finding cites the OWASP Top 10 category and the CWE it comes from, so the output is reviewable by somebody who was not in the room.
Fill a shell with credentials, without them touching disk
eval "$(rta kv env --prefix APP_)"Or for a tool that wants a file:
rta kv get tls-cert --out /tmp/server.pem # written at 0600Answer "what changed here" without parsing porcelain
rta git overview
rta git status -o json | jq '.rows'
rta git blame internal/grant/grant.gogit overview is the branch, what it tracks, how far it has drifted, any rebase or merge left half-finished, staged/modified/untracked told apart, and the last commit's age. It works against a local checkout or a remote URL cloned in memory, and it is read-only.
A remote URL is a person's affordance, and rta refuses one over MCP. Every git capability is read-only and needs no grant, which is what puts it on an agent's tool list with nothing asked — and a URL an agent composes is an outbound request on the way there and a stranger's commit messages arriving in the model's context on the way back. That is http.get, which is gated. Point an agent at a checkout you made.
Run an agent against a scratch directory only
cd /tmp/scratch
rta mcp install claude --as sandbox --root /tmp/scratchEvery path argument must sit under a root, and the default root is the directory the server started in. The client launches the server, so the root goes into the registration; rta prints the roots at startup rather than leaving them to be discovered from a refusal.
Widen deliberately, never by default:
rta mcp install claude --as sandbox --root ~/projects --root /tmp/scratchBe asked instead of refused, while you are at the machine
rta mcp install claude --consent --consent-notifyA call needing a grant nobody issued parks rather than failing:
rta agent pending
rta agent show # including what it would do, from its own --dry-run
rta agent allow # shows the call and asks `Allow once? [y/N]`Grants says what answering does, that it creates no standing grant, and why this is off by default: only turn it on when you are actually present.
Ship the record somewhere durable
Every row carries the sequence number the record assigned it, and --after takes one, so an append picks up exactly where the last one stopped:
ARCHIVE=~/audit/rta.jsonl
cursor=$(jq -rs 'map(.[0] | tonumber) | max // 0' "$ARCHIVE" 2>/dev/null || echo 0)
rta agent log --after "$cursor" --limit 500 -o json | jq -c '.rows[]' >> "$ARCHIVE"Run it on a timer and the archive grows by what happened and nothing else. The cursor lives in the archive itself, so there is no second file to lose, and a run that finds nothing new writes nothing.
--since asks the same question the way a person does — --since 2h while something is going wrong, --since 2026-08-30 when writing it up. It is the right flag for reading and the wrong one for shipping: a duration has a boundary, and a sequence number does not.
The date appears on a row exactly when the rows are not all from today, so a live view stays narrow and an archive is still readable a month later.
Put it on a dashboard
The Grafana stack takes this in two halves. On a machine, rta meets both without a listener or a port; in a pod, where there is no node_exporter to write into, rta mcp serve --observe is the answer instead — see below.
Loki takes the lines. The cursor above is already what a log shipper wants — an append-only record with a sequence number — so point Promtail or Alloy at the JSONL file the recipe above writes, or run the same loop into logger/vector.
Prometheus takes the counters. One command writes the standard text exposition format into node_exporter's textfile collector:
rta agent metrics > /var/lib/node_exporter/textfile_collector/rta.prom.$$ \
&& mv /var/lib/node_exporter/textfile_collector/rta.prom.$$ \
/var/lib/node_exporter/textfile_collector/rta.promWrite-then-rename because the collector reads the file whenever it likes, and half a file is a parse error that drops every series in it. A timer every minute is plenty; nothing here changes faster than that.
In a pod there is no textfile collector, so a hosted server exposes the same numbers itself:
rta mcp serve --as work --http :8443 --token-file tokens.txt --observe :9090--observe is a second listener and never the --http one. /livez and /readyz answer without a credential — a liveness probe that needs a token is one more thing to get wrong at three in the morning — and /healthz answers as readiness for tooling that asks by that name. /metrics sits behind the same bearer check MCP does, because these counters name which agent called what and how often it was refused, and "the port is only bound internally" is the assumption that goes wrong. Point a ServiceMonitor at it with a bearerTokenSecret holding one of the tokens from --token-file.
The two probes mean different things, which is the only reason there are two. /livez consults nothing and says the process is still serving: restart me if this stops answering. /readyz writes to the data directory and reports whether the record can actually be written — a detached volume or a full disk leaves a server that still accepts connections and still authenticates callers while silently failing at the one thing it is for, and that is the state that should take a pod out of rotation rather than restart it.
| Series | What it counts |
|---|---|
rta_agent_calls_total | every call ever, including any retention has dropped — a counter that survives rotation |
rta_agent_calls_recorded_total{capability,agent,outcome,authorized} | the retained record, split the four ways worth splitting |
rta_agent_calls_retired_total | what retention dropped, so the gap between the two above is visible rather than merely handled |
rta_grants_active{capability,agent} | reach in force right now — a grant bound to a plugin build that no longer answers covers nothing, and is not counted |
rta_agent_pending | calls parked, waiting for a person |
rta_record_intact | 1 while the hash chain verifies end to end |
rta_record_bytes, rta_record_segments | the record's own size |
rta_record_intact == 0 is the alert worth having. A record that stops verifying is either a bug or somebody editing it, and both are things to hear about in minutes rather than at the next review:
- alert: RtaRecordBroken
expr: rta_record_intact == 0
for: 1m
annotations:
summary: "rta's agent record no longer verifies — run `rta agent log --detail`"Nothing is kept to produce any of this: rta stores no counters, so every number is one you could recompute from the record itself.
The record is hash-chained, so an edited or missing entry is visible:
rta agent log --detailThat makes tampering visible, not impossible — which is the realistic goal for a local file, and enough to catch the quiet single-line edit.
Set up a machine with no terminal
Everything here is idempotent, so it belongs in a provisioning script, a Dockerfile, or a dotfiles bootstrap that runs on every login:
# The credential goes in the store, never on a command line.
rta kv set staging-db-password --file /run/secrets/db-password
# The environment, one line per plugin.
rta profile set staging --note "shared staging" --ttl 8h \
--plugin pg --set host=db.staging.internal --set sslmode=require \
--secret password=kv:staging-db-password
rta profile set staging --plugin s3 --set endpoint=https://s3.staging.internal
# The ceiling, in your own policy file rather than in any repository.
rta policy requireThree rules worth knowing before you write that script:
- A value on a command line is in
ps, in your history, and in most CI logs.rta kv setasks for a value at a terminal when given none, takes--file, and--file /dev/stdinworks from a pipe.rta profile set --setrefuses a declared credential outright, so this is enforced rather than advised. --setstates the whole block. A second run that mentions onlyhostremovessslmode— and says which keys it dropped.--onlydoes the same with its fences, and names the ones it takes off asunfenced. Restate the block you mean.$RTA_CONFIGmatters in a container. With no config directory the config path falls back to./.rta.yaml, and a working-directory file is not honoured: itsprofiles:,plugins:anddashboard:blocks are all ignored, because that file could have come from a repository you cloned.rta profile setrefuses to write there rather than succeeding silently.
Then check it, without unlocking anything:
rta profile list && rta policy show && rta doctorOne config for the whole team, in git
A team that names its deployments consistently can write the whole map once — every app, every environment, every cluster — and every member points rta at the same checked-in file. What makes this safe to share is that the file carries references and coordinates, only: where things are and which stored entry fills each credential, never a value.
infra/rta.yaml, in the repository the team already owns:
# yaml-language-server: $schema=config.schema.json # `rta config schema > config.schema.json`, committed beside it
profiles:
shop-staging:
note: "shop, staging — safe to hand to an agent"
ttl: 8h
plugins:
pg@685186a7f1c2:
kube: staging/shop/svc/postgres:5432
secrets: {password: kube:postgres-creds/password}
pg/analytics@685186a7f1c2: # a second database, picked as --profile shop-staging/analytics
kube: staging/shop/svc/postgres-analytics:5432
secrets: {password: kube:analytics-creds/password}
s3@a586c1f19b04:
set: {endpoint: https://s3.staging.internal, bucket: shop-assets}
secrets: {secret-key: kv:shop-staging-s3}
shop-prod:
note: "shop, production — grants only, short ones"
ttl: 1h
plugins:
pg@685186a7f1c2:
set: {host: db.prod.shop.internal, sslmode: require}
secrets-from: prod/shop
secrets: {password: kube:postgres-creds/password}Each member's shell setup names it:
export RTA_CONFIG="$HOME/dev/infra/rta.yaml"Two facts carry the whole arrangement:
RTA_CONFIGis the trust decision. A config path somebody named is honoured in full; a.rta.yamlmerely sitting in a cloned repository is not — its profiles are ignored, so a repository cannot ship aprodprofile pointing at your cluster and wait for you tocdinto it. Exporting the variable is the moment a person chooses to trust the team's file.- Credentials stay personal even though the map is shared.
kv:shop-staging-s3names an entry in each member's own encrypted store — the file says which entry, each person runsrta kv set shop-staging-s3once with their own value.kube:references go further: the Secret is read from the cluster at call time with each member's own kubectl credentials, so RBAC keeps deciding who can actually resolve the profile, and offboarding is the cluster access removal the team already does.
New machine, whole setup:
export RTA_CONFIG=... && rta kv set shop-staging-s3 && rta doctorrta doctor then names anything missing per profile — the pin that does not match the installed plugin, the store entry not yet created — which is the onboarding checklist, generated instead of maintained.
Check a machine is set up, without unlocking anything
rta kv status # where the store is and what can open it
rta profile list # configured environments, and whether each is usable
rta doctorkv status answers without unlocking the store, which makes it safe in a provisioning script.
Back up a datastore, and put the backup somewhere else
Six plugins write a backup, and the shape is deliberately the same in each — --out <file> on the way out and, where rta has the other half, the file as the first argument on the way in. vault and etcd spell the first half snapshot, because that is the word each of them uses for it:
rta pg dump --profile shop-prod --out shop-$(date +%F).dump
rta mysql dump --profile shop-prod --out shop-$(date +%F).sql
rta mariadb dump --profile shop-prod --out shop-$(date +%F).sql
rta vault snapshot --profile vault-prod --out vault-$(date +%F).snap
rta qdrant dump --profile search-prod --collection docs --out docs-$(date +%F).snapshot
rta etcd snapshot --profile coord-prod --out etcd-$(date +%F).snapEvery one of them writes 0600, refuses to overwrite a file that already exists, removes what it made if the run fails, and prints how to restore the file it just wrote. That last line is the point: a backup nobody knows how to restore is a belief about a backup.
Five of the six have an rta restore. etcd does not, and that is etcd rather than rta. Its v3 protocol streams a snapshot out and takes nothing back in — no service in it carries a restore RPC — so restoring is etcdutl snapshot restore, which builds a data directory with etcd stopped, on every member, from the same file. etcd.snapshot's own receipt prints that sequence at the moment you take the backup. rta ships no etcd restore rather than a command that would take the name of the thing you need and then not do it.
All of them refuse over MCP, and that is not a gap to be granted around. A whole datastore has no blast radius a grant could name, so the refusal is the answer rather than a missing feature — an agent that needs rows still has pg.query or mysql.query, bounded per call. Restores are Destructive, so a person types through --yes before a file replaces a database.
Getting the file off the machine is the second command, not a flag:
mkdir backups && mv shop-$(date +%F).dump backups/
rta s3 bucket upload backups --profile backups-s3 --bucket nightly --prefix shop/$(date +%F)/And back, the same two steps in reverse:
rta s3 bucket download --profile backups-s3 --bucket nightly --prefix shop/2026-08-01/ --out restored
rta pg restore restored/shop-2026-08-01.dump --profile shop-staging --yesTwo gated commands rather than one capability reaching into two services, which is on purpose: each half is separately consented, separately scoped, and separately in the record.
cnpg is the exception, and it is a different shape on purpose. A CloudNativePG cluster already knows where its backups go — an object store, the barman-cloud plugin's ObjectStore that the cluster names, or a volume snapshot class, configured on the cluster itself — so there is no file for rta to write and no destination for it to choose:
rta cnpg backup show --cluster shop-prod-db # how it is set up, where backups land, and the schedules
rta cnpg backup request --cluster shop-prod-db # ask the operator to take one now, by the method the cluster is set up for
rta cnpg backup list --cluster shop-prod-db # what it did, and where it wentshow follows the cluster to the object store it names and reads what the resource states: the destination, the retention, the recovery window and the names of the Secrets it references, never a Secret. request says which method it used when the cluster has only one, and refuses with the list when it has several.
The request carries a cluster reference and nothing else. Destination, credentials, retention and encryption all come from the cluster, which is what makes this the one mutating capability in an otherwise read-only plugin: there is no place for a caller to point it at. It is Write, so an agent needs a grant for it — one that names the cluster, so consent is about this database rather than about the plugin.
It is refused outright for a cluster that configures no backup. CloudNativePG accepts such a request and fails it minutes later, in a place nobody is watching — so rta reads the cluster first and tells you now.
Know what your dump does not carry. Each of these backs up one thing, and the material it needs beside itself lives somewhere the dump cannot reach. Every one of them says this on its own receipt as well, at the moment you take the backup — this table is the same facts where a backup strategy gets planned rather than where one gets run:
| Dump | What it leaves behind | Where that lives |
|---|---|---|
pg.dump | roles, tablespaces — a restore onto a fresh server fails on every ownership line | pg_dumpall --globals-only |
mysql.dump, mariadb.dump | users and grants — a single-database dump carries no mysql.user rows | mariadb-dump --system=users; MySQL has no such flag, so mysqldump mysql |
qdrant.dump | aliases — the snapshot restores the collection, not the name pointing at it | the alias API |
vault.snapshot | the unseal keys, and the snapshot is sealed with the source cluster's — a restore without them is a file you cannot open | wherever operator init output was stashed, never Vault itself |
etcd.snapshot | the cluster's own identity — membership, peer URLs and TLS material. The restore mints a new cluster ID and takes the topology from its own flags | your etcd configuration, wherever that is kept |
Related
- Grants · Team policy · The record
- Writing a plugin — if the capability you want does not exist yet
Next
For a security team — deploying rta, committing a ceiling, handing out roles, reviewing grants and the record, stopping an agent.