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.

Quickstart

Install pyrula-agents, then scaffold a runnable agent:

Terminal window
pyrula init --agent
pyrula dev --open

The packages require a source build until their first public release. See Install for the current commands.

pyrula init --agent writes app.py: a support agent with one tool, lookup_order. It runs offline as written, against a scripted fake model, so there’s no API key and no broker to set up first.

Edit one line. The generated file sets llm = FakeLLM(...) and lists the alternatives in a comment right above it:

from pyrula.agents.llm import AnthropicLLM
llm: Any = AnthropicLLM(model="claude-sonnet-4-6") # reads ANTHROPIC_API_KEY
from pyrula.agents.llm import OpenAILLM
llm: Any = OpenAILLM(model="gpt-4o-mini") # reads OPENAI_API_KEY

Each client reads its key from the environment. The model is always the one named in the file. For other providers, install pyrula-agents[litellm] and use LiteLLM with any model string LiteLLM supports.

pyrula dev --open starts a local server, submits one turn against support with its default message, runs it, and opens the dev UI so you can see the turn’s event stream. The terminal output looks like this:

=== Pyrula Dev Server ===
Target: app.py:support
Using in-memory store - history clears on exit; --valkey-url to persist
UI: http://127.0.0.1:7978/ui
Press Ctrl+C to stop
support submitted ae43518948a0429cb2b0f62a341c4a8a
tool lookup_order
completed - Order A-1042 has shipped. Total $42.00.

support calls lookup_order, the fake model reads the result back, and the turn completes with the reply as its result. The server keeps running after that: the /ui page lists the finished turn, and you can submit more from there or over HTTP against /agents.

If your own agent’s parameters have no defaults, the startup turn can fail (a missing required argument, for instance). That doesn’t take the server down: pyrula dev prints one line saying the turn didn’t complete and why, then keeps serving so you can submit a turn with the arguments it needs.

An agent is an async function decorated with @agent. It takes a context (ctx) and runs on the workflow engine, so the same replay and recovery rules apply.

This example uses a fake LLM and an in-memory store, so it runs with no API key and no broker.

import asyncio
from pyrula.agents import AgentContext, agent, step
from pyrula.agents.testing import AgentRunner, FakeLLM, FakeResponse, InMemoryStore
@step
async def lookup() -> str:
return "42"
@agent(timeout=30)
async def greet(ctx: AgentContext, name: str) -> str:
await ctx.emit("started", {"name": name}, id="started")
answer = await lookup()
return f"hello, {name} ({answer})"
async def main() -> None:
runner = AgentRunner(agent=greet, store=InMemoryStore(), llm=FakeLLM([FakeResponse(text="hi")]))
events = await runner.run(name="ada")
done = [e for e in events if e.kind == "run:complete"]
print(done[0].payload["result"]) # hello, ada (42)
asyncio.run(main())

AgentRunner is the in-process harness for tests. The events it returns are the same run:* and step:* events the engine writes to the stream (see Runs as Event Streams).

@step and ctx.emit are durable. On replay, a completed step returns its recorded result instead of running again. The harness can prove it:

async def prove_it() -> None:
runner = AgentRunner(agent=greet, store=InMemoryStore(), llm=FakeLLM([FakeResponse(text="hi")]))
runner.inject_crash_at("named", nth=1, when="pending") # crash before lookup starts
await runner.run(name="ada") # first attempt stops at the boundary
await runner.run(name="ada") # resume runs lookup once
asyncio.run(prove_it())

Side effects that run through ctx are recorded for crash recovery. See Determinism for the replay requirements.

For production:

  • Workers for the tier that executes turns in production.
  • The CLI for pyrula worker, which needs --llm-ref to name the model when your agent declares one with @agent(llm=...) - as the scaffold does.