Skip to main content

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_key means 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 seeWhat it means
No tool calls at allThe agent never called your domain — usually a prompt problem
document.read then nothingIt read the document and didn't act — check the prompt's final instruction
A red spanThe 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.

@agent replaces the class

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:

CallPurpose
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):

  1. Explicit WaxellClient(api_url=..., api_key=...) constructor args
  2. WaxellClient.configure(...) set globally at startup
  3. ~/.waxell/config (written by wax login)
  4. Environment variables: WAX_API_KEY, WAX_API_URL, WAX_TENANT

If none resolve, calls silently no-op. Run wax whoami to verify.

Migrating from waxell-observe

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

MistakeSymptomFix
Pushed to the wrong tenantAgent "vanishes"; it's live somewhere elsewax whoami; target one with wax -p prod push
Re-pushing the same version:409 semver_conflictVersions are immutable — bump version:
Expecting signals fire to return the answerYou get a run_id, not a resultIt's async by design. Poll wax signals show, or have the agent call back through a domain
Agent replies but nothing happens in your appRun completes, your database is untouchedIt never called your domain. Check the trace for tool calls; name the action explicitly in the prompt
Setting only WAXELL_API_KEYRuntime calls silently no-opSet WAX_API_KEY instead
os.environ["OPENAI_API_KEY"] inside a workflowWorks locally, breaks in deploymentUse ctx.secrets.get("OPENAI_API_KEY")
Instantiating the class after @agentTypeError: 'AgentSpec' object is not callableDon't instantiate. The platform runs the agent.
from waxell_sdk import agentWorks in editable install, breaks for customersAlways from waxell_runtime import agent

What's next