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.
pyrula --help # db | dev | init | reproject | schedules | turns | workerpyrula 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:
pyrula dev # auto-discover app.py/agents.py here, run it, open the UIpyrula dev app.py # find the @agent in the module and run itpyrula dev app.py:support # explicit targetpyrula dev --store valkey://… # attach to a running system, browse every run| Mode | What it does | Needs |
|---|---|---|
| Embedded (a target, or auto-discovered) | runs the agent in-process on an in-memory store so its runs show up in the UI | nothing - zero infra |
Attach (--store …) | browses every agent and workflow run in a shared store | a 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.
pyrula dev app.py:support --llm-ref app:llm --port 8000Setup tiers
Section titled “Setup tiers”Pick the tier you need. Each is one command, and your code never changes between them:
- Zero infra:
pyrula dev app.py:agent --open. In-memory store, embedded worker, live UI. Nothing to install or run. - Full multi-process visibility (one container): run Valkey
(
docker run -p 6379:6379 valkey/valkey), point your workers andpyrula dev --store valkey://localhost:6379at it. Now the cockpit sees every run, no matter which process produced it. - Your real deployment:
pyrula dev --store <your valkey>for a read-only attach to a staging-like store during local work.
Attach mode (--store)
Section titled “Attach mode (--store)”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.
Local dev UI
Section titled “Local dev UI”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.
pyrula dev app.py:support --llm-ref app:llm --open # open the browser on startuppyrula dev app.py:support --llm-ref app:llm --no-ui # API only, no UIThe 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.
Mount it into your own app
Section titled “Mount it into your own app”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 AgentRouterfrom pyrula.agents.web.ui_ext import register_agent_renderersfrom pyrula.workflows.web import mount_run_ui
router = AgentRouter(worker=worker) # InlineWorker, or a WorkerProxy for separate workersrouter.attach(app, prefix="/agents") # your existing FastAPI appregister_agent_renderers() # llm/tool/block transcript renderersmount_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).
worker
Section titled “worker”Run the worker tier for separate-mode deployments. Point it at the same store the API uses.
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.
pyrula db check --valkey-url redis://localhost:6379pyrula 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.
pyrula store binding show --valkey-url valkey://127.0.0.1:6392pyrula store binding set --project acme --environment production --valkey-url valkey://127.0.0.1:6392pyrula store binding clear --valkey-url valkey://127.0.0.1:6392project: acmeenvironment: productionbound_by: clibound_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.
pyrula turns list-quarantined --agent support --limit 50pyrula turns inspect --turn-id <id> --agent supportpyrula turns requeue --turn-id <id> --agent supportpyrula turns gc --older-than 30d --postgres-dsn "$PYRULA_POSTGRES_DSN" --dry-runrequeue 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.
reproject
Section titled “reproject”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.
pyrula reproject --agent support # one agentpyrula reproject --agent support --run-id <id> # one run (--run-id requires --agent)pyrula reproject # every archived run, every agentProjection itself is opt-in on the store (PostgresStore(..., project=True)) and is
covered in Observability.