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

DataMode
Static FAQ, service descriptionstext
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}" }
}

3. What your endpoint should return

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}`;
}

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.

Security checklist

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:

ToolArgumentsReturns
search_stationsquery (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_stationidone 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.

The general rule: if a value exists only to make your own UI work, it does not belong in a tool response. Return an identifier and let your front end resolve it.

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.

Takeaway: if your product can't act on a result, don't let the model see it. A 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.

Build the filtering into the API, not the client. The moment you have more than one AI surface — a web widget and ChatGPT — any rule you put in the widget is a rule the other client doesn't have.

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:

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.