Install the application
Include the loader once on the page that hosts the embedded application.
<script src="https://cdn.signal.ceyo.ai/embed/v1.js"></script>The loader registers Ceyo.mount(). Mount after the target element exists in the document.
Mount the application
Pass a CSS selector or DOM element as the target. The application fills the container width, while height controls its height.
const embed = Ceyo.mount("#signal-visibility", {
sessionToken,
height: "780px",
title: "AI visibility",
theme: {
colors: { primary: "#0F766E" },
},
settings: {
title: "AI Visibility",
showProjectName: false,
},
onReady: () => console.log("Signal is ready"),
onError: (error) => console.error("Signal error:", error),
});Complete browser flow
<div id="signal-visibility"></div>
<script src="https://cdn.signal.ceyo.ai/embed/v1.js"></script>
<script>
async function createSession() {
const response = await fetch("/api/signal-session", { method: "POST" });
if (!response.ok) throw new Error("Unable to create embed session");
const session = await response.json();
return { token: session.token, expiresAt: session.expires_at };
}
(async () => {
let currentSession = await createSession(); // Keep tokens in memory.
let embed;
let refreshInFlight;
async function refreshSession(currentToken, updateHandle) {
if (currentSession.token !== currentToken) return currentSession.token;
if (refreshInFlight) return refreshInFlight;
refreshInFlight = (async () => {
const response = await fetch("/api/signal-session/refresh", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({ session_token: currentToken }),
});
const session = response.ok
? await response.json()
: await createSession();
if (currentSession.token !== currentToken) return currentSession.token;
currentSession = {
token: session.token,
expiresAt: session.expiresAt || session.expires_at,
};
if (updateHandle) embed.updateToken(currentSession.token);
return currentSession.token;
})().finally(() => {
refreshInFlight = null;
});
return refreshInFlight;
}
embed = Ceyo.mount("#signal-visibility", {
sessionToken: currentSession.token,
height: "780px",
onTokenExpired() {
return refreshSession(currentSession.token, false);
},
});
window.signalEmbed = embed;
})();
</script>Mount options
Only sessionToken is required. All appearance, behavior, and callback options are optional.
type MountOptions = {
sessionToken: string;
height?: string; // default: "720px"
minWidth?: string; // default: "360px"
title?: string; // iframe title; default: "Visibility"
embedUrl?: string; // staging/testing override only
theme?: ThemeOptions;
settings?: SettingsOptions;
localization?: LocalizationOptions;
onTokenExpired?: () => string | null | Promise<string | null>;
onReady?: () => void;
onError?: (message: string) => void;
};
type SettingsOptions = {
title?: string; // heading; default: "AI Visibility"
subtitle?: string; // optional text below heading; default: ""
showProjectName?: boolean; // project label; default: true
showProjectAdminTabs?: boolean; // Settings + read-only Users; default: false
showLocationAdminTabs?: boolean; // Settings + read-only Users; default: false
enablePromptDetail?: boolean; // prompt navigation; default: true
autoOpenSingleLocation?: boolean; // locations-only projects; default: true
};
type LocalizationOptions = {
locale: string; // BCP 47, for example "nl-NL"
messages?: TranslationMessages; // required and complete unless locale is English
};
type TranslationMessages = Record<MessageKey, string>;
type ThemeOptions = {
colors?: Partial<ThemeColors>;
typography?: Partial<ThemeTypography>;
shape?: Partial<ThemeShape>;
spacing?: Partial<ThemeSpacing>;
density?: ThemeDensity; // default: "compact"
};
type ThemeColors = {
primary: string; // default: "#16181d"
primaryText: string; // default: "#ffffff"
background: string; // default: "transparent"
surface: string; // default: "#ffffff"
text: string; // default: "#16181d"
muted: string; // default: "#6b7280"
border: string; // default: "#e5e7eb"
success: string; // default: "#047857"
successSurface: string; // default: "#ecfdf5"
warning: string; // default: "#b45309"
warningSurface: string; // default: "#fffbeb"
danger: string; // default: "#b91c1c"
dangerSurface: string; // default: "#fef2f2"
};
type ThemeTypography = {
// Bundled: Inter Variable, Roboto Variable, Open Sans Variable, Montserrat Variable (normal/italic, 100–900)
fontFamily: string; // default: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif"
baseSize: string; // default: "14px"
smallSize: string; // default: "12.5px"
titleSize: string; // default: "22px"
titleWeight: string; // default: "700"
bodyWeight: string; // default: "400"
};
type ThemeShape = {
radius: string; // default: "10px"
cardRadius: string; // default: "10px"
controlRadius: string; // default: "10px"
pillRadius: string; // default: "7px"
};
type ThemeSpacing = {
pagePadding: string; // default: "20px"
panelPadding: string; // default: "16px"
cardPadding: string; // default: "16px 18px"
gap: string; // default: "12px"
controlHeight: string; // default: "34px"
};
type ThemeDensity = "compact" | "comfortable";Leave embedUrl unset in production so the loader uses the matching Signal application. The override exists for Signal-provided staging and local test builds.
An embedUrl override must be absolute. Use HTTPS in production.
Returned handle
Ceyo.mount() returns the managed iframe and methods for replacing its token or removing it cleanly.
type EmbedHandle = {
readonly iframe: HTMLIFrameElement;
updateToken(token: string): void;
unmount(): void;
};
const embed: EmbedHandle = Ceyo.mount("#signal-visibility", options);Token refresh
Keep the current token in memory. The iframe invokes onTokenExpired about one minute before expiration and after a recoverable expired, invalid, or revoked-session response. Exchange the current token through your authenticated backend. If that exchange is no longer valid, mint a new session for the signed-in user. Return the replacement token, or null or undefined when access should remain locked.
let currentSession = {
token: initialToken,
expiresAt: initialExpiresAt,
};
async function refreshSession(
currentToken: string,
updateHandle: boolean,
): Promise<string | null> {
const response = await fetch("/api/signal-session/refresh", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({ session_token: currentToken }),
});
const replacement = response.ok
? response
: await fetch("/api/signal-session", { method: "POST" });
if (!replacement.ok) return null;
const session = await replacement.json();
currentSession = {
token: session.token,
expiresAt: session.expires_at,
};
if (updateHandle) embed.updateToken(currentSession.token);
return currentSession.token;
}
const embed = Ceyo.mount("#signal-visibility", {
sessionToken: currentSession.token,
onTokenExpired() {
return refreshSession(currentSession.token, false);
},
});