Publish & Share

Embed Chipp Agents into Alchemist Projects

Bind and embed your Chipp AI agents into Alchemist projects with inline panels, floating widgets, and both authenticated and anonymous visitor support.

|View as Markdown
Hunter Hodnett
Hunter HodnettCPTO at Chipp
||1 min read
#embed#alchemist#integration#agents#deployment

Embed your Chipp AI agents directly into Alchemist projects with a native, composable runtime (no iframe) that supports both authenticated users and anonymous visitors. This guide walks you through binding an agent, choosing an identity mode and a surface (inline panel, floating widget, or both), theming, host context, and the anonymous-to-authenticated upgrade path.

Overview

Alchemist projects can bind and embed existing Chipp agents from the same organization. The embedded agent runs in a composable runtime that:

  • Supports authenticated SaaS users and anonymous visitors
  • Offers two surfaces: inline chat panel and native floating widget (both share the same runtime)
  • Uses server-policy-controlled host context so arbitrary browser claims never become trusted instructions
  • Tracks usage and billing normally to the organization owning the agent
  • Allows upgrade of an anonymous visitor to an authenticated user without losing the conversation

Prerequisites

  • A Chipp organization with at least one Chipp agent
  • Access to the Alchemist project where you want to embed the agent
  • Both the Chipp agent and Alchemist project must belong to the same organization

Step 1: Bind an Existing Chipp Agent

Only agents from the same organization can be bound. Nothing is cloned or auto-created.

Via the Alchemist Dashboard

  1. Open your Alchemist project and go to the Settings tab
  2. In the Embedded agent section, select the Chipp agent from the dropdown (only same-org agents appear)
  3. Click Bind agent (or Replace binding if one already exists). Remove unbinds it.
  4. In the Generate chat UI + backend integration section, choose an Identity mode:
    • Authenticated users only
    • Anonymous visitors only
    • Both
  5. Tick the surfaces you want (Inline panel, Floating widget) and click Generate integration

Alchemist commits the generated code into your project: a vendored copy of the embed client at web/src/lib/embedded-agent/embed-client/, the panel and widget components, and a backend session proxy mounted at /api/embedded-agent. The routes it adds depend on the identity mode:

Identity modeSession proxy routes
Authenticated users onlyPOST /session
Anonymous visitors onlyPOST /session/anonymous
BothPOST /session, POST /session/anonymous, POST /session/upgrade

Generating the integration also sets the binding’s anonymous access to match: on for “Anonymous visitors only” and “Both”, off for “Authenticated users only”. Once installed, the section shows the active surfaces, an Add button for any surface you skipped, and Remove generated integration.

Below it, the Embedded agent settings section has three controls:

  • Anonymous visitors: whether anonymous chats are saved (retained) or not saved (ephemeral). Shown when anonymous access is on.
  • Page context keys: the host-context keys the agent accepts, and an optional max value length. See Host-Provided Context.
  • Chat session key: Rotate key replaces the project client key the session proxy uses. See Rotating the project client key.

You can also ask Alchemist in chat to install the embedded agent for you.

Step 2: Choose Your Surface

The embedded Chipp agent can appear as:

  • Inline Panel: a chat panel embedded inline in your page layout
  • Floating Widget: a launcher button with a floating chat overlay (launcher + open/close/minimize + unread badge)
  • Both: both surfaces on one page, reading and writing the exact same shared runtime

Both surfaces are built on the same embed client, so they share conversation history, message state, and all runtime behavior.

Step 3: The Client Library

The embed client (@chipp/embed-client) is a private package. It is not published to npm. The generated integration vendors its source into your project at web/src/lib/embedded-agent/embed-client/, and the generated components import it from there (./embed-client/index.ts). Do not edit the vendored files; Alchemist regenerates them.

Step 4: Set Up the Runtime

The runtime always needs a short-lived, scoped session token. Your browser code never holds a permanent Chipp credential.

Minting a session (server side)

Your project’s backend mints the token by calling the Alchemist client API with the project’s client key (alch_proj_...), which stays on the server:

EndpointBodyPurpose
POST /api/client/embed/session{ externalUserId, email, name?, workspaceId?, sessionScope? }Authenticated user session
POST /api/client/embed/session/anonymous{ workspaceId?, sessionScope? }Anonymous visitor session
POST /api/client/embed/session/upgrade{ anonymousToken?, externalUserId, email, name? }Upgrade anonymous to authenticated
POST /api/client/embed/session/rotate{ token }Refresh a session
POST /api/client/embed/session/revoke{ token }Revoke a session

Every request sends Authorization: Bearer alch_proj_.... A session response looks like { "data": { "token", "expires_at", "application_id" } }. Tokens last one hour.

The generated session proxy already does this for you. Your frontend calls your own backend (/api/embedded-agent/session, /session/anonymous, or /session/upgrade), never Chipp directly.

Using the token (browser side)

typescript
import {
  createHttpEmbedTransport,
  createEmbedChatController,
  mountChippEmbedPanel,
} from "./embed-client/index.ts";

// Your backend returns { token, applicationId, runtimeBaseUrl }
let session = await mintEmbeddedAgentSession("/api/embedded-agent/session");

const transport = createHttpEmbedTransport({
  baseUrl: session.runtimeBaseUrl,
  applicationId: session.applicationId,
  token: session.token,
  getToken: () => session.token, // lets you re-mint without rebuilding the controller
});

// The controller is the shared state machine
const controller = createEmbedChatController(transport);

mountChippEmbedPanel(document.getElementById("chat-container")!, controller);

Anonymous Mode

Anonymous mode uses the same code. The only difference is that your backend mints the token with /api/client/embed/session/anonymous (the generated proxy exposes it at /api/embedded-agent/session/anonymous). The binding must allow anonymous access, or the mint is refused. Generating the integration in “Anonymous visitors only” or “Both” mode turns it on.

In “Both” mode, the generated panel and widget try a signed-in session first. When the visitor is not signed in (the proxy answers 401), they start an anonymous session instead.

Each anonymous session is scoped to one pseudonymous visitor and the bound agent, and isolation is enforced server-side: a visitor cannot read or resume another visitor’s conversation. Minting a new anonymous session creates a new visitor, so the generated widget does not re-mint anonymous sessions.

Step 5: Mount Your Surface(s)

Inline Panel Only

typescript
const handle = mountChippEmbedPanel(
  document.getElementById("chat-panel")!,
  controller,
  {
    autoLoadHistory: true, // default true
    placeholder: "Ask a question...", // composer placeholder
  }
);

// Later, if you need to unmount:
handle.destroy();

Floating Widget Only

typescript
import { mountChippEmbedWidget } from "./embed-client/index.ts";

const widget = mountChippEmbedWidget(
  document.getElementById("chat-widget-slot")!,
  controller,
  {
    position: "bottom-right", // or "bottom-left"
    launcherLabel: "Chat with us", // default "Open chat"
    initiallyOpen: false,
  }
);

// widget.open() / .close() / .minimize() / .toggle()
// widget.getState() -> "closed" | "open" | "minimized"
// widget.getUnreadCount()
widget.destroy(); // Unmount when needed

Both Inline Panel + Floating Widget

Both surfaces read and write to the exact same controller, so they stay in sync:

typescript
import { mountChippEmbedSurfaces } from "./embed-client/index.ts";

const surfaces = mountChippEmbedSurfaces({
  mode: "both", // or "inline" | "floating"
  controller,
  inline: { container: document.getElementById("chat-panel")! },
  floating: {
    container: document.getElementById("chat-widget-slot")!,
    options: { position: "bottom-right" },
  },
});

// surfaces.inline, surfaces.floating, surfaces.destroy()

Theming & Design Tokens

Both surfaces use CSS custom properties. Each one falls back to a common host token (--primary, --background, --foreground, --muted-foreground, --border, --radius), so a project that already defines those (the Alchemist-generated design system does) gets a matching panel with no configuration.

TokenFallbackPurpose
--chipp-embed-accent-color--primary, then #4f46e5Send button, streaming indicator, active thread
--chipp-embed-background--background, then #ffffffPanel background
--chipp-embed-foreground--foreground, then #111111Text color
--chipp-embed-muted--muted-foreground, then #6b7280Timestamps, placeholder, secondary labels
--chipp-embed-border-color--border, then #e5e7ebComposer border, dividers
--chipp-embed-radius--radius, then 0.5remCorner radius
--chipp-embed-error-color#dc2626Error banner and retry
--chipp-embed-font-familyinheritFont stack (inherits the host page)

Widget-Specific Tokens

TokenDefaultPurpose
--chipp-embed-widget-size3.5remLauncher button diameter
--chipp-embed-widget-offset1.5remDistance from the viewport edge, before the safe-area inset
--chipp-embed-widget-z-index2147483000Stacking order
--chipp-embed-widget-panel-width380pxFloating panel width
--chipp-embed-widget-panel-height600pxFloating panel height

Applying Theme Overrides

typescript
import { applyChippEmbedThemeOverrides } from "./embed-client/index.ts";

const container = document.getElementById("chat-container")!;

applyChippEmbedThemeOverrides(container, {
  "--chipp-embed-accent-color": "#FF6600",
  "--chipp-embed-background": "#FAFAFA",
  "--chipp-embed-foreground": "#1F2937",
  "--chipp-embed-widget-offset": "24px",
});

Or set them directly in CSS:

css
#chat-container {
  --chipp-embed-accent-color: #ff6600;
  --chipp-embed-background: #fafafa;
  --chipp-embed-foreground: #1f2937;
  --chipp-embed-widget-offset: 24px;
}

Host-Provided Context

You can supply optional page/record/workspace context to the agent without it becoming a trusted instruction or authorization. Context is advisory data only: it is filtered by a server-side policy and is never used for access control.

Sending Context from Your Host

Pass getHostContext when you mount a surface. The panel calls it at send time, so it always reads the latest value:

typescript
mountChippEmbedPanel(container, controller, {
  getHostContext: () => ({
    page: window.location.pathname,
    projectId: "proj-123",
    recordName: "Invoice #123",
  }),
});

The generated components take a context prop that does the same: <EmbeddedAgentPanel context={{ page }} />. Values must be strings, numbers, or booleans; lists of primitives are joined with ”, ”, and nested objects are dropped. Never send a key that carries personal data.

Policy Validation (Server-Side)

The server checks context against the binding’s contextPolicy before the agent sees it:

  • Only keys listed in allowedKeys pass. Every other key is dropped.
  • Values are capped in length (maxValueLength, default 500 characters).
  • A binding with no policy, or an empty allowedKeys, accepts no host context.
  • Accepted context goes into a clearly marked untrusted-data block, never into the instructions.

Set the policy in the project’s Settings tab, under Embedded agent settings > Page context keys: list the keys separated by commas, optionally set a max value length (1 to 2000), and click Save context keys. Saving an empty list turns page context off. A binding allows at most 50 keys, each up to 100 characters.

The same policy is set with PUT /api/projects/{projectId}/embed/binding/context-policy and the body { "contextPolicy": { "allowedKeys": ["page", "projectId"], "maxValueLength": 500 } }.

If the project defines its agent in chipp/agent.json, every agent sync (sync_chipp_agent) sets the allowed keys from that file’s contextKeys and replaces what you saved here. An empty or missing contextKeys turns page context off. For those projects, edit contextKeys in the file instead.

Anonymous → Authenticated Upgrade

When an anonymous visitor signs in, their in-flight conversation is attached to their authenticated consumer account without leaking it to any other identity.

With the generated integration (“Both” mode)

An integration generated in “Both” mode already does the upgrade. It adds:

  • POST /api/embedded-agent/session/upgrade on your backend (src/services/embedded-agent/router.ts). It requires a signed-in user. The browser sends only { "anonymousToken" }; the route takes the user’s id, email, and name from your server session, never from the request body, and calls Chipp’s upgrade endpoint with the project client key.
  • upgradeEmbedSession(identity, anonymousToken) in src/services/embedded-agent/client.ts, for your own server code.
  • In the browser (web/src/lib/embedded-agent/session-client.ts): upgradeEmbeddedAgentSession(anonymousToken) and notifyEmbeddedAgentSignedIn().

What you do after a visitor signs in:

  • Sign-in with a page reload (a redirect or a form post): nothing. The panel and widget keep the anonymous token for the browser tab (sessionStorage) and upgrade it on the next load.
  • Sign-in without a reload (an SPA login): call notifyEmbeddedAgentSignedIn() right after login succeeds. Any anonymous panel or widget on the page upgrades and keeps the conversation.
typescript
// Path relative to your file, e.g. from web/src/App.svelte:
import { notifyEmbeddedAgentSignedIn } from "./lib/embedded-agent/session-client.ts";

await login(email, code);
notifyEmbeddedAgentSignedIn();

After the upgrade, the chat also mints a normal signed-in session, so the agent gets your app’s agent tools. The upgrade token itself carries none.

The upgrade route needs the full project client key. The Builder preview runs with a narrower key that can mint sessions but cannot upgrade them, so in the preview an in-page sign-in leaves the chat anonymous, and the next page load starts a new signed-in chat. The deployed app carries the conversation over.

“Authenticated users only” and “Anonymous visitors only” integrations have no upgrade route. To add one, generate the integration again in “Both” mode.

User Experience Flow (calling the API yourself)

  1. Visitor chats anonymously: no authentication required; the session is pseudonymous
  2. Visitor signs in on your host app
  3. Your backend calls the upgrade endpoint with the project client key:
bash
POST /api/client/embed/session/upgrade
Content-Type: application/json
Authorization: Bearer alch_proj_...

{
  "anonymousToken": "eyJhbGciOi...",   // the anonymous session token
  "externalUserId": "user-123",         // your user's ID
  "email": "user@yourcompany.com"
}
  1. Server response: { "data": { "token", "expires_at", "application_id", "merged" } }. merged is true when an anonymous conversation was found and moved to the user.
  2. Your frontend swaps the token: if you built the transport with getToken, update the value it returns and the controller keeps running.
typescript
// After sign-in
session = { ...session, token: upgradeResponse.data.token };
// getToken: () => session.token now returns the authenticated token

No cross-identity leakage occurs because:

  • The upgrade is server-initiated and server-validated
  • The anonymous consumer comes only from the verified anonymous token, never from a client-supplied ID
  • The anonymous token is revoked after the upgrade

Rate Limits & Abuse Prevention

Anonymous sessions have extra limits on top of the normal per-consumer chat limit:

  • Session creation: 20 anonymous sessions per IP per agent per hour
  • Message volume: 15 messages per anonymous visitor per minute

If a limit is exceeded, the request fails with 429 Too Many Requests. Show the visitor a friendly error.

Isolation & Security Guarantees

An Anonymous Session Cannot:

  • Read or enumerate another visitor’s conversation
  • Resume another visitor’s messages
  • Make requests outside its bound agent

Every Read/Mutate Operation is Scoped By:

  1. Session identity: the consumer bound to the token
  2. Application ID: the bound agent
  3. Organization ID: cross-org access is denied at the binding level

This is enforced server-side, not browser-side. The client cannot override or spoof these boundaries.

Billing & Attribution

Usage from both authenticated and anonymous visitors is metered through Chipp’s normal billing and attributed to the organization that owns the bound agent. Because only same-org agents can be bound, that is the same organization as the Alchemist project. There is no separate billing path.

Mobile & Responsive Behavior

  • Desktop: panel fills its container; widget floats in a bottom corner
  • Mobile (480px wide or less): the open widget goes full screen
  • Notch/Home Indicator: the launcher position adds env(safe-area-inset-*) to --chipp-embed-widget-offset, so it does not hide behind notches or home indicators

Accessibility

  • Keyboard: Enter sends, Shift+Enter adds a new line, Escape closes the open widget
  • Focus trap: when the widget opens, focus stays inside it; closing restores focus to the element that had it before
  • ARIA live region: new and unread messages are announced to screen readers (aria-live="polite")

Conversation Retention & History

Retention applies to anonymous sessions on a binding:

  • Retained (the default): anonymous conversations are stored like any other conversation.
  • Ephemeral: anonymous conversations are never written to the database. They use the same zero-retention store as an organization with Zero Data Retention on.

Authenticated users’ conversations are always stored and can be resumed across devices.

An anonymous visitor’s identity lives only as long as their session token. A new anonymous session is a new visitor with an empty history.

Set retention in the project’s Settings tab, under Embedded agent settings > Anonymous visitors: “Saved (like signed-in chats)” is retained, “Not saved (kept up to 4 hours)” is ephemeral. The control shows only while anonymous access is on. The change applies to new anonymous chats.

The same setting is PUT /api/projects/{projectId}/embed/binding/anonymous-access with the body { "anonymousAccess": { "enabled": true, "retention": "retained" } } (or "ephemeral"). Sending null turns anonymous access off. Generating the integration again sets enabled from the identity mode and keeps the retention you chose.

Rotating the project client key

The generated session proxy reads CHIPP_PROJECT_CLIENT_KEY (an alch_proj_ key) to mint chat sessions. Chipp provisions it when you generate the integration. The Client Tickets Portal uses the same key.

To replace it, open Settings > Embedded agent settings > Chat session key and click Rotate key. Chipp mints a new key, stores it, and pushes it to your deployed app:

  • When the result says the new key is live, the app restarts with it in a minute or two. Then click Revoke old key.
  • When the result says the key is saved but not yet in your running app, it reaches a managed app on its next deploy. Revoke the old key only after that deploy.

The old key keeps working until you revoke it, so the app does not lose chat while it restarts. Revoke old key refuses the key the app uses now.

The same actions are POST /api/projects/{projectId}/embed/client-key/rotate (returns { outcome, newKeyPrefix, applied, previousKeyId }) and POST /api/projects/{projectId}/embed/client-key/revoke-previous with { "keyId": "<previousKeyId>" }.

Loading History

typescript
const controller = createEmbedChatController(transport);

// Load previous messages (if this session has history)
await controller.loadHistory();

// Or select a specific thread
await controller.selectThread(threadId);

Troubleshooting

Anonymous Session Not Being Created

  1. Check the binding allows anonymous access: the anonymous mint is refused when the binding’s anonymous access is off. The Embedded agent settings section says “Anonymous visitors are off” in that case. Generate the integration again in “Anonymous visitors only” or “Both” mode to turn it on.
  2. Check network errors: in browser DevTools, look at the POST /api/embedded-agent/session/anonymous request to your own backend; a 503 from the generated proxy means the project client key is not configured
  3. Check rate limits: a 429 means you hit the per-IP session limit; wait up to an hour

Conversation Not Persisting

  1. Check retention: the binding may be set to ephemeral for anonymous visitors
  2. Check for a re-mint: a new anonymous session is a new visitor, so it starts empty

Theme Not Applying

  1. Apply overrides after the element exists: call applyChippEmbedThemeOverrides() after the container element is in the DOM
  2. Check specificity: custom properties follow normal CSS specificity; ensure no other rules override them

Widget Not Appearing on Mobile

  1. Check for overflow: ensure the page body and parent elements don’t have overflow: hidden
  2. Check z-index: if other floating elements stack higher, raise --chipp-embed-widget-z-index

Upgrade Failing

  1. Check the client key: your backend needs a project client key (alch_proj_...) with full scope. The Builder preview key cannot upgrade, so test the upgrade on the deployed app.
  2. Check the token: anonymousToken must be the exact token from the anonymous session (not modified or decoded). An expired or revoked anonymous token still upgrades the user, but merged comes back false.

Examples

React Component (Authenticated)

jsx
import { useEffect, useRef } from "react";
import {
  createHttpEmbedTransport,
  createEmbedChatController,
  mountChippEmbedPanel,
} from "./embed-client/index.ts";

export function ChatPanel() {
  const containerRef = useRef(null);

  useEffect(() => {
    let handle = null;
    let cancelled = false;

    (async () => {
      const res = await fetch("/api/embedded-agent/session", { method: "POST", credentials: "same-origin" });
      const session = await res.json();
      if (cancelled || !containerRef.current) return;

      const transport = createHttpEmbedTransport({
        baseUrl: session.runtimeBaseUrl,
        applicationId: session.applicationId,
        token: session.token,
      });
      const controller = createEmbedChatController(transport);
      handle = mountChippEmbedPanel(containerRef.current, controller);
    })();

    return () => {
      cancelled = true;
      handle?.destroy();
    };
  }, []);

  return <div ref={containerRef} style={{ height: "600px" }} />;
}

API Reference

createHttpEmbedTransport(config)

Creates a transport for communicating with the embedded agent API.

Parameters:

  • baseUrl (string): runtime base URL
  • applicationId (string): bound agent ID
  • token (string, required): scoped session token minted by your backend
  • getToken (function, optional): returns the current token for each request
  • fetchImpl (function, optional): custom fetch implementation (defaults to global fetch)

Returns: EmbedTransport

createEmbedChatController(transport)

Creates the shared state machine for all surfaces.

Methods:

  • getState(): current state (status, threads, messages, etc.)
  • subscribe(callback): listen for state changes; returns an unsubscribe function
  • loadHistory(): load previous messages
  • selectThread(threadId): switch threads
  • sendMessage(text, { hostContext }?): send a message
  • retry(): retry the last failed message
  • cancel(): cancel the current streaming response
  • answerConfirmation(confirmationId, approved): confirm or cancel a write the agent proposed

Returns: EmbedChatController

mountChippEmbedPanel(container, controller, options?)

Mounts the inline chat panel.

Parameters:

  • container (HTMLElement): DOM node to mount into
  • controller (EmbedChatController): shared controller
  • options (object, optional):
    • placeholder (string): composer placeholder text
    • autoLoadHistory (boolean): load history on mount (default true)
    • getHostContext (function): returns host context for each message

Returns: { destroy() }

mountChippEmbedWidget(container, controller, options?)

Mounts the floating widget.

Parameters:

  • container (HTMLElement): DOM node to mount into
  • controller (EmbedChatController): shared controller
  • options (object, optional): all panel options, plus:
    • position (string): "bottom-right" | "bottom-left" (default "bottom-right")
    • initiallyOpen (boolean): mount open (default false)
    • launcherLabel (string): launcher accessible label (default "Open chat")
    • panelLabel (string): dialog accessible label (default "Chat")
    • onStateChange (function): called when the widget opens, closes, or minimizes

Returns: { open(), close(), minimize(), toggle(), getState(), getUnreadCount(), destroy() }

mountChippEmbedSurfaces(options)

Mounts the inline panel, the floating widget, or both against one controller.

Parameters:

  • mode (string): "inline" | "floating" | "both"
  • controller (EmbedChatController): shared controller
  • inline (object): { container, options? }, required for "inline" and "both"
  • floating (object): { container, options? }, required for "floating" and "both"

Returns: { inline, floating, destroy() }

applyChippEmbedThemeOverrides(element, overrides)

Apply CSS custom properties to override the theme.

Parameters:

  • element (HTMLElement): element to apply the theme to
  • overrides (object): { "--custom-property": "value" }

Returns: void

Support & Next Steps