# Connect to Pyrula Cloud

> Scaffold an app, export one key, run pyrula dev and open the run in Pyrula Cloud.

Source: https://docs.pyrula.io/getting-started/cloud/

The scaffold runs offline against a scripted model, so no provider key. This page uses
Python 3.11 to 3.15 and a POSIX shell, in an empty directory. Stop `pyrula dev` with Ctrl+C.

## 1. Scaffold, connect and run

In Cloud, open **Setup** for your project and environment and copy the key. A key is for one
environment; its runs appear only there.

A `development` or `staging` key sends prompts, replies and tool traffic (capture level `io`);
a `production` key keeps them on your runtime (`lifecycle`). To change that, see
[What leaves your runtime](https://docs.pyrula.io/getting-started/what-leaves-your-runtime.md#who-sets-it).

```bash
pip install "pyrula[agents,cloud]"
pyrula init
export PYRULA_CLOUD_API_KEY="<key>"
pyrula dev app.py
```

`pyrula init` prints `wrote app.py`. `pyrula dev` runs the agent once and prints a link to the run:

```text
Target: app.py:support
Using SQLite store .pyrula/dev.db - runs survive a restart; --fresh starts empty
Cloud: connecting to <origin>
Cloud: connected to <origin> as runtime <runtime-id> (project <project-id>, environment <environment>)
  support  submitted  <run-id>
  tool  lookup_order
  completed - Order A-1042 has shipped. Total $42.00.
Cloud: run <run-id> -> <link>
```

## 2. When it does not connect

If Cloud is unreachable or refuses the key, `pyrula dev` keeps running locally and prints one
line saying why. Pyrula retries an unreachable Cloud, or one answering 500 or 429, with backoff.
Runs that finish meanwhile are held in memory (bounded by `PYRULA_CLOUD_BUFFER_MAX`) and sent
once it connects; a process that stops first drops them and logs `cloud_held_discarded_at_stop`.
To check setup without starting anything:

```bash
pyrula dev --check
```

It never registers a runtime and exits 1 if a check failed; fixes are in
[Troubleshooting](https://docs.pyrula.io/getting-started/troubleshooting.md). With no key, `pyrula dev` prints
`Cloud: off (no PYRULA_CLOUD_API_KEY) - running locally only`.

## 3. Next

- [Add one boundary to an existing app](https://docs.pyrula.io/getting-started/existing-app.md)
- [Send existing traces](https://docs.pyrula.io/getting-started/otel.md)
- [Crash and resume](https://docs.pyrula.io/getting-started/restart.md)

## In your own code

For an existing runtime, install `"pyrula[cloud]"` and set the key where your workers start:

```bash
pip install "pyrula[cloud]"
```

Keys look like `pyr_<env>_...` with `<env>` of `live`, `test` or `dev`. A malformed key stops
start-up with a `ConfigurationError` naming `PYRULA_CLOUD_API_KEY`. With
`PYRULA_CLOUD_CONTROL=off` the key is not read.

## Settings

| Variable | Effect |
|---|---|
| `PYRULA_CLOUD_URL` | Cloud to connect to. Defaults to `https://api.pyrula.io`; set it for self-hosting. |
| `PYRULA_CLOUD_CONTROL` | Unset, `derived` or `full`: report and accept cloud commands. `telemetry`: report only. `off`: no connector is built. Other values stop start-up. |
| `PYRULA_RUNTIME_NAME` | Label for this runtime in the UI. Defaults to a generated name. |
| `PYRULA_RUNTIME_ID` | Stable identity across restarts. Defaults to a fresh id per process. |
| `PYRULA_CLOUD_CAPTURE` | Capture level cap, see [What leaves your runtime](https://docs.pyrula.io/getting-started/what-leaves-your-runtime.md#who-sets-it). |
| `PYRULA_CLOUD_ALLOWED_RUNNERS` | Comma-separated workflow/agent names Cloud may act on; control for any other name is refused. Unset allows all. |
| `PYRULA_CLOUD_OUTBOX_PATH` | Spool location, or `off` to disable. |
| `PYRULA_CLOUD_OUTBOX_SYNC` | `full` fsyncs every spool write. |
| `PYRULA_CLOUD_OUTBOX_MAX_AGE_S` | How long an unacked spooled event is kept. Default 7 days. |
| `PYRULA_CLOUD_CLAIM_GATE_SECONDS` | Wait for the first store-binding verdict before a polling worker takes work. Default 10; 0 disables. See [below](#one-store-per-project-and-environment). |
| `PYRULA_CLOUD_DRAIN_DEADLINE_S` | Most time a shutdown spends sending buffered events. Default 5. |
| `PYRULA_REDACT_KEYS` | Extra field names to redact ([redaction policy](https://docs.pyrula.io/getting-started/what-leaves-your-runtime.md#your-redaction-policy)). |

## Not losing events

Critical events (run lifecycle, steps, tool and usage results, terminals) go to a local spool,
the outbox, before they're sent, and are deleted once Cloud accepts them. Token deltas and other
live-stream events aren't spooled; when one is dropped, Cloud is told where the gap is.

The spool is on by default at `$XDG_STATE_HOME/pyrula/` (`~/.local/state/pyrula/`), one file
per project.

- **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`. Rows are
  separated only by environment name, so one project's `production` key would send another'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.
- **A damaged spool:** an unreadable file never stops your app from starting. Pyrula renames it
  to `<spool>.corrupt-<UTC yyyymmddTHHMMSSZ>`, logs one `outbox_quarantined` warning and starts
  a fresh spool; the newest three moved files are kept. A spool written by a newer SDK stops the
  connector; upgrade. A spool held open by another live process is left alone, and one it
  cannot read or write (another user's state directory, a read-only mount) logs one
  `outbox_unreadable` warning and runs without a spool.
- **Opting out:** `PYRULA_CLOUD_OUTBOX_PATH=off` keeps delivery to the life of the process,
  with no backpressure.

### Outbox contents

- Events already redacted for your capture level, as plaintext SQLite. File `0600`, directory
  `0700`.
- A row stays until Cloud accepts it or `PYRULA_CLOUD_OUTBOX_MAX_AGE_S` passes; an expired row
  counts as dropped and is reported.
- A restarted runtime sends the rows a killed process left for the same project and
  environment. This relies on `flock`; on Windows or a filesystem without it, those rows age
  out instead.
- A row is only sent while this runtime matches the store binding current when it was written.
- Deleting the file while stopped discards the backlog.
- Keys without a project id get a file named for a digest of the key; rotating the key starts
  a new file.

### 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 a production store first, so a laptop or CI job holding a
production key can't do it by accident:

```bash
pyrula store binding show
pyrula store binding set --project P --environment E
pyrula store binding clear
```

`clear` warns that the store's history will go to whichever environment binds next. Stop that
store's workers first.

Give each environment's workers their own store: a separate Valkey, a separate database index
on a standalone server, or a key namespace (`PYRULA_VALKEY_NAMESPACE` or `--namespace`), the
only option on a cluster. Unset means the namespace `"default"`.

- A Valkey ACL limiting a runtime to `~<namespace>:*` keeps it out of other namespaces. The
  store needs `+eval +evalsha +script|load` and sends no `FUNCTION` command
  ([recipe](https://docs.pyrula.io/getting-started/managed-valkey.md#when-the-server-refuses-scripts)).
- A worker whose key belongs to another project or environment runs nothing from the store,
  reports nothing and accepts no control. It logs an ERROR, reports not ready on `/readyz`,
  and the Workers page shows a notice. A mismatched process can still submit runs; the store's
  matched workers run them.
- While the store is unbound and Cloud has not answered register, a polling worker waits up
  to `PYRULA_CLOUD_CLAIM_GATE_SECONDS` before taking work. A store already bound to the key's
  project skips the wait.
- Run Valkey with `maxmemory-policy noeviction`: an evicted binding unbinds the store, and
  the next runtime to check binds it again.

### When 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` | 2x high water | Above this, the spool refuses new events and claiming stops. |

Only an ack or the age limit deletes rows. The heartbeat reports `egress_pressure` (`ok`,
`high_water` or `saturated`). Cloud trails by a fraction of a second under healthy load (median
0.21 s for `run_start`) and by seconds or minutes after a burst or outage.

### Stopping

Shutdown drains the queue up to `PYRULA_CLOUD_DRAIN_DEADLINE_S`. With a spool, critical events
left behind go out on the next start and only stream detail is lost; without one, undelivered
critical events are lost too. The runtime logs the counts at exit:

```
cloud_drain_timeout stream_discarded=N critical_undelivered=M spool_enabled=S
```

## If nothing appears

- **Key set but `pyrula-cloud-client` not installed:** start-up raises a configuration error.
  Install it or unset the key.
- **No error, nothing in the UI:** the key probably isn't reaching the process (an unexported
  variable, or a container without the environment). Check where the runtime runs:

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