The config panel

The config panel is our character editor, rendered inside your product, so your customer can configure their own character without leaving your UI and without you rebuilding the editor. You embed a script; it mounts an iframe; a short-lived token scopes that iframe to exactly one character on exactly one account.

::: warning Live in both environments, and not yet exercised end to end. The panel and its mint are deployed, and the credential blocker is gone: the mint is gated on the account owner, and a connected-app context resolves to the owner of the account it names — so your server can mint a panel token for an account you provisioned without any human signing in.

What has been driven end to end from a partner credential: registering a client, minting a token, mounting the panel in a third-party page, and the editor loading live character data. What has not: saving an edit from inside the panel, and the chat simulator. Exercise those in the sandbox before you rely on them.

One caveat that will bite before any of the others: every panel request checks usage limits, and fails closed if it cannot. When metered billing is off or the account service is unreachable, every panel-token request answers 503 embed_budget_unavailable. See caps.

Confirm with your Fluid contact which environment you are pointed at before you build against this. :::

How it is meant to fit together

your server ─┐
             │ 1. register your origins        POST /api/strategy/embed/clients   -> ftep_… client id
             │ 2. mint a panel token           POST /api/strategy/embed/tokens    -> fte_…  token
             ▼
your page ───── <script src=".../embed.js"> ──> FluidTalkEmbed.mount({ getToken })
                                                        │
                                                        ▼
                                             iframe on talk.fluidvip.com
                                             Authorization: Bearer fte_…

Steps 1 and 2 happen on your server. The token — never the credential that minted it — is what reaches the browser.

Mounting it

You embed a <script>. You never write the <iframe> yourself; the loader creates it, sizes it, and owns the message channel.

<div id="panel"></div>
<script src="https://talk.fluidvip.com/embed.js"></script>
<script>
  const handle = window.FluidTalkEmbed.mount({
    container: document.getElementById("panel"),
    characterId: "…",
    clientId: "ftep_…",
    getToken: async () => {
      // Your backend calls the mint endpoint and returns its JSON.
      const r = await fetch("/api/fluidtalk/panel-token", { method: "POST" });
      return r.json();
    },
    onReady:   () => {},
    onSaved:   () => {},
    onError:   (e) => console.error(e.code, e.message),
  });
</script>

container and characterId are required — the loader throws if either is missing. handle exposes refresh(), isReady(), setTheme(theme) and destroy().

The panel enforces a minimum size of 360 × 520 and defaults to 760 tall. There is no popup mode; iframe is the only mode.

The token handshake

getToken() is called when the panel signals it is ready, and again before each expiry. It may resolve to the mint response or to a bare token string.

Your server mints:

curl -X POST https://api-talk.fluidvip.com/api/strategy/embed/tokens \
  -H "X-Partner-Key: fv_pk_…" \
  -H "X-Partner-Account: 9c1f4b7e-2a58-5d03-b6e1-77af0c9d3218" \
  -H "Content-Type: application/json" \
  -d '{
    "client_id": "ftep_…",
    "origin": "https://app.acme.example",
    "character_id": "…"
  }'
{
  "embed_token": "fte_…",
  "token_id": "…",
  "expires_at": "2026-08-25T10:22:41.000000+00:00",
  "expires_in": 600,
  "character_id": "…",
  "origin": "https://app.acme.example",
  "message_cap": 30
}
Property Value
Prefix fte_
Default lifetime 600 seconds (10 minutes), env-tunable
Hard ceiling 900 seconds (15 minutes); floor 30
At rest sha256 only. The plaintext is returned once and never persisted
Presented as Authorization: Bearer fte_… — a header, never a query parameter
Scope exactly one character, on one account

Hold it in memory. Nothing else. Not localStorage, not sessionStorage, not a cookie, not the iframe URL. It is short-lived precisely so that holding it in a variable is sufficient, and the loader re-mints ahead of expiry (60s of lead, never sooner than 15s apart) so nothing you build has to persist it.

expires_in is what drives that refresh. If you omit it, no refresh is scheduled and the panel goes dead at expiry — pass the mint response through verbatim rather than reshaping it.

Revoking is immediate: POST /api/strategy/embed/tokens/{id}/revoke, or revoke the whole client and every token under it stops on its next request. All resolution failures — bad prefix, unknown token, revoked, expired, revoked parent — answer the same opaque 401 Invalid embed token.

Registering your origins

An origin must be registered before a panel can be framed by it. You register a client, which holds up to 20 origins:

curl -X POST https://api-talk.fluidvip.com/api/strategy/embed/clients \
  -H "X-Partner-Key: fv_pk_…" \
  -H "X-Partner-Account: 9c1f4b7e-2a58-5d03-b6e1-77af0c9d3218" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme Desktop",
    "origins": ["https://app.acme.example", "http://127.0.0.1:8731"]
  }'
{
  "id": "…",
  "client_id": "ftep_…",
  "name": "Acme Desktop",
  "origins": ["https://app.acme.example", "http://127.0.0.1:8731"],
  "revoked": false,
  "created_at": "2026-08-25T09:58:02.117400+00:00"
}

Origins are normalised to scheme://host[:port] — lowercased, default ports dropped — and matched at request time by exact string equality. There is no wildcard support of any kind: no globs, no suffix matching, no subdomain patterns. A path, a query string, a fragment, embedded credentials, or a non-numeric port all cause the entry to be dropped silently at registration. If every entry you sent is dropped you get:

{
  "error": "no_valid_origins",
  "message": "Provide at least one https origin (or an http loopback origin for a desktop app). file:// cannot be allowlisted."
}

The canonical origin is then used in three places at once — the allowlist comparison, the frame-ancestors CSP directive the panel is served with, and the postMessage target. The mint response returns it so the value that was allowlisted and the value you post to are the same string. postMessage is never targeted at "*".

What may be registered

Shape Registrable? What it actually proves
https://host[:port] Yes, always Real authority. Authenticated by DNS and TLS — only the party controlling that hostname can produce it. Use this wherever you can
http://localhost:PORT, http://127.0.0.1:PORT, http://[::1]:PORT Yes Nothing about identity. Any process on the user's machine can bind that port and produce that exact origin
app://… or another custom scheme Only if an operator has named the scheme in EMBED_APP_SCHEMESempty by default It identifies; it does not authenticate. Scheme names are globally unregistered, so another application may claim the same one
file:// No. Refused unconditionally See below
data:, blob:, javascript:, about:, the literal null No. Refused unconditionally Opaque origins, or not origins at all

Plain http:// is accepted only on those three loopback hosts. http://app.acme.example is not registrable.

The desktop constraint, stated honestly

If you ship a Windows or Mac desktop application as well as a web app, this is the part that will shape your architecture, so it is worth being blunt about it.

A file:// origin can never be allowlisted. Not by configuration, not by request, not by any future version of this API. A file:// document is an opaque origin in every browser engine: the browser sends Origin: null, postMessage can only be targeted at "*", and CSP has no source expression that matches it — frame-ancestors 'none' denies it and frame-ancestors * allows it and everyone else. There is no way to express "this file, not that file", because to the browser there is no difference.

Accepting the literal string null as an allowlist entry would mean allowing every sandboxed iframe, every data: document and every other local file on that machine to embed the panel and receive the token. That is not a narrower grant than "anyone"; it is "anyone". So it is refused, and _FORBIDDEN_SCHEMES subtracts these schemes from the operator-configurable list too, meaning an env typo cannot re-open the hole.

You have two options, and both are weaker than https://:

Serve your UI from a loopback HTTP server (the usual Electron/Tauri pattern once file:// proves unworkable) and register http://127.0.0.1:PORT. This is representable and string-comparable, which is why it is accepted. Understand what it proves: nothing about identity. Any process on the end user's machine can bind that port and produce that exact origin, and there is no cross-application isolation on loopback. It scopes an embed to the user's own machine, not to your app. It is accepted because the alternative is no desktop support at all — not because it is secure.

Register a custom scheme. Chromium emits an Origin header for a custom scheme only when that scheme was registered as standard and secure; otherwise the request carries Origin: null and you are back to the file:// problem. When it is emitted it compares fine. The caveat is the same as loopback and slightly worse: a scheme name is globally unregistered, so any other application on the machine may claim app://partner and be indistinguishable from yours. This mode is off by default — an operator must name your scheme in EMBED_APP_SCHEMES before it is registrable at all.

::: warning Do not design as though loopback or a custom scheme authenticates your app. Neither does. Scope what the panel can reach accordingly: a panel token is already limited to one character on one account and expires in ten minutes, and on desktop that bound is the only one doing real work. Prefer serving your desktop UI from your real https:// origin if your architecture permits it. :::

Caps that apply inside the panel

These are checked on every request bearing a panel token, at the auth door, not per endpoint.

Cap Default Error when exceeded
Messages per panel session 30 429 embed_session_cap — "This embedded session has reached its limit. Reopen the panel to continue."
Spend per app per account per day (UTC) $5.00 429 embed_daily_cap — "This app has reached today's usage limit on this account."
Requests per minute per token 120 429 rate limited
Anonymous pre-flight per IP per minute 60 429 rate limited

Two more failure modes matter:

  • 402 insufficient_tokens — the account's balance is empty.
  • 503 embed_budget_unavailable — usage limits cannot be verified. This is what every panel-token request returns when metered billing is off or the account service is unreachable. It is the fail-closed direction, and it is the state a default deployment is in today.

The panel also enforces a route allowlist: anything outside the character-editing, photo, binding and chat-simulator routes answers 403 not_available_in_embed. A panel token cannot be repurposed into a general API credential.

Wiring "Create your own account"

The panel carries a button for the customer to claim the account you provisioned. It hands you the intent; you mint the link and open it.

On your server — the key never leaves it:

// POST /api/registration-link
const r = await fetch(
  `https://api-account.fluidvip.com/api/partner/accounts/${ownerId}/registration-link`,
  { method: "POST", headers: { "X-Partner-Key": PARTNER_KEY, "Content-Type": "application/json" },
    body: "{}" },
);
return r.json();   // { registration_url, expires_at }

In your page — the browser only ever sees the resulting https:// URL:

FluidTalkEmbed.mount({
  // …
  onRegisterRequested: async () => {
    const { registration_url } = await (await fetch("/api/registration-link", { method: "POST" })).json();
    window.open(registration_url, "_blank", "noopener,noreferrer");   // web
    // Electron: shell.openExternal(registration_url)
    // Tauri:    the opener plugin
  },
});

::: warning Hand the URL over whole. The token is after the #. Anything that trims the fragment — a shortener, a redirect through your own domain, a chat client that "cleans" URLs — lands your customer on a dead page. And open the operating system's browser, not a webview: the page runs a bot challenge, and a webview without cookies will fail it. :::

Minting replaces any outstanding link for that account, and it is 409 already_registered once they have claimed it. See the registration link.

Known gaps

Stated so you do not plan around them:

  • "Create your own account" is yours to complete, and always will be. The panel emits register-requested with a null URL — that is not a stub. Minting the link needs your partner key, which must never reach a browser, and opening the real browser needs shell.openExternal, which an iframe cannot do. You are the only party that can do either. See wiring it — until you do, the button appears to do nothing.
  • resize reports the current height, not a content-driven ideal, so auto-sizing is approximate.
  • There is no light theme. The editor does not restyle. This is a documented limitation, not a bug.