# Integration

Source: https://ceyo.ai/docs/signal/embedding-integration

### 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);
  },
});
```
