Best Practices
Recommended patterns for building production-ready Waxell agents. For the full
API surface, see @workflow and
Execution Context.
Agent design
Keep agents focused
Each agent should have a single, clear responsibility:
# Good: single responsibility
@agent(name="email_classifier", description="Classifies incoming emails")
class EmailClassifier:
...
@agent(name="email_responder", description="Drafts email responses")
class EmailResponder:
...
An agent that "classifies, responds, and archives" is three agents. Splitting them gives you three independently versioned, independently governed, separately attributable units — and lets one fail without taking the others down.
Delegate between them with ctx.spawn("email_responder", inputs={...}) rather
than merging them.
Reuse through tools and workflows
Extract shared behaviour into tools and workflows, and reference them by name:
agents:
- name: support_agent
tools: [classify_text, lookup_order]
workflows: [escalation]
Workflow design
Prefer small steps
The tool boundary is the checkpoint boundary, so step size is directly a restart-cost decision:
@workflow("process_order")
async def process_order(self, ctx, order: dict):
# Each ctx.tool(...) is independently checkpointed.
validated = await ctx.tool("validate", order=order)
enriched = await ctx.tool("enrich", data=validated)
return await ctx.tool("process", data=enriched)
Work done between tool calls isn't checkpointed. If repeating a computation
would hurt — an expensive query, a paid API call — it belongs in a @tool.
Handle errors deliberately
@workflow("resilient")
async def resilient(self, ctx, payload: dict):
try:
return await ctx.tool("risky_operation", payload=payload)
except ValidationError:
return await ctx.tool("handle_validation_error", payload=payload)
except ExternalServiceError:
return await ctx.tool("retry_with_backoff", payload=payload)
Catching changes your control flow, not the audit trail — the failure is still
recorded on the span and as an ExecutionIncident. And a policy block is not
an exception: a governed stop finishes the run in BLOCKED with a recorded
decision, so there's nothing to catch.
Prompting
Be explicit, and ask for JSON
@tool
async def classify_intent(self, ctx, message: str) -> dict:
return await ctx.llm.generate(
prompt=(
"Classify this support message by its primary topic as exactly one "
"of: billing, technical, general. "
'Reply with JSON: {"category": "<one of the three>"}.\n\n'
f"{message}"
),
output_format="json",
task="classification",
)
Validate structured output
Ask for JSON and validate it — a model can always return something malformed, and you want that caught at the boundary:
from pydantic import BaseModel
class Analysis(BaseModel):
sentiment: str
confidence: float
topics: list[str]
@tool
async def analyze(self, ctx, text: str) -> dict:
raw = await ctx.llm.generate(
prompt=f"Analyze and return JSON with sentiment, confidence, topics:\n\n{text}",
output_format="json",
task="analysis",
)
return Analysis.model_validate(raw).model_dump()
Keep expensive mistakes out of the prompt
The most valuable design rule: if being wrong is expensive, it doesn't belong in the prompt. A model should decide which invoice this is; it should not be what decides whether two invoices are the same record. Put judgement in the agent and arithmetic in a domain action. See where to put logic.
Governance
Bound cost, turns, and time
Declare limits on the agent:
agents:
- name: email_sender
max_budget_usd: 2.00 # hard USD cap (default 10.00)
max_turns: 15 # agent turns (default 25)
timeout_seconds: 120 # wall time
Set these explicitly. The defaults are generous by design, and an agent that loops will spend to them.
These caps cover one agent. Once you're running several, move the rules into the
policy engine instead: policies are scoped across agents, workflows, tools,
models, and teams, and an agent can't drop one by editing its own manifest. See
Adding Governance for policy() + wax policies push,
and Policy Categories for the full set.
A run stopped by a policy finishes BLOCKED with an ExecutionIncident.
Require approval for sensitive actions
Approval is a property of the domain action:
domains:
- name: accounts
actions:
- name: delete_account
requires_approval: true
The run pauses with PausedReason.awaiting_approval and holds no compute until
someone decides, then resumes from its checkpoint. Attaching it to the action
means every agent that can reach it inherits the requirement — you can't forget
it on the fifth agent. See Domains.
Constrain what the agent can reach
Prefer domain actions over credentials. Giving an agent document.read is
auditable and revocable; giving it a database URL is neither.
Tighten the boundary further with Data Access and Network policies, which bound what any agent can read or reach regardless of what its manifest declares.
Testing
Test at three levels: individual tools, whole workflows, and governed end-to-end runs.
from waxell_infra.testing.harness import AgentTestHarness
async def test_classification_workflow():
result = await AgentTestHarness.run_agent(
ClassifierAgent,
"classify",
message="I can't log in",
)
assert result["category"] == "technical"
run_agent is an async classmethod taking (agent_cls, workflow, **inputs). It
instantiates the real agent with full governance and sandboxing, so what passes
here is close to what runs in production. To control model output, use the fakes
in waxell_infra.testing.fakes.
Monitoring
What the platform records for you
Every run automatically produces a span tree, an LlmCallRecord per model call,
policy decisions, incidents, and usage events — see
what a run leaves behind.
There's nothing to instrument. Use standard Python logging for anything extra;
it's captured in the run's logs.
What to alert on
telemetry:
alerts:
- p95_latency_ms > 5000
- error_rate > 0.05
- cost_per_run > 0.50
sample_rate: 1.0
Beyond thresholds, the signal worth watching is runs that complete with an empty tool loop — a green run that did nothing. The trace shows why: a policy block, a missing tool, or a prompt problem.
Next steps
- Production Guide — deploy with these practices
- Governance Tutorial — limits, approvals, and the policy engine
- Policy Categories — all 49 categories
- How the Runtime Works — the full path of a run