Setup requirements by architecture
The install writes the chat, the token route and the variables. It can't change how your app is built. This page lists what Frankie needs from your architecture, then the common setups that need more than the defaults.
It's based on a real integration: a Next.js dashboard on Vercel with a separate API service on Cloud Run. How Apollo set it up shows what went wrong there and how each problem was fixed.
What Frankie needs, whatever your stack
Check each line before you go live.
| Requirement | Why | If it's missing |
|---|---|---|
| A user-facing API described by an OpenAPI spec. The routes your own frontend calls for a signed-in user, not internal, admin or service-key routes. | The agent can only do what the spec describes. Internal routes expect a service key, not a user. | Actions return 401, 403 or 404, or act with more power than the user has. |
| The API checks each user's access itself. | The agent acts as the user. Your API is the boundary, not Frankie. | A user could reach someone else's data through the agent. |
The chat can reach the API. Same origin, or CORS that allows your app's origin (with credentials for cookies, or the Authorization header for tokens). | Browser-run actions are fetch calls from your page. | Actions fail with a network or CORS error. |
A token endpoint on a backend that can see the session. POST /api/frankie/token must receive the user's cookie or session. | It signs the user token Frankie trusts. See Chat as the signed-in user. | 401 while signed in, and no widget in production. |
The same FRANKIE_JWT_SECRET everywhere tokens are signed or verified. Your token endpoint, and any API that verifies Frankie's user token. | One side signs, the other verifies. Different values fail every call. | 401 on every action while signed in. |
| Allowed sites list every address the chat runs on. | Frankie refuses sessions from any other origin. | The chat doesn't load. See Go live, step 4. |
| Frankie's stylesheet loads after Tailwind, from your global CSS, never from a component. | Its cascade layers must come after yours. | Your app's padding and borders disappear, or the chat looks unstyled. |
/chat can fill its area, if you use the full-page chat inside an app shell. | A padded, width-capped shell boxes the chat in. | The chat shows as a small panel with a gap around it. |
Who makes the request
Most of the setups below come down to this choice. Set it per API under Product agent → How Frankie calls this API.
| User's browser | Frankie's server | |
|---|---|---|
| Who calls your API | The page, with fetch | Frankie's servers |
| How the user is identified | Your app's own session: cookies, or a header your page adds with the SDK's api.headers | The user's Frankie token, sent in the header named in Send the user's sign-in in this header |
| Your API must accept | What it already accepts from your frontend | A bearer token signed with FRANKIE_JWT_SECRET (HS256), or your JWKS keys |
| Your API must be reachable from | The user's browser (CORS if it's on another origin) | The public internet |
| Cookies | Work | Don't work |
| Uses Send the user's sign-in in this header | No. The browser sends only what your page sends. | Yes. Blank sends no sign-in. |
Where browser-run calls go, first match wins:
- The API base URL set on the connection in the dashboard, when its origin is one the SDK trusts with your sign-in: the page's own origin, the origin of
api.baseUrl, or one ofapi.allowedOrigins. Or when the SDK has noapi.baseUrlof its own. - The SDK's
api.baseUrl, when it's set to anything other than the page's own origin. Since@frankieai/react0.2.35 this also wins over a connection URL on an untrusted host, so the call carries your sign-in. - The spec's
servers[0], when it's an absolutehttpsURL. - The page's own origin.
api.headers() and non-omit credentials only go to those trusted origins. A call sent anywhere else still runs, without them, and the browser console warns once. If your API answers on more than one origin (a custom domain and its *.run.app URL), list the others in api.allowedOrigins, and set the connection's API base URL to the same value as api.baseUrl.
One Next.js app with its API routes
The simplest setup, and what the install assumes. The pages and /api/* routes are one deployment on one origin.
| Set | Value |
|---|---|
| Who makes the request | User's browser |
Spec servers | Leave out, or use a relative URL. Calls go to the page's origin. |
SDK api | The install's default: the page's origin, with cookies. |
| CORS | Not needed. |
FRANKIE_JWT_SECRET | On this one deployment. |
Your API routes already read the session cookie, so each action runs as the user with no extra work.
A frontend and a separate API service
For example, a dashboard on Vercel and an API on Cloud Run, on different origins. The page and the API are different deployments, often with different secrets stores.
| Set | Value | Why |
|---|---|---|
Spec servers[0] | The API's public URL | Browser-run calls go there instead of to the dashboard. |
| Or the connection's API base URL | The API's public URL | Wins over the spec. Use it per environment. |
| How the browser signs in | Either cookies (credentials: "include") and CORS with credentials, or a token header (api.headers) and CORS that allows Authorization | Cookies set by the dashboard's domain usually don't reach a different site. A token header always does. |
FRANKIE_JWT_SECRET | The same value on the dashboard (signs) and the API (verifies) | If they differ, every call is 401. |
| Secret access | The API's runtime identity can read the secret (on Cloud Run with Secret Manager: the service account has Secret Manager Secret Accessor on it) | The deploy can succeed while the service can't read the value. |
| The API's routes | A public, user-facing /v1 that authenticates the user on every request | Internal routes trust a service key, not a person. |
The token-header pattern, which works across sites with no cookies: the page gets the user's 5-minute token from your own token endpoint and sends it to the API as a bearer. The API verifies it with FRANKIE_JWT_SECRET.
let cached: { value: string; expires: number } | null = null;
export const hostApi = {
baseUrl: process.env.NEXT_PUBLIC_API_URL!, // the API, not this app; never a hard-coded fallback
// Every other origin the same API answers on, so the token goes there too.
allowedOrigins: process.env.NEXT_PUBLIC_API_ALT_ORIGINS?.split(",").filter(Boolean) ?? [],
credentials: "omit" as const,
headers: async (): Promise<Record<string, string>> => {
if (!cached || cached.expires < Date.now()) {
const res = await fetch("/api/frankie/token", { method: "POST" });
const token = res.ok ? ((await res.json()) as { token: string }).token : undefined;
// Tokens live 5 minutes: refresh a minute early.
cached = token ? { value: token, expires: Date.now() + 4 * 60_000 } : null;
}
return cached ? { Authorization: `Bearer ${cached.value}` } : {};
},
};
On the API, accept that token next to your own: verify HS256 with FRANKIE_JWT_SECRET, require sub and exp, then load the user by sub and run your usual access checks. Don't trust other claims for permissions. The HMAC key is the secret's UTF-8 bytes as set: it looks like base64, but don't decode it.
The API's CORS allowlist, read from an env variable, must include the app's origins and its local dev origin, with methods GET, POST, PUT, PATCH, DELETE and headers Authorization, Content-Type. Allow credentials only if you use cookies. If the API's responses link to app pages, build those links from a configured public app URL, never the API's own host.
Check the secret matches. New installs' /api/frankie/health reports jwtSecretFingerprint: the first 8 hex characters of the secret's SHA-256, never the secret. The install's readiness card compares it with your workspace's secret and shows Token secret doesn't match (or FRANKIE_JWT_SECRET isn't set) when they differ. For a separate API, add the same value to its health route and compare after each deploy.
import { createHash } from "node:crypto";
const fingerprint = (s?: string) => (s ? createHash("sha256").update(s).digest("hex").slice(0, 8) : null);
A cookie-session app or a token API
Pick the mode from how your API identifies users.
| Your API identifies users by | Choose | Your API must accept | Set |
|---|---|---|---|
| A session cookie | User's browser | Nothing new | Same origin, or CORS with credentials and credentials: "include". Leave Send the user's sign-in in this header blank: it isn't used. |
| A bearer token your frontend holds (Supabase, Firebase ID token, your own) | User's browser | Nothing new | api.headers returns that token. The install does this for Supabase. |
| A bearer token, and you want the agent to work without a browser (for example scheduled jobs) | Frankie's server | Frankie's user token, signed with FRANKIE_JWT_SECRET, in the header you name | Send the user's sign-in in this header: usually Authorization. An API base URL Frankie's servers can reach. |
A cookie-only API can't use Frankie's server: Frankie never has your cookies.
Monorepos
| Do | Why |
|---|---|
| Install into the app your users sign in to. Connect GitHub asks which app; name it in the prompt for an AI assistant. | The widget, token route and spec live in that app. |
| Generate the spec from the code that defines the routes, next to the API. | A hand-written spec drifts. |
Commit a copy where Frankie reads it, public/frankie.openapi.json in the app folder. | A Connect GitHub install reads the committed file. A spec only served at a URL isn't connected until you add the URL. |
| Test for drift. A test that regenerates the spec and fails if either committed copy differs. | A new route without a spec entry is invisible to the agent. |
Apollo kept one table of operations in the API (apps/api/src/v1/operations.ts): each entry is the route, its access check, its handler and its OpenAPI entry. A script writes the spec to the API folder and to the dashboard's public/frankie.openapi.json, and a test fails when either is out of date. The spec can't list a route that doesn't exist, or skip one that does.
An app shell with padding or a max width
If /chat renders inside your app's layout, the layout's padding and max-w-* column box it in.
| Set | Value |
|---|---|
FrankieChatPage | fillViewport={false} and a full-height class, so it sizes to its parent instead of the viewport. |
| Your shell, while the chat shows | No padding, no width cap, no outer scroll. |
.app-main:has(.frankie-fullbleed) { padding: 0; overflow: hidden; }
.app-content:has(.frankie-fullbleed) { max-width: none; }
<div className="frankie-fullbleed h-full">
<FrankieChatPage {...props} fillViewport={false} className="h-full rounded-none border-0" />
</div>
New installs mark a padded or width-capped wrapper in your layout with data-frankie-shell, and the chat page lifts it while it's shown. If the wrapper is one of your components rather than a plain element, the pull request asks you to do it.
How Apollo set it up
Apollo is a platform for building voice agents: a Next.js dashboard on Vercel, an API service on Cloud Run, Firebase sign-in and Google Secret Manager. Six things failed on the way to working actions.
| # | Symptom | Cause | Fix |
|---|---|---|---|
| 1 | The dashboard's layout broke: padding and borders gone. | Frankie's stylesheet was imported in a component, so its cascade layers loaded before Tailwind's. | Import it once from globals.css, after @import "tailwindcss". |
| 2 | The connected API didn't match the repository. | The spec was only served at a URL. The committed public/frankie.openapi.json step was skipped. | Generate the spec and commit it to the dashboard's public/frankie.openapi.json, with a drift test. |
| 3 | POST /restaurants returned 404. | The backend's spec (internal routes, service key) was connected, not a user-facing one. | A public /v1 that authenticates each user and checks access, and a spec of only those routes. |
| 4 | It was unclear whether the sign-in header mattered. | Send the user's sign-in in this header applies only to Frankie's server. | Browser mode: send the token from the page with api.headers. |
| 5 | Every action got 404 from the dashboard. | Browser-run calls went to hostApi.baseUrl + path, and the install set that to the page's origin, ignoring the spec's servers. | Point baseUrl at the API. Browser calls now use the spec's servers[0] by default. |
| 6 | 401 while signed in. | FRANKIE_JWT_SECRET differed between the signer (Vercel) and the verifier (the API). Then the API's runtime service account couldn't read the new secret. | One value in Secret Manager for the API, the same on Vercel; grant the service account access; compare fingerprints. |
Teaching the agent
Once actions work, how well the agent uses them depends on your spec and your errors.
| Give it | Why | Example |
|---|---|---|
| Operation summaries and descriptions | They're its manual. It reads them to choose an action and to know what to ask first. | "Buys a US number and connects it to the agent. This costs money every month until released. Always confirm with the user first, naming the number and the agent." |
| Parameter descriptions that say where values come from | The agent chains calls instead of guessing ids. | agentId: "An agent id from listAgents." |
| Skills for multi-step flows | A skill turns single actions into a workflow: what to ask, in what order, and what to report. | "Set up a new agent": create it, pick a number, confirm the cost, buy it. |
| Errors with a reason and a sentence | The agent repeats your error to the user. "Unauthorized" helps no one. | 401 { "error": "Sign in again to do that (the sign-in may have expired).", "reason": "expired_token" } |
For sign-in failures, a machine-readable reason (missing_token, invalid_token, expired_token, account_unavailable) in the body and the WWW-Authenticate header lets you diagnose a refused call from the response alone. Never put the secret, or anything derived from it beyond a fingerprint, in an error.