# Cherry developer docs: full text > Every Cherry developer documentation page, concatenated into one file for AI > agents and no-JS clients. Individual pages: append `.md` to any docs URL > (e.g. https://portal.cherry.fun/docs/quickstart.md), or see https://portal.cherry.fun/llms.txt for the index. > > Machine-readable API: the OpenAPI 3.1 spec for the Cherry Apps + Bot APIs is > at https://portal.cherry.fun/openapi.json (import into Postman / codegen / agents). --- # Cherry developer docs Cherry lets you add real chat to your product, and drive it from your backend. There are two integration surfaces, designed to work together: ## Chat Embed SDK A drop-in widget that renders Cherry chat inside your web app. Install one npm package, mount it onto a `
`, and your users are chatting. It handles wallet auth, theming, and realtime updates for you. - **Package:** `@cherrydotfun/chat-embed-sdk` - **Use it for:** putting a chat room (or a list of rooms) on your site, themed to match your brand. - [Quickstart →](https://portal.cherry.fun/docs/quickstart.md) > **Tip:** Want to see it first? Try the [live embed demo and theme builder](https://cherry.fun/chat-embed-example/), a no-signup, no-backend playground where you can switch themes, pick a display mode, and copy a ready-to-paste integration snippet. ## Cherry API A REST API you call from **your backend** to manage chat programmatically: create rooms on the fly, send messages, and moderate members. It has two key types, each a single opaque token from the portal: a **project key** (`cherry_sk_`) for rooms and messages your project owns, and a **bot key** (`cherry_bot_`) for a named bot that acts with its own in-room identity (including [moderating rooms it's promoted in](https://portal.cherry.fun/docs/guides/moderation-bot.md)). - **Base URL:** `https://api.cherry.fun/api/v1/apps` (project/Apps API) · `https://api.cherry.fun/api/v1/bots` (Bot API) - **Use it for:** creating a room per game, match, order, or community automatically, then surfacing it in an embed. - [Cherry API authentication →](https://portal.cherry.fun/docs/api/authentication.md) ## How they fit together The two surfaces compose into the most common pattern: your backend **creates a room** with the Cherry API, and your frontend **embeds** it with the SDK. For example, a game spins up a room per match: the server calls the Cherry API to create the room and add the players, then the web client mounts the embed pointed at that room. The same flow works for a marketplace order, a token-gated community, or a support thread. ## Where to go next - New here? Start with the [Quickstart](https://portal.cherry.fun/docs/quickstart.md), a public chat embed in about five minutes. - Building the full experience? The **Chat Embed SDK** and **Cherry API** sections cover authentication, theming, dynamic rooms, and the complete reference. > **Tip:** Building with an AI coding assistant? Point it at our [`llms.txt`](https://portal.cherry.fun/llms.txt), a single plain-text file that indexes and links every page in these docs in a format LLMs read well. Paste the URL into your assistant (or let it fetch it) and it has the whole SDK and API at hand. See [For AI agents](https://portal.cherry.fun/docs/ai/for-ai-agents.md) for that plus ready-made integration skills. --- # Quickstart Get a live, public Cherry chat embed running on your site in about five minutes, no backend required. This uses **wallet-only** auth, where visitors connect their own Solana wallet to chat in a public room. > **Tip:** Want to see it first? Try the [live embed builder](https://cherry.fun/chat-embed-example/), a no-signup demo where you can theme the widget in real time and copy a ready-to-paste integration snippet. ## 1. Sign in with your wallet Cherry signs you in with your Solana wallet, so there's no email or password to set up. 1. Open the [portal](https://portal.cherry.fun/login) and click **Connect Wallet**. 2. Choose your Solana wallet, then approve the signature request. This is Sign In With Solana (SIWS): the signature proves you own the wallet and starts your session. Nothing goes onchain and there's no gas. 3. You land on your dashboard, signed in. ## 2. Create a project A project holds your embeds, bots, and API keys, so you need one before you can create an embed. 1. On the [dashboard](https://portal.cherry.fun/dashboard), click **Create Project**. On your first visit the empty state offers the same button. 2. Enter a **Name**. That's the only required field; description, notification email, and teammate invites are optional and editable later. 3. Click **Create Project**. Cherry opens the new project, where **Chat embeds** live. ## 3. Create an embed In your project, open **Chat embeds** → **New embed** and give it a name. Then: 1. Copy the **embed ID** (your `appId`). 2. Under **Allowed origins**, add the origin you'll embed from (e.g. `http://localhost:3000` for local dev, and your production URL). 3. Make sure the embed is **enabled**. > **Note:** The name you give an embed is also the visible title of its chat room, so pick something visitors should see. Renaming the embed later renames the live chat too; the new title reaches already-open widgets within a few minutes. > **Warning:** An embed only loads on origins you've allow-listed, and only while it's enabled, otherwise it returns `401`. Add your dev and production origins before testing. ## 4. Install the SDK ```bash npm install @cherrydotfun/chat-embed-sdk ``` ## 5. Mount the widget Point the embed at a public room and mount it onto a container element: ```ts import { CherryEmbed } from '@cherrydotfun/chat-embed-sdk'; const chat = new CherryEmbed({ appId: 'YOUR_EMBED_ID', container: '#cherry-chat', roomId: 'YOUR_ROOM_ID', mode: 'single', theme: { mode: 'dark', primaryColor: '#FF5BA8' }, }); await chat.mount(); ``` ```html
``` That's it: a themed chat room, live on your page. ## No build step? Use a script tag For a plain HTML site, load the SDK from npm via jsDelivr. The bundle exposes `window.CherryEmbedSDK`: ```html
``` ## Next steps - **Authenticate your own users** (tie chat identity to your users' wallets via your backend) → [Chat Embed SDK › Authentication](https://portal.cherry.fun/docs/embed/authentication.md) - **Theme it to your brand:** presets and the full theme reference → [Chat Embed SDK › Theming](https://portal.cherry.fun/docs/embed/theming.md) - **Create rooms from your backend** and surface them in the embed → [Cherry API › Create rooms & attach to embeds](https://portal.cherry.fun/docs/api/rooms.md) --- # Guide: Add public chat to your app The fastest way to ship chat: a public room any visitor can join by connecting their wallet. No backend required. **You'll need:** an embed created in [the portal](https://portal.cherry.fun/dashboard) (in `wallet-only` mode), its `appId`, your origin allow-listed, and a public room's `roomId`. ## 1. Install ```bash npm install @cherrydotfun/chat-embed-sdk ``` ## 2. Add a container ```html
``` ## 3. Mount ```ts import { CherryEmbed } from '@cherrydotfun/chat-embed-sdk'; const chat = new CherryEmbed({ appId: 'YOUR_EMBED_ID', container: '#cherry-chat', roomId: 'YOUR_ROOM_ID', mode: 'single', theme: { mode: 'dark', primaryColor: '#FF5BA8' }, }); await chat.mount(); ``` That's a complete, themed, public chat room. Visitors connect their own wallet to post. > **Tip:** Want to see it before you wire it up? Try the [live embed builder](https://cherry.fun/chat-embed-example/), a no-signup, no-backend demo where you can switch themes and display modes in real time and copy the matching integration snippet. ## Make it a floating widget Drop the `container`, switch `position`, and start collapsed. By default the widget renders no launcher of its own, so wire your own control to open it; set `chatBubble: true` instead and the SDK renders one for you (see [display modes](https://portal.cherry.fun/docs/embed/display-modes.md) for both paths and the SDK version `chatBubble` needs): ```ts const chat = new CherryEmbed({ appId: 'YOUR_EMBED_ID', roomId: 'YOUR_ROOM_ID', position: 'floating-right', collapsed: true, }); await chat.mount(); // collapsed starts hidden: open it from your own UI document.querySelector('#open-chat')?.addEventListener('click', () => chat.toggle()); ``` ## Where next - Tie chat identity to *your* users → [Authenticated chat](https://portal.cherry.fun/docs/guides/authenticated-chat.md). - Theme it → [Theming](https://portal.cherry.fun/docs/embed/theming.md). --- # Guide: Theme your embed Your embed is mounted and running on the Cherry defaults. This guide takes it from there to a chat that reads as part of your product, in a few honest lines of config. **You'll need:** a working embed (see the [Quickstart](https://portal.cherry.fun/docs/quickstart.md)) and your brand's colors, or just your site's URL if you want an AI agent to pull them for you. ## 1. Set the mode and primary color Two values get you most of the way. Pick `mode` from your app's real surface (dark or light), and set `primaryColor` to your brand color: ```ts const chat = new CherryEmbed({ appId: 'YOUR_EMBED_ID', container: '#cherry-chat', roomId: 'YOUR_ROOM_ID', theme: { mode: 'dark', primaryColor: '#9162FF' }, }); ``` `primaryColor` drives your own message bubbles and the send button, so the chat immediately reads as yours. Everything else sits on a neutral base derived from the mode. ## 2. Complete the seeds Add two or three more colors and the embed derives its whole palette from them (surfaces, chrome, links, inks, borders), with contrast floors enforced: ```ts theme: { mode: 'dark', primaryColor: '#9162FF', // brand color: own bubble + send button backgroundColor: '#0D0416', // app content surface: background + surfaces accentColor: '#FF57C1', // secondary highlight: links + mentions // incomingBubbleColor: '#1a1030', // optional: a distinct color for received messages } ``` The one thing that matters here is honest inputs. Take `backgroundColor` from your app's actual content surface, not a colored header or a hero image; a colored nav band is chrome, not the background. Set `accentColor` to the secondary color you use for links and highlights. Leave `incomingBubbleColor` unset unless your brand has its own second color for received messages. Pass the honest seeds and let the engine derive the rest, rather than hand-setting individual slots. ## 3. Or let an AI agent extract them If you would rather not read colors off your own site by hand, hand it to an agent. The embed editor in the [dashboard](https://portal.cherry.fun/dashboard) generates a setup prompt that already carries the theme methodology; copy it into your AI assistant, give it your site's URL, and it extracts the seeds by role and applies them. Agents that read Markdown can also fetch the full extraction playbook directly: ``` https://portal.cherry.fun/docs/embed/theming.md ``` ## 4. Check it with your eyes Applying is not the same as done. Screenshot the embed in place and look at it: do incoming bubbles read clearly, are links visible, does your own bubble feel like your primary button? The engine holds three contrast floors for you (body text and incoming text at least 4.5:1, the send icon at least 3:1), so if something looks off it is almost always a wrong input, usually the mode or a chrome color mistaken for the background. Fix the seed, not the output. ## 5. Adjust a single slot when you need to The seeds cover most brands. When you have a specific measured value to place (a header tint, an exact ink your brand authors), set that one slot and the engine keeps deriving everything else: ```ts theme: { mode: 'dark', primaryColor: '#9162FF', backgroundColor: '#0D0416', headerColor: '#150A22', // one measured override, everything else stays derived } ``` Every settable key is in the [theme reference](https://portal.cherry.fun/docs/embed/theming.md#theme-reference). Reach for an override when you have a value to place, not as the default workflow: composing the whole palette by hand fights the engine. And if your brand's fill is genuinely a gradient, add `gradients: 'on'` (or pass a measured `linear-gradient(...)` as the fill) instead of a flat color. If you want the chat to sit over your page rather than on its own ground, give `backgroundColor` an alpha value and optionally a blur: see [Transparency and blur](https://portal.cherry.fun/docs/embed/theming.md#transparency-and-blur). ## Where next - The full extraction method, contrast rules, and every theme key → [Theming](https://portal.cherry.fun/docs/embed/theming.md). - Point an AI agent at the whole integration → [For AI agents](https://portal.cherry.fun/docs/ai/for-ai-agents.md). --- # Guide: Authenticated chat with your users' wallets Tie chat identity to your users by minting short-lived tokens on your backend and having users sign a wallet challenge. This is the `app-trusted+wallet` mode, the default for production embeds. **You'll need:** an embed in `app-trusted+wallet` mode, its `appId` and **app secret** (open your project in [the dashboard](https://portal.cherry.fun/dashboard) → Chat embeds and API keys), and a backend you control. ## 1. Mint tokens on your backend Sign a JWT with your app secret. The user's wallet is `sub`, your app is `app_id`. Keep the secret server-side. ```ts // POST /api/cherry-embed-token import jwt from 'jsonwebtoken'; import { randomUUID } from 'node:crypto'; export function mintCherryToken(walletAddress: string) { return jwt.sign( { sub: walletAddress, app_id: process.env.CHERRY_APP_ID }, process.env.CHERRY_APP_SECRET, { algorithm: 'HS256', expiresIn: '5m', jwtid: randomUUID() }, ); } ``` > **Note:** A full runnable example is in the public [`chat-embed-sdk`](https://github.com/cherrydotfun/chat-embed-sdk/tree/main/example/app-trusted+wallet) repo, under `example/app-trusted+wallet` (its `server.js`). ## 2. Connect the wallet and mount Register `signChallengeHandler` before `mount()`: the iframe calls it to verify wallet ownership. ```ts import { CherryEmbed } from '@cherrydotfun/chat-embed-sdk'; const provider = window.phantom?.solana; const { publicKey } = await provider.connect(); const walletAddress = publicKey.toString(); const { token } = await fetch('/api/cherry-embed-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ walletAddress }), }).then((r) => r.json()); const chat = new CherryEmbed({ appId: 'YOUR_EMBED_ID', container: '#cherry-chat', roomId: 'YOUR_ROOM_ID', token, walletAddress, signChallengeHandler: async (message) => { // message: Uint8Array. Sign the bytes as-is, don't re-encode them const { signature } = await provider.signMessage(message, 'utf8'); return signature; // Uint8Array }, }); await chat.mount(); ``` ## 3. Session renewal is automatic The embed token expires in ~5 minutes, so mint it fresh right before `mount()`; don't cache it. After the initial exchange the iframe renews its session silently via a rotating refresh token; there's nothing to refresh on a timer. To force a fresh exchange (e.g. after an account switch), mint a new token and call `chat.setToken(token)`. ## Where next - Create rooms from your backend → [A room per game / match / order](https://portal.cherry.fun/docs/guides/room-per-entity.md). - Full auth detail → [Embed › Authentication](https://portal.cherry.fun/docs/embed/authentication.md). --- # Guide: A room per game / match / order Spin up a dedicated chat room for each unit of activity in your app (a game match, a marketplace order, a support ticket) and drop your users straight into it. This combines the [Cherry API](https://portal.cherry.fun/docs/api/rooms.md) (server) with the [embed SDK](https://portal.cherry.fun/docs/embed/display-modes.md) (client). ## 1. Create the room from your backend When the activity starts (a match is made, an order is placed), call the Cherry API. Add the participants as `initialMembers` so they're active immediately. ```ts // your server - CHERRY_API_KEY is your project key: the one opaque // `cherry_sk__` token copied from the portal. const res = await fetch('https://api.cherry.fun/api/v1/apps/groups', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CHERRY_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ ownerWallet: hostWallet, title: `Match #${matchId}`, initialMembers: [playerA, playerB], }), }); const { roomId } = await res.json(); // persist roomId alongside your match/order record ``` ## 2. Greet them with the bot (optional) See the [Messages API](https://portal.cherry.fun/docs/api/messages.md) for the full send-message endpoint. ```ts await fetch(`https://api.cherry.fun/api/v1/apps/groups/${roomId}/messages`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.CHERRY_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ content: 'Match starting - good luck! 🎮' }), }); ``` ## 3. Open the room in the embed Pass the `roomId` to a `single`-mode embed, or, if the user moves between activities, mount once in `external-controlled` mode and call `setRoom()`: ```ts // single room new CherryEmbed({ appId: 'YOUR_EMBED_ID', container: '#chat', roomId, mode: 'single' }); // or, switch rooms as the user navigates chat.setRoom(roomId); ``` ## 4. Clean up When the activity ends, delete the room (it must be app-created): ```ts await fetch(`https://api.cherry.fun/api/v1/apps/groups/${roomId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${process.env.CHERRY_API_KEY}` }, }); ``` ## Where next - [Cherry API › Create rooms & attach to embeds](https://portal.cherry.fun/docs/api/rooms.md) for the full endpoint detail. - [Moderation bot](https://portal.cherry.fun/docs/guides/moderation-bot.md) to keep rooms healthy. --- # Guide: Build a moderation bot Run a bot that keeps a room healthy (delete spam, mute offenders, ban repeat abusers), acting as **its own identity** in the room, not as the room owner. This uses the **two-key** model. A bot can only moderate a room where it holds a privileged **role**, so the flow has two halves: 1. A **project key** (`cherry_sk_`) sets the room up: creates it, invites the bot's wallet, and promotes it to `moderator`. 2. The **bot key** (`cherry_bot_`) does the moderating via the `/api/v1/bots/*` endpoints. **You'll need:** - A **bot** in your project with a **bot key** that has the [`bots:groups:moderate` scope](https://portal.cherry.fun/docs/api/scopes.md). Note its **wallet**: from the portal's bot section, or `GET /api/v1/bots/me`. - A **project key** with `groups:create`, `members:invite`, and `members:moderate` to set the room up. ## 1. Give the bot a moderator role (project key) The bot's authority comes from its in-room role. Create a room your project owns (or reuse one), invite the bot's wallet, activate it, then promote it. ```ts // CHERRY_PROJECT_KEY is your `cherry_sk__` token. const OWNER = { Authorization: `Bearer ${process.env.CHERRY_PROJECT_KEY}`, 'Content-Type': 'application/json', }; // create a room the project owns const { roomId } = await ( await fetch('https://api.cherry.fun/api/v1/apps/groups', { method: 'POST', headers: OWNER, body: JSON.stringify({ ownerWallet: HOST_WALLET, title: 'General' }), }) ).json(); // invite the bot's wallet and activate it immediately await fetch(`https://api.cherry.fun/api/v1/apps/groups/${roomId}/members`, { method: 'POST', headers: OWNER, body: JSON.stringify({ wallets: [BOT_WALLET], autoAccept: true }), }); // promote it to moderator, now the bot can moderate THIS room await fetch( `https://api.cherry.fun/api/v1/apps/groups/${roomId}/members/${BOT_WALLET}/role`, { method: 'POST', headers: OWNER, body: JSON.stringify({ role: 'moderator' }) }, ); ``` ## 2. Moderate as the bot (bot key) Now switch to the **bot key**. Every call is authorized by two independent checks: the `bots:groups:moderate` scope *and* the bot's moderator role in `roomId`. ```ts // CHERRY_BOT_KEY is your `cherry_bot__` token. const BOT = { Authorization: `Bearer ${process.env.CHERRY_BOT_KEY}`, 'Content-Type': 'application/json', }; // delete a spam message await fetch('https://api.cherry.fun/api/v1/bots/deleteGroupMessage', { method: 'POST', headers: BOT, body: JSON.stringify({ roomId, messageId }), }); // mute an offender (shadow-ban: they can't post, existing messages stay) await fetch('https://api.cherry.fun/api/v1/bots/muteGroupMember', { method: 'POST', headers: BOT, body: JSON.stringify({ roomId, wallet }), }); // ban a repeat abuser and wipe their messages await fetch('https://api.cherry.fun/api/v1/bots/banGroupMember', { method: 'POST', headers: BOT, body: JSON.stringify({ roomId, wallet, deleteMessages: true }), }); ``` `kickGroupMember`, `unbanGroupMember`, and `unmuteGroupMember` follow the same `{ roomId, wallet }` shape - see the [full list](https://portal.cherry.fun/docs/api/members.md). ## Watching for messages to act on Give the bot something to react to: - **Bot key:** long-poll `GET /api/v1/bots/getUpdates` (scope `bots:updates:poll`) or configure a webhook (`bots:webhook:manage`) to receive new messages as they arrive. - **Project key:** poll `GET /api/v1/apps/groups/:roomId/messages` (scope `messages:read`) for room history - respect the [rate limits](https://portal.cherry.fun/docs/api/rate-limits.md) and poll at a sane interval. ## Why two keys: the layered permission model The scope and the role are **separate gates**, and both must pass: - The **scope** (`bots:groups:moderate`) authorizes the API *call*. - The bot's **in-room role** (owner / admin / moderator) authorizes the *action on others*. > **Warning:** A scope alone is not permission to act. A bot with `bots:groups:moderate` that is only a plain member, or isn't in the room, is **denied** (`403 INSUFFICIENT_ROOM_ROLE`). This is why step 1 (promote to moderator) is required. ## Rules to remember - The bot **can't moderate the room owner**, and a moderator bot can't act on admins. - Bots **can't set roles**: only a project key (or a human owner/admin) can promote the bot in step 1. - **Mute the bot** to instantly disable it: a muted bot's moderation calls are rejected. A working kill-switch for a misbehaving bot. - **Ban** with `deleteMessages: true` purges the member's messages; **mute** leaves existing messages in place. ## Where next - [Members & moderation](https://portal.cherry.fun/docs/api/members.md), both moderation models and every endpoint. - [Scopes](https://portal.cherry.fun/docs/api/scopes.md) · [Rate limits & errors](https://portal.cherry.fun/docs/api/rate-limits.md). --- # For AI agents These docs are built to be consumed by AI coding assistants. Point your agent at them and it can [scaffold a Cherry integration end to end](https://portal.cherry.fun/docs/quickstart.md). ## Reading these docs Every page is available in the format an agent works best with. There are four ways to consume the docs: - **The rendered HTML pages** at [`/docs`](https://portal.cherry.fun/docs.md). The normal site, for a browsing agent or a human. - **Plain-Markdown mirrors.** Append `.md` to any docs URL for a JavaScript-free Markdown copy of that page (for example `/docs/quickstart` becomes [`/docs/quickstart.md`](https://portal.cherry.fun/docs/quickstart.md)). Internal links in a mirror point at other `.md` mirrors, so an agent can crawl doc to doc entirely in Markdown. - **[`llms.txt`](https://portal.cherry.fun/llms.txt) and [`llms-full.txt`](https://portal.cherry.fun/llms-full.txt).** `llms.txt` is a single indexed, linked table of contents with the key facts (package name, API base URL, token format) up front, following the [llms.txt convention](https://llmstxt.org). `llms-full.txt` is every page concatenated into one file, for pulling the whole docs set in a single fetch. - **The dashboard setup prompt.** The embed editor in the [dashboard](https://portal.cherry.fun/dashboard) generates a ready-to-paste prompt distilled for your exact embed (its `appId`, auth mode, and theme). It is the fastest way to hand an agent a correct, configured starting point. ``` https://portal.cherry.fun/llms.txt # index of every page https://portal.cherry.fun/llms-full.txt # the entire docs set in one file ``` Paste a URL (or the file's contents) into your AI assistant before asking it to integrate Cherry; in Cursor, Claude, or similar it becomes context the model pulls the right API and SDK details from. ## Integration skills Cherry ships ready-made **agent skills** (`SKILL.md` files for tools like Claude Code) that turn "add Cherry chat to my app" into a guided, correct integration: the right auth mode, backend token signing, theming, events, and security rules. | Skill | Use it for | Source | |---|---|---| | `cherry-embed-integration` | Embedding Cherry chat with `@cherrydotfun/chat-embed-sdk` (web, React Native, Flutter) | [cherrydotfun/chat-embed-sdk](https://github.com/cherrydotfun/chat-embed-sdk/tree/main/skills) | | `cherry-miniapp-integration` | Running an existing dApp inside Cherry with `@cherrydotfun/miniapp-sdk` | [cherrydotfun/miniapp-sdk](https://github.com/cherrydotfun/miniapp-sdk/tree/main/skills) | If your assistant supports skills, install the one that matches your task and ask it to integrate Cherry: it will follow the same patterns documented here. ## Theming Theming has its own methodology, and the setup prompt already carries the essentials seeded with your embed's current colors. The full extraction playbook lives at [Theming](https://portal.cherry.fun/docs/embed/theming.md); an agent that reads Markdown can fetch it directly at: ``` https://portal.cherry.fun/docs/embed/theming.md ``` The essentials an agent should get right, each covered in depth there: - **Ask first.** Before writing any theme values, ask the developer how the chat should be themed: (A) match a website (URL), (B) match a design file, (C) exact colors, or (D) the default Cherry look. Do not assume. - **Pass honest seeds, not a hand-built palette.** The embed derives its full palette (around 60 variables) from 2 to 4 color seeds (`primaryColor`, `backgroundColor`, `accentColor`, and an optional `incomingBubbleColor`) plus `mode` as the base variant, with contrast floors enforced. Set the seeds and let the engine derive the rest; override a single slot only when you have a measured value for it. - **Trust the render over the token.** A site's `--primary` can be stale or aspirational. Confirm the primary against what actually renders (the loudest CTA fills across a few pages, the positive or active UI, and the styled `::selection` highlight) and pass that color verbatim. - **Reconstruct the second color.** If the brand has a soft tinted panel or card fill that clears the contrast floors, pass it as `incomingBubbleColor`; otherwise leave incoming to the engine. Decide this by the criteria, do not ask. - **Transparency is opt-in.** The chat can render see-through over the page, but only do it when the developer asks for it. Leave every surface opaque otherwise. - **Verify.** Screenshot the result and confirm body text at least 4.5:1, incoming text at least 4.5:1, and the send icon at least 3:1. A failure means an input is wrong (usually the mode), so fix the input, not the output. ## Tips for accurate output - The npm package is **[`@cherrydotfun/chat-embed-sdk`](https://portal.cherry.fun/docs/embed/installation.md)**; the class is **`CherryEmbed`**. - A **floating** embed (`position: 'floating-right'` / `'floating-left'`) needs a launcher, and there are exactly two supported ways to get one: the host page renders its own button and calls `chat.toggle()` / `chat.show()`, or the config sets `chatBubble: true` and the SDK renders the launcher itself, with `chatBubbleBadge` (`'dot'` default, `'count'`, `'off'`) choosing its unread badge. Do not invent a third, and do not build both. `chatBubble` / `chatBubbleBadge` ship in SDK 0.1.7, the version the portal pins and the snippets generate; on 0.1.6 and older both keys are ignored and the widget mounts with no launcher, so an integration still pinned there needs its own button. Inline embeds ignore the flag: see [Display modes](https://portal.cherry.fun/docs/embed/display-modes.md). - The [Cherry API](https://portal.cherry.fun/docs/api/authentication.md) has two surfaces: the **Apps API** at **`https://api.cherry.fun/api/v1/apps`** (auth **`Bearer cherry_sk__`**, a project key) and the **Bot API** at **`https://api.cherry.fun/api/v1/bots`** (auth **`Bearer cherry_bot__`**, a bot key). Each key is **one opaque token** copied from the portal, never assembled from an app ID plus a secret. `cha__` is a deprecated legacy format. - Bot moderation is gated by **two** layers: the `bots:groups:moderate` scope **and** the bot's in-room role (owner/admin/moderator) - see [Members & moderation](https://portal.cherry.fun/docs/api/members.md). - Keep API keys and the **app secret** server-side: the secret signs embed tokens; API keys authenticate the Cherry API. --- # Installation The Chat Embed SDK renders Cherry chat inside your web app. It's published on npm and works in any modern bundler (Vite, Next.js, Webpack) or straight from a ` ``` > **Tip:** Pin the version in the CDN URL (`@0.1.7`) so a future release can't change behavior under you. With npm, your lockfile already does this. ## Mobile (React Native / Flutter) The SDK is browser-only, so it can't run directly in a mobile JS runtime. On mobile you run it inside a WebView on a small host page and bridge wallet signing to the native layer: see [React Native & Flutter](https://portal.cherry.fun/docs/embed/mobile.md). ## Requirements - A Cherry **embed** (created in your [project's](https://portal.cherry.fun/dashboard) **Chat embeds** section), it gives you the `appId`. - Your site's origin added to the embed's **Allowed origins**. - A mount target: an element on your page (for inline embeds) or nothing (for floating widgets). ## Next steps - [Configuration](https://portal.cherry.fun/docs/embed/configuration.md), every option `CherryEmbed` accepts. - [Authentication](https://portal.cherry.fun/docs/embed/authentication.md), wallet-only vs. backend-signed tokens. --- # Configuration You configure the widget by passing a config object to the `CherryEmbed` constructor: ```ts import { CherryEmbed } from '@cherrydotfun/chat-embed-sdk'; const chat = new CherryEmbed({ appId: 'YOUR_EMBED_ID', container: '#cherry-chat', roomId: 'YOUR_ROOM_ID', mode: 'single', theme: { mode: 'dark', primaryColor: '#FF5BA8' }, }); await chat.mount(); ``` > **Tip:** Want to try options before writing code? The [live embed builder](https://cherry.fun/chat-embed-example/) lets you toggle display mode, theme, and colors in real time and copies out a ready-to-paste config snippet, no signup or backend required. ## Options | Option | Type | Default | Notes | |---|---|---|---| | `appId` | `string` | | **Required.** Your embed's ID, from [the portal](https://portal.cherry.fun/dashboard). | | `container` | `string \| HTMLElement` | | **Required for inline embeds.** CSS selector or element to mount into. Omit for floating widgets. | | `roomId` | `string` | | The room to open. Required in `single` mode; optional in `list` / `external-controlled`. | | `mode` | `'single' \| 'list' \| 'external-controlled'` | `'single'` | How rooms are presented; only `single` is available today. See [Display modes](https://portal.cherry.fun/docs/embed/display-modes.md). | | `position` | `'inline' \| 'floating-right' \| 'floating-left'` | `'inline'` | Inline mounts into `container`; floating pins the chat panel to a viewport corner. | | `collapsed` | `boolean` | `false` | For floating widgets, start hidden; open from your own UI via `show()` / `toggle()`, or from the built-in launcher below. While hidden, unread counters accrue. See [Unread indicators](https://portal.cherry.fun/docs/embed/unread-indicators.md). | | `chatBubble` | `boolean` | `false` | Render the SDK's own round launcher button beside a floating widget. Floating only: silently ignored when `position` is `inline`. Off by default, so a launcher you already draw yourself keeps working. Pair with `collapsed: true`. See [Display modes](https://portal.cherry.fun/docs/embed/display-modes.md) for the SDK version it needs. | | `chatBubbleBadge` | `'dot' \| 'count' \| 'off'` | `'dot'` | Unread badge on that launcher: `dot` for a bare dot (a lone `@` for mentions), `count` for the unread number (`@ N` with mentions, capped at `99+`), `off` for none. Ignored without `chatBubble`; fed by `unreadState` with nothing to wire. See [Display modes](https://portal.cherry.fun/docs/embed/display-modes.md). | | `token` | `string` | | Short-lived JWT for `app-trusted` / `app-trusted+wallet` auth. See [Authentication](https://portal.cherry.fun/docs/embed/authentication.md). | | `walletAddress` | `string` | | The user's Solana address, for wallet-backed auth. Not needed in pure `app-trusted` mode: the token's `sub` claim carries it. | | `signChallengeHandler` | `(message: Uint8Array) => Promise` | | Signs the wallet challenge for wallet-backed auth. Register **before** `mount()`. Not used in pure `app-trusted` mode. | | `resolveUsers` | `(wallets: string[]) => Promise>` | | Answer with your app's display names and avatars for those wallets, so the chat shows your users instead of `.sol` domains and shortened addresses. Called with up to 50 wallets at a time; return `null` for anyone you don't know. Requires the per-embed switch (see [Your users' names](https://portal.cherry.fun/docs/embed/host-identity.md)). SDK 0.1.7 or newer. | | `searchUsers` | `(params: { query?: string; cursor?: string; limit: number }) => Promise` | | Search your own user directory, so `@`-autocomplete finds people Cherry has never heard of. Same switch as above. SDK 0.1.7 or newer. | | `getUser` | `(wallet: string) => Promise` | | Reserved: a detailed single-user lookup for the profile view. Not called yet. SDK 0.1.7 or newer. | | `userProfiles` | `Record` | | Profiles you already have at mount time, so the first paint shows your names with no round-trip. Same shape as `setUserProfiles`. SDK 0.1.7 or newer. | | `identityToken` | `string` | | Bearer token sent as `Authorization` on requests to the **Profile endpoint** configured in the portal. Memory only, never persisted; refresh it with `setIdentityToken()`. SDK 0.1.7 or newer. | | `theme` | `EmbedTheme` | | Colors, typography, and surface treatment (transparency and blur). See [Theming](https://portal.cherry.fun/docs/embed/theming.md). | | `layout` | `EmbedLayout` | | Toggle UI chrome (header, avatars, input, …). See below. | | `embedUrl` | `string` | `'https://embed.cherry.fun'` | Origin the chat iframe is served from. Only override for self-hosted deployments. | ## Layout `layout` controls the structural visibility of the UI the iframe renders (which chrome shows). It is the counterpart to [`theme`](https://portal.cherry.fun/docs/embed/theming.md), which controls colors, typography, and surface treatment (transparency and blur). ```ts const chat = new CherryEmbed({ appId: 'YOUR_EMBED_ID', container: '#cherry-chat', layout: { showHeader: true, headerTitle: 'Support', showMemberCount: true, showInput: true, }, }); ``` | Field | Type | Notes | |---|---|---| | `showHeader` | `boolean` | Show the room header bar. | | `headerTitle` | `string` | Override the header title. | | `showMemberCount` | `boolean` | Show the member count. | | `showInput` | `boolean` | Show the composer (set `false` for a read-only feed). | You can change layout after mount with `chat.setLayout({ … })`. Older builds also accepted `showAvatars`, `showTimestamps`, `showReactions`, and `maxHeight`; the runtime sanitizer now drops them, so those keys are accepted but ignored. To size the widget, set the height on the `container` element (e.g. `
`) rather than through `layout`. ## App-trusted (zero-signature) Embeds set to pure `app-trusted` auth pass only `token`; there is no wallet connect on the page, so `walletAddress` and `signChallengeHandler` are omitted entirely: ```ts // The backend derives the wallet from its own authenticated session and puts // it in the token's `sub` claim; the page never learns or sends the address. const { token } = await fetch('/api/cherry-embed-token', { method: 'POST' }) .then((r) => r.json()); const chat = new CherryEmbed({ appId: 'YOUR_EMBED_ID', container: '#cherry-chat', roomId: 'YOUR_ROOM_ID', token, }); await chat.mount(); ``` Because your backend's word is the only identity proof, the mode runs with server-side restrictions (a fail-closed room allowlist, message rate limits, moderation off by default). Turn it on yourself in the portal's auth-mode selector. Trust model, token contract, and the full restrictions list: [Authentication](https://portal.cherry.fun/docs/embed/authentication.md). ## Next steps - [Authentication](https://portal.cherry.fun/docs/embed/authentication.md) · [Display modes](https://portal.cherry.fun/docs/embed/display-modes.md) · [Theming](https://portal.cherry.fun/docs/embed/theming.md) · [SDK API reference](https://portal.cherry.fun/docs/embed/api-reference.md) --- # Authentication Cherry chat identifies users by their Solana wallet. The SDK supports three auth modes: you pick one when you [configure the embed](https://portal.cherry.fun/docs/embed/configuration.md) in the portal, and wire the matching client code. Choose based on whether you run a backend and whether you want users to prove wallet ownership. | Mode | Backend? | Wallet signature? | Use when | |---|---|---|---| | `wallet-only` | No | Yes (user signs) | No backend; users bring their own wallet. Public rooms. | | `app-trusted+wallet` | Yes | Yes (user signs) | You run a backend and want verified wallet identity. **Most common.** | | `app-trusted` | Yes | No | Zero-signature: your backend is the sole identity source. Self-serve: pick it in the portal's auth-mode selector. | ## wallet-only No backend. The user connects a wallet and signs a challenge; the iframe handles the flow. Just mount: ```ts const chat = new CherryEmbed({ appId: 'YOUR_EMBED_ID', container: '#cherry-chat', roomId: 'YOUR_ROOM_ID', mode: 'single', }); await chat.mount(); ``` ## app-trusted + wallet Your backend mints a short-lived token for the connected wallet, and the user signs a challenge to prove they own it. This is the default for the public API. ### 1. Mint tokens on your backend The token is a JWT signed with your embed's **app secret** (from the portal), with the user's wallet as `sub` and your `appId` as `app_id`. Keep the secret server-side only. ```ts // POST /api/cherry-embed-token - your server import jwt from 'jsonwebtoken'; import { randomUUID } from 'node:crypto'; const token = jwt.sign( { sub: walletAddress, app_id: process.env.CHERRY_APP_ID }, process.env.CHERRY_APP_SECRET, { algorithm: 'HS256', expiresIn: '5m', jwtid: randomUUID() }, ); ``` > **Note:** A complete, runnable token server lives in the public [`chat-embed-sdk`](https://github.com/cherrydotfun/chat-embed-sdk/tree/main/example/app-trusted+wallet) repo, under `example/app-trusted+wallet` (its `server.js`). ### 2. Mount with the token + a sign handler Fetch a token, then mount. Register `signChallengeHandler` **before** `mount()`: the iframe calls it to get the user's signature. ```ts const provider = window.phantom?.solana; const { publicKey } = await provider.connect(); const walletAddress = publicKey.toString(); const { token } = await fetch('/api/cherry-embed-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ walletAddress }), }).then((r) => r.json()); const chat = new CherryEmbed({ appId: 'YOUR_EMBED_ID', container: '#cherry-chat', roomId: 'YOUR_ROOM_ID', token, walletAddress, signChallengeHandler: async (message) => { // message: Uint8Array. Sign the bytes as-is, don't re-encode them const { signature } = await provider.signMessage(message, 'utf8'); return signature; // Uint8Array }, }); await chat.mount(); ``` ### 3. Session renewal is automatic The embed token is short-lived (~5 min) by design: mint it fresh right before `mount()`, don't cache it. After the initial exchange the iframe holds a short-lived Cherry session and silently re-establishes it from a rotating refresh token whenever the page (re)loads, so there's nothing for you to refresh on a timer. If you ever need to force a fresh exchange (e.g. the user switches accounts in your app), mint a new token and call `chat.setToken(token)`. ## app-trusted (zero-signature) The same backend-minted token as above, but with no wallet signature at all: your backend is the sole source of identity truth, and the user never connects a wallet or signs anything on your page. This mode is self-serve: pick it in the portal's auth-mode selector when you configure the embed. ### How it works 1. Your backend authenticates the user through its own login session. 2. It mints the exact same JWT as in `app-trusted+wallet`: `sub` is the user's wallet, `app_id` is your embed ID, HS256 with the app secret, ~5 min expiry, unique `jwtid`. 3. The page fetches the token and mounts with it. No `walletAddress`, no `signChallengeHandler`, no wallet UI: the token's `sub` claim carries the identity. ```ts // Your backend derives the wallet from ITS OWN session. Never accept a // wallet address from the request body: with no signature to check, a // body-supplied address would let any client impersonate any wallet. const { token } = await fetch('/api/cherry-embed-token', { method: 'POST' }) .then((r) => r.json()); const chat = new CherryEmbed({ appId: 'YOUR_EMBED_ID', container: '#cherry-chat', roomId: 'YOUR_ROOM_ID', token, }); await chat.mount(); ``` ### The trust model In the wallet-backed modes the user proves wallet ownership with an Ed25519 signature that Cherry verifies. Here Cherry only verifies that the token was signed with your app secret; whatever `sub` your backend asserts is accepted as-is. Cherry cannot tell a real wallet owner from an impersonated one, which has two consequences: - `sub` must come only from your authenticated session, never from client input (request body, query, headers you don't control). - The mode runs with server-side restrictions, below. ### Server-side restrictions - **Allowed rooms are mandatory and fail-closed.** The chat works only in the rooms allowed for your embed; any other room returns `403`. - **Message rate limits** apply (about 20 messages per minute per user by default); exceeding them returns `429`. - **Moderation from the embed is off by default**, governed by the embed's server-side policy. - **Embed sessions live up to 15 minutes** and refresh automatically, so there is nothing to refresh on your side. Treat `403` and `429` responses from these rules as policy, not as bugs in your integration or a broken token. > **Note:** Turn this mode on yourself: open the embed in the portal and pick **App-trusted (zero-signature)** in the auth-mode selector. Make sure the rooms you need are allowed for the embed. ## Security notes - The **app secret** signs tokens: keep it on your server, never ship it to the browser. - Tokens are short-lived by design; mint them fresh right before `mount()`. Session renewal afterwards is automatic. - `walletConnectRequested` fires when a user clicks "connect" in a preview state: re-arm auth by calling `setToken()` and `setWalletAddress()`. (Wallet-backed modes only; it never fires in pure `app-trusted`.) ## Next steps - [Display modes](https://portal.cherry.fun/docs/embed/display-modes.md) · [SDK API reference](https://portal.cherry.fun/docs/embed/api-reference.md) - Full walkthrough: [Guides › Authenticated chat](https://portal.cherry.fun/docs/guides/authenticated-chat.md) --- # Display modes Two independent axes control how the widget appears: **mode** (how rooms are presented) and **position** (how it mounts on the page). ## Room modes Set with `mode`. ### `single` (default) Locks the embed to one room (`roomId`). No room switcher: ideal for a support widget or a single community room. ```ts new CherryEmbed({ appId: 'YOUR_EMBED_ID', container: '#cherry-chat', roomId: 'YOUR_ROOM_ID', mode: 'single', }); ``` > **Warning:** Only `single` is available today. `list` and `external-controlled` (and the `roomChanged` event) are documented ahead of runtime support; selecting them currently behaves like `single`. ### `list` When available, `list` will render Cherry's room-list UI so the user picks among the rooms they belong to (omit `roomId`). Not wired up yet: currently behaves like `single`. ```ts new CherryEmbed({ appId: 'YOUR_EMBED_ID', container: '#cherry-chat', mode: 'list', }); ``` ### `external-controlled` When available, your app will drive navigation: mount once, then call `setRoom()` whenever your UI changes, e.g. the user opens a different match, order, or channel, and listen for `roomChanged`. Not wired up yet: currently behaves like `single`, and `roomChanged` does not fire. ```ts const chat = new CherryEmbed({ appId: 'YOUR_EMBED_ID', container: '#cherry-chat', mode: 'external-controlled', }); await chat.mount(); // later: switch rooms from your own UI chat.setRoom('room_abc123'); chat.on('roomChanged', ({ roomId }) => { console.log('active room is now', roomId); }); ``` > **Tip:** Pair `external-controlled` with the [Cherry API](https://portal.cherry.fun/docs/api/rooms.md): create a room on your backend, then call `setRoom(newRoomId)` to drop the user straight into it. ## Position > **Tip:** Want to see the modes side by side? Toggle floating vs. inline live in the [Cherry embed builder](https://cherry.fun/chat-embed-example/), no signup or backend required. Set with `position`. - **`inline`** (default): mounts into your `container` element and flows with your layout. - **`floating-right` / `floating-left`:** pins the chat panel to a viewport corner. Omit `container`. Use `collapsed: true` to start hidden. Unread counters accrue while it is hidden, so whatever opens the chat can carry a dot: see [Unread indicators](https://portal.cherry.fun/docs/embed/unread-indicators.md). A floating widget needs something to open it, and there are two supported paths: render the launcher yourself, or let the SDK render one with `chatBubble`. Pick one, not both. ### Launcher A: your own button The default. The widget renders no launcher of its own, so your page draws the control and drives the widget through the SDK: ```ts const chat = new CherryEmbed({ appId: 'YOUR_EMBED_ID', roomId: 'YOUR_ROOM_ID', position: 'floating-right', collapsed: true, }); await chat.mount(); // collapsed starts hidden: wire your own button to open it document.querySelector('#open-chat')?.addEventListener('click', () => chat.toggle()); ``` Toggle a floating widget programmatically with `chat.show()`, `chat.hide()`, and `chat.toggle()`. To hang an unread badge on your own button, copy the recipe in [Unread indicators](https://portal.cherry.fun/docs/embed/unread-indicators.md). ### Launcher B: the built-in `chatBubble` `chatBubble: true` hands the launcher to the SDK: a round button beside the panel that toggles the widget on click and swaps its chat icon for a close icon whenever visibility changes, including the changes you make yourself through `show()` / `hide()` / `toggle()`. > **Note:** `chatBubble` and `chatBubbleBadge` need `@cherrydotfun/chat-embed-sdk` **0.1.7 or newer**. Older builds ignore both keys and mount a floating widget with no launcher at all, so keep your own button until you have upgraded. ```ts const chat = new CherryEmbed({ appId: 'YOUR_EMBED_ID', roomId: 'YOUR_ROOM_ID', position: 'floating-right', collapsed: true, // start closed, behind the button chatBubble: true, // the SDK renders the launcher chatBubbleBadge: 'count', // 'dot' (default) | 'count' | 'off' }); await chat.mount(); ``` `collapsed: true` with `chatBubble: true` is the recommended pairing: the widget starts closed and the button is the only Cherry surface on the page until someone clicks it. The flag defaults to `false`, so an integration that already draws its own launcher keeps working untouched. It applies to `floating-right` / `floating-left` only and is silently ignored on inline embeds. While it is on, the SDK owns the stacking order: the button sits on top and the chat panel one z-index below it. `destroy()` removes the button, and a `mount()` that rejects on the ready timeout leaves it standing, so `destroy()` is also how you clean up a failed mount. Its labels are English only (`Open chat` / `Close chat`), so leave the flag off if you need localized ones. **Badge modes.** The button carries the unread badge for you, and `chatBubbleBadge` picks how loud it is: | Value | Unread messages | Mentions | |---|---|---| | `'dot'` (default) | a bare 12px dot | one `@` pill, no number | | `'count'` | the unread count, capped at `99+` | the same pill, with `@` in front of the number | | `'off'` | nothing | nothing | In `'count'` mode the number is always the unread tally. A mention puts an `@` in front of it inside that one pill, never a second badge beside it, and `@` stands alone while unread is 0 with a mention still outstanding. The mention count itself is never drawn as a number. The badge feeds itself from the `unreadState` event, so there is nothing to wire up. It hides only when both counters are zero, clears on sign-out and on every viewer switch (`signOut()`, `setToken()`, and a `setWalletAddress()` that changes accounts), and stays blank for an anonymous viewer, who has no unread state to begin with. **It follows your theme.** Until the chat loads, the button takes its fill from the `theme` you pass: `ownBubbleColor` when that is a plain color, otherwise a gradient from `primaryColor` to `accentColor`, or a flat `primaryColor` when there is no accent, otherwise Cherry pink `#ff5ba8`. Icon ink comes from the luminance of that flat color, so a gradient `ownBubbleColor` falls back to `primaryColor` for the ink calculation, unless `ownBubbleTextColor` sets it outright; every `setTheme()` / `resetTheme()` restyles the button, and the color the embed's own engine actually resolved takes over once the iframe reports it. The badge is a different pink and starts on `#ff1493`, then adopts the mention colors that same engine resolved, which the theme can override with [`mentionBadgeColor` / `mentionBadgeTextColor`](https://portal.cherry.fun/docs/embed/theming.md), so it ends up matching the in-chat `@` badge exactly; no config key recolors it directly, and it stays pink on an embed deploy older than the SDK. Its 2px ring is separate: set `--cherry-bubble-badge-ring` on your page to match a dark background. Badge text uses `theme.fontFamily`, falling back to Inter and then the system UI face; the SDK never fetches a font for it. ## Next steps - [Theming](https://portal.cherry.fun/docs/embed/theming.md) · [SDK API reference](https://portal.cherry.fun/docs/embed/api-reference.md) --- # Theming The widget renders entirely from CSS variables, and you set them through the `theme` object you pass to `CherryEmbed` at mount (or update later with `setTheme()`). Every color in the UI is a token you can address. The palette is around 60 variables; roughly 40 of them are keys you can set directly, and the rest are derived from those. There are two ways to drive it, and you can mix them: - **Seeds (recommended).** Pass a brand color and your background (2 to 4 colors in all) and a derivation engine builds the rest of the palette from them, with contrast floors enforced. This is the fastest route to a theme that looks native, and it is what the [live builder](https://cherry.fun/chat-embed-example/) and the dashboard editor emit. - **Granular overrides.** Set any specific slot listed in the [theme reference](#theme-reference) and it is used as given. Anything you do not set stays derived. **Explicit always beats derivation.** A value you set is used verbatim, re-anchored for contrast only where a floor would otherwise break; everything you leave unset is computed from the seeds. So the practical advice is: start from the seeds, and override a single slot when you need to adjust one point. Composing the whole 40-slot palette by hand is supported, but it is usually a mistake, because it fights the engine and tends to read as a tint filter over the default theme rather than a native palette. One override where it counts is fine. ```ts import { CherryEmbed } from '@cherrydotfun/chat-embed-sdk'; new CherryEmbed({ appId: 'YOUR_EMBED_ID', container: '#cherry-chat', roomId: 'YOUR_ROOM_ID', theme: { mode: 'dark', primaryColor: '#9162FF', backgroundColor: '#0D0416', accentColor: '#FF57C1', }, }); ``` > **Tip:** Want to see these colors change in real time? The [live embed builder](https://cherry.fun/chat-embed-example/) lets you tweak presets, brand hue, and per-side accents with no signup, then copies a ready-to-paste integration snippet. The interactive editor in the [dashboard](https://portal.cherry.fun/dashboard) previews a theme live and emits the config too. > **Note:** Theming controls **colors, typography, and surface treatment** (transparency and blur) only. To show or hide structural chrome (the header, member count, or composer), use the separate `layout` config object, not `theme`: for example `layout: { showHeader: false }`. Its fields are `showHeader`, `headerTitle`, `showMemberCount`, and `showInput`. See [Configuration › Layout](https://portal.cherry.fun/docs/embed/configuration.md#layout). ## The seeds The recommended minimum is a brand pair: **`primaryColor` plus `backgroundColor`**. The brand color takes over your own messages and the send button, the background recolors the surfaces, and together they re-tone the whole chat, received messages included. What each seed reaches depends on which others are present: - **`primaryColor`** is the brand color. It drives your own message bubble, the send button, and the own-side accents. On its own it re-tones the outgoing side and leaves the surfaces and the received messages on the Cherry defaults. - **`backgroundColor`** is the app surface. It drives the background, the cards, the header and input chrome, the borders, and the text polarity (light ink on a dark surface, dark ink on a light one). On its own it recolors those surfaces but keeps the Cherry message palette (the own bubble and the received bubbles are unchanged), so pair it with a brand color to re-tone the messages too. - **`accentColor`** is the secondary highlight. It drives links and mentions, and it counts as a brand color for the pairing above. - **`incomingBubbleColor`** is an optional fourth seed: a distinct fill for received messages. With a brand color and a `backgroundColor` both set, the engine already re-tones received messages as a neutral elevation of your background; set this only when the brand has its own second color for received messages (see [the soft-fill rule](#the-soft-fill-incoming-candidate)). `mode` sets the base variant, dark or light, and with it the text polarity for the whole palette. **Contrast floors.** Body text lands at least 4.5:1, incoming text at least 4.5:1, and the send icon at least 3:1. The engine re-anchors a derived color automatically when a seed would otherwise fall short. ## Granular overrides Set any key from the [reference](#theme-reference) below and the engine uses it verbatim, then re-anchors its dependents. Setting `incomingBubbleColor`, for example, makes the engine recompute incoming text and links against that color instead of the neutral it replaced. Everything you do not set stays derived, so a few targeted overrides sit cleanly on top of a seed-derived palette. The most common override is a measured text ink. When a site authors its own inks, pass them so the own bubble and reply slabs read native: ```ts theme: { mode: 'dark', primaryColor: '#86EFAC', backgroundColor: '#111113', textColor: '#FAFAFA', // measured on-dark body ink ownBubbleTextColor: '#14151C', // measured dark ink the brand puts on light fills } ``` Pass a measured ink at high confidence (an authored token, not a guessed near-black); the engine re-anchors each for legibility. The one thing to avoid is composing the entire palette slot by slot, which is what produces the tint-filter look. Reach for an override when you have a specific value to place, not as the default workflow. ## Gradients The own bubble and send button are a flat solid fill by default when you drive from seeds. Turn on gradients when the brand genuinely uses one: - For the engine's derived sweep from your seeds, add `gradients: 'on'`. - For a specific measured `linear-gradient(...)`, pass it as a fill value: a seed (`primaryColor`, `accentColor`, or `backgroundColor`), or one of the other fill slots (the own bubble, send button, header, or input) in the [theme reference](#theme-reference). The engine emits it verbatim, contrast-checks companions against its worst stop, and, for a seed, derives the rest of the palette from the gradient's midpoint blend. ```ts theme: { mode: 'dark', backgroundColor: '#070A15', accentColor: '#3773F5', primaryColor: 'linear-gradient(272deg, #39D0D8, #22D1F8)', // measured brand CTA } ``` The sanitizer accepts only a strict `linear-gradient` (an optional angle or `to`-corner, 2 to 4 stops, each a safe solid color plus an optional percentage) and rebuilds it from parts. `url()`, `var()`, `calc()`, conic and radial gradients, and comments are all rejected. Solid values must be hex, `rgb(a)`, or `hsl(a)`. Anything unsafe or off-list is silently dropped, so a value that "does nothing" is usually a sanitizer rejection: fix the value, do not reach for another param. `fontFamily` is likewise limited to an allowlist (Inter, Geist, Outfit, and so on) and falls back if the family is not loaded. ## Transparency and blur The widget can render as a see-through layer over your page. There is no overlay mode to switch on and no key for it: transparency is a **value form** of three surface keys you already have, `backgroundColor`, `headerColor`, and `inputColor` (see [theme reference](#theme-reference)). Each of the three reads one of four ways: - **Omitted** - the surface is opaque. The header and the composer derive their tone from the seeds; `backgroundColor` falls back to the mode default. This is the default. - **A solid color** - used verbatim, opaque. - **An alpha color** (`#RGBA`, `#RRGGBBAA`, or `rgba()` / `hsla()` with alpha below 1) - the surface paints as glass at that alpha, and derivation anchors on its RGB channels, the color behind the glass. - **The bare keyword `transparent`** - fully see-through. It carries no color, so the anchor is derived instead: the header takes the derived surface tone, the composer the derived composer tone, and the background the mode default. With a brand seed present, the background anchor is synthesized in the brand's tone (from your `primaryColor`, or `accentColor` if no primary is set): a near-black ground, near-white in light mode, carrying a trace of that hue at very low chroma, so the surfaces and received bubbles re-tone toward your brand. When you know the color you are overlaying, pass it with `00` alpha (`#0D041600`) rather than the bare keyword: equally see-through, and the engine anchors on your real color. A gradient value is treated as opaque. Transparency on `backgroundColor` is what makes the widget itself see-through, so your page shows through the transcript. It reaches only the widget, never your page. The chrome is separate: the header, the composer, and the surfaces stay opaque unless their own key says otherwise. Three things then adjust on their own: - **Transcript labels get opaque pills.** The conversation-start line, the date separators, and the empty state would otherwise sit as bare text over your page, so each gets a backing chip in the derived surface tone. Follows `backgroundColor`. - **Dimmed controls switch to solid tones.** The disabled send button, the disabled toolbar icons, and the muted placeholder normally dim with opacity, which over glass would let your page show through the control and muddy its color. They are emitted as solid derived colors instead, so a dimmed control looks the same on any page. Follows `backgroundColor`. - **The "Powered by Cherry.fun" strip changes.** This is the thin attribution band directly under the composer. It follows `inputColor`, not the background: an opaque or unset composer keeps the flat band even over a see-through background, and a see-through composer dissolves the band into a solid derived label. ### The blur keys > **Note:** Requires `@cherrydotfun/chat-embed-sdk` 0.1.7 or newer. `headerBlur` and `inputBlur` set a `backdrop-filter` inside the iframe, frosting the transcript that scrolls beneath the header and the composer. The transcript is pulled under the surface only when the blur is above 0, with matching padding, so no message hides behind it. `backgroundBlur` is host-side: a cross-origin iframe cannot sample the page around it, so the SDK applies this one to the iframe element, frosting your page behind the whole widget. Over `setTheme()`, `headerBlur` and `inputBlur` travel with the theme; `backgroundBlur` is re-applied to the iframe element by the same call, but only when the patch actually carries a `backgroundBlur` key. `resetTheme()` clears the host-side frost along with everything else, so re-pass `backgroundBlur` afterwards if you want it back. ### Worked example: a see-through chat A chat that sits over the page, with frosted chrome: ```ts theme: { mode: 'dark', primaryColor: '#9162FF', backgroundColor: '#0D041600', // the page's own ground at 00 alpha: see-through, real anchor headerColor: '#150A22CC', // tinted glass chrome inputColor: '#150A22CC', headerBlur: 16, inputBlur: 16, backgroundBlur: 6, // host-side: frosts the page behind the widget } ``` Keep the chrome alpha high enough that the header title and the composer text stay readable. The two surface blurs frost the transcript scrolling under the chrome; `backgroundBlur` frosts the page under the widget. ## Theme reference The color keys below, plus `mode`, `fontFamily`, `fontSize`, `gradients`, and the blur keys, make up the `EmbedTheme` type. Colors accept hex, `rgb(a)`, or `hsl(a)`, including the alpha and `transparent` forms in [Transparency and blur](#transparency-and-blur); named colors and newer functions like `oklch()` or `lab()` are dropped by the sanitizer. In normal use you set only the seeds and let the engine derive the rest; every key here can also be set directly, and doing so overrides its derived slot (see [Granular overrides](#granular-overrides)). ### Seeds | Field | Notes | |---|---| | `primaryColor` | Brand color. Drives the own bubble and send button. | | `backgroundColor` | App surface. Drives the background, neutral surfaces, and inks. | | `accentColor` | Secondary highlight. Drives links and mentions. | | `incomingBubbleColor` | Optional fourth seed: a distinct fill for received messages. | ### Base | Field | Type | Notes | |---|---|---| | `mode` | `'dark' \| 'light'` | Base variant; sets the text polarity for the whole palette. | | `fontFamily` | `string` | Allowlisted family, e.g. `'Inter'`, `'Geist'`, `'Outfit'`; falls back to Inter if unloaded. | | `fontSize` | `'sm' \| 'md' \| 'lg'` | Base text size. | | `gradients` | `'on' \| 'off'` | Own bubble and send button gradient. Flat by default when driving from seeds; `'on'` emits the derived sweep. | ### Surfaces and borders | Field | Notes | |---|---| | `surfaceColor` | Cards and raised surfaces. | | `borderColor` | Borders and dividers. Derived from `textColor` opacity when unset. | ### Text | Field | Notes | |---|---| | `textColor` | Primary body text (on-mode ink). | | `textSecondaryColor` | Muted / secondary text (meta, timestamps). | | `linkColor` | All clickable text in an incoming bubble: links, @mentions, $tickers, handles. | | `linkColorOwn` | The same, inside your own bubble. | | `mentionColor` | Deprecated alias for `linkColor`. | ### Bubbles | Field | Notes | |---|---| | `incomingBubbleBorderColor` | Border for incoming bubbles. | | `ownBubbleColor` | Own (outgoing) bubble fill. Defaults from `primaryColor`. | | `ownBubbleTextColor` | Text inside own bubbles. | ### Header and input | Field | Notes | |---|---| | `headerColor` / `headerTextColor` | Header bar background / text. | | `inputColor` / `inputTextColor` | Composer background / text. | | `sendButtonColor` | Send button fill. Defaults from `primaryColor`. | | `sendButtonTextColor` | Send button icon color (defaults to white). | ### Embed cards | Field | Notes | |---|---| | `ownEmbedBgColor` | Background for embed-like cards on your messages (token / link / group previews, reply quote). Derived from `ownBubbleColor`. | | `otherEmbedBgColor` | The same, on incoming messages. Derived from `incomingBubbleColor`. | | `embedCardColor` | Deprecated alias for `otherEmbedBgColor`. | ### Per-side message accents | Field | Notes | |---|---| | `messageOwnAccentColor` / `messageOwnAccentSoftColor` | Accents on your messages (reply bars, token cards, inline reactions), active / soft. | | `messageOtherAccentColor` / `messageOtherAccentSoftColor` | The same, on others' messages. | ### Floating UI | Field | Notes | |---|---| | `messageActionsColor` / `messageActionsTextColor` | Hover message-actions menu background / text. | | `tooltipColor` / `tooltipTextColor` | Tooltip background / text. | | `emojiPickerColor` | Emoji picker surface. | | `iconButtonColor` / `iconButtonHoverColor` | Input toolbar icons (GIF, emoji, attach) and their hover state. | | `avatarHoverColor` | Avatar hover ring. | | `loaderColor` | Loading spinner. | | `modalOverlayColor` | Modal backdrop. | ### Branded accents | Field | Notes | |---|---| | `dangerColor` | Destructive and error tone. | | `gmColor` | GM counter badge in the header (default amber). | | `roleBadgeColor` | Moderation role chips (MOD / OWNER). Defaults to the mention color. | | `mentionBadgeColor` | Fill of the unread-mention count pill on the in-chat "@" button. Derived from `primaryColor` when unset. Solid colors only, a gradient here is dropped. | | `mentionBadgeTextColor` | Ink on that pill. Defaults to white, which no seed moves, so set it when a light `mentionBadgeColor` needs dark ink. | The floating launcher's own unread badge inherits both once the iframe reports the resolved theme, so setting them keeps the [`chatBubble`](https://portal.cherry.fun/docs/embed/display-modes.md) badge and the in-chat "@" badge on the same color. ### Blur Blur radii in px, sanitized to a whole number clamped to 0 to 40. A numeric string is accepted (`'16'`); a CSS length is not (`'16px'` is rejected). Transparency itself has no key, since it is a value form of `backgroundColor`, `headerColor`, and `inputColor`. See [Transparency and blur](#transparency-and-blur). | Field | Type | Notes | |---|---|---| | `headerBlur` | `number \| string` | In-iframe backdrop blur under the header. Visible only when `headerColor` is see-through. | | `inputBlur` | `number \| string` | The same for the composer, paired with a see-through `inputColor`. | | `backgroundBlur` | `number \| string` | Host-side blur of your page behind the widget, applied by the SDK to the iframe element. Visible only when `backgroundColor` is see-through. | ## Runtime updates ```ts chat.setTheme({ primaryColor: '#22C55E' }); // merges with the current theme chat.resetTheme(); // clear back to defaults before applying a full new theme ``` > **Note:** When switching between full themes (for example a light and dark toggle), call `resetTheme()` first so stale derived colors do not leak into the new theme. `resetTheme()` also clears the host-side frost, so re-pass `backgroundBlur` in the new theme if you want it. ## Matching a brand Everything above is the reference for what the theme accepts. This second half is a playbook for the harder task: turning a real brand into honest seeds. Whether you do it by hand or drive an AI agent, the work follows the same loop: 1. **Extract honest inputs**: the brand colors by role, plus the correct mode. 2. **Hand them to the engine**: set `theme: { mode, primaryColor, backgroundColor, accentColor }`, and nothing else at first. 3. **Screenshot the embed**: a headless browser, or the [live builder](https://cherry.fun/chat-embed-example/) with the theme applied. 4. **Judge adversarially**: would this look native in the product? 5. **If it misses, fix the inputs, not the outputs.** A wrong role (chrome mistaken for background) or the wrong mode (a light widget on a dark product) is the cause about nine times out of ten. Re-run from step 1. A per-slot override is the last resort here, and it should be justified by a value you measured, not guessed. Re-check the inputs first. ## Choose your starting point A theme comes from one of four sources. Pick the one that fits, follow that path below, and do not mix them: - **(A) Match a live site** - read the colors off the real rendered site. - **(B) Match a design file** - read them from a screenshot, a Figma export, exported CSS, a brand-book PDF, or a mood image. - **(C) Exact colors** - you already have the brand values; map them to roles and apply. - **(D) Default** - keep the Cherry look and set at most `{ mode }`. > **Note:** Integrating through an AI agent? The generated setup prompt tells it to ask you exactly this question first, then follow the matching path, so you reach the same result hands-off. ## Extract from a live site (A) Open the **real site** (headless browser, or fetch and render). Do not theme from memory of the brand. Pull colors in this priority order, because earlier sources are authored intent and later ones are guesses: 1. **CSS custom properties** - the site's own design tokens (`--color-brand`, `--chakra-colors-*`, `--text-primary`, and so on). Dump them all, cluster by hue, and identify the real ones by which selectors use them. This is the best place to start: these are values the brand's own designers named. But a token is a claim, not the verdict: an authored `--primary` can be stale or aspirational while the shipped UI renders a different color, so confirm it against what actually renders (see [Render dominance](#render-dominance)). 2. **Computed styles of key elements** - `getComputedStyle` of the page background, the primary call-to-action fill (Connect, Sign up, the loudest button), links, panel and card surfaces, headings and body text, and the styled `::selection` highlight (a site restyles text selection only with a color it treats as core, so it is a high-signal read on the real primary). 3. **Pixel sampling** - a fallback only. Take the median of a region; never eyedrop a single pixel of a gradient or a glow. ### Render dominance An authored token is a claim; the rendered UI is the verdict. When a site's `--primary` disagrees with what the pixels actually show, trust the pixels. Confirm the primary against three independent render probes and take the color they agree on: - **The loudest CTAs' computed fills**, sampled across 2 to 3 pages (not a single hero). The button the product pushes users toward carries the brand. - **The positive or active UI elements** - the "up" tiles, the success and active states, the on-brand highlights. These are painted with the identity color, not a decorative one. - **The styled `::selection` highlight** - a color a site only bothers to apply to text selection when it treats it as core. When these agree on a color the token does not name, that color is the brand's real identity color and the token loses. A site can declare one color as its `--primary` while every CTA, active tile, and selection highlight renders another; the render wins, and the rendered color is the brand color (see [the worked example](#worked-example-a-token-that-lied)). Pass the winning color verbatim as `primaryColor`: do not soften or reroute it for being bright, because the engine owns large-fill treatment (its pastel cap handles the true extremes). **Practical note:** `getComputedStyle(el, '::selection')` frequently returns `transparent`, because the real value lives in a CSS variable scoped to a different element. Do not trust that read: select some text programmatically (the Selection API) and pixel-sample the rendered highlight instead. ### Role discipline This is where themes go wrong. Read each color by its **role**, not by how prominent it looks: - **An identity color appears on 2 to 3 pages** as a call-to-action, a logo, or links. Verify across sub-pages. A color that lives only on the landing hero is decoration, not brand. In one real case a "wine" accent turned out to be a hero-background haze on a site that is actually mono hot-pink. - **Site chrome is not the app background.** A colored header or nav band maps to the **header** role (or nothing), never to `backgroundColor`. Painting the background with a chrome color breaks the neutral base. Take the background from the real app **content surface**. - **Full-page video, canvas, or media backgrounds** cannot be sampled off the media. Take the background from the brand's **UI panels or overlay chrome** (a dialog body, a raised card), not from a video frame. - **Pick the mode from the real app surface.** A dark product means `mode: 'dark'`, a light product `mode: 'light'`. Do not guess from the logo. Beware scheme-adaptive sites: headless Chromium defaults to light, which can read the wrong palette, so probe **both** `prefers-color-scheme` values and choose the one the user's product actually renders. - **Capture the text-ink tokens** if the site authors them. A brand's "black" is usually its own tint (for example `#14151C`, not `#000000`), and its on-light body ink might be `#1A202C`. Record on-dark ink and on-light ink separately. These become optional [granular overrides](#granular-overrides), passed only at high confidence. - **Detect gradient CTAs.** Check the CTA's computed `background-image`. If the loudest button is a genuine multi-stop `linear-gradient(...)`, that is the brand fill: pass it through. A flat CTA is not a gradient; do not invent one. Note the roles you extracted (background, primary CTA, accent, inks, mode) before applying, so a mis-read is caught before the screenshot. ### The soft-fill incoming candidate Brands with a two-color system often ship a **soft fill**: a tinted panel, card, or chip background that carries the second brand color at low saturation (a pale lavender behind a purple brand, a mint wash behind a green one). That is the brand's own answer to "what does a secondary surface look like", which is exactly the role the incoming bubble plays. So treat it as a candidate fourth seed and decide it by the criteria below: 1. Read the computed `background-color` of the brand's secondary surfaces (cards, panels, secondary tiles, chips). 2. If a soft fill exists **and** it clears the floors (incoming text at least 4.5:1 against it, and enough separation from the page background that the bubble is not the same blob), pass it as `incomingBubbleColor`. It reconstructs the brand's second color in the transcript natively. 3. If the measured fill is only a hair too pale to clear the separation floor, do not drop the candidate: construct the minimal same-hue sibling that passes. Hold the hue, add just enough chroma or depth, and make the smallest change that clears the floor. The brand's second color still comes through, just deep enough to sit apart from the background. 4. If there is no soft fill at all, leave `incomingBubbleColor` unset and let the engine derive incoming as a neutral elevation of the background (which often lands near that soft fill anyway). Make the call from these criteria rather than leaving it open: if a qualifying soft fill exists, set `incomingBubbleColor` to it (or to its same-hue sibling); if not, leave incoming to the engine. ### Worked example: a token that lied Take a site whose authored `--primary` token reads `#2E6FD9`, a blue, while three independent render probes disagree with it and agree with each other: both call-to-action buttons compute their fill as `#E2703A` (a warm orange), the positive and active tiles are the same orange, and the styled `::selection` highlight is too. The token is aspirational; the real brand color is that orange. Render dominance surfaces it, and the site's own UI legend confirms the blue is the explicit *secondary*. So the orange goes into `primaryColor` verbatim, `#E2703A` exactly, raw and bright, with no softening: the engine owns how a large fill is treated, and its pastel cap steps in only at the true extremes. The site's light page surface becomes `backgroundColor` (a light ground), and the blue secondary goes to `accentColor` for links and mentions. The brand's soft tinted tile `#DCE7FA` clears every floor as the soft fill and becomes `incomingBubbleColor`. The two-color brand system then reappears intact in the chat: raw orange own bubbles and send button, blue links and mentions, and a soft blue-tinted incoming, all on a light ground. ## From design assets (B) Read colors from whatever was attached: a screenshot, a Figma export, CSS, a PDF, a mood image. Any format is fine. Identify each color by its role in the artifact: the dominant surface (and thus the mode), the loudest action color (primary CTA), the secondary highlight (accent), and any heading or body inks. The same role discipline applies: a large decorative wash is not the accent, a colored banner is not the background, chrome is not the background. If the artifact is monochrome, keep it monochrome; do not invent a hue. **Double-check your reading** before applying (background `#0D0416`, CTA `#9162FF`, accent `#FF57C1`, dark mode). Assets are lossy; confirm the roles before you screenshot. ## Exact colors (C) and default (D) For (C), you already have the exact colors. Map them to roles, pick `mode` from the background, apply, and verify. Do not "improve" the values. For (D), set no color seeds; leave `theme` at most `{ mode }`. ## Verify Applying is not done. **Screenshot the embed** and judge it adversarially yourself. The bar is not "internally harmonious", it is "would a user believe this shipped as part of their product?". Walk the checklist: - **No acid clashes.** Surfaces read as neutral brand-tinted, not as a second loud hue. The brand lives in about two places (the own bubble and send), not everywhere. - **Incoming messages are readable.** Others' bubble text is legible, and the bubble is distinct from the background, not the same blob. - **Your own bubble reads as the brand's CTA.** The own fill matches the product's primary button feel. - **Links are visible.** Links and mentions on both sides are readable and on-brand, not washed gray or near-invisible. - **Timestamps are muted.** The own meta line (timestamp, views, lock) is dimmer than body text, not solid white. - **The reply slab is not an eyesore.** The quote block inside a bubble is a delicate tint of the fill, not a bright white slab with harsh dark text. **Numeric floors** (check, do not eyeball): body text at least 4.5:1, incoming text at least 4.5:1, send icon at least 3:1. The engine guarantees these from honest seeds, so a floor failure means an input is wrong: the wrong mode (a light theme on a dark product), or a chrome color mistaken for the background. And if the chat looks half-themed, with your messages or the received bubbles still on the Cherry palette, that is not a contrast fault but an incomplete seed set: the messages re-tone only when a brand color and a `backgroundColor` are both present (see [The seeds](#the-seeds)). Iterate the loop above: when it misses, go back to the inputs, not to hand-editing output vars. ## Common mistakes - **Chrome color is not a background seed.** A colored header band is the header role, never `backgroundColor`. Painting the background with a nav-bar color is the classic mis-theme. - **Wrong mode.** A light widget on a dark product (or the reverse) breaks every contrast floor at once. Pick the mode from the real app surface, and probe both color schemes on adaptive sites. - **Composing the whole palette by hand.** Setting every slot by taste, instead of passing seeds and overriding the few you need, is what produces the tint-filter look. Pass the seeds and let the engine derive. - **A darkened warm seed reads as brown.** Warm hues muddy when darkened for white text, and the engine already prefers a vivid fill with dark ink in that zone. Pass the true brand orange and trust the derivation; do not pre-darken it to "help". - **A gradient value that does nothing** is a sanitizer rejection, not a no-op slot. Fix the value to a strict `linear-gradient` (or a plain solid); do not reach for another param. ## Next steps - [SDK API reference](https://portal.cherry.fun/docs/embed/api-reference.md): `setTheme`, events, and the full method surface. - [Configuration](https://portal.cherry.fun/docs/embed/configuration.md): rooms, identity, and layout. - [For AI agents](https://portal.cherry.fun/docs/ai/for-ai-agents.md): point a coding assistant at these docs to scaffold and theme the embed. --- # Your users' names and avatars By default the chat labels people by their **wallet identity**: a `.sol` domain if they own one, otherwise a shortened address like `7xKX…gAsU`. If your app already has usernames and avatars, it can supply them, and the widget renders your users instead. This is a **visual overlay, scoped to one running widget**: - Cherry stores none of these names. Nothing is written to a profile. - The **wallet remains the author** of every message. Moderation, deletes, and mention routing all keep working off the wallet. - Nothing changes how that person appears in the Cherry app, or in anyone else's embed. > **Note:** This is a label, not a login. It does not sign anyone in, and it is not a > substitute for tying chat identity to your users: that is what [Authentication](https://portal.cherry.fun/docs/embed/authentication.md) does. ## 1. Turn it on Open the embed in the [dashboard](https://portal.cherry.fun/dashboard), then **General** → **Who your users appear as** → **Show your app's names and avatars**. Until that switch is on, the widget never asks, and whatever your code returns is ignored. There is one switch per embed, so a staging embed can use it while production does not. ## 2. Choose where the widget asks Two transports, one contract. Pick either; you do not need both. | | Answers from | Configure | |---|---|---| | **Your page** | the browser, over the SDK | pass `resolveUsers` in the config | | **Your backend** | a profile endpoint the widget calls directly | set **Profile endpoint** on the same card | The endpoint wins when both are available. Answering from the page needs no backend work; answering from your backend is the better fit for mobile WebViews (where the host page is a thin shim), and it is unaffected by your app's render loop. ### From your page > **Warning:** The SDK transport, `resolveUsers`, `searchUsers`, `userProfiles`, `setUserProfiles()`, `invalidateUserProfiles()` and bearer auth through `chat.setIdentityToken()`, needs `@cherrydotfun/chat-embed-sdk` **0.1.7 or newer**. On 0.1.6 these options are ignored, so use the backend transport below until you have upgraded. ```ts const chat = new CherryEmbed({ appId: 'YOUR_EMBED_ID', container: '#cherry-chat', roomId: 'YOUR_ROOM_ID', // Called with up to 50 wallets per call, batched over a 16 ms window. // Return null (or omit a wallet) for anyone you don't know. resolveUsers: async (wallets) => { const rows = await myApi.usersByWallet(wallets); return Object.fromEntries( wallets.map((w) => [ w, rows[w] ? { displayName: rows[w].name, avatarUrl: rows[w].photo } : null, ]), ); }, // Optional: makes @mention autocomplete search YOUR directory. searchUsers: async ({ query, cursor, limit }) => { const page = await myApi.searchUsers({ query, cursor, limit }); return { users: page.items, nextCursor: page.next }; }, }); ``` Register nothing and the previous behavior is unchanged: the widget asks once, learns your page cannot answer, and stops asking. ### From your backend Set **Profile endpoint** to a base URL. The widget appends its own paths: | Request | Body / query | Response | |---|---|---| | `POST {url}/resolve` | `{ ids: string[] }` | `{ users: { [wallet]: profile \| null } }` | | `GET {url}/search` | `?query=&cursor=&limit=` (`limit` is capped at 100) | `{ users: [{ id, displayName?, avatarUrl? }], nextCursor? }` | | `GET {url}/users/:wallet` | none | `profile \| null` (reserved for the profile view) | Four things to get right: - **CORS.** The caller is the **iframe**, not your page: allow the origin `https://embed.cherry.fun`. - **HTTPS.** The iframe is served over HTTPS, so an `http://` endpoint is blocked as mixed content. Plain HTTP works only for `localhost` during development. - **No cookies.** Requests are sent with `credentials: 'omit'`, deliberately: the widget must never be walked into replaying a visitor's ambient session at your API. For auth, pass a bearer token with `chat.setIdentityToken(token)`. It is held in memory only, and sent on these requests as `Authorization: Bearer …`. - **Who is asking.** Each request carries `X-Cherry-App-Id` with the embed's ID, so one endpoint can serve several embeds. > **Warning:** Wallet addresses are the only thing the widget sends you, and its request > arrives unauthenticated unless you set a token. Return the same public profile > you would show any visitor, never an email, a role, or anything else you would > not print next to a message. The URL is delivered to the iframe by Cherry, never by your page, so a script on your site cannot repoint identity resolution somewhere else. Turning the switch off stops the widget from calling it immediately, without clearing the field. ## 3. Push changes as they happen The widget only asks about wallets it hasn't resolved yet, so a rename in your app is invisible to an already-open chat. Push it: ```ts chat.setUserProfiles({ [wallet]: { displayName: 'New name' } }); // avatar kept chat.invalidateUserProfiles([wallet]); // re-ask your resolver chat.invalidateUserProfiles(); // re-ask for everyone ``` - Pushed fields are **merged** onto what the widget already knows: sending only `displayName` leaves the avatar alone. Include a field with an empty value to clear just that field. - Push `null` for a wallet to say you no longer know that person, and the chat falls back to their Cherry identity. - `invalidateUserProfiles` means *refresh*, not *forget*: the current name stays on screen while the fresh answer is in flight, so the row updates once instead of blinking through the fallback. `userProfiles` in the config does the same as `setUserProfiles` for people you already know at mount time, so the first paint is right with no round-trip. ## What the widget does with your answer **It never blocks rendering.** Cherry's own label paints immediately and is replaced when your answer lands. A slow resolver delays a name, never the message. **It caches per widget, in memory.** Nothing is persisted (no `localStorage`, no server round-trip), and closing the widget forgets every name. Answers are re-asked after five minutes, or when you invalidate them. A wallet you answered `null` for is remembered as unknown, so it is asked about once, not once per render. **It stops asking a resolver that is broken.** Three consecutive failures, or timeouts (the deadline is a few seconds, since a name is decorative), disable the transport until you push an update or the visitor reloads. That keeps a long scroll from hammering a dead endpoint. **Everything you send is sanitized before it reaches the DOM**, because it arrives from outside Cherry: | Field | Rules | |---|---| | `displayName` | One line: newlines and tabs become spaces. Zero-width characters and bidi overrides are stripped (both are used to mint lookalikes of an existing member). Trimmed to 48 characters with an ellipsis. | | `avatarUrl` | Absolute `http(s)` only. `data:` and `blob:` are refused: they are an unbounded byte channel into the chat surface. | Anything that does not survive is dropped, and that field falls back to the Cherry identity. Unknown fields are ignored. Bot and verified markers, and moderation roles, are never overridden by a supplied name. ## Mentions With `searchUsers` registered, `@`-autocomplete searches your directory instead of only the room's history. That is the difference between "@Alice finds nothing" and "@Alice finds Alice", since Cherry's own search only knows domains and wallets. Picking a suggestion inserts the name with spaces turned into underscores (`@Alice_Smith`): the mention grammar stops at the first space, so an untouched name would be cut in half. The wallet travels with the mention invisibly, so routing and notifications are unaffected. Only the label changes. ## Try it The SDK repository ships a runnable bench for this feature: [`example/host-identity/`](https://github.com/cherrydotfun/chat-embed-sdk/tree/main/example/host-identity). It exercises both transports, lets you edit a name or avatar per user by hand, and includes a probe that answers with deliberately dangerous values so you can watch the sanitizer work. ## Next steps - Every option and method: [Configuration](https://portal.cherry.fun/docs/embed/configuration.md) · [SDK API reference](https://portal.cherry.fun/docs/embed/api-reference.md) - Tie chat identity to your logged-in users: [Authentication](https://portal.cherry.fun/docs/embed/authentication.md) - Run the widget in a mobile app: [React Native & Flutter](https://portal.cherry.fun/docs/embed/mobile.md) --- # Moderation Every embed ships with a built-in moderation layer you configure in the editor's **Moderation** tab (open the embed in the [dashboard](https://portal.cherry.fun/dashboard), then **Moderation**). The rules are enforced by Cherry's servers on the message send path, not in the browser, so a visitor cannot bypass them by editing client code or calling the API directly. Moderation is off by default: a new embed allows everything. Turn on only the rules you need, then **Save** (moderation is stored with the rest of the embed's settings). > **Note:** This is different from the read-only **Embed policy** card in the editor. The Moderation tab is your own self-serve setting. The separate Embed policy (whether embed users can perform moderation actions like kick, ban, or mute, plus rate and message-length limits) is set by Cherry and shown read-only. See [Authentication](https://portal.cherry.fun/docs/embed/authentication.md) for the policy limits. ## Minimum wallet age Require a visitor's Solana wallet to be at least a given number of days old, measured from its first on-chain transaction, before they can post. It is a low-friction filter against throwaway wallets and freshly minted bots. - Range: **0 to 365 days**. `0` turns the check off (the default). - A wallet with no transactions has an age of zero, so any non-zero threshold blocks it. ## Blocked words A list of words that silently hide a message. When a posted message contains a blocked word, it is shadow-dropped: the **sender still sees their own message**, but no one else receives it. There is no visible rejection, so a spammer gets no signal to adjust and retry. - Matching is **case-insensitive** (words are stored lowercased and de-duplicated). - Up to **200 words**, each **1 to 100 characters**. ## Links Choose how links posted in the chat are handled: | Policy | Behavior | |---|---| | **Allow all** (default) | Links are never touched. | | **Allowlist** | Only links whose host is in your allowed-domains list pass; every other link is blocked. An empty allowlist blocks every link. | | **Block all** | Every link is blocked. | When you pick **Allowlist**, add the domains you trust: - Enter **bare hosts** like `example.com`, lowercased, with no scheme, port, or path (`https://example.com/path` is rejected). - Each host is a valid domain (labels of `a-z`, `0-9`, and `-`), up to 253 characters. - Up to **100 domains**. ## Images and GIFs Two independent toggles, both **on** by default. Turn **Images** off to block image messages, or **GIFs** off to block GIFs. They are separate switches, so you can allow one and block the other. ## Limits at a glance | Rule | Limit | |---|---| | Minimum wallet age | 0 to 365 days | | Blocked words | up to 200, each 1 to 100 characters | | Allowlist domains | up to 100 bare hosts, each up to 253 characters | The portal rejects values outside these bounds before saving, and the server enforces the same limits, so an out-of-range value never reaches a live embed. ## Next steps - [Configuration](https://portal.cherry.fun/docs/embed/configuration.md) · [Authentication](https://portal.cherry.fun/docs/embed/authentication.md) · [Theming](https://portal.cherry.fun/docs/embed/theming.md) - [SDK API reference](https://portal.cherry.fun/docs/embed/api-reference.md) --- # Unread indicators The embed reports unread and mention counts to your page, where nothing is drawn by default: inside the chat the "@" badge is always on for a signed-in viewer, but on your own page the indicator is yours to render. > **Note:** The host side of this feature, the `unreadState` event, `getUnreadState()`, `getUnreadCount()`, `refreshUnreadState()` and visibility reporting from `show()` / `hide()`, needs `@cherrydotfun/chat-embed-sdk` **0.1.7 or newer**. On 0.1.6 the event never fires and the getters do not exist, so counters cannot be observed from the host page; the iframe still keeps them. ## What you get The case this was built for is a chat icon in your own header. Subscribe to `unreadState`, and the icon carries a dot while the widget is closed, plus a louder one when someone addressed the viewer directly: ```ts chat.on('unreadState', ({ total }) => { chatIcon.classList.toggle('has-unread', total.unread > 0); chatIcon.classList.toggle('has-mention', total.mentions > 0); }); // Nothing is emitted for a signed-out viewer, so clear the dot yourself. chat.on('authStateChange', (signedIn) => { if (!signedIn) chatIcon.classList.remove('has-unread', 'has-mention'); }); ``` That is the whole integration. Everything below is the detail behind those two numbers. ## The `unreadState` event | Field | Type | Holds | |---|---|---| | `rooms` | `UnreadRoomState[]` | Counters for the room this embed renders. 0 or 1 entries today. | | `rooms[].roomId` | `string` | The room the counters belong to. | | `rooms[].unread` | `number` | Unread messages in that room. | | `rooms[].mentions` | `number` | Unread signals that addressed the viewer. | | `total` | `{ unread: number; mentions: number }` | Sums across `rooms`. With one room it mirrors that entry. | ```ts chat.on('unreadState', (state) => { // state: // { // rooms: [{ roomId: 'room_abc123', unread: 7, mentions: 2 }], // total: { unread: 7, mentions: 2 }, // } }); ``` `rooms` describes the room this embed renders and nothing else: the iframe never reports the visitor's other chats. So it holds 0 or 1 entries today, with `total` mirroring the single entry, and the array shape is future-proof for list mode. Emission is held until the room join resolves, so you never catch a half-built snapshot mid-join; `rooms` comes back empty only when the embed has no `roomId` to join or the join failed. Read `total`, or index defensively (`rooms[0]?.unread`), rather than assuming `rooms[0]` exists. > **Note:** `mentions` counts everything that addressed the viewer: @-mentions, replies to their messages, **and reactions on those messages**. It is the same signal that drives the in-chat "@" badge, so a bare thumbs-up on the viewer's message lights up a mention dot. That is the intended behavior, not a bug to report. ### When it fires | Moment | What arrives | |---|---| | After the viewer's session loads | One full snapshot: the initial state. | | On every counter change | The new snapshot, debounced, so a burst of arriving messages settles into a single emit. | | On `refreshUnreadState()` | A re-emit of the current snapshot, throttled to one per 250ms with the trailing call coalesced, and held until the iframe's readiness gate lifts. | Two silences are deliberate: - **Preview mode.** An anonymous visitor has nothing to catch up on, so nothing is emitted before sign-in, nor after `chat.signOut()`, which drops the cached snapshot back to `null`. Reset your own indicator on `authStateChange(false)`. - **Before your first command.** The iframe starts emitting events, `unreadState` and the legacy `unreadCount` alike, only once the host has sent it at least one command. `mount()` always sends one, so this bites only if you drive the iframe with your own `postMessage` instead of the SDK. ## Pull instead of subscribe The SDK caches the latest snapshot, so a host that repaints on its own schedule can read it directly. | Method | Signature | Notes | |---|---|---| | `getUnreadState` | `() => UnreadState \| null` | The cached snapshot. `null` until the first event lands. | | `getUnreadCount` | `(roomId?: string) => number` | Unread messages across every reported room, or in one room when you pass `roomId`. | | `refreshUnreadState` | `() => void` | Asks the iframe to re-emit `unreadState`. | ```ts chat.getUnreadState(); // UnreadState | null chat.getUnreadCount(); // unread messages across every reported room chat.getUnreadCount(roomId); // unread messages in one room chat.refreshUnreadState(); // ask the iframe to re-emit unreadState ``` Both getters are synchronous cache reads, so poll them as often as your UI repaints. `refreshUnreadState()` is different: it crosses the iframe bridge and answers asynchronously through the `unreadState` event. The iframe throttles those solicited flushes to one per 250ms (coalescing the trailing call) and holds them until its readiness gate lifts, so a tight loop of calls buys you nothing. Keep it to a coarse interval, seconds rather than frames, and remember the iframe already pushes on every change: subscribing beats polling. ## When the counters grow Counters accrue only while the chat is not being read: hidden through `hide()`, mounted `collapsed`, or scrolled up into history. The SDK reports widget visibility to the iframe on `show()`, `hide()`, `toggle()`, and right after mount (0.1.7 or newer), so this is automatic as long as you use those methods. > **Warning:** A widget you hide with your own CSS instead of `hide()` still counts as open, so it keeps marking incoming messages read and nothing ever accrues. Toggle floating widgets through the SDK. The two numbers clear on different boundaries, which decides what your dot can mean: - **`unread` clears once the viewer reaches the newest message.** On reopen the chat first freezes an "Unread messages" divider above everything that arrived while you were away, so the viewer can see where they left off, and then marks those messages read, dropping `total.unread` back to 0. The reset is conditional: it runs only when the viewport actually settles at the tail, so a jump that parks the view on the divider instead of the bottom defers it until the viewer returns to the newest message. - **`mentions` keeps its own boundary.** It survives the reopen and comes down only as the viewer steps through the in-chat "@" badge. A mention dot bound to `total.mentions` therefore outlives the click that opened the chat, which is deliberate: a mention is worth seeing even after a glance at the room. Because reactions count as mentions, that badge can also outlive the last unread message. ## Cherry-recommended UI The indicator is yours to draw, so the sizes and the pink below are what Cherry recommends for it, scaled to whatever icon you hang the badge on: | Piece | Size | Notes | |---|---|---| | Dot | about 30% of the host icon: 12px on a 40px icon | A 2px ring in the page background color keeps it legible where it overlaps a busy icon. | | Counter pill | about 40% of the host icon: 16px on a 40px icon. Cherry's own 56px launcher uses 18px. | That is the pill height; let the width grow with the number. | | Color | `#ff1493` | Cherry pink with white text, the pink the chat itself uses. | | Mention | one pill reading `@ N` | A single pill, the "@" and the number separated by a 2 to 3px gap. Never two badges side by side. The number stays the unread count; "@" is a flag, not a counter. | One copy-paste wiring, dot and counter in the same element: ```html ``` One counting model, not two: the number in that pill is always `total.unread`, capped at `99+`. `total.mentions` never becomes a number of its own, it only decides whether the "@" is drawn, so the pill reads `@ 7` while seven messages are unread and drops to a lone "@" once `unread` clears with the mention still outstanding. That lone "@" is the boundary described above, made visible. With `SHOW_COUNTS` off the same rule paints a bare dot for unread and upgrades it to a lone "@" when someone addresses the viewer, which is the quietest version of the same badge. Cherry's own launcher centres the badge on a fixed point instead of pinning its corner, so the dot to pill swap does not shift it. ## Floating widgets: the badge is built in If the embed is floating, you do not have to draw any of this. `chatBubble: true` renders Cherry's own launcher button and puts the badge on it for you. Out of the box that badge is the quiet version, `chatBubbleBadge: 'dot'`: a bare 12px dot while messages are unread, upgraded to a lone "@" pill once someone addressed the viewer, and never a number. Switch to `chatBubbleBadge: 'count'` for the full model above: one pill, the number always the unread count capped at `99+`, "@" in front of it while a mention is outstanding, and "@" alone once unread clears. Either mode reads `unreadState` itself, hides only when both counters are zero, and clears on sign-out and on every viewer switch, so there is nothing to subscribe to. `'off'` draws no badge at all. Badge text follows `theme.fontFamily` and falls back to Inter, then the system UI face, so the launcher reads as part of the chat it opens. Color follows too: Cherry pink `#ff1493` out of the box, then the mention colors the embed's own theme engine resolved once the iframe reports them, which is what makes the launcher badge and the in-chat "@" badge match. The theme can override those two with [`mentionBadgeColor` / `mentionBadgeTextColor`](https://portal.cherry.fun/docs/embed/theming.md), and both badges move together. On an embed deploy older than the SDK nothing is reported and the badge stays pink. No config key recolors it directly. That is the recommended path for `floating-right` / `floating-left` embeds. The recipe above stays the answer for an indicator you hang on your own UI, such as a chat icon in your header. Setup, the SDK version it needs, and the theming details: [Display modes](https://portal.cherry.fun/docs/embed/display-modes.md). ## Next steps - [SDK API reference](https://portal.cherry.fun/docs/embed/api-reference.md) · [Configuration](https://portal.cherry.fun/docs/embed/configuration.md) · [Display modes](https://portal.cherry.fun/docs/embed/display-modes.md) --- # React Native & Flutter The Chat Embed SDK is browser-only: it creates an `