{% extends "base.html" %} {% block title %}Examples — Waffle{% endblock %} {% block content %}
{{ request.url.scheme }}://{{ request.url.netloc }}.
Every recipe below assumes a Poetry project with the SDK installed.
mkdir my-agent && cd my-agent poetry init --no-interaction --name my-agent --python ">=3.11,<4.0" poetry add heywaffle claude-agent-sdk
Agent instance[tool.waffle] agent = "my_agent.agent:app"
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient, ResultMessage
from waffle_sdk import Agent, AgentContext
app = Agent("hello", project="default")
@app.webhook(source="hello")
async def reply(item: dict, ctx: AgentContext) -> dict:
options = ClaudeAgentOptions(
system_prompt="You are a helpful assistant.",
permission_mode="bypassPermissions",
)
async with ClaudeSDKClient(options=options) as client:
await client.query(item["prompt"])
async for msg in client.receive_response():
if isinstance(msg, ResultMessage):
return {"reply": msg.result or ""}
return {"reply": ""}
No __main__.py, no [project.scripts] entry — the
unified waffle CLI discovers the agent via the
[tool.waffle] block above.
waffle test — run a handler locally, no hub neededFor iterating on prompts, debugging tool-use, or calling the agent as a one-off script. The SDK will try to stream the Claude session JSONL to the hub if it's reachable; otherwise it silently runs offline.
cd ~/path/to/my-agent poetry run waffle test
poetry run waffle test reply --input '{"prompt":"hi"}'Force-disable trace streaming:
poetry run waffle test reply --input '{"prompt":"hi"}' --no-traceOr point at a remote hub:
poetry run waffle test reply --input '{"prompt":"hi"}' \
--hub {{ request.url.scheme }}://{{ request.url.netloc }}waffle serve — persistent worker, triggered by webhooks
The agent connects to the hub, claims items from its queue over SSE, and dispatches them
to the right decorated handler. Items arrive via POST /webhooks/<source>
whenever an external system (Linear, GitHub, n8n, your own app) sends a payload that
matches the source + filter declared on a @app.webhook.
cd ~/path/to/my-agent
poetry run waffle serve --hub {{ request.url.scheme }}://{{ request.url.netloc }}curl -sX POST {{ request.url.scheme }}://{{ request.url.netloc }}/webhooks/hello \
-H 'content-type: application/json' \
-d '{"prompt":"what is the capital of Peru?"}'The hub returns the inbox item id immediately. The result appears in the UI's Inbox view + as a session in Sessions once the handler finishes.
# Fire only when the payload contains action:"create"
@app.webhook(source="linear", filter={"action": "create"})
async def on_linear_create(item, ctx): ...
# Nested keys match by dot-path — type.name == "Issue"
@app.webhook(source="linear", filter={"type.name": "Issue"})
async def on_issue(item, ctx): ...
# Empty filter (or omitting it) matches everything from that source
@app.webhook(source="hello")
async def on_hello(item, ctx): ...@app.cron — run on a schedule
The hub's scheduler fires your handler on a cron expression. Each tick creates an inbox
item with source = "cron:<handler_name>" (auto-generated, so multiple
cron handlers in one agent route correctly) and the payload you declared.
@app.cron("0 9 * * *", payload={"prompt": "morning check"})
async def morning_check(item, ctx):
...
@app.cron("0 17 * * 1-5", payload={"prompt": "end-of-day"})
async def end_of_day(item, ctx):
...
Keep the agent running with waffle serve — crons create inbox items, which
the SDK dispatches the same way as webhook-triggered items.
Test a cron handler locally with its declared payload as the default:
poetry run waffle test morning_check # uses the @app.cron payload
poetry run waffle test morning_check --input '{"prompt":"override"}'@app.task — sync request / response API
POST /api/invoke/<agent> blocks until the agent finishes and returns the
result in one HTTP response — no inbox polling, no SSE. Useful for AI features in a web
app, Slack slash commands, anywhere you just need an answer. Declare the handler with
@app.task().
# Optional: typed input. Bad payloads dead-letter immediately, no retries.
from pydantic import BaseModel
class AskRequest(BaseModel):
prompt: str
@app.task()
async def ask(item: AskRequest, ctx) -> dict:
options = ClaudeAgentOptions(system_prompt="Be terse.")
async with ClaudeSDKClient(options=options) as client:
await client.query(item.prompt)
async for msg in client.receive_response():
if isinstance(msg, ResultMessage):
return {"reply": msg.result or ""}
return {"reply": ""}curl -sX POST {{ request.url.scheme }}://{{ request.url.netloc }}/api/invoke/hello \
-H 'content-type: application/json' \
-d '{"prompt":"summarise Waffle in one sentence"}'const res = await fetch("{{ request.url.scheme }}://{{ request.url.netloc }}/api/invoke/hello", {
method: "POST",
headers: {
"content-type": "application/json",
"Authorization": "Bearer " + YOUR_TOKEN, // only needed when WAFFLE_API_TOKEN is set
},
body: JSON.stringify({ prompt: "summarise Waffle" }),
});
const data = await res.json();
console.log(data);
The agent must be running via waffle serve when you call /api/invoke —
the hub routes the item to a free worker and holds the HTTP response open until it completes.
Maximum one @app.task per agent in v0.3.
# /etc/systemd/system/waffle.service Environment=WAFFLE_CORS_ORIGINS=https://app.example.com,http://localhost:3000 Environment=WAFFLE_API_TOKEN=$(openssl rand -hex 32) # generate once, keep secret
waffle deploy — ship the agent to the hub as a Docker container
Once the handler works, push it to the hub so it runs as a managed Docker container
next to the hub itself. No separate VPS, no Procfile, no babysitting waffle serve
in a tmux session. The hub stops + replaces the previous container, restarts on crash,
and exposes a Deployments view with live logs.
pyproject.toml[tool.waffle.deploy] env_keys = ["ANTHROPIC_API_KEY", "LINEAR_TOKEN"] # secrets to inject at start memory = "512m" cpus = "1"
poetry run waffle secrets set hello \
ANTHROPIC_API_KEY=sk-ant-... \
LINEAR_TOKEN=lin_xxx \
--hub {{ request.url.scheme }}://{{ request.url.netloc }}
poetry run waffle secrets list hello \
--hub {{ request.url.scheme }}://{{ request.url.netloc }}Values are encrypted on the hub host and never returned over HTTP — only injected as env vars when the container starts.
poetry run waffle deploy --hub {{ request.url.scheme }}://{{ request.url.netloc }}
First build is ~90s (apt installs nodejs+npm so MCP servers
via npx work out of the box); redeploys are fast thanks to layer cache.
Container runs as non-root agent UID 1000, with
--restart=unless-stopped and labels
waffle.managed=true so it doesn't collide with other apps on the host.
Hub-side prereqs (Docker daemon, --host 0.0.0.0, ufw rule, Caddy SSE
config) live in
WAFFLE_DOCKER.md.
Two routes — pick per agent:
waffle deploy — the hub manages the container's lifecycle. Restart/Stop/Delete from the Deployments page; logs in the UI.# ~/waffle/Procfile hello: bash -c 'cd /home/sqrt/agents/hello && poetry run waffle serve' triage: bash -c 'cd /home/sqrt/agents/linear-triage && poetry run waffle serve' scraper: bash -c 'cd /home/sqrt/agents/recipe-scraper && poetry run waffle serve'
See deploy/ in the Waffle repo for a full systemd + Caddy example.