Cookbook
Agents & skills · for AI agents

Brand the chat — colours and logo

Match a customer's brand on a share link or embedded widget: one colour, a logo, and CSS only when the brand guide demands it.

MCP tools:list_sharesconfigure_shareset_pwa_brandingset_custom_domain

Principle — give one colour, not a palette. Foreground colours are derived from it on save, so every combination stays readable. Picking your own text colours is how you ship a button nobody can read.

Branding lives on the share, not the agent: the same agent can be a white-label widget on one site and a plain link somewhere else. So get the token first.

list_shares(agent_name="mortgage-advisor")
# → [{ token: "RuqY…", label: "Website widget", config: { theme_color: "#F7B331", … } }]
 
configure_share(
  agent_name="mortgage-advisor",
  token="RuqY…",
  theme_color="#F7B331",
  logo_url="https://cdn.example.com/brand/mark-512.png",
)

Only the fields you pass change — the rest of the configuration is left alone.

Renaming a share

label is the name at the top of the chat page and the browser tab's title. It is a field on configure_share like any other:

configure_share(agent_name="mortgage-advisor", token="RuqY…", label="Pip — reading buddy")

You never need to create a replacement share and delete the old one to rename it. A new share means a new token, so the link already embedded in the customer's site stops working — the visible name and the address are not the same thing, and only one of them is safe to change after launch.

A second colour, only if the brand has one

Most brands have one colour and need nothing here. Some have a primary and a highlight — pass the second one as highlight_color and it is used where something must be visible without competing with the primary action:

configure_share(
  agent_name="pip",
  token="ENhs…",
  theme_color="#0B4230",      # primary: buttons, the current storyline step, user bubbles
  highlight_color="#D9A922",  # highlight: second chart series, citation markers
)

Its foregrounds are derived on save exactly like the primary's, so the same readability guarantee holds. Leave it out and nothing changes anywhere — --acc-2 falls back to the primary, which is what every single-colour brand wants and requires no configuration.

Don't spread it further by hand. You can override our component classes from custom_css, and you will regret it: those class names are our internals, they change, and when they do your branding breaks silently. Set the two colours and let the components decide where each belongs.

Colour: pass one, get four

theme_color is the brand's primary colour, #RGB or #RRGGBB. On save the platform derives the foregrounds by WCAG contrast and stores them:

DerivedUsed for
theme_color_fgtext on the brand colour — send button, user bubbles, unread badge
theme_color_textthe brand colour used as text, on a light background
theme_color_text_darkthe same, on a dark background

A light brand colour therefore gets dark text on it rather than white. #F7B331 with white text measures 1.83:1 — below even the 3:1 floor for UI components, meaning the label is genuinely unreadable; the derived dark foreground measures 10.21:1.

Do not compute a palette yourself and do not try to set the text colours — they are server-derived and your values are overwritten. Passing one colour is the whole job.

Clearing it (theme_color="") drops the derived values too and returns the widget to the default blue. Leaving it unset does the same — the built-in tokens are already a matched pair.

Logo: aspect ratio decides where it can be used

Pass an absolute URL. The logo appears in two places with different constraints:

  • Header bar — height-aligned, width free. Any aspect ratio works, including a wide wordmark.
  • Collapsed bubble — square, because the logo is the shape of the bubble. Only ratios between 0.74 and 1.35 are used here; a wide wordmark falls back to the platform icon instead of being squeezed into a strip too small to read.

So for a wide wordmark, the practical answer is two files: the wordmark for the header, a square mark for the bubble. If the customer only has a wordmark, the header still shows their brand and the bubble shows a neutral icon — better than an illegible sliver.

Do not crop a wordmark to square. Cropping leaves half a word, which is no longer their trademark.

When the brand guide overrides everything

Some customers have a strict palette that will not follow ours in either theme. custom_css is injected after the brand variables, so a plain declaration already wins — no !important needed. (It used to be required, because the colours were set as inline styles. They are not any more.)

⚠️ Read this before writing any CSS

You must write two blocks: :root for light, html[data-theme="dark"] for dark.

:root matches in both themes and outranks the platform's dark rules (same specificity, and yours comes later). So a palette written only under :root means the visitor's light/dark toggle changes nothing at alldata-theme flips, no variable follows it.

This has already happened in production: an agent put a full dark palette under :root, the page looked great, and the theme button became a dead control that nobody noticed for weeks. The page looks correct, which is exactly why this goes unreported.

If the customer genuinely wants one theme only, set the share's theme field to light or dark instead. That hides the toggle rather than breaking it.

Correct shape — copy this and replace the values:

/* LIGHT — the light values go here, not the dark ones */
:root{
  --acc: #6b21a8;        /* brand primary — backgrounds, focus rings */
  --acc-fg: #ffffff;     /* text on --acc: YOU own the contrast here */
  --acc-text-l: #581c87; /* brand colour used as text, light theme */
  --bg: #fdfbff;
  --surface: #ffffff;
  --text: #1e1b2e;
  --border: #e6e0f0;
}
/* DARK — required whenever you touched --bg / --surface / --text above */
html[data-theme="dark"]{
  --acc: #a855f7;
  --acc-fg: #1a0b2e;
  --acc-text-d: #c084fc; /* note: -d, the dark-theme variant */
  --bg: #120c1d;
  --surface: #1c142b;
  --text: #f0e9ff;
  --border: #32254a;
}

That is the full set worth overriding — the widget consumes few colours on purpose.

Which variables need the dark block? The ones that describe the surface, because they invert between themes: --bg, --surface, --text, --border. --acc is the brand colour and normally stays the same in both, though the example brightens it for the dark theme because a deep purple on a near-black background is hard to see.

Two more things to hold on to:

  • custom_css carries no readability guarantee. theme_color does. Reach for CSS only for what the derivation cannot express, and keep theme_color set as the baseline.
  • Setting --acc-text-l and --acc-text-d to the same value defeats their purpose. They exist precisely so the brand colour can be readable as text against opposite backgrounds.

configure_share returns a warnings array when it detects the single-:root mistake — read it. The console shows the same warning next to the CSS editor.

css_url loads an external stylesheet instead, for customers who keep their own theme file.

The interactive controls your colours drive

Every accent-coloured control in the chat reads the same pair — background --acc, text --acc-fg:

ControlSelector (stable)
Ask-form submit button (the forms agents render with ```ask blocks).ca-form .casub
Ask-form option rows.ca-form label (hover: :hover)
Ask-form selected row.ca-form label:has(input:checked)
Ask-form radio/checkbox itself.ca-form input (via accent-color)
Send button / user bubbles / unread badge.composer .send, .m.user .content, .sbadge
Icebreaker question buttons (hover).qs button
Hold-to-talk button while recording — background, label, and the pulsing glow.ca-talk.rec
PWA install button.pwabar .go, .pwacard .go

Selected rows come styled out of the box — brand-coloured border, a 10% brand tint behind the row, and the native control painted with accent-color — all driven by --acc, so setting theme_color is again the whole job. The voice button's recording glow likewise derives from --acc (via color-mix at 22%/55% alpha), so it pulses in the brand colour automatically; override it in custom_css only for a deliberately different treatment:

/* softer, wider recording glow — both themes if you also touch surfaces */
:root .ca-talk.rec{
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--acc) 15%, transparent),
              0 0 40px color-mix(in srgb, var(--acc) 40%, transparent);
}

Only reach for CSS when the brand guide demands a different treatment:

/* stronger selected state, both themes (remember the two-block rule above) */
:root .ca-form label:has(input:checked){
  border-color: var(--acc);
  background: color-mix(in srgb, var(--acc) 18%, transparent);
  font-weight: 600;
}
html[data-theme="dark"] .ca-form label:has(input:checked){
  background: color-mix(in srgb, var(--acc) 24%, transparent);
}

The rule that keeps them all readable: anything you paint with --acc gets its text from --acc-fg — never a literal colour. The classic failure is overriding --acc to a light brand yellow in the dark block and leaving white text: the ask-form button becomes yellow-on-white-text at 1.8:1 and the customer reports "the form button is unreadable in dark mode". If you override --acc in custom_css, you own --acc-fg in the same block, both themes — or better, don't use CSS for this at all: set theme_color and the platform derives a compliant --acc-fg for you.

Make the chat installable (PWA)

Standalone chat pages (/s/… links, QR codes, and the alias URLs below) are installable apps: visitors can add them to their home screen, with your own icon and name. Two knobs, configured per agent — what gets installed to a home screen is one agent's entry page, so each agent is its own app with its own icon (a support agent and a pre-sales agent install as two different apps):

set_pwa_branding(
  agent="support",                                                # which agent's install branding
  icon_source_url="https://cdn.example.com/brand/mark-1024.png",  # ONE square master image ≥192×192
  install_prompt="banner",                                        # banner (default) | card | off
)
  • The master image is fetched once and the platform generates the whole set — browser-tab favicon, 192/512 install icons and the Android maskable variant. Non-square masters are centre-cropped, so use the square mark, not a wide wordmark.
  • install_prompt controls what visitors see on the chat page: banner is a slim dismissible bar (Android/Chrome triggers the system install dialog; iOS gets a "Share → Add to Home Screen" walkthrough since Apple offers no API), card is a more visible first-visit card, off disables the invitation. Dismissals are remembered per visitor.
  • Icons are optional — without them the platform icons are used. Call with just the agent name to read that agent's current configuration.

When the tenant alias and the agent alias are both set, create_share / list_shares return a pretty_urlhttps://chat.agent4.io/t/<tenant-alias>/<agent-alias>. That is the link to give people and print on material: readable, memorable, and it survives token rotation (the platform lazily maintains a share behind it). The /s/<token> form stays for embeds and machines.

No pretty_url in the response? The aliases aren't set — fix that rather than shipping the token link: agent alias via create_agent(alias=…) or PUT /agents/{name}/alias; the tenant alias lives in console → Settings. Setting an agent's alias is itself a publish action: the alias page auto-creates an anonymous share on first visit.

The customer's own domain

The hosted chat page can serve on the customer's domain — https://chat.client.com/ shows their branded page, certificate issued automatically.

set_custom_domain(domain="chat.client.com", agent_alias="menu")
# → { status: "pending", cname_target: "endpoint.agent4.io", last_error: "…" }

The sequence matters, and it is asynchronous:

  1. Tell the customer to add a CNAME from their subdomain to the cname_target in the response (endpoint.agent4.io). Subdomains only — an apex domain (client.com) cannot take a CNAME; have them use chat.client.com, or a DNS provider with CNAME flattening.
  2. On Cloudflare the record must be DNS only (grey cloud). Proxied records resolve to Cloudflare's IPs and verification fails — last_error says so explicitly, with the observed addresses. This is the most common failure; read last_error before guessing.
  3. Verification re-runs automatically every 10 minutes; call set_custom_domain again (same arguments) or check tenant_info() to see the current status. active means live — the first visit issues the certificate.

One domain per workspace. agent_alias empty lands on the tenant's branded page; set it to land directly on one agent's chat. Existing chat.agent4.io links keep working — this is an additional entrance, not a replacement — and chat identity is per-domain: a visitor's history on chat.agent4.io does not follow them to the custom domain.

Requires a plan that includes custom domains (403 = the tenant needs to upgrade).

The conversation search modal (Spotlight)

The standalone page and the web client have a ⌘K conversation search. It follows the brand automatically — the modal reads the same variables everything else does (--surface, --border, --text, --muted, --overlay, --surface-hover, --accent-soft, and the --acc-text pair for highlighted matches) — so setting theme_color is already the whole job here too.

For brand guides that need more, the class names are stable and custom_css outranks the component's own styles:

PartSelector
Backdrop.ca-ss-mask
The panel.ca-ss
Query input.ca-ss input
Result row / selected row.ca-ss-item / .ca-ss-item.sel
Result title / snippet.ca-ss-t / .ca-ss-s
Highlighted match in a snippet.ca-ss-s mark
"related" badge (semantic hits).ca-ss-sem
Sidebar search button.sb-search
/* rounder panel, brand-tinted selected row — remember the two-block rule above */
:root{ }
.ca-ss{ border-radius: 20px; }
.ca-ss-item.sel{ background: color-mix(in srgb, var(--acc) 10%, transparent); }
html[data-theme="dark"] .ca-ss-item.sel{ background: color-mix(in srgb, var(--acc) 16%, transparent); }

As everywhere: the two-theme rule applies the moment you touch surface colours, and plain declarations win — no !important.

Form controls and the native date picker

Ask-form inputs (.ca-form input.cain) and the dialog input (.ca-in) are token-styled — surface, text, border, and an accent focus ring all follow the theme and your theme_color. The system dialogs (rename/confirm) share the Spotlight treatment: frosted backdrop, breathing elevation, brand glow in dark.

The calendar is hybrid. On desktop (mouse) the platform draws its own calendar — fully token-styled, brand-coloured selected day, localized month/weekday names — stable selectors .ca-dp, .ca-dp-d, .ca-dp-d.sel, .ca-dp-d.today if a brand guide needs more. On touch devices the native OS picker is kept deliberately: the mobile wheel beats anything drawn in a web page, and its panel is OS-private UI that CSS cannot reach — there the platform controls what is reachable (theme-pinned color-scheme, the token-styled input field, the inverted calendar icon) and nothing more exists to control.

Scope your element selectors. A bare input[type="text"] { … } written for the composer also captures the platform's dialogs and form fields — a white composer style bleeding into a dark rename dialog is the classic symptom, and the CSS lint now flags it (broad_element_selector). Write .composer textarea or #input instead. The system dialogs sit at higher specificity, so the accident no longer lands — but the lint tells you before you rely on an accident.

Also on the share

  • input_modetext (default) or voice, which opens straight into hold-to-talk. Needs a speech-to-text backend enabled on the platform.
  • launcher — the bubble's hover tooltip.
  • theme — force light / dark, or leave empty to follow the visitor's system setting. Prefer empty: the widget sits inside the customer's page and should match it.

Verify

Open the chat_url from list_shares and look at the send button. If the label is hard to read, theme_color is not set and something is overriding it — check custom_css for a hard-coded color.