Drop-in Widget — pouchy.ai/embed
The zero-code way to put a Pouchy companion in any page: one <iframe> renders
a complete chat UI (bubbles, composer, streaming replies), authenticated by the
same session token your backend already mints (POST /v1/sessions) or a user's
PAT. When you outgrow the canned UI, everything here is available headless via
@pouchy_ai/companion-sdk — the widget is built on it.
Embed
<iframe
src="https://pouchy.ai/embed?token=SESSION_TOKEN&theme=dark&accent=%23ff6b81"
style="width: 380px; height: 560px; border: 0; border-radius: 16px"
allow="clipboard-write"
></iframe>
Query params
| Param | Values | Effect |
|---|---|---|
token |
pchy_… |
Session token or PAT. Optional — can be delivered via pouchy:init instead (below). |
theme |
dark |
Dark palette. Default light. |
accent |
%23rrggbb (URL-encoded hex) |
Recolors the send button + user bubbles. Text ink is auto-picked by luminance so any accent stays readable. |
Channel mode — no backend (?channel=)
The embed above needs a session token, which needs POST /v1/sessions, which
needs the project Secret Key — i.e. a server. A merchant on a hosted storefront
(Shopify, Squarespace, Wix …) has no server; they have a theme file. For them the
widget speaks the web channel instead:
<iframe
src="https://pouchy.ai/embed?channel=CHANNEL_TOKEN"
title="Chat"
style="position:fixed;right:16px;bottom:16px;width:380px;height:560px;border:0;border-radius:16px;z-index:2147483000"
allow="clipboard-write"
></iframe>
Create a web channel under 渠道; the dashboard shows this snippet with the
token already filled in. ?channel= takes the TOKEN, never a URL — the widget
builds location.origin + '/api/channels/' + token itself, so a query string can
only ever address Pouchy's own inbound route.
Identity is UNVERIFIED in this mode, and that is the trade. The inbound route
is CORS * and the visitorId is client-asserted. The widget stores a random
UUID per browser, so an id is unguessable — which is not unforgeable: anyone
holding the token can address a visitorId they already know. Sound for product
Q&A and storefront help; not sound for orders, addresses or account state.
For those, enable verifyVisitor on the connector and sign the visitorId with
the inboundSecret from your own server (docs/channel-setup.md → "Web widget
visitor identity") — which means having a server, and puts you back on the token
path above.
Replies arrive inline on the POST response, so there is no streaming in this
mode; pouchy:message still fires when one lands.
postMessage protocol
For parents that prefer not to put the token in a URL, and to drive the widget
live. Every message the widget posts carries source: 'pouchy-widget'.
Widget → parent:
| Type | Payload | When |
|---|---|---|
pouchy:loaded |
— | The page booted (safe moment to send pouchy:init). |
pouchy:ready |
{ session } |
Connected; the companion is live. |
pouchy:message |
{ text } |
An assistant reply landed. |
pouchy:sent |
{ text } |
A user message left the composer (typed or via pouchy:send). |
pouchy:tool_call |
{ id, name, args } |
The companion called an app tool (declare tools via the SDK for full round-trips). |
pouchy:error |
{ code?, message } |
Connect/turn error. |
Parent → widget:
| Type | Payload | Effect |
|---|---|---|
pouchy:init |
{ token, surface? } |
Connect with this token (alternative to ?token=). |
pouchy:send |
{ text } |
Send a user message programmatically. |
pouchy:context |
{ event } |
Push a world-state event (same shape as SDK sendWorldState). |
const frame = document.querySelector('iframe');
window.addEventListener('message', (ev) => {
if (ev.data?.source !== 'pouchy-widget') return;
if (ev.data.type === 'pouchy:loaded')
frame.contentWindow.postMessage({ type: 'pouchy:init', token: SESSION_TOKEN }, 'https://pouchy.ai');
if (ev.data.type === 'pouchy:message') console.log('companion said:', ev.data.text);
});
Security model
The embedding page is the trust anchor. The widget pins the embedder's origin
(learned from the referrer at load, or adopted from the first parent message
when the referrer is stripped) and afterwards only accepts control messages
from that origin and only posts session/tool payloads back to it — never to
'*', never to sibling frames or popups (ev.source !== window.parent is
rejected outright). Treat session tokens like the credentials they are: mint
them short-lived per user from your backend.
Known limits (v1)
- Widget chrome is English (the conversation itself follows the agent/user
language). A
langparam is a planned follow-up. - Text modality only — for voice, use the SDK's
connectCallin your own UI.