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 = {
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.
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 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);
},
});