Quickstart
Waxell agents are declared in a waxell.yaml manifest, or defined in Python with @agent. Either way the runtime handles tracing, policy enforcement, working memory and LLM routing — you write the business logic.
One canonical import path: everything customer-facing is exposed under waxell_runtime. Don't import from waxell_sdk — that's an internal implementation detail.
from waxell_runtime import agent, workflow, tool # ✓ canonical
from waxell_sdk import agent # ✗ internal, will break
Install
pip install waxell # canonical meta-package — pulls runtime + observe + the wax CLI
If you need just the runtime engine without observability (size-constrained, separate observability path, etc.), pip install waxell-runtime is the alternate entry point and stays available forever.
For an isolated CLI install (recommended if you only want wax on your PATH), use pipx install waxell. Full options and troubleshooting live in the CLI reference.
Verify the install worked
wax --version # the wax CLI is installed and on your PATH
python -c "import waxell_runtime" # the SDK imports cleanly (no output = success)
If wax isn't found or the import errors, see the CLI install troubleshooting (PATH, pipx, PEP 668).
Authenticate
wax login # writes credentials to ~/.waxell/config
wax whoami # confirms your API key + tenant resolve
Your first agent
Most agents are declared in a waxell.yaml manifest — this is what wax push
calls "manifest mode (recommended)", and it's how the agents running in
production are defined. You can also define them in Python; that's further down.
We'll build a document classifier: it reads an inbound document, decides what kind it is, extracts the key fields, and files it back into your application. Real shape, not a toy.
1. Declare it
version: 1
defaults:
owner: you@yourcompany.com
tags: [quickstart]
agents:
- name: doc_intake
framework: pydantic_ai
version: "1.0.0"
description: Classify an inbound document and file it.
model: anthropic:claude-sonnet-4-6
timeout_seconds: 120
# What the agent may reach in YOUR application.
domains: [document]
system_prompt: |
You classify inbound business documents.
Steps:
1. Call document.read(document_id=<the document_id>) to get the text.
2. Decide which type it is: invoice, purchase_order, or quote.
3. Extract: customer_name, doc_number, total, currency, date.
4. Call document.classify_result(document_id=..., doc_type=...,
customer_name=..., extracted_json="<JSON string of your fields>").
Make exactly one classify_result call, then reply with one line.
Never invent parties, amounts or dates — use only what the document says.
# What starts a run.
signals:
- name: document_received
source_type: api
description: A document landed and needs classifying.
schema:
document_id:
type: string
description: The stored document id.
idempotency_key: $.document_id
Three things are doing the real work:
domains: [document]— the agent never touches your database. It calls named actions; your service answers. See Domains.signals— how a run starts.idempotency_keymeans a webhook delivered twice produces one run, not two.system_prompt— judgement only. Note what it does not do: it never decides whether two documents are the same record. That belongs in your domain, where being wrong is cheap to fix.
2. Validate, then push
wax push --dry-run # parse + show what would upload, without touching the platform
wax push # auto-discovers waxell.yaml in the current directory
wax agents list
Always dry-run first — it catches typos and schema mistakes for free.
3. Fire a signal
wax signals fire document_received --payload '{"document_id":"doc-123"}'
This returns a run_id immediately — it does not wait for the agent. Runs
proceed in the background, which is what keeps your API fast.
wax signals show <run_id> # the run and its result
wax runs list # recent runs
4. Read the trace
The part worth learning properly:
wax traces list
wax runs show <run_id>
Or open Observe → Runs. Either way you get the span tree — every LLM call, tool call and domain call, nested, timed, failures in red, cost attached.
When an agent misbehaves, the trace tells you which of three things happened:
| What you see | What it means |
|---|---|
| No tool calls at all | The agent never called your domain — usually a prompt problem |
document.read then nothing | It read the document and didn't act — check the prompt's final instruction |
| A red span | The call failed; the error is on the span |
What you just built
signal ──► doc_intake ──► document.read (your app)
└──► document.classify_result (your app)
│
span tree · cost · policy decisions · audit
Governed by default. You didn't configure any of that.
Alternative: define agents in Python
from waxell_runtime import agent, workflow, tool
@agent(name="lead_research", description="Research a lead and draft outreach")
class LeadResearchAgent:
@tool
async def fetch_company(self, ctx, domain: str) -> dict:
"""Look up a company by domain."""
return await ctx.domain("company", "lookup", domain=domain)
@workflow("draft_email")
async def draft_email(self, ctx, lead_id: str):
company = await ctx.tool("fetch_company", domain="acme.com")
email = await ctx.llm.generate(
prompt=f"Write a 3-sentence intro for {company['name']}",
output_format="text",
task="outreach_email",
)
return {"email": email, "company": company["name"]}
Push it to the platform:
wax push agent.py
wax agents list
That's the loop. The runtime handles the rest.
After decoration, LeadResearchAgent is an AgentSpec, not a class you instantiate. The platform constructs and runs the agent for you.
What ctx gives you
Inside any @workflow or @tool:
| Call | Purpose |
|---|---|
await ctx.tool("name", **inputs) | Invoke another @tool on this agent |
await ctx.domain("entity", "action", **inputs) | Call a Waxell domain endpoint |
await ctx.llm.generate(prompt=..., output_format=...) | Routed LLM call (cost-aware, policy-aware) |
ctx.secrets.get("OPENAI_API_KEY") | Read a secret from the configured provider |
For IDE autocomplete, type-hint ctx:
from waxell_runtime import WorkflowContext, ToolContext, Context
@workflow("draft_email")
async def draft_email(self, ctx: WorkflowContext, lead_id: str): ...
@tool
async def fetch_company(self, ctx: ToolContext, domain: str) -> dict: ...
Directory layout for larger agents
When a single file gets unwieldy:
my_agent/
agent.py # @agent class with tools=[...] workflows=[...]
tools/
fetch_company.py
workflows/
draft_email.py
# my_agent/agent.py
from waxell_runtime import agent
@agent(
name="lead_research",
description="Research a lead and draft outreach",
tools=["fetch_company"], # references tools/fetch_company.py
workflows=["draft_email"], # references workflows/draft_email.py
)
class LeadResearchAgent:
pass
The decorator strings are filenames (no .py), not Python symbols. One workflow per file is the convention.
Configuration
Config priority (highest first):
- Explicit
WaxellClient(api_url=..., api_key=...)constructor args WaxellClient.configure(...)set globally at startup~/.waxell/config(written bywax login)- Environment variables:
WAX_API_KEY,WAX_API_URL,WAX_TENANT
If none resolve, calls silently no-op. Run wax whoami to verify.
If you previously set WAXELL_API_KEY for the observe SDK, also set WAX_API_KEY. The runtime SDK reads only WAX_* (no fallback). Setting WAX_API_KEY alone covers both packages — observe falls back to it.
Use Claude Code with Waxell
If you use Claude Code, install the bundled skill so Claude understands the SDK conventions when you ask it to extend your agent:
wax claude-init
This drops a project-scoped skill at .claude/skills/waxell-runtime/SKILL.md. Claude activates it whenever it sees from waxell_runtime import ... in your project and will write idiomatic agent code for you. Re-run wax claude-init after upgrading waxell-runtime to refresh the skill.
For a global install, use --scope user. For just printing the skill content, use --print-only.
Common mistakes
| Mistake | Symptom | Fix |
|---|---|---|
| Pushed to the wrong tenant | Agent "vanishes"; it's live somewhere else | wax whoami; target one with wax -p prod push |
Re-pushing the same version: | 409 semver_conflict | Versions are immutable — bump version: |
Expecting signals fire to return the answer | You get a run_id, not a result | It's async by design. Poll wax signals show, or have the agent call back through a domain |
| Agent replies but nothing happens in your app | Run completes, your database is untouched | It never called your domain. Check the trace for tool calls; name the action explicitly in the prompt |
Setting only WAXELL_API_KEY | Runtime calls silently no-op | Set WAX_API_KEY instead |
os.environ["OPENAI_API_KEY"] inside a workflow | Works locally, breaks in deployment | Use ctx.secrets.get("OPENAI_API_KEY") |
Instantiating the class after @agent | TypeError: 'AgentSpec' object is not callable | Don't instantiate. The platform runs the agent. |
from waxell_sdk import agent | Works in editable install, breaks for customers | Always from waxell_runtime import agent |
What's next
- How the runtime works — signal to result, and where to put logic
- Execution tiers — shared, warm, or your own container
waxell.yamlreference — every supported field- Runtime overview — execution context, durability, backends
- SDK overview — every decorator and spec type
- First agent tutorial — guided walkthrough with explanation
- Workflows tutorial — multi-step orchestration patterns