Building a tools-mode API
How to connect your own backend to FodyChat so the AI can answer from live data — prices, availability, order status — and, safely, a signed-in user's own data (balance, orders, history).
Want the chat to prepare a route or open an app-owned choice instead? Read the client actions integration guide.
1. When you need this
| Data | Mode |
|---|---|
| Static FAQ, service descriptions | text |
| Large knowledge base (files/URLs) | rag |
| Live data for a specific query (price, availability, order status) | tools |
| Data belonging to the specific signed-in visitor (their balance, their orders, their history) | tools + a context param (section 4) |
If the question is "what's the price of the deluxe room" — regular tools is enough (sections 2–3). If it's "what's my balance" — you need section 4, or you'll open a security hole.
2. One strategy = one endpoint
A strategy is a function-calling tool: the AI decides when to call it and with what arguments. FodyChat's backend makes the HTTP request to your API and feeds the result back into the conversation.
{
"strategyId": "room_price",
"description": "Price and availability for a room on given dates",
"endpoint": "https://api.yourapp.com/rooms/price",
"method": "GET",
"params": { "checkin": "{checkin}", "checkout": "{checkout}", "type": "{roomType}" }
} strategyId— a unique tool name (backend-only, the visitor never sees it).description— this is what the AI reads to decide when to call this tool instead of another. Be specific about what it looks up and for which kind of question.params— a template for the query string (GET) or request body (POST). Values like{name}are placeholders the AI fills in from the conversation.
3. What your endpoint should return
- Plain JSON, HTTP 200. Return errors as JSON too (
{ "error": "..." }) with a clear message — the AI will explain it to the visitor in plain language. - Keep the response small. It goes straight into the model's prompt and is billed as input tokens. Cap lists to a reasonable number (e.g. the last 10 items, not the entire history).
- Don't return third-party data you don't need to. If the response is about one user, strip anything identifying someone else (another person's address, contact info, or an individual rating tied to one specific transaction) unless the question genuinely requires it.
- For a mutating action (booking, charging, canceling), set
"confirm": trueon the strategy — FodyChat won't execute it automatically; it shows the visitor a confirmation card first.
4. A signed-in user's own data — safely (contextParams)
The problem
Asking the AI to "get the user's ID from the conversation" is unsafe — it's free-text chat input that can be mistyped or deliberately spoofed. A project's publishable key is not a secret (it's visible in the embed snippet's page source), so without protection, anyone who can call your widget's /api/chat directly could, in principle, supply someone else's ID and get their data back.
The fix: a short-lived signed token FodyChat never reads
sequenceDiagram
participant S as Your site (browser)
participant B as Your backend
participant F as FodyChat backend
S->>B: 1. Already signed in (session/cookie)
B-->>S: 2. Signed, opaque token (mint endpoint)
S->>F: 3. mount() with payload.toolContext.yourToken
F->>B: 4. Calls your strategy, token passed through as-is
B-->>F: 5. Verifies the signature itself → data or 401
F-->>S: Answer in chat
Step 1 — on your side, while the user is normally authenticated (your existing cookie/session), mint a token shaped subject.expiry.signature:
const PREFIX = "chatbal:"; // scopes what this token can be used for
const TTL_SECONDS = 15 * 60; // short — re-minted every time the chat opens
function mint(secret, userId) {
const exp = Math.floor(Date.now() / 1000) + TTL_SECONDS;
const subject = PREFIX + userId;
const sig = hmacSha256Truncated(secret, `${subject}.${exp}`);
return `${subject}.${exp}.${sig}`;
} - Reuse the secret from your existing session system — you don't need a new one just for this.
- The prefix matters: even if this token leaked (for example, into a third-party LLM provider's logs — FodyChat may run on OpenAI, Anthropic, or Cloudflare Workers AI depending on configuration), it can't be replayed as a full login session — only for the one narrow, read-only lookup it was minted for.
- Keep the TTL short (10–15 minutes) — it only needs to live for a single chat session.
Step 2 — hand the token to the page through your own, already cookie-authenticated endpoint, and pass it into ChatEngine.mount():
fetch('/api/me/chat-token').then(r => r.json()).then(({ token }) => {
ChatEngine.mount({
endpoint: 'https://chat.fodylabs.com/api/chat',
payload: {
project: 'your-project-id',
key: 'pub_...',
toolContext: { yourToken: token } // any key name you like
}
});
}); The widget forwards payload as-is — no widget code changes needed.
Step 3 — mark the strategy param as a contextParams entry:
{
"strategyId": "my_balance",
"description": "The signed-in user's current balance",
"endpoint": "https://api.yourapp.com/me/chat/balance",
"method": "GET",
"params": { "token": "{yourToken}" },
"contextParams": ["yourToken"]
} Anything listed in contextParams is excluded from the function-calling schema — the AI never sees it as something to ask the visitor for, and can't forge it. FodyChat substitutes the value straight from toolContext, bypassing the model entirely.
Step 4 — your endpoint verifies the signature itself (FodyChat never does — it's a blind pass-through):
function verify(secret, token) {
const [subject, expStr, sig] = token.split(".");
if (!subject?.startsWith(PREFIX)) return null;
if (Date.now() / 1000 > Number(expStr)) return null; // expired
if (!constantTimeEqual(sig, hmacSha256Truncated(secret, `${subject}.${expStr}`))) return null; // bad signature
return subject.slice(PREFIX.length); // userId
} Use a constant-time comparison for the signature, not a plain === on raw strings — otherwise it can be guessed via response-time differences.
- The token is a signed, expiring blob — never the raw user ID.
- The signing secret is reused from your existing session system (not a FodyChat secret).
- A distinct prefix/scope so the token can't be replayed for anything broader.
- Short TTL (minutes, not days).
- Signature check is constant-time.
- The endpoint returns only what's needed to answer — not the whole user profile.
- No third-party data (other people's addresses/contacts/individual ratings).
- Lists are capped to a reasonable size — not the entire history.
5. Wiring it up
Create a project in tools mode, set its allowed origins, and configure strategies as JSON — your FodyLabs contact will set this up with you, or you can do it directly via the admin API (PUT /api/projects/:id/strategies).
Actions in your app are a separate integration. The host page passes an allowlist via ChatEngine.mount({ clientActions, onAction }). FodyChat proposes an allowed action and its arguments; actions marked for confirmation show a choice card before execution. Your onAction handler validates inputs, checks permissions and updates your app. Taxi uses this pattern to help prepare rides and excursions while keeping the booking flow in Taxi. Do not use a prompt as the source of truth for booking state or authorization.
6. A worked example: Radioller
Radioller is an internet radio product of ours running on ~50,000 stations, and it is the reference implementation of everything above. It is worth reading because it shows the two decisions that matter most in practice: what you filter out and what you refuse to return.
One endpoint, two tools
A single PHP file (/app/api/assistant.php) serves both strategies, switched by a tool query parameter:
| Tool | Arguments | Returns |
|---|---|---|
search_stations | query (station name or tag/genre, in the user's language) and/or country (ISO 3166-1 alpha-2) | totalCount, matched tags, plus up to 8 stations |
get_station | id | one station record |
Note the cap of 8. The catalog has tens of thousands of matches — "jazz" alone returns 452 — but every row returned is billed as input tokens on every subsequent turn. Return the best few, and give the model a totalCount so it can honestly say there are more.
Genre searches use Radioller's published tag catalog and station-tag associations as well as station names. Tag associations are a separate index from the MySQL station rows, so searches such as “разговорные станции” can find tagged stations even when the station name does not contain that word.
Return only what the answer can actually use
The chat renders an inline audio player next to each station, so an obvious shortcut would be to return the stream URL in the tool result. We deliberately don't: a stream URL in the tool payload is a stream URL in the model's prompt, and from there it can be repeated into chat, logged, or scraped. The tool returns a station page link; the browser widget fetches the stream separately from a public endpoint when it builds the player.
Filter server-side on a flag you already have
Radioller's pages are HTTPS, so a station that only streams over plain HTTP cannot play in a browser at all. Our first attempt handled this in the UI — detect HTTP, hide the player, apologize. That was the wrong layer: the model still saw, recommended, and linked stations it could not play.
The database already had an ats column (Apple's App Transport Security flag, maintained for the iOS app) that is a useful browser-playback signal. Search returns stations with ats = 1 or ats IS NULL; only stations explicitly marked ats = 0 are filtered out. This avoids hiding records where the legacy flag was never populated, such as Kommersant FM. The original ats value is preserved and exposed separately from the browser-playable decision.
WHERE clause is cheaper and more reliable than teaching the model to avoid something — and far more reliable than correcting it after the fact.
Cache it, and check that your host agrees
The endpoint sets Cache-Control: public, max-age=30, because popular queries repeat constantly. Worth verifying with curl -I rather than trusting your code: on our shared host an .htaccess rule written for an unrelated audio-streaming script was applying Header always set Cache-Control "no-store" to the whole API directory, silently overriding the header PHP sent and sending every single call to MySQL.
7. The same tools in ChatGPT and other MCP clients
A tools-mode endpoint and an MCP server are the same idea with different transports. For Radioller specifically, we also expose a remote MCP server at https://chat.fodylabs.com/mcp/radioller (Streamable HTTP). It calls the same endpoint described in section 6 — meaning the ats filter and the withheld stream URL apply identically. This does not mean every FodyChat project automatically gets an MCP endpoint.
For a listener, the practical flow is: connect this MCP server in an assistant that supports custom remote MCP servers, allow its read-only tools, then ask naturally — for example, “Find Polish rock radio.” The assistant calls search_stations, receives station metadata and Radioller links, and writes the answer. The user must connect the server first; asking an unconfigured assistant will not reach our MCP endpoint.
See the Radioller assistant setup guide for the current Claude link, client-specific notes, and example prompts.
How a result is displayed
In ChatGPT, custom MCP access and its setup controls depend on the account and workspace. Where available, add the server URL in ChatGPT's custom MCP app/connector flow. A plain MCP server like ours provides data and links; the assistant then writes an ordinary chat reply. Returning structuredContent makes the data easier for the model to use, but does not by itself produce richer visuals.
So in ChatGPT our stations arrive as text plus links — the inline audio players are specific to the embedded web widget, which can run its own JavaScript on the page.
What inline playback in ChatGPT would require
Rendering a real player inside ChatGPT means shipping a UI resource, not just data:
- Register an HTML resource with the MCP Apps MIME type
text/html;profile=mcp-appand point the tool at it via_meta.ui.resourceUri(ChatGPT still accepts the older alias_meta["openai/outputTemplate"]). - Declare the streaming origin in the widget's CSP (
_meta.ui.csp/openai/widgetCSP) — an undeclared media origin is blocked by the sandboxed iframe. - Keep playback behind a tap. Standard browser autoplay rules apply inside the iframe, and audio is not one of the permissions a host grants.
Testing your own widget privately needs no review; only public directory listing does. We have not shipped this for Radioller — note that the stream URL stays out of the tool payload either way, so a widget would resolve it the same way our web player does.
Moving target. OpenAI renamed "Apps SDK" to "Plugins" and rebuilt custom UI on the open MCP Apps standard, so older tutorials referencing text/html+skybridge are out of date. Check the current OpenAI plugin docs before building.