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, notsessionStorage, 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_SCHEMES — empty 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-requestedwith anullURL — that is not a stub. Minting the link needs your partner key, which must never reach a browser, and opening the real browser needsshell.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. resizereports 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.
Related
- The registration link — what the panel's button is meant to open
- Limits and failures — the caps that apply outside the panel
- Authentication — connector tokens, which are a different credential entirely