Quickstart
Install pyrula-agents, then scaffold a runnable agent:
pyrula init --agentpyrula dev --openThe packages require a source build until their first public release. See Install for the current commands.
Generated agent
Section titled “Generated agent”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.
Using a real model
Section titled “Using a real model”Edit one line. The generated file sets llm = FakeLLM(...) and lists the
alternatives in a comment right above it:
from pyrula.agents.llm import AnthropicLLMllm: Any = AnthropicLLM(model="claude-sonnet-4-6") # reads ANTHROPIC_API_KEY
from pyrula.agents.llm import OpenAILLMllm: Any = OpenAILLM(model="gpt-4o-mini") # reads OPENAI_API_KEYEach 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.
Run the agent
Section titled “Run the agent”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:supportUsing in-memory store - history clears on exit; --valkey-url to persistUI: 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.
Minimal agent
Section titled “Minimal agent”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 asynciofrom pyrula.agents import AgentContext, agent, stepfrom pyrula.agents.testing import AgentRunner, FakeLLM, FakeResponse, InMemoryStore
@stepasync 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).
Durable side effects
Section titled “Durable side effects”@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.
Further reading
Section titled “Further reading”For production: