Skip to main content

Phase 4: Go Fully Native

You are here if: you want the full power of the Waxell platform -- durable workflows, declarative agent definitions, built-in governance, and production infrastructure. This is a manual migration guide for converting existing agents to native Waxell.

What you will have after this phase: agents defined with @agent, @workflow, @tool, and @router decorators running on Waxell's durable runtime with checkpoint/resume, full governance, and production backends.


The Mapping

Every pattern in your existing agent code has a native Waxell equivalent:

Your existing codeWaxell SDK equivalent
LLM API calls (openai.chat.completions.create(...))await ctx.llm.generate(prompt=..., task=...)
Orchestration logic (function sequences, if/else, loops)@workflow decorator with ctx.log_step()
External API calls (requests.get(...), SDK clients)await ctx.domain("service", "action", ...)
Tool functions@tool decorator
Agent class or entry point@agent decorator
LLM-based classification / routing@router decorator with decision spec
Manual observability (print, logging, custom dashboards)Remove it -- built-in
Manual cost trackingRemove it -- built-in
Manual retry logicRemove it -- built-in via durable workflows

Step-by-Step: Converting a LangChain Agent

Here is a complete walkthrough converting a LangChain customer support agent to native Waxell.

Before: LangChain

import os
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.tools import tool as langchain_tool
from langchain.agents import create_tool_calling_agent, AgentExecutor

@langchain_tool
def lookup_customer(customer_id: str) -> str:
"""Look up customer details by ID."""
import requests
resp = requests.get(
f"https://api.myapp.com/customers/{customer_id}",
headers={"Authorization": f"Bearer {os.environ['APP_API_KEY']}"},
)
return resp.json()

@langchain_tool
def search_kb(query: str) -> str:
"""Search the knowledge base for relevant articles."""
import requests
resp = requests.get(
f"https://api.myapp.com/kb/search",
params={"q": query},
headers={"Authorization": f"Bearer {os.environ['APP_API_KEY']}"},
)
return resp.json()

@langchain_tool
def create_ticket(customer_id: str, category: str, summary: str) -> str:
"""Create a support ticket."""
import requests
resp = requests.post(
"https://api.myapp.com/tickets",
json={
"customer_id": customer_id,
"category": category,
"summary": summary,
},
headers={"Authorization": f"Bearer {os.environ['APP_API_KEY']}"},
)
return resp.json()

llm = ChatOpenAI(model="gpt-4o", temperature=0)
prompt = ChatPromptTemplate.from_messages([
("system", (
"You are a customer support agent. Look up the customer, "
"search the knowledge base, classify the issue, and create a ticket. "
"Respond with a helpful message."
)),
("human", "Customer {customer_id}: {message}"),
("placeholder", "{agent_scratchpad}"),
])

tools = [lookup_customer, search_kb, create_ticket]
agent = create_tool_calling_agent(llm, tools, prompt)
executor = AgentExecutor(agent=agent, tools=tools, verbose=True)

# Run it
result = executor.invoke({
"customer_id": "C-1234",
"message": "I was charged twice for my subscription",
})
print(result["output"])

After: Waxell Native

from waxell_sdk import agent, workflow, tool, router, WorkflowContext, RouterContext

@agent(
name="support-agent",
description="Handles customer support tickets with classification and response",
signals=["support_ticket"],
domains=["customers", "knowledge_base", "tickets"],
)
class SupportAgent:

# ----------------------------------------------------------------
# Tools: External integrations become domain calls
# ----------------------------------------------------------------

@tool
async def lookup_customer(self, ctx: WorkflowContext, customer_id: str) -> dict:
"""Look up customer details by ID."""
return await ctx.domain("customers", "get_details", customer_id=customer_id)

@tool
async def search_kb(self, ctx: WorkflowContext, query: str) -> dict:
"""Search the knowledge base for relevant articles."""
return await ctx.domain("knowledge_base", "search", query=query)

@tool
async def create_ticket(
self,
ctx: WorkflowContext,
customer_id: str,
category: str,
summary: str,
) -> dict:
"""Create a support ticket."""
return await ctx.domain(
"tickets", "create",
customer_id=customer_id,
category=category,
summary=summary,
)

# ----------------------------------------------------------------
# Router: LLM-based classification replaces AgentExecutor
# ----------------------------------------------------------------

@router("classify_and_handle", decision="classify_ticket")
async def classify_and_handle(self, ctx: RouterContext) -> dict:
"""Classify the ticket and route to the appropriate handler."""
# Gather context for the LLM decision
customer = await ctx.tool(
"lookup_customer",
customer_id=ctx.inputs.get("customer_id"),
)
ctx.add_signal("customer_info", customer)

kb_results = await ctx.tool(
"search_kb",
query=ctx.inputs.get("message"),
)
ctx.add_signal("kb_results", kb_results)

return await ctx.route()

# ----------------------------------------------------------------
# Workflows: Each category gets a dedicated handler
# ----------------------------------------------------------------

@workflow("handle_billing")
async def handle_billing(
self,
ctx: WorkflowContext,
customer_id: str,
message: str,
) -> dict:
"""Handle billing issues."""
# Generate a response using the LLM router
response = await ctx.llm.generate(
prompt=(
f"Generate a helpful billing support response.\n"
f"Customer issue: {message}"
),
output_format="json",
task="billing_response",
)

# Create a ticket
ticket = await ctx.tool(
"create_ticket",
customer_id=customer_id,
category="billing",
summary=message,
)
ctx.log_step("ticket_created", {"ticket_id": ticket.get("id")})

return {"response": response, "ticket": ticket}

@workflow("handle_technical")
async def handle_technical(
self,
ctx: WorkflowContext,
customer_id: str,
message: str,
) -> dict:
"""Handle technical issues."""
kb_result = await ctx.tool("search_kb", query=message)
ctx.log_step("kb_searched", {"results_count": len(kb_result)})

response = await ctx.llm.generate(
prompt=(
f"Using this knowledge base article: {kb_result}\n\n"
f"Generate a helpful technical support response for: {message}"
),
output_format="json",
task="technical_response",
)

ticket = await ctx.tool(
"create_ticket",
customer_id=customer_id,
category="technical",
summary=message,
)
ctx.log_step("ticket_created", {"ticket_id": ticket.get("id")})

return {"response": response, "ticket": ticket}

What Changed and Why

Direct API calls became domain calls

Before: requests.get(f"https://api.myapp.com/customers/{customer_id}") with hardcoded URLs and manual auth headers.

After: await ctx.domain("customers", "get_details", customer_id=customer_id) -- the control plane routes this to your configured domain endpoint with proper authentication, audit logging, and error handling.

AgentExecutor became a router

Before: LangChain's AgentExecutor lets the LLM decide which tools to call in a loop. The classification happens implicitly as part of the agent's reasoning.

After: The @router decorator makes classification explicit. The LLM sees the available capabilities and selects one. The decision is logged, auditable, and governed by policies.

Manual tool definitions became @tool

Before: @langchain_tool decorators with string return types and inline HTTP calls.

After: @tool decorators with typed parameters that delegate to domain calls. The tool is registered with the control plane and tracked in every execution.

Orchestration logic became workflows

Before: The AgentExecutor handles all orchestration in a single opaque loop.

After: Each handling path (handle_billing, handle_technical) is an explicit workflow with named steps. Each step is a checkpoint -- if the process crashes after kb_searched, it resumes from there.

Observability code was removed

Before: verbose=True for console output, plus any custom logging or metrics you added.

After: Nothing. Every LLM call, tool invocation, domain call, and workflow step is automatically tracked by the runtime. The control plane dashboard shows everything.


What You Gain at Phase 4

Durable Workflows with Checkpoint/Resume

Every workflow step is a durable checkpoint. If your process crashes, the WorkflowEnvelope resumes execution from the last completed step. No data is lost, no work is repeated.

Full Governance Lifecycle

The DynamicPolicyManager evaluates policies at every hook point:

  • Pre-execution: Can this agent run right now?
  • Pre-step: Can this workflow step proceed?
  • Post-step: Should execution continue based on this step's output?
  • Post-execution: Record the final outcome for audit

Infrastructure Package

Native Waxell agents run on production-grade infrastructure:

ComponentDefault (local dev)Production
State backendInMemoryBackendDjangoRuntimeBackend (PostgreSQL)
Token semaphoreInMemoryTokenSemaphoreRedisTokenSemaphore
Task executionIn-processCeleryTaskStatusProvider
Governance hooksNonePolicyGovernanceHook

Switching from local dev to production happens automatically when the waxell_infra Django app loads -- no code changes needed.

Generation Layer

For agents that produce content (emails, reports, summaries), the generation package provides:

  • RAG (Retrieval-Augmented Generation) pipelines
  • Prompt versioning and A/B testing
  • Content synthesis with quality controls
  • Model routing per task type

Multi-Tenancy

Native agents support full tenant isolation:

  • Per-tenant policies and model configurations
  • Per-tenant billing and cost tracking
  • Per-tenant audit trails
  • Database-level data isolation

Management Commands

Operate your agents via Django management commands:

# List all registered agents
python manage.py agent_list

# Run an agent directly
python manage.py agent_run support-agent

# Interactive agent shell
python manage.py agent_shell support-agent

# Run agent tests
python manage.py agent_test support-agent

# View execution trace
python manage.py agent_trace <execution_id>

Migration Checklist

Use this checklist when converting an existing agent:

  • Identify all LLM calls and map them to ctx.llm.generate() with appropriate task values
  • Identify all external API calls and map them to domain calls via ctx.domain()
  • Identify tool functions and convert them to @tool decorators
  • Identify the main orchestration flow and convert it to a @workflow or @router
  • Define the @agent decorator with name, description, signals, and domains
  • Remove all manual observability code (logging wrappers, metrics, cost tracking)
  • Remove all manual retry logic (the runtime handles this)
  • Configure domain endpoints in the control plane
  • Register signal types in the control plane
  • Test with python manage.py agent_test <agent_name>
  • Verify execution traces with python manage.py agent_trace <execution_id>

Next Steps