Skip to main content

Webhook routing + Claude Agent SDK observability

Project description

Waffle

Webhook routing + Claude Agent SDK observability. Write your agent as a standalone Python program; Waffle gives it an inbox (webhooks, cron, sync API), retries with dead-lettering, and a web UI that shows every Claude session it ran.

                                ┌─────────────────────────────────────────┐
                                │          Waffle hub (FastAPI)           │
external webhook  ──────────────▶ POST /webhooks/{source}                 │
sync API client   ──────────────▶ POST /api/invoke/{agent}                │
cron scheduler    ─── internal ─▶                                         │
                                │                                         │
                                │  inbox per agent  ─── SSE ──▶  agent    │
                                │  trace ingest     ◀──────────  agent    │
                                │                                         │
                                │  /ui/  dashboard + session views        │
                                └─────────────────────────────────────────┘

Agents live in their own projects, anywhere on disk. They depend on waffle_sdk (this repo) and run as their own OS process. The hub stays language-agnostic in spirit — it talks HTTP to the agents and SQLite to itself.

Contents

  1. Quickstart — start the hub
  2. Build your first agent
  3. Use case 1 — Develop locally with waffle test
  4. Use case 2 — Triggered by external webhooks
  5. Use case 3 — Run on a schedule (cron)
  6. Use case 4 — Synchronous request/response API
  7. Inspecting what happened in the UI
  8. Reference

Quickstart — start the hub

git clone https://github.com/you/waffle.git
cd waffle
poetry install --extras hub        # hub deps live behind the [hub] extra
poetry run waffle hub              # → http://127.0.0.1:8765
poetry run waffle hub --reload     # dev mode with autoreload
poetry run waffle hub --port 9000  # custom port

poetry install (no extras) pulls in just the SDK, which is what agents need. Add --extras hub when you want to run the web server too.

Leave the hub running in its own terminal for everything below.


Build your first agent

We'll use the Recipe Converter as the running example: it takes a URL and returns a structured recipe by asking Claude to fetch the page.

mkdir -p ~/agents/recipe-converter
cd ~/agents/recipe-converter
poetry init --no-interaction --name recipe-converter --python ">=3.11,<4.0"
poetry add heywaffle claude-agent-sdk

Tell Waffle where your agent lives by adding a [tool.waffle] block to pyproject.toml:

[project]
name = "recipe-converter"
version = "0.1.0"
requires-python = ">=3.11,<4.0"
dependencies = [
    "heywaffle (>=0.2.0,<0.3.0)",
    "claude-agent-sdk (>=0.1.0)",
]

[tool.waffle]
agent = "recipe_converter.agent:app"

[tool.poetry]
packages = [{ include = "recipe_converter", from = "src" }]

[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"

Write the agent in src/recipe_converter/agent.py. Define an Agent, then decorate handlers with @app.webhook, @app.cron, or @app.task:

from claude_agent_sdk import (
    ClaudeAgentOptions, ClaudeSDKClient, ResultMessage,
)
from waffle_sdk import Agent, AgentContext

app = Agent("recipe-converter", model="claude-sonnet-4-6")

RECIPE_SCHEMA = {
    "type": "object",
    "properties": {
        "title": {"type": "string"},
        "source_url": {"type": "string"},
        "ingredients": {"type": "array", "items": {"type": "string"}},
        "instructions": {"type": "array", "items": {"type": "string"}},
        "servings": {"type": ["string", "null"]},
        "prep_time": {"type": ["string", "null"]},
        "cook_time": {"type": ["string", "null"]},
    },
    "required": ["title", "source_url", "ingredients", "instructions"],
}


async def _extract(url: str) -> dict:
    options = ClaudeAgentOptions(
        system_prompt="Extract a recipe from the URL using WebFetch.",
        tools=["WebFetch"],
        permission_mode="bypassPermissions",
        output_format={"type": "json_schema", "schema": RECIPE_SCHEMA},
    )
    async with ClaudeSDKClient(options=options) as client:
        await client.query(f"Extract the recipe at: {url}")
        async for msg in client.receive_response():
            if isinstance(msg, ResultMessage):
                return msg.structured_output or {}
    raise RuntimeError("no result from Claude")


# Triggered by `POST /webhooks/recipe` (use case 2). Source "recipe" is just
# a label — your real integration would use "whatsapp" or "linear" etc.
@app.webhook(source="recipe")
async def convert(item: dict, ctx: AgentContext) -> dict:
    return await _extract(item["url"])
poetry install

Your agent now supports four modes of operation. They're all driven by the unified waffle CLI; there's no per-agent script.


Use case 1 — Develop locally with waffle test

When to use this: writing the handler, iterating on prompts, debugging tool use. No hub required.

cd ~/agents/recipe-converter
poetry run waffle test                                      # list available handlers
poetry run waffle test convert \
  --input '{"url":"https://visitsweden.nl/te-doen/eten-drinken/recepten/vegan-meatballs-recept/"}'

waffle test discovers your agent via [tool.waffle] agent = ... in the local pyproject.toml, runs the named handler with the parsed --input, and prints a coloured summary panel with the duration and the result JSON. Failures print a clean error panel and exit with code 1.

For cron handlers the declared payload is the natural test default:

poetry run waffle test weekly_audit          # uses the @app.cron payload
poetry run waffle test weekly_audit --input '{"task":"override"}'

Bonus: traces appear in the UI automatically. If the hub at http://127.0.0.1:8765 is reachable, the SDK tails the JSONL session file Claude Code writes and forwards each line to the hub. You'll see the session in the dashboard within a second of completion. To skip explicitly:

poetry run waffle test convert --input '{...}' --no-trace
poetry run waffle test convert --input '{...}' --hub https://other-hub.example.com

Use case 2 — Triggered by external webhooks

When to use this: Linear / WhatsApp / GitHub / any external system pushes events. Waffle receives them, picks the right agent based on a filter, and queues the item for processing.

T1 — hub (already running from Quickstart)

T2 — agent in inbox mode

cd ~/agents/recipe-converter
poetry run waffle serve

On startup the agent:

  1. Registers its decorated subscriptions and crons with the hub (idempotent — safe to restart).
  2. Opens an SSE connection to /inbox/recipe-converter/stream.
  3. For each item event: claim → dispatch to the matching handler → POST complete (or fail with the error).

T3 — simulate the webhook

curl -sX POST http://127.0.0.1:8765/webhooks/recipe \
  -H 'content-type: application/json' \
  -d '{"url":"https://visitsweden.nl/te-doen/eten-drinken/recepten/vegan-meatballs-recept/"}'

T2's log shows processing item N (source=recipe)completed item N. Inspect the result:

curl -s 'http://127.0.0.1:8765/inbox/recipe-converter?status=completed' | jq '.[-1].result'

Or open http://127.0.0.1:8765/ui/agents/recipe-converter/inbox — the item is there with a link to its trace.

How the URL maps to the agent

The recipe in /webhooks/recipe and the source="recipe" in @app.webhook(...) must be the same string — that is the only thing connecting them. The hub doesn't know about agent names from the URL; it looks up subscriptions whose source field matches.

POST /webhooks/<source>                  ─┐
                                          │ same string
@app.webhook(source="<source>",  ─────────┘
             filter={...})
async def handler(item, ctx): ...

You pick the string yourself — it can be recipe, whatsapp, linear, inbound-mail, anything. Pick names that match the upstream system that will be calling the URL.

The webhook response tells you whether routing actually matched:

curl -sX POST http://127.0.0.1:8765/webhooks/recipe \
  -H 'content-type: application/json' -d '{"url":"..."}' | jq
# {
#   "enqueued": [42],
#   "skipped_duplicates": [],
#   "matched_agents": ["recipe-converter"]    ← non-empty means routed
# }

matched_agents: [] means no agent had a subscription that fits — your URL string doesn't match any registered source, or filters rejected the payload, and the item is dropped. To see what's currently registered:

curl -s http://127.0.0.1:8765/subscriptions | jq

Sharing one webhook URL across multiple agents

A common reality: Linear sends all webhook events for all your projects to one URL. Waffle routes by filter, so each agent only sees what it asked for.

# linear-triage agent: only DEVOPS team's Issues and Comments
@app.webhook(
    source="linear",
    filter={
        "data.team.key": "DEVOPS",
        "type": ["Issue", "Comment"],
    },
)
async def triage(item, ctx): ...

Filter rules: dot-notation lookup, scalar value = equality, list value = "in". All keys must match (AND). One inbound webhook can fan out to several agents — each gets its own inbox copy and is deduped per-agent on (source, idempotency_key, agent).

Re-sending the same payload during testing

The hub deduplicates webhook items by hashing (source, payload) — this is what makes webhook retries from providers like Linear safe. It also means that if you test with the exact same curl twice, the second call returns "skipped_duplicates": ["recipe-converter"] and no new item is queued.

To re-trigger with the same payload, either add a unique Idempotency-Key header:

curl -sX POST http://127.0.0.1:8765/webhooks/recipe \
  -H 'content-type: application/json' \
  -H "Idempotency-Key: $(date +%s)-$RANDOM" \
  -d '{"url":"https://visitsweden.nl/..."}' | jq

…or use the sync API (Use case 4), which generates a fresh UUID idempotency key per call and returns the handler's result directly.

Retries and dead-lettering

If the handler raises, the item goes back to pending and is re-pushed via SSE. After MAX_ATTEMPTS (default 3) the item moves to deadletter and stays visible in the UI with the last error.


Use case 3 — Run on a schedule (cron)

When to use this: weekly check, daily report, periodic sync. The hub's scheduler turns cron expressions into inbox items — your agent doesn't need to know about time at all.

Declare the cron with @app.cron:

# Mondays 09:00 — payload is what the handler will receive as `item`
@app.cron("0 9 * * 1", payload={"task": "weekly_audit"})
async def weekly_audit(item, ctx):
    ...

Run waffle serve. The agent registers its crons (idempotent), and the hub's scheduler (ticks every 30s) enqueues items at the right moments. Your handler treats them like any other item.

Each @app.cron registers under a synthetic source cron:<handler_name> so multiple cron handlers in the same agent route correctly without conflict.

For impatient testing without waiting for the next minute, register a cron via API and force a faster schedule:

curl -sX POST http://127.0.0.1:8765/crons/register \
  -H 'content-type: application/json' \
  -d '{
    "agent": "recipe-converter",
    "schedule": "* * * * *",
    "source": "recipe",
    "payload": {"url":"https://visitsweden.nl/te-doen/eten-drinken/recepten/vegan-meatballs-recept/"}
  }'
# within ~60s your inbox-mode agent receives an item

Use case 4 — Synchronous request/response API

When to use this: scripts, REST clients, Slack slash commands — anything that needs the handler's result back immediately.

Add a @app.task handler to your agent:

@app.task()
async def convert(item, ctx):
    return await _extract(item["url"])

Same inbox pipeline as the other use cases. The difference: the hub holds your HTTP connection open until the agent posts complete (or fails out to deadletter).

Prerequisite: agent must be running via waffle serve (T1 + T2 from Use case 2).

curl -sX POST http://127.0.0.1:8765/api/invoke/recipe-converter \
  -H 'content-type: application/json' \
  -d '{"url":"https://visitsweden.nl/te-doen/eten-drinken/recepten/vegan-meatballs-recept/"}' \
  | jq

# {
#   "item_id": 17,
#   "result": { "title": "Veganistische gehaktballetjes ...", "ingredients": [...], ... }
# }

Add ?timeout=N to override the default 300-second wait.

code meaning
200 handler completed; body has {item_id, result}
502 handler failed MAX_ATTEMPTS=3 times; body has {item_id, attempts, last_error}
503 no agent of that name is currently connected to the hub
504 agent did not complete within ?timeout=N seconds (default 300)

The 503 is an upfront check so you don't sit around waiting on a queue no one is reading.

Calling from a browser (CORS + bearer token)

Same pattern as the curl example, but two env vars on the hub enable it from a web page:

WAFFLE_CORS_ORIGINS=https://app.example.com,http://localhost:3000 \
WAFFLE_API_TOKEN=some-long-random-string \
poetry run waffle hub
  • WAFFLE_CORS_ORIGINS — comma-separated list of origins the hub will send CORS headers for. Leave unset for same-origin or curl-only use.
  • WAFFLE_API_TOKEN — if set, POST /api/invoke/{agent} requires Authorization: Bearer <token> and returns 401 otherwise. Leave unset in dev / on localhost.

Both apply only to /api/invoke/* at the moment. Webhooks, agent endpoints, and the UI remain open (see security notes below).

Then in your web app:

<script>
async function callAgent() {
  const r = await fetch("https://waffle.example.com/api/invoke/recipe-converter", {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "Authorization": "Bearer some-long-random-string",
    },
    body: JSON.stringify({ url: "https://visitsweden.nl/..." }),
  });
  if (!r.ok) throw new Error(await r.text());
  const { item_id, result } = await r.json();
  console.log(result);
}
</script>

Caveat about the token in client-side JS. Anything you put in a browser's JavaScript is visible to anyone who opens devtools. The bearer token here is a low-bar measure — it keeps casual scrapers and random traffic out, it does not protect you against a determined user of your web app. For stronger guarantees, put a backend in front (your own API that holds the real token and proxies to Waffle), or use short-lived per-user tokens. Rotate the token by restarting the hub with a new value if it leaks.

file:// origins. Browsers send Origin: null for pages opened directly from disk. Either add null to WAFFLE_CORS_ORIGINS, or serve your HTML via python -m http.server and whitelist http://localhost:8000.


Inspecting what happened in the UI

Open http://127.0.0.1:8765/ui/.

  • Dashboard (/ui/) — recent sessions, recent inbox items grouped per agent, registered subscriptions and cron triggers. Auto-refresh 5s.
  • Agent inbox (/ui/agents/{agent}/inbox) — full inbox table: status, attempts, claimed-by, last error, trace link. Auto-refresh 3s.
  • Session detail (/ui/sessions/{session_id}) — three tabs:
    • Conversation — chat-bubble view, user + assistant text only.
    • Transcript — every meaningful event as a row: colored badge (USER, THINKING, WEBFETCH, …), one-line preview, tokens / duration / timestamp on the right.
    • Debug — same row layout but includes everything (system events, ai-title, last-prompt, per-turn tokens summary, …).

Click any row to open a sticky detail pane on the right. For tool_use events the pane shows the INPUT JSON and the paired TOOL RESULT · {duration} (or ERROR · … if the tool reported failure) together — no need to scroll back and forth.

Stats header at the top of every session detail: turn count, wall duration, total input/output tokens, raw line count.


Reference

CLI commands

poetry run waffle hub                          # start the hub on 127.0.0.1:8765
poetry run waffle hub --port 9000              # custom port
poetry run waffle hub --reload                 # autoreload while editing hub code
WAFFLE_DB_PATH=/tmp/x.db poetry run waffle hub # custom DB location

poetry run waffle test                         # list handlers in cwd's agent
poetry run waffle test <name> --input '{...}'  # run a single decorated handler
poetry run waffle test <name> --no-trace       # skip trace forwarding
poetry run waffle test <name> --hub https://…  # use a different hub

poetry run waffle serve                        # connect agent to hub, process inbox
poetry run waffle serve --hub https://…        # custom hub URL
poetry run waffle serve --runner my-laptop     # identifier for this runner
poetry run waffle serve --concurrency 4        # override declared concurrency
poetry run waffle serve --no-trace             # don't forward Claude traces

poetry run waffle deploy                       # build wheel + ship to hub as Docker container
poetry run waffle deploy --hub https://…       # deploy to a remote hub
poetry run waffle secrets set <agent> KEY=VAL  # store an env var for a deployed container
poetry run waffle secrets list <agent>         # list keys (values stay encrypted on the hub)
poetry run waffle secrets unset <agent> KEY    # remove a secret

See deploy/README.md for the Docker deploy flow.

Environment variables

Variable Applies to Purpose
WAFFLE_DB_PATH hub SQLite file location (default data/waffle.db)
WAFFLE_CORS_ORIGINS hub Comma-separated allowlist of origins for browser fetch(). Empty = CORS middleware not mounted.
WAFFLE_API_TOKEN hub If set, POST /api/invoke/* requires Authorization: Bearer <token>. Empty = no auth (dev).
WAFFLE_HUB_URL agent Hub URL the agent connects to (default http://127.0.0.1:8765).
WAFFLE_RUNNER_NAME agent Identifier used when claiming items (default: hostname).

Decorator API quick reference

from pydantic import BaseModel
from waffle_sdk import Agent, AgentContext

app = Agent(
    "my-agent",
    project="default",
    description="What this agent is for",
    model="claude-sonnet-4-6",
    concurrency=2,
)

# Triggered by POST /webhooks/<source> on the hub.
@app.webhook(source="linear", filter={"data.team.key": "DEVOPS"})
async def triage(item, ctx): ...

# Time-driven. The schedule string is a standard 5-field cron expression.
@app.cron("0 9 * * 1", payload={"task": "weekly_audit"})
async def weekly_audit(item, ctx): ...

# Optional: typed input. Bad payloads dead-letter immediately, no retries.
class ConvertRequest(BaseModel):
    url: str

# Triggered by POST /api/invoke/{agent}. Max one task handler per agent.
@app.task()
async def convert(item: ConvertRequest, ctx): ...

Point Waffle at this app instance via pyproject.toml:

[tool.waffle]
agent = "my_agent.agent:app"

Hub HTTP endpoints

Path Purpose
POST /webhooks/{source} External webhook ingest, routed via subscriptions
POST /api/invoke/{agent}?timeout=N Sync request/response (waits for handler)
POST /subscriptions/register Register {agent, source, filter}
GET /subscriptions List subscriptions
POST /crons/register Register {agent, schedule, source, payload}
GET /crons List cron triggers
GET /inbox/{agent} List items (filter ?status=…)
GET /inbox/{agent}/stream SSE stream of inbox items (used by SDK)
POST /inbox/{agent}/claim/{id} Mark an item claimed by a runner
POST /inbox/{agent}/complete/{id} Mark an item completed with result
POST /inbox/{agent}/fail/{id} Mark an item failed with error; pass permanent: true to skip retries
POST /traces/{session_id}/events Trace ingest (SDK forwards JSONL lines)
GET /traces / GET /traces/{session_id} Inspect captured traces
GET /ui/... Web UI
GET /docs OpenAPI / Swagger UI

Architectural principles

  • Agents are self-contained programs. waffle test runs handlers without the hub. Hub integration (inbox, traces, sync API) kicks in when you run waffle serve and the hub is reachable.
  • The inbox is the universal primitive. Webhooks, cron, and sync API all create inbox items; the SDK dispatches each to the right decorated handler.
  • Cron → inbox item, not cron → agent invocation. Time triggers create items the same way webhooks do, so retries / dead-lettering / observability work the same.
  • No agent config UI. Agents are code; subscriptions and cron triggers are declared via decorators and registered on startup.
  • Secrets split. The hub holds inbound webhook signing secrets (when added). Agents hold outbound API tokens (Notion, Linear, …) in their own env.

Project layout

waffle/
├── pyproject.toml
├── README.md
├── data/waffle.db                       # SQLite (gitignored)
└── src/
    ├── waffle_sdk/                      # depended on by every agent
    │   ├── agent.py                     # Agent class + decorators + dispatch
    │   ├── discovery.py                 # find Agent via [tool.waffle] in pyproject
    │   ├── runner.py                    # invoke_local + serve (inbox loop)
    │   ├── filter_match.py              # webhook filter matcher (vendored)
    │   ├── trace.py                     # JSONL session file watcher
    │   └── types.py                     # AgentContext
    └── waffle/                          # the server + CLI
        ├── cli.py                       # `waffle hub|test|serve` dispatcher
        ├── commands/                    # subcommand implementations
        ├── server.py                    # FastAPI app + endpoints
        ├── models.py                    # SQLModel: Subscription, InboxItem,
        │                                # CronTrigger, TraceEvent
        ├── pubsub.py                    # in-memory SSE fan-out + sync waiters
        ├── scheduler.py                 # cron tick loop
        ├── filtering.py                 # subscription filter DSL
        └── ui/
            ├── parsing.py               # JSONL → Conversation/Transcript/Debug
            ├── router.py                # Jinja2 routes
            └── templates/

Agents live outside this repo — see "Build your first agent" above for the full walkthrough.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

heywaffle-0.3.0.tar.gz (105.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

heywaffle-0.3.0-py3-none-any.whl (122.1 kB view details)

Uploaded Python 3

File details

Details for the file heywaffle-0.3.0.tar.gz.

File metadata

  • Download URL: heywaffle-0.3.0.tar.gz
  • Upload date:
  • Size: 105.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/2.1.3 CPython/3.13.5 Darwin/25.3.0

File hashes

Hashes for heywaffle-0.3.0.tar.gz
Algorithm Hash digest
SHA256 992f7d417c4814c2466deb881d84853fedcfb465c38263c6930d6a3d6572e1fc
MD5 53892ac225bdf066cfccc20dc55f8097
BLAKE2b-256 32a71ba669f617d76bd7f7d7c3cc2875d52a7b12d79e36ffa646d75e8fa96218

See more details on using hashes here.

File details

Details for the file heywaffle-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: heywaffle-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 122.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/2.1.3 CPython/3.13.5 Darwin/25.3.0

File hashes

Hashes for heywaffle-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 047b8b2ca54809e62b0ef70b2a92a01b3720a1a7dea01347df49e54617937f57
MD5 f9ae01d9ce2927949313262de5f0e0ca
BLAKE2b-256 a18e73c93a355843ba6a8e7f727a742893ae865b774b9fcc019f91b7de7deb14

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page