Metadata-Version: 2.4
Name: waggle-chat
Version: 0.10.28
Summary: Communication channel between AI agents and chat platforms (Telegram, Matrix, …)
Project-URL: Homepage, https://github.com/Tiny-Hive/waggle
Project-URL: Repository, https://github.com/Tiny-Hive/waggle.git
Project-URL: Issues, https://github.com/Tiny-Hive/waggle/issues
Author: Your Name (Claude)
License: MIT
License-File: LICENSE
Keywords: agent,channel,claude-code,matrix,mcp,telegram
Classifier: Development Status :: 3 - Alpha
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.11
Requires-Dist: aiohttp>=3.10
Requires-Dist: mcp>=1.0
Requires-Dist: pydantic>=2.7
Requires-Dist: pyyaml>=6.0
Provides-Extra: dev
Requires-Dist: mypy>=1.11; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Provides-Extra: matrix
Requires-Dist: matrix-nio>=0.25; extra == 'matrix'
Provides-Extra: telegram
Requires-Dist: aiogram>=3.13; extra == 'telegram'
Provides-Extra: telegram-test
Requires-Dist: pyrogram>=2.0.106; extra == 'telegram-test'
Requires-Dist: telethon>=1.43; extra == 'telegram-test'
Requires-Dist: tgcrypto>=1.2.5; extra == 'telegram-test'
Description-Content-Type: text/markdown

<p align="center">
  <img src="docs/assets/banner.png" alt="waggle banner" width="100%" />
</p>

# waggle 🐝

> Communication channel between AI agents and chat platforms.
> Like the bee waggle dance — a precise, repeatable signal that gets the message across.

`waggle` is a thin runtime that wires **one agent** to **many chat platforms**. It carries messages between the two, nothing more — it does not decide what the agent should remember, when it should speak, or which tools it can call.

The product question we are answering: *"Where should the chat-side plumbing of an AI agent live?"* Today it is half inside the agent, half inside ad-hoc plugins. waggle pulls that plumbing into one well-scoped layer that one human can control.

## Design pillars

- **One agent, many channels.** The same agent answers from Telegram, Matrix, Discord, … and always carries a single, unified context — never split into per-chat or per-topic conversations.
- **Strict scope: the chat layer only.** Scheduling, tool registries, multi-agent supervision, persistent message archives — all out of scope. They live upstream or downstream of waggle, not inside it.
- **Owner is sovereign.** Exactly one human is the owner. Only the owner can restart the agent, reset a session, or compact memory. Everyone else just chats.
- **External world can knock.** Alerting tools, CI, or schedulers can push prompts into the agent's active chat through authenticated triggers — but they can never invoke owner-only operations.
- **Tweakable settings, stable runtime.** Most configuration changes apply hot. Restarts are rare and predictable.

## Mental model

```
external trigger ─┐
                  ├─→ waggle ─→ agent ─→ waggle ─→ chat platforms
chat platforms ───┘
```

waggle sits in the middle. Inputs (chat messages, external triggers) flow in. Outputs (replies, reactions, edits) flow out. Owner-only operations (restart, status, …) are a side-channel only the configured owner can reach.

## Capabilities at a glance

waggle MUST:

- Reach users across multiple chat platforms (Telegram day 1; Matrix / Discord / … later) and run several in parallel for the same agent
- Receive every incoming message labelled with platform, chat, and sender; surface attachments to the agent; persist accepted messages to an external store for the dashboard to render
- Send replies, reactions, edits, and selectable-choice prompts (inline keyboards / quick replies) where the platform supports them — degrade gracefully where it doesn't
- Accept authenticated **external triggers** (alerts, CI, schedulers) that inject a prompt into the agent's active chat
- Enforce a dual allow-list (group and user); silently drop rejected messages and emit the rejection event to an external observability channel
- Read all settings from a single configuration source; allow most changes to apply hot
- Expose **owner-only operations** to a single configured human: agent supervision (status, restart, pause), session management (fresh session, abort turn), and CLI conversation-compact
- **Proactively notify the owner** when the agent's session or conversation changes by itself (auto-compaction, crash restart, timeout abort, recovery reset, …)

## Non-goals

- Cron / scheduling — use a separate scheduler that fires our inject trigger
- Managing more than one agent — waggle owns exactly one
- Tool registry / MCP server — waggle is the chat layer, not the tool layer
- CLI switching (Claude / Codex / Gemini) — that is the agent's concern
- Persistent message history beyond what the chat platform itself stores
- Splitting forum-group topics into separate conversations — the agent always has a unified context across every chat
- Exposing owner-only operations to anyone other than the owner

For the full specification with examples and constraints, see [`docs/REQUIREMENTS.md`](./docs/REQUIREMENTS.md).

## Inspiration & why a separate project

The design is inspired by [PleasePrompto/ductor](https://github.com/PleasePrompto/ductor), an excellent multi-transport agent runtime that controls Claude Code / Codex / Gemini from Telegram & Matrix. Reading ductor's source clarified a lot of the right-shape ideas — the channel-context tag, the webhook inject pattern, the transport-agnostic core.

We chose to build `waggle` separately rather than fork or reuse ductor for three reasons:

1. **Scope discipline.** ductor bundles transport, session management, memory, cron, sub-agents and multi-CLI proxying into one runtime. We want only the leftmost slice — the channel layer — and to keep every other concern out by construction. Forking would mean stripping more than we add.
2. **Deployment shape.** ductor targets a single local machine via `pipx`. Our deployment is multi-pod Kubernetes with one agent per StatefulSet and per-bot RBAC; the channel runtime needs to be embedded as a sidecar or library inside each agent, not run as a separate user-installed daemon.
3. **Wire-format ownership.** We want the channel tag, the inject contract, and the auth model to be ours to evolve without staying compatible with an upstream that has a broader audience and different priorities.

ductor remains a reference and a benchmark — we will deliberately reuse its proven shapes (transport interface, inject wire format) where they fit.

## Install

```bash
pip install "waggle-chat[telegram]"
```

That's it. The `[telegram]` extra pulls in `aiogram`. Use `[matrix]` for matrix-nio, `[telegram-test]` for the pyrogram test harness, `[dev]` for tests + linters. The Python module is imported as `waggle` (the PyPI distribution name is `waggle-chat` because `waggle` was already taken on PyPI by an unrelated 2018 project).

waggle assumes the [Anthropic claude-code CLI](https://docs.claude.com/en/docs/claude-code) is on `PATH` — install it separately:

```bash
npm install -g @anthropic-ai/claude-code
```

### Dev install from source

```bash
git clone https://github.com/Tiny-Hive/waggle.git
cd waggle
pip install -e ".[telegram,dev]"
pytest tests/   # 230+ unit tests across 13 modules
```

### Without PyPI (pin to a GitHub release / tag)

```bash
# from a specific GitHub release
pip install "waggle-chat[telegram] @ https://github.com/Tiny-Hive/waggle/releases/download/v0.7.12/waggle_chat-0.7.12-py3-none-any.whl"

# or straight from the git tag
pip install "waggle-chat[telegram] @ git+https://github.com/Tiny-Hive/waggle.git@v0.7.12"
```

## Quick start

```bash
# 1. Get a Telegram bot token from @BotFather
# 2. Find your numeric user_id (talk to @userinfobot)

export WAGGLE_TG_BOT_TOKEN=<your bot token>
export WAGGLE_OWNER_ID=<your numeric user_id>

# Single-file echo demo (no claude — just proves the pipeline)
python -m waggle.cli  # or: python examples/echo_bot.py
```

`/start` the bot in Telegram and send a message — it should echo back. The
echo bot exercises the full waggle pipeline (access gate, rate limit, accept
events) but skips the claude subprocess. Use it as a smoke test before
deploying the real agent.

### Deploy to Kubernetes

A reference manifest lives at [`deploy/k8s/waggle-dev.yaml`](./deploy/k8s/waggle-dev.yaml).
It assumes:

- A namespace exists (`geisha-house` in our deployment)
- Three secrets: `waggle-dev-tg-token` (bot_token), `waggle-dev-anthropic`
  (auth_token for the LLM gateway), `waggle-dev-inject` (shared_secret for
  the /inject endpoint)
- A `local-path` storage class (or change `storageClassName` to your default)

Apply:

```bash
kubectl -n <ns> create secret generic waggle-dev-tg-token \
    --from-literal=bot_token="$WAGGLE_TG_BOT_TOKEN"
kubectl -n <ns> create secret generic waggle-dev-anthropic \
    --from-literal=auth_token="$ANTHROPIC_AUTH_TOKEN"
kubectl -n <ns> create secret generic waggle-dev-inject \
    --from-literal=shared_secret="$(openssl rand -hex 32)"
kubectl apply -f deploy/k8s/waggle-dev.yaml
```

The bundled Dockerfile produces a `python:3.12-slim` image with Node 22 + the
claude CLI installed. Tag and push it to your registry, then update the image
ref in the manifest.

## Configuration

waggle reads a single YAML file (mounted at `/etc/waggle/config.yaml` in the
K8s manifest). Sample:

```yaml
transports:
  - name: tg
    kind: telegram
    bot_token: env:WAGGLE_TG_BOT_TOKEN     # env-ref → resolved at load
    drop_pending_updates: true             # ignore messages while bot was offline

access:
  users: [123456789]                        # DM allow-list
  groups:
    -1001234567890:                         # group chat_id (negative)
      users: []                             # empty = anyone in group OK
      require_mention: true                 # only @bot or reply-to-bot reach the agent
  rate_limit:
    user_per_minute: 30
    chat_per_minute: 60
    window_seconds: 60.0

owner:
  user_id: 123456789                        # must be in access.users

events:
  sink:
    kind: http                              # stdout | http
    url: https://obs.example.com/ingest
    secret: env:WAGGLE_OBS_SECRET           # ≥16 chars
    timeout_seconds: 5.0

inject:
  enabled: true
  bind: "0.0.0.0:8080"
  shared_secret: env:WAGGLE_INJECT_SECRET   # ≥16 chars
  default_chat_id: 123456789                # fallback target
```

**Hot reload** — editing this file (or the underlying ConfigMap) picks up
changes within ~1s for `access.*` blocks. `transports`, `owner`, `inject`
require a pod restart.

For the full grammar and validators, see [`waggle/config.py`](./waggle/config.py).

## Operating modes

waggle supports two runtime modes, configured via `WAGGLE_MODE`:

| Mode | Env | Architecture | Billing |
|---|---|---|---|
| **CLI** (default) | `WAGGLE_MODE=cli` | waggle is parent, spawns `claude -p` as subprocess (stream-json) | Programmatic credit ($200/mo) |
| **Channel** | `WAGGLE_MODE=channel` | Claude is parent (interactive in tmux), waggle runs as MCP child | Subscription quota (Max plan) |

Both modes share the same Telegram client, access control, rate limiting, owner ops, inject endpoint, hook receiver, and status indicator. The difference is who spawns whom and which billing lane is used.

### Channel mode details

Channel mode declares the `claude/channel` experimental MCP capability. Inbound Telegram messages are pushed as `notifications/claude/channel` with `{content, meta}` params. Claude processes them and calls waggle's MCP tools (reply, react, edit_message, etc.) to respond.

Requires: `--dangerously-load-development-channels server:waggle` flag on Claude CLI. The entrypoint runs Claude in a tmux session to provide a PTY.

## Status indicator

While the agent processes a turn, waggle posts a live-updating status message in the source chat:

```
🤔 Thinking…        → initial state
🔧 Tool: Bash       → tool in use
📝 Writing reply…   → post-tool, composing answer
```

The indicator carries inline-keyboard buttons ("🧠 Show thinking", "✅ Show tasks") and is automatically deleted when the turn completes. In rapid-fire scenarios (multiple messages at once), only one indicator is shown per chat (deferred attach on first tool-start).

## Compact indicator

When Claude auto-compacts the conversation context (or when the owner runs `/compact`), waggle surfaces it on Telegram:

```
🗜 Compacting context…   → PreCompact hook fires
✅ Context compacted      → PostCompact hook fires (edits the above)
```

Requires `PreCompact` + `PostCompact` HTTP hooks in Claude Code's settings.json (the waggle entrypoint registers these automatically).

## Hook receiver

Internal HTTP server on `127.0.0.1:8091` that receives Claude Code hook events:

| Hook | Endpoint | Purpose |
|---|---|---|
| `PreToolUse` | `/hooks/tool-start` | Update status indicator with tool name |
| `PostToolUse` | `/hooks/tool-end` | Clear current tool label |
| `Stop` | `/hooks/stop` | Reset task state, delete indicator |
| `PreCompact` | `/hooks/compact-start` | Post compact indicator |
| `PostCompact` | `/hooks/compact-end` | Edit to "compact complete" |

The hook receiver is internal-only (localhost bind, no auth, no K8s Service). Separate from the external inject endpoint on `:8080`.

## Owner operations

Send these directly to the bot from your owner account (the `user_id`
configured in `owner.user_id`):

| Command | Effect |
|---|---|
| `/status` | Liveness, turn count, last-inbound preview |
| `/pause` | Drop new inbound silently (won't reach claude) |
| `/resume` | Restore normal flow |
| `/abort` | Kill the in-flight turn, restart claude immediately |
| `/restart` | Restart claude after a 5-second cancel window |
| `/new` | Drop session context after a 5-second cancel window (fresh conversation) |
| `/compact` | Pipe `/compact` to claude (uses claude's own conversation-compact) |

While `/restart` or `/new` is pending, send the literal text `cancel` to
abort it. Non-owner senders fall through to claude as a normal prompt.

## External triggers (`/inject`)

Push a prompt into the agent's active chat from any external system
(alertmanager, CI, scheduler, dashboard). The endpoint is bearer-auth +
fire-and-forget — your caller doesn't block on the agent's reply.

```bash
curl -X POST http://waggle.svc.cluster.local:8080/inject \
  -H "Authorization: Bearer $WAGGLE_INJECT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"prompt": "alertmanager: prod 5xx > 1%", "source": "alertmanager"}'
# → {"submitted": true, "chat_id": "123456789"}
```

See [`docs/API.md`](./docs/API.md) for the full contract (response shapes,
error codes, AcceptEvent / RejectEvent JSON for the dashboard).

## Troubleshooting

**Bot doesn't reply.** Check:

```bash
kubectl -n <ns> logs <pod> --tail=50 | grep -E "submit_inbound|tool reply|is_error"
```

The orchestrator logs every accepted inbound (`→ chat=… user=…: …`) and every
turn result (`← chat=… is_error=…`). If you see the inbound but no
reply, the issue is in the claude subprocess. If you don't see the inbound,
check the access gate (next).

**Bot ignores a user.** The pod logs a one-line reject event for every
filtered inbound — search for the user's id:

```bash
kubectl -n <ns> logs <pod> | grep "<user_id>" | grep "reject"
```

Likely culprits: user not in `access.users`, group not in `access.groups`,
or rate-limit budget exhausted (`reason: rate-limited-*`). Edit the
configmap and the change applies hot within 1s.

**Bot not receiving messages in a group.** Telegram's bot privacy mode
blocks bots from seeing non-mention messages in groups by default. Promote
the bot to admin in that group, OR disable privacy via @BotFather → Bot
Settings → Group Privacy → Turn off.

**Pod restarts.** waggle's claude subprocess is launched with `--continue`
so the conversation picks up where it left off. The accept event for the
last in-flight inbound was already persisted; the agent's reply may not have
been delivered. The orchestrator's `_respawn_if_dead()` notifies the owner
when the subprocess died between turns.

**`/inject` returns 401.** The bearer header doesn't match
`inject.shared_secret`. Verify the secret is ≥16 chars in the configmap and
that the env-ref resolves correctly (`env:WAGGLE_INJECT_SECRET` requires
the env var to be set on the pod).

## Layout

```
waggle/
├── waggle/                  # Python package
│   ├── cli.py               # Entry: mode dispatch (cli → orchestrator, channel → channel_server)
│   ├── orchestrator.py      # CLI mode: turn loop, gate, dispatch, stream-json subprocess
│   ├── channel_server.py    # Channel mode: MCP server with claude/channel capability
│   ├── channel_tag.py       # <channel> XML tag builder
│   ├── access.py            # allow-list + hot-reload
│   ├── rate_limit.py        # sliding-window limiter
│   ├── events.py            # AcceptEvent / RejectEvent + sinks (stdout, http)
│   ├── owner_ops.py         # /status, /pause, /resume, /abort, /restart, /new, /compact
│   ├── inject.py            # POST /inject HTTP endpoint
│   ├── hook_receiver.py     # Internal hook receiver (tool status, compact indicator)
│   ├── status_indicator.py  # Live "🤔 Thinking… / 🔧 Tool: X" Telegram message
│   ├── typing_indicator.py  # Telegram "typing…" bubble
│   ├── tools_server.py      # MCP tools claude calls (reply, react, edit, …)
│   └── clients/telegram.py  # aiogram polling + outbound methods
├── deploy/k8s/              # K8s manifest example
├── docs/                    # REQUIREMENTS, ROADMAP, DESIGN, API, TESTS, PRD
├── examples/                # Single-file runnable examples
├── scripts/                 # E2E test scripts (live Telegram via Pyrogram)
└── tests/                   # 230+ unit tests + 47-case E2E suite
```

For deeper architecture see [`docs/DESIGN.md`](./docs/DESIGN.md). For the
test catalogue see [`docs/TESTS.md`](./docs/TESTS.md). For closing reports
of each shipped batch see [`docs/reports/`](./docs/reports/).

## License

[MIT](./LICENSE).
