Skip to content
Pyrula
Pre-release: the packages are not on PyPI yet, so pip install does not resolve. Build from source to try it today.

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.

Terminal window
pip install pyrula-cloud-client

Or as an extra of the core package:

Terminal window
pip install 'pyrula[cloud]'
Terminal window
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.

The defaults cover a single runtime using the hosted service. Configure these variables for self-hosting, stable runtime identity, or outbox tuning.

VariableEffect
PYRULA_CLOUD_URLThe 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_NAMELabel this runtime in the UI. Defaults to a generated name.
PYRULA_RUNTIME_IDGive the runtime a stable identity across restarts. Defaults to a fresh id per process.
PYRULA_CLOUD_OUTBOX_PATHMove the durability spool, or set it to off to disable. See below.
PYRULA_CLOUD_OUTBOX_SYNCfull makes every spool write reach the disk platter. See below.
PYRULA_CLOUD_OUTBOX_MAX_AGE_SHow long an unacked spooled event is kept. Defaults to 7 days. See below.
PYRULA_CLOUD_CLAIM_GATE_SECONDSHow 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_KEYSExtra field names to redact before anything leaves the process, comma-separated, added to the built-in secret-shaped names. See your redaction policy.

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_PATH on a persistent volume, or the spool dies with the container.
  • Several projects on one host: never share one PYRULA_CLOUD_OUTBOX_PATH between them. Rows are separated only by environment name, so a production key of one project would send another project’s production rows.
  • Power loss: writes reach the OS, not the disk (~29 µs per event). Set PYRULA_CLOUD_OUTBOX_SYNC=full to 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=off disables the spool (delivery only for the life of the process, and no backpressure).
  • Events already redacted for your capture level, as plaintext SQLite. File 0600, directory 0700; 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.

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:

Terminal window
pyrula store binding show
pyrula store binding set --project P --environment E
pyrula store binding clear

clear 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.

VariableDefaultEffect
PYRULA_CLOUD_OUTBOX_HIGH_WATER_BYTES256 MBAbove this, submit() raises BackpressureRejected. Claimed work keeps running and drains the backlog.
PYRULA_CLOUD_OUTBOX_HARD_CAP_BYTES2× the high waterAbove 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.

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 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=S

The cloud shows the missing stream detail as sequence gaps on the run.

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:

Terminal window
python -c "import os; print(bool(os.environ.get('PYRULA_CLOUD_API_KEY')))"