Skip to main content

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