Connect to Pyrula Cloud
Pyrula Cloud is a hosted UI for watching and controlling runs. Connecting to it takes an install and an environment variable - there is no code to write and nothing to wire up.
1. Install the client
Section titled “1. Install the client”pip install pyrula-cloud-clientOr as an extra of the core package:
pip install 'pyrula[cloud]'2. Set your API key
Section titled “2. Set your API key”export PYRULA_CLOUD_API_KEY="your-key"Workflows and agents now report to the cloud without code changes.
The runtime checks for the key at startup. If it is absent, nothing connects and everything keeps working locally - so the same code runs connected or not, and you can leave the package installed in environments that should not report.
Optional settings
Section titled “Optional settings”The defaults cover a single runtime using the hosted service. Configure these variables for self-hosting, stable runtime identity, or outbox tuning.
| Variable | Effect |
|---|---|
PYRULA_CLOUD_URL | The cloud to connect to. Defaults to https://api.pyrula.io. Set it for a self-hosted deployment; the connect wizard puts the right value in the snippet it gives you. |
PYRULA_RUNTIME_NAME | Label this runtime in the UI. Defaults to a generated name. |
PYRULA_RUNTIME_ID | Give the runtime a stable identity across restarts. Defaults to a fresh id per process. |
PYRULA_CLOUD_OUTBOX_PATH | Move the durability spool, or set it to off to disable. See below. |
PYRULA_CLOUD_OUTBOX_SYNC | full makes every spool write reach the disk platter. See below. |
PYRULA_CLOUD_OUTBOX_MAX_AGE_S | How long an unacked spooled event is kept. Defaults to 7 days. See below. |
PYRULA_CLOUD_CLAIM_GATE_SECONDS | How long a polling worker waits for the first store-binding verdict before taking work. Defaults to 10 seconds; 0 disables the wait. See One store per project and environment. |
PYRULA_REDACT_KEYS | Extra field names to redact before anything leaves the process, comma-separated, added to the built-in secret-shaped names. See your redaction policy. |
Not losing events
Section titled “Not losing events”Critical events (run lifecycle, steps, tool and usage results, terminals) are written to a local spool, the outbox, before they are sent, and deleted once the cloud accepts them. The cloud dedupes by idempotency key, so re-sends are free. The spool is re-read on startup and whenever the send buffer drains. Token deltas and other live-stream events are not spooled; if one is dropped, the cloud is told where the gap is.
It’s on by default, at $XDG_STATE_HOME/pyrula/ (~/.local/state/pyrula/), one file per
project, created on first use.
- Containers: put
PYRULA_CLOUD_OUTBOX_PATHon a persistent volume, or the spool dies with the container. - Several projects on one host: never share one
PYRULA_CLOUD_OUTBOX_PATHbetween them. Rows are separated only by environment name, so aproductionkey of one project would send another project’sproductionrows. - Power loss: writes reach the OS, not the disk (~29 µs per event). Set
PYRULA_CLOUD_OUTBOX_SYNC=fullto fsync each event. - Keys without a project id get a file named for a digest of the key. Rotating the key starts a new file; delete the old one by hand.
- Opting out:
PYRULA_CLOUD_OUTBOX_PATH=offdisables the spool (delivery only for the life of the process, and no backpressure).
Outbox contents
Section titled “Outbox contents”- Events already redacted for your capture level, as plaintext SQLite. File
0600, directory0700; looser permissions are tightened on open. - A row stays until the cloud accepts it or for
PYRULA_CLOUD_OUTBOX_MAX_AGE_S(7 days). A row the age limit removes counts as dropped and is reported. - A row is only ever sent while this runtime is matched to the store binding that was current when the row was written.
- Deleting the file while the runtime is stopped discards its backlog. Runs still in the runtime’s own store can still be re-sent by its terminal sweep.
One store per project and environment
Section titled “One store per project and environment”A run store belongs to one project and environment. The first runtime to register
against an unbound store binds it; bind it explicitly first in production with
pyrula store binding set --project P --environment E, so a laptop or CI job holding a
production key cannot bind it by accident.
Point each environment’s workers at their own store: a separate Valkey, a separate
database index on a standalone server, or a key namespace (PYRULA_VALKEY_NAMESPACE,
or --namespace on the CLI) - the only option on a cluster, which has one database.
Every store has a namespace; unset defaults to "default", not “no namespace”. A
namespace prefixes every key the store writes; nothing about the binding, the run
vocabulary, or the Lua functions changes.
A Valkey ACL restricting a runtime’s credentials to the key pattern ~<namespace>:*
stops it reading or writing another namespace’s data if misconfigured - but it is not a
full confinement. Construction calls FUNCTION LOAD, which needs +function; that
category also grants FUNCTION FLUSH/FUNCTION DELETE, which are server-wide and
unaffected by any key pattern, so a runtime with +function can still remove every
namespace’s functions. A runtime’s ACL also needs ~_pyrula:* (or the specific key)
alongside its namespace pattern: the prune tombstone below is one global key no
namespace pattern reaches.
Pruning a stale Lua function library (ValkeyStore.prune_stale_function_libraries,
run after a rolling deploy) affects every namespace on a server: the library, and its
prune tombstone (_pyrula:{functions}:pruned_libraries), are loaded and tracked once
per server, not once per namespace, so a prune takes a stale version away from every
store that server hosts, not only the one that triggered it - and an old-build store in
any namespace sees it as pruned rather than silently reloading it.
A worker whose key belongs to another project or environment runs nothing from the
store, reports nothing, and accepts no control for it. It logs an ERROR naming the
binding (only “another project” for a different project, never that project’s id), reports
not ready on /readyz where the runtime serves one, and the Workers page shows a notice.
Submissions are unaffected by this rule: a mismatched process can still submit runs, since submitting only writes to the store, and the store’s matched workers run them.
While the store is unbound and the cloud has not yet answered register, a polling worker
waits up to PYRULA_CLOUD_CLAIM_GATE_SECONDS (default 10 seconds) before taking work. Work
it takes during that wait is reported only once the store is matched. A store already bound
to this key’s own project skips the wait and takes work at once. The wait never opens for a
binding that could not be read at all (a store the worker cannot reach yet) - only a binding
confirmed unbound by an actual read releases it.
Run Valkey with maxmemory-policy noeviction. An evicted or flushed binding unbinds the
store; the next runtime to check it binds it again.
A user-configured OTLP exporter that carries a cloud key sends what it is given - this rule does not apply to it.
Manage the binding with the CLI:
pyrula store binding showpyrula store binding set --project P --environment Epyrula store binding clearclear is compare-and-delete: it removes the binding only if nothing has rebound the
store since it was read, and it warns that the store’s history will be sent to whichever
environment binds next. Stop that store’s workers first.
When the cloud stops accepting
Section titled “When the cloud stops accepting”| Variable | Default | Effect |
|---|---|---|
PYRULA_CLOUD_OUTBOX_HIGH_WATER_BYTES | 256 MB | Above this, submit() raises BackpressureRejected. Claimed work keeps running and drains the backlog. |
PYRULA_CLOUD_OUTBOX_HARD_CAP_BYTES | 2× the high water | Above this, the spool refuses new events and logs a warning (at most once a minute). Claiming stops too. |
Size never deletes rows; only an ack or the age limit does. So an outage costs freshness
and new admissions, not history: a dead cloud throttles a live runtime. The heartbeat
reports egress_pressure (ok / high_water / saturated) and the backlog size. 256 MB
is about five minutes of the busiest runtime measured.
A spool left by an older client can start above the high water; the runtime then refuses new runs until it has shipped that backlog. Delete the file to discard it instead.
Cloud freshness
Section titled “Cloud freshness”Under healthy load the cloud trails by a fraction of a second (median 0.21 s for
run_start, 0.27 s for step:done; ~25 ms idle). Under bursts or after an outage it can
lag by seconds or minutes. Spooled critical events are retried while retained.
Stream events can be dropped under pressure, and outbox rows can expire before
delivery. Alert on run outcomes, not on the age of the last event.
Stopping
Section titled “Stopping”Stopping drains the queue up to a deadline. Critical events left behind are already in the spool and are sent by the next runtime with the same key. Stream events left behind are lost, and the runtime says how many at exit:
cloud_drain_timeout stream_discarded=N critical_undelivered=M spool_enabled=SThe cloud shows the missing stream detail as sequence gaps on the run.
If it does not connect
Section titled “If it does not connect”The key is set but the package is not installed. The runtime raises a
configuration error at startup rather than starting up disconnected and leaving
you to wonder why nothing appears. Install pyrula-cloud-client, or unset
PYRULA_CLOUD_API_KEY.
Nothing appears in the UI and no error. The key is almost certainly not reaching the process - an unexported shell variable, or a container that was not passed the environment. Check it is visible where the runtime actually runs:
python -c "import os; print(bool(os.environ.get('PYRULA_CLOUD_API_KEY')))"