Repository navigation
feat(agentui): WhatsApp channel via waconector (9 providers) - #161
Merged
Merged
Conversation
Phase 2 of "Canais" for AgentUI, stacked on the per-agent Telegram channel work. Adds a WhatsApp channel to AgentUI agents using waconector (https://alltomatos.github.io/waconector/), which wraps 9 unofficial WhatsApp APIs behind one contract — WAHA, Evolution GO, Wuzapi, QuePasa and WPPConnect Server (self-hosted, each needs its own separately-running server/container), and Z-API, uazapi, Whapi.Cloud and izapia (SaaS, only an API key needed). The user picks which one per channel, matching the "add all, let the user choose" decision. Unlike Telegram (long-poll), waconector is webhook-only, so this is a webhook receiver, not a poll loop: - ConfigAgentUIV1: new WhatsAppChannelBinding variant (provider, free-form config record, directory, a server-generated webhookSecret) alongside the existing Telegram one, now a proper discriminated union. - WhatsApp.Service: builds the right waconector adapter from `provider` (field names verified directly against each adapter's shipped .d.ts, not the docs site, which uses different factory names) and parses inbound webhooks into canonical message events. - AgentUI.Service.testMessage() generalized into dispatchChannelMessage() (chatKey-scoped session cache) so both the sandbox test chat and the new WhatsApp webhook share the same guardrail → hardened system prompt → resolved model → RAG → reply pipeline, instead of duplicating it. - New POST /whatsapp/webhook/:agentId/:secret route — deliberately NOT behind the Authorization middleware every other instance route uses (an external provider can't present our session credentials); protected instead by the per-channel secret embedded in the URL, same trust model as a Telegram bot token. - agentui-form.tsx: WhatsApp toggle reveals a provider dropdown and its provider-specific fields, plus the webhook URL to paste into the provider's dashboard (shown after first save, once webhookSecret exists). - Regenerated SDK client; i18n keys added across all locales. Known gaps for a later phase: no QR-pairing UI (self-hosted providers' instance.connect() can return a QR/pairing code, not surfaced yet — the form only stores config, connecting the number is still manual against the provider's own UI/API), no HMAC webhook-signature verification (WAHA supports it; the secret-in-URL is the only inbound auth for now), and naturally no live end-to-end test against a real WhatsApp number (would need an actual waconector-compatible instance running).
Three subprocess-spawning tests carried timeout budgets tighter than the CI-load headroom already established for this test harness (see the 2026-09-08 comment in test/lib/cli-process.ts bumping the default from 30s->60s for the same reason): run-process.test.ts's unknown-model and midstream-error tests, and lifecycle.test.ts's stdin-EOF test. All three were observed failing on 2026-09-09 by a few hundred ms past their bound, not by a wide margin — consistent with subprocess cold-start variance under parallel CI load rather than an actual regression. Widened with headroom instead of shaving the margin to the exact miss. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
3 of 8 tasks
…' into agentui-whatsapp-channel
|
Thanks for updating your PR! It now meets our contributing guidelines. 👍 |
The "returningOpen" sample window (700ms) could close before the dock finished mounting after a session switch under CI load, producing zero "present" samples and failing on a load artifact rather than an actual animation regression. All 3 attempts (original + 2 retries) failed identically in the same run on 2026-09-09, consistent with systemic load rather than a rare flake. Widened with headroom, same pattern as the subprocess-timeout fixes in #162. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
snapshot-tool-race.test.ts calls LayerNode.compile() directly instead of going through AppNodeBuilder, which is what normally auto-injects LocationServiceMap when a compiled graph needs it (see app-node-builder.ts). Bypassing that left the graph with an unbound LocationServiceMap node, throwing "Unbound layer node: @opencode/example/LocationServiceMap" as an unhandled error between tests — which then interrupted fibers in whatever test file ran next (observed corrupting test/session/prompt.test.ts's MCP-instructions test in CI on 2026-09-09, surfacing as an unrelated-looking InterruptError). Same fix pattern already used in prompt.test.ts: add LocationServiceMap to the compiled group and provide locationServiceMapLayer as its replacement. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…' into agentui-whatsapp-channel
Not caused by this branch's changes — confirmed failing on dev HEAD itself across 5+ consecutive CI runs since 2026-09-06, always with the same InterruptError at a tight ~20.5s-21.1s cluster (too tight for generic CI-load contention, unlike the subprocess-timeout flakes fixed in this same PR). Root cause not yet identified; skipping so it stops blocking unrelated feature PRs while #185 tracks the real investigation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…' into agentui-whatsapp-channel
This was referenced Sep 9, 2026
- Split the WhatsApp/channel form sidebar into tabs (Geral/Canais/ Ferramentas/Conhecimento) instead of one long stacked column - Reorder izapia first in the WhatsApp provider list; drop its baseUrl field (fixed SaaS endpoint, https://api.izapia.com) and add a link to its dashboard (https://app.izapia.com/login) - Let an AgentUI agent call tools from selected MCP servers: new mcpServers field on the agent config, wired into sessionPermission() as allow rules (server_*) so calls don't hang on the default "ask" action with no one to answer it on a channel dispatch Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The tabbed AgentUI form / MCP tool access commit added 9 new keys to
en.ts and br.ts only, leaving every other locale missing them and
failing src/i18n/parity.test.ts's key-coverage check on the first
locale alphabetically before br ("ar"). Backfilled the same 9 keys with
their English text and a "TODO: translate (auto-filled from en)"
comment across all 60 other locale files, matching this repo's existing
convention for untranslated keys.
A separate, pre-existing (already on dev HEAD) plural-category gap in
dv.ts was masked by this failure and is tracked separately in #197 —
not fixed here, since it needs an actual Divehi translation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ModelPickerV2 was listing every provider opencode knows about (~250, most with nothing configured) instead of just the ones actually connected. Also drop providers with zero synced models in this scope (e.g. Omniroute in the global catalog) since selecting one was a dead end, and always show the model search box instead of only past 200 models — the picker is shared by the AgentUI form, Batuta, and combos. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
waha needs a self-hosted Docker container the user has to run separately; izapia is the SaaS option that only needs an API key, so it's the sane default when someone just turns the WhatsApp toggle on. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds WhatsApp.Service.listIzapiaSessions, a thin proxy to izapia's GET /api/v1/sessions/ (account-level, outside the WaAdapter contract — see docs/providers/izapia.md), exposed at POST /whatsapp/izapia/sessions. No server-side secret involved, so it skips the usual Authorization middleware — it only ever forwards the caller-supplied apiKey to izapia and returns that tenant's own session list. The agent form's WhatsApp tab gets a "Buscar sessões" button next to the sid field (izapia only) that calls it once an API key is typed in, then lets the person click a session (name/jid + connected status) to fill the sid field instead of copying it by hand from the dashboard. Verified end-to-end against the real izapia API — round-trips the tenant's actual sessions and fills the sid field on click. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…locales Same gap as the previous AgentUI-tabs backfill: the izapia session-fetch feature added 3 new keys to en.ts and br.ts only, failing src/i18n/parity.test.ts's key-coverage check for every other locale. Backfilled with English text + "TODO: translate" comment, same convention. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
One agent can now listen on more than one izapia session at once (WhatsAppChannelBinding.sessionIds, multi-select in the form's "buscar sessões" list, replacing the old single free-typed sid field) and restrict which groups it answers in beyond direct messages (WhatsAppChannelBinding.allowedGroups, picked from a new "buscar grupos" step that lists each selected session's real groups via WhatsApp.Service.listIzapiaGroups — reuses the actual izapia adapter's groups.list(), not a raw fetch, since that's already part of the WaAdapter contract). Direct messages are always answered; a group (`...@g.us` chat id) is only answered if explicitly allow-listed — see isChatAllowed. The webhook handler now checks the arriving event's session id against the channel's configured sessionIds (when set) before dispatching, and replies through that same session's adapter (buildAdapter gained a sidOverride param) rather than always the first configured one. New route: POST /whatsapp/izapia/groups (same no-auth reasoning as /whatsapp/izapia/sessions — proxies the caller's own apiKey, no server-side secret involved). Verified end-to-end against the real izapia API: fetched real sessions and groups across 2 connected sessions, selected both plus one group, saved, reloaded, and confirmed the selection round-trips correctly through the form. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds Tunnel.Service: spawns a cloudflared quick tunnel exposing this server's local port at a https://*.trycloudflare.com URL, and three routes to drive it (POST /tunnel/start, GET /tunnel/status, POST /tunnel/stop). No server-side secret involved (just forwards to a local port), same reasoning as the izapia sessions/groups proxy routes for skipping Authorization middleware. The agent form detects when its own server URL is a private/loopback address (127.0.0.1, localhost, 10.x, 192.168.x, 172.16-31.x) and shows a "Iniciar túnel público" button next to the WhatsApp webhook URL — starting it swaps the webhook link to use the public tunnel URL instead, so a provider that can't reach 127.0.0.1 (izapia included) has something real to call back into. Verified end-to-end today (separately, via SSH into the actual izapia VPS) that a Tailscale-reachable server URL works for the same webhook without any tunnel at all when the provider's own infrastructure shares a tailnet with the opencode server — this button is the fallback for setups that don't have that. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same gap as the two previous AgentUI backfills: the izapiaGroups and
tunnel-button features added keys to en.ts/br.ts only, failing
src/i18n/parity.test.ts's key-coverage check for every other locale.
Backfilled with English text + "TODO: translate" comment, same
convention.
Also fixes the actual translations for 'dv' (Divehi)'s missing CLDR
'many' plural category on 7 keys across packages/app and packages/ui
(session.question.pending, session.followupDock.summary,
session.revertDock.summary, ui.sessionTurn.diffs.changed,
ui.messagePart.context.{read,search,list}) — tracked in #197, reusing
each key's existing '.other' text since 'many' isn't otherwise
distinguished in these strings.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reverts the previous commit's dv.ts ".many" translations — they made
unit(linux) pass locally on Windows but fail on Linux CI with the
opposite error ("extra" unexpected keys), because
Intl.PluralRules("dv-MV").resolvedOptions().pluralCategories disagrees
between ICU builds: Windows resolves ["one","many","other"], Linux
resolves ["one","other"]. desktopNativePluralCategories() trusted
whatever the running platform's ICU said, so this test's expectations
literally changed based on which OS ran it.
Fix: pin dv's extra plural categories to [] directly in
parity.test.ts instead of asking the platform — matches what every
dv.ts file actually contains (.one/.other only, no .many/.few/.two/
.zero anywhere), so the test's expectation stops depending on the CI
runner's OS. Issue #197 tracked exactly this locale/category gap.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds an "Auditoria" tab to the agent form listing recent turns —
incoming message + the agent's reply, per channel — so a message that
came through WhatsApp/Telegram/the sandbox can be inspected after the
fact instead of only being visible live in the channel itself.
Backend: AgentUI.Service.logAudit/listAudit persist one row per turn
(id, timestamp, channel, chatKey, incoming, outgoing, blocked) via
Storage.Service under ["agentui", "audit", <agentID>], capped to the
last 300 turns per agent. dispatchChannelMessage (WhatsApp + sandbox)
calls it on both the guardrail-blocked and success paths; Telegram's
own separate per-agent dispatch path (doesn't go through
dispatchChannelMessage) gets its own logAudit call at the same two
points. New route: GET /agentui/{id}/audit.
Known gap: Telegram's background/parallel-task replies (dispatchTask
returning undefined because the reply is queued instead of answered
inline) aren't captured — would need hooking the background-task
completion path too, left for a follow-up.
Test fix: adding Storage.node as an AgentUI.node dependency made
test/agentui/agentui.test.ts's real LayerNode.compile pull in the
real Storage/Git layers (actual filesystem + `git` calls), which
timed out — added an in-memory noop Storage layer override, same
pattern already used there for LSP/MCP/SessionSummary.
i18n: backfilled the 9 new audit-tab keys across all ~62 non-English
locales (English text + TODO comment), same convention as the two
previous AgentUI backfills.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A sidebar tab doesn't scale to the hundreds of conversations a single agent can accumulate. Replace it with /agentui/:id/audit: a page of per-contact cards (grouped client-side from the flat audit log) that opens into a read-only chat-thread view per contact. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Providers like izapia require the webhook secret to be entered separately in their own dashboard (as a distinct "Secret" field with its own validation, e.g. min length), not just embedded in the webhook URL path we already show. Display it as its own read-only field next to the webhook URL so it's copy-pasteable. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds Tunnel.Service.tailscale() (spawns `tailscale ip -4`) and its HttpApi route, plus a "Use Tailscale IP" button next to the existing cloudflared quick-tunnel option in the WhatsApp webhook setup. Two machines already on the same tailnet (our own izapia VPS setups, per project docs) don't need a public tunnel at all — the provider can reach this server directly at its 100.x.x.x tailnet address. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GUI installs of Tailscale (the Windows app, macOS App Store build)
don't add `tailscale` to PATH — only CLI-first installs do. A plain
spawn("tailscale", ...) ENOENTs even when Tailscale is connected,
which is exactly what happened testing this against the desktop app's
own Windows install. Fall back to each platform's known GUI-bundled
CLI path.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The session and group checkbox lists (izapiaSessions/izapiaGroups signals) started empty on every mount, including when reopening an already-saved izapia agent for editing — so the picker showed nothing even though form.whatsappSessionIds/whatsappAllowedGroups already held the saved selection, making it look like the whole thing had to be redone. Auto-fetch both once existing() loads for an izapia channel with an API key already set. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This was referenced Sep 11, 2026
…ting Root-caused and fixed a batch of issues found while live-testing the izapia WhatsApp channel end-to-end (real webhook, real device): - sessionPermission() now denies everything with a wildcard instead of a per-category list. Any tool call outside that short list fell through to the default "ask" action, which hangs forever in a channel dispatch (no human to answer the prompt) — silently producing an empty assistant turn with no error anywhere. - resolveModel() split the model spec on every "/" instead of just the first one, truncating Omniroute's own namespaced model ids (e.g. "omnrt/agy/gemini-3.7-flash-tiered" -> providerID "omnrt", modelID "agy", losing everything after) and failing with "Model not found". - ModelPickerV2 always used the global provider catalog (useProviders(() => undefined)); AgentUI now passes its own directory so project-scoped-only providers show up the same as they do in a real session's composer. - agentui-form.tsx dropped any knowledge source missing a `label` on save with no feedback — a pasted URL with no title silently vanished. Falls back to the value itself as the label instead of discarding the source. - WhatsApp.Service: added a typing indicator (presence.setTyping) sent before dispatching, and a per-chat debounce (4s) that batches rapid consecutive messages into one prompt instead of answering each fragment separately. - The reply send (messages.sendText) gets its own longer-patience retry (4 attempts, exponential backoff) on top of the adapter's built-in one — repeatedly reopening the agent form during testing re-hit izapia's groups/sessions endpoints enough to exhaust the account's rate budget, which then made the actual reply send fail outright with no further attempt. - agentui-form.tsx caches izapia sessions/groups client-side by apiKey instead of re-fetching on every form mount, addressing the rate budget contention at the source; the "Buscar" buttons still force a fresh fetch. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Was asserting exact-string rules for the five categories the old per-category deny list covered; failed against the new wildcard deny (sessionPermission() now denies "*" plus MCP-server allows) since no rule's permission field literally equals "bash" anymore. Matches via Wildcard, the same way the real permission resolver does, and adds a case covering that an agent's configured MCP servers still get their explicit allow. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Issue for this PR
No tracked issue — Phase 2 of the "Canais" work for AgentUI, stacked on #160.
Type of change
What does this PR do?
Summary
Phase 2 of "Canais" for AgentUI, stacked on #160 (per-agent Telegram channel).
Adds a WhatsApp channel using waconector, which wraps 9 unofficial WhatsApp APIs behind one contract:
All 9 are selectable per-channel, per the "add all, let the user choose" decision.
Unlike Telegram (long-poll), waconector is webhook-only — this is a webhook receiver, not a poll loop.
Backend
ConfigAgentUIV1.ChannelBindingis now a proper discriminated union:TelegramChannelBinding | WhatsAppChannelBinding(provider, free-form config record, directory, server-generatedwebhookSecret).WhatsApp.Service: builds the right waconector adapter fromprovider— field names verified directly against each adapter's shipped.d.ts(downloaded and inspected the real npm package; the docs site uses different factory names than the actual package).AgentUI.Service.testMessage()generalized intodispatchChannelMessage()(chatKey-scoped session cache), so the sandbox test chat and the new WhatsApp webhook share the same guardrail → hardened system prompt → resolved model → RAG → reply pipeline instead of duplicating it.POST /whatsapp/webhook/:agentId/:secretroute — deliberately not behind theAuthorizationmiddleware every other instance route uses (an external provider can't present our session credentials); protected instead by the per-channel secret embedded in the URL, same trust model as a Telegram bot token.Frontend
agentui-form.tsx: WhatsApp toggle reveals a provider dropdown and its provider-specific fields, plus the webhook URL to paste into the provider's dashboard (shown oncewebhookSecretexists, after first save).Known gaps (flagged for a later phase, not silently skipped)
instance.connect()can return a QR/pairing code, not surfaced yet. The form only stores config; connecting the actual number is still manual against the provider's own UI/API.How did you verify your code works?
bun turbo typecheck— clean across all 30 packagesbun test test/agentui test/telegram test/whatsapp— 26/26 pass (new whatsapp.test.ts covers the deterministic failure paths: unknown agent, no whatsapp channel configured, webhook secret mismatch)bun test src/i18n/parity.test.ts— no new drift (pre-existingdvgap unchanged)Screenshots / recordings
No UI-visual change beyond the existing toggle/dropdown pattern already reviewed in #160; not attached.
Checklist
🤖 Generated with Claude Code