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?theme=dark&accent=%23ff6b81#token=SESSION_TOKEN"
style="width: 380px; height: 560px; border: 0; border-radius: 16px"
allow="clipboard-write"
></iframe>
Query params
| Param | Values | Effect |
|---|---|---|
token |
pchy_… |
Session token only in the query string (?token=) — a page URL lands in platform and proxy access logs and is readable by every script on the host page, and a session token expires where a PAT does not. Prefer the fragment shown above (#token=…, never sent to a server) or pouchy:init (below), which keeps the token out of the URL entirely; a PAT belongs only on one of those two. |
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, attachments? } |
An assistant reply landed. attachments (present only when the turn produced files, e.g. a spreadsheet) is [{ fileId, fileName, mimeType, url }] — in both the token mode and the ?channel= mode. |
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 a URL token; the recommended form). |
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
sibling frames or popups (ev.source !== window.parent is rejected outright).
Until the origin is known, only the payload-free handshake (pouchy:loaded)
and pouchy:error are posted to window.parent with targetOrigin '*'.
Session, message and tool-call posts (pouchy:ready {session}, pouchy:message,
pouchy:tool_call, …) are held and delivered to the pinned origin once it
is learned — so with a URL token (?token= / #token=) and a stripped
referrer, the parent receives them after its first message to the widget (any
pouchy:* message, e.g. pouchy:init or pouchy:context), not before. 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.