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.

CLI

The pyrula command ships with pyrula-workflows; installing pyrula-agents adds the agent subcommands. It covers running agents locally, running the worker tier, and managing the store and in-flight turns.

Terminal window
pyrula --help # db | dev | init | reproject | schedules | turns | worker

pyrula dev accepts file targets such as module.py:agent_name. Worker commands use Python import refs such as your_module:agent_name; configured LLM or deps objects use the same import-ref shape, for example your_module:llm.

pyrula dev is the local cockpit. One command, two modes, and the input picks which:

Terminal window
pyrula dev # auto-discover app.py/agents.py here, run it, open the UI
pyrula dev app.py # find the @agent in the module and run it
pyrula dev app.py:support # explicit target
pyrula dev --store valkey://… # attach to a running system, browse every run
ModeWhat it doesNeeds
Embedded (a target, or auto-discovered)runs the agent in-process on an in-memory store so its runs show up in the UInothing - zero infra
Attach (--store …)browses every agent and workflow run in a shared storea shared store (Valkey) your workers already use

The UI is read-only either way: it shows your agents and workflows and their runs. You start runs the way you always do (your worker, your code, the API); pyrula dev is the window onto them.

With no --valkey-url, embedded mode uses an in-memory store, so there’s nothing to set up. If you give a bare module (or run pyrula dev with no arguments), it discovers the @agent targets for you; with several, it lists them so you can pick one.

Terminal window
pyrula dev app.py:support --llm-ref app:llm --port 8000

Pick the tier you need. Each is one command, and your code never changes between them:

  1. Zero infra: pyrula dev app.py:agent --open. In-memory store, embedded worker, live UI. Nothing to install or run.
  2. Full multi-process visibility (one container): run Valkey (docker run -p 6379:6379 valkey/valkey), point your workers and pyrula dev --store valkey://localhost:6379 at it. Now the cockpit sees every run, no matter which process produced it.
  3. Your real deployment: pyrula dev --store <your valkey> for a read-only attach to a staging-like store during local work.

pyrula dev --store valkey://localhost:6379 opens the UI with no agent loaded and no worker. It reads from the store your system already writes to and lists every name - agents and plain @workflows alike - with their runs; open any run for its live timeline. Because the event renderers are pure functions on the event payload, agent transcripts (LLM/tool/block) render without importing your agent code. It requires a shared store, because in-memory is per-process and can’t see another process’s runs.

pyrula dev mounts a small web UI at /ui (on by default). It serves on port 7978 unless you pass --port (or set PYRULA_DEV_PORT), and steps to the next free port if that one is busy - the startup banner prints the URL it settled on. Open http://127.0.0.1:7978/ui and you see your agents and workflows, each with its runs. Click a run for a live timeline that streams its events as they happen: tool calls, LLM steps, and errors each render as a row, with the payload inline.

Terminal window
pyrula dev app.py:support --llm-ref app:llm --open # open the browser on startup
pyrula dev app.py:support --llm-ref app:llm --no-ui # API only, no UI

The UI is local-only and unauthenticated by design: it binds to the dev server, holds no state of its own, and is meant for the inner-loop of building an agent. It is not a hosted console.

pyrula dev is a convenience wrapper. If you already run your own FastAPI app - or a separate-worker fleet - you can mount the same UI yourself. It attaches to any FastAPI app alongside your agent routes:

from pyrula.agents.web.router import AgentRouter
from pyrula.agents.web.ui_ext import register_agent_renderers
from pyrula.workflows.web import mount_run_ui
router = AgentRouter(worker=worker) # InlineWorker, or a WorkerProxy for separate workers
router.attach(app, prefix="/agents") # your existing FastAPI app
register_agent_renderers() # llm/tool/block transcript renderers
mount_run_ui(app, prefix="/ui", control=router)

The UI reads runs from the shared store, so with a separate-worker deployment it needs a real store (Valkey/Postgres), not the in-memory one. The same no-auth caveat applies: keep it behind localhost or a private network; it is not a drop-in production console (that is Pyrula Cloud).

Run the worker tier for separate-mode deployments. Point it at the same store the API uses.

Terminal window
pyrula worker \
--agent app:support \
--valkey-url redis://localhost:6379 \
--llm-ref app:llm \
--deps-ref app:services \
--max-concurrent-turns 50 \
--poison-max-retries 3 \
--shard-count 64 \
--shard-lease-ttl 30 \
--shard-lease-interval 10

--deps-ref injects a shared dependencies object. The poison, heartbeat, and orphan-scan flags map to the same WorkerOptions knobs covered in Workers.

--shard-count, --shard-lease-ttl, and --shard-lease-interval (shown above at their defaults) configure how this worker splits and leases each agent’s queue - the same mechanism WorkflowWorker uses, covered in full in the workflows guide’s Scaling across shards. Every worker serving the same agent needs the same --shard-count. --shard-lease-interval must be strictly less than --shard-lease-ttl; the worker refuses to start otherwise, since a renewal that arrives at or after the TTL can let its own lease lapse before it gets renewed. This command has no flag for cluster mode; point a worker at a Valkey cluster by embedding WorkerRuntime in your own process with a ValkeyStore(cluster=True, ...) instead of running it through pyrula worker.

Valkey self-bootstraps, so it needs no setup. Postgres archival needs its schema applied once. Use check to validate connectivity before starting workers - with --postgres-dsn, it also verifies the archive schema is migrated to the revision this pyrula version expects, not just that the database is reachable.

Terminal window
pyrula db check --valkey-url redis://localhost:6379
pyrula db check --valkey-url redis://localhost:6379 --postgres-dsn "$PYRULA_POSTGRES_DSN"
pyrula db migrate --postgres-dsn "$PYRULA_POSTGRES_DSN"

A schema that is missing or was migrated by a different pyrula version fails check, migrate, and the first run of a worker alike with the same message: what revision was found versus expected, and what to do about it. The archive schema is a single folded Alembic revision with no upgrade path between versions, so an existing archive from a different pyrula version has to be recreated (a fresh database) rather than migrated in place, then re-migrated with pyrula db migrate.

Show, set or clear which project and environment a Valkey store belongs to. See One store per project and environment.

Terminal window
pyrula store binding show --valkey-url valkey://127.0.0.1:6392
pyrula store binding set --project acme --environment production --valkey-url valkey://127.0.0.1:6392
pyrula store binding clear --valkey-url valkey://127.0.0.1:6392
project: acme
environment: production
bound_by: cli
bound_at: 2026-10-10T00:23:54+00:00

--valkey-url defaults to PYRULA_VALKEY_URL, or valkey://localhost:6379 if that is unset. Add --cluster to connect to a Valkey Cluster deployment instead of a single node.

--namespace (default: PYRULA_VALKEY_NAMESPACE, or "default" if that is also unset - every store has a namespace) scopes every key the command reads or writes, including the binding itself, to one store sharing a Valkey instance or cluster with others. Every other command taking --valkey-url in this reference takes the same --namespace, defaulted the same way; point every process in one environment at the same namespace, or none connect to the same store. A bad namespace (wrong characters, over 64 characters) is rejected at the command line with exit status 2, before any connection is attempted.

set is idempotent: if the store is already bound to the same project and environment it prints that binding and exits 0. It fails with exit status 1 and prints the existing binding if the store is bound to a different project or environment. clear is compare-and-delete: it removes only the binding it just showed, fails with exit status 1 if another process rebound the store in between, and warns that the store’s history goes to whichever environment binds next - stop that store’s workers first.

show on a value that cannot be parsed back into a binding reports it as malformed (exit 1) along with the raw stored text, instead of printing a traceback. clear --malformed removes exactly that raw text, by compare-and-delete against it rather than against a parsed binding - the only way to remove a value clear on its own cannot read.

Inspect and recover individual turns.

Terminal window
pyrula turns list-quarantined --agent support --limit 50
pyrula turns inspect --turn-id <id> --agent support
pyrula turns requeue --turn-id <id> --agent support
pyrula turns gc --older-than 30d --postgres-dsn "$PYRULA_POSTGRES_DSN" --dry-run

requeue takes a turn the worker quarantined as poison and puts it back in the queue, once you’ve fixed whatever broke it. gc deletes archived turns past a cutoff; --dry-run counts them first.

Scheduled runs have their own subcommand, covered in Schedules.

Rebuild the conversation, usage, and cost read tables from the archived event log. Use it to backfill runs archived before you enabled projection, or after editing the price table. Needs Postgres.

Terminal window
pyrula reproject --agent support # one agent
pyrula reproject --agent support --run-id <id> # one run (--run-id requires --agent)
pyrula reproject # every archived run, every agent

Projection itself is opt-in on the store (PostgresStore(..., project=True)) and is covered in Observability.