For AI agents: a documentation index is available at /llms.txt, and the full corpus at /llms-full.txt. A markdown version of any page on this site is available by appending .md to its URL path — the homepage is at /index.md.

Advanced

Patterns the SDKs handle for you — and how to reproduce them when you're calling the endpoint directly.

Token refresh strategy

Tokens expire. The expected pattern, regardless of language or platform:

  • Mint a token from your backend by calling /api/auth/token with the secret key. Cache the access_token alongside its expires_at.
  • Reuse the cached token until you're within 30 seconds of expires_at, then mint a fresh one.
  • If multiple requests need a token at the same time, deduplicate so only one mint is in flight — pending requests wait for that single result.
token-cache.js
// Mint on your backend, cache with the returned expires_at (unix seconds),
// and refresh 30s early. Dedupe concurrent mints so only one is in flight.
let cached = null; // { accessToken, expiresAt }
let pending = null; // a mint already in flight
async function getAccessToken() {
const now = Math.floor(Date.now() / 1000);
if (cached && now < cached.expiresAt - 30) return cached.accessToken;
if (pending) return pending; // wait for the in-flight mint
pending = fetch("https://api.ai-autocomplete.com/api/auth/token", {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.MAGICX_SECRET_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ product_id: process.env.MAGICX_PRODUCT_ID }),
})
.then((r) => r.json())
.then((token) => {
cached = { accessToken: token.access_token, expiresAt: token.expires_at };
return token.access_token;
})
.finally(() => {
pending = null;
});
return pending;
}

401 retry

If a token is rejected mid-flight (clock skew, server-side revocation), force-refresh once and retry the original request. This mirrors what the SDKs do internally.

retry-on-401.js
// Force-refresh once on a 401, then retry the original request. Never loop.
async function suggest(body) {
let res = await callSuggest(await getAccessToken(), body);
if (res.status === 401) {
cached = null; // drop the rejected token
res = await callSuggest(await getAccessToken(), body); // one retry only
}
if (res.status === 401) {
throw new Error("Token rejected after refresh"); // surface it, don't retry again
}
return res.json();
}
function callSuggest(token, body) {
return fetch("https://api.ai-autocomplete.com/api/suggest", {
method: "POST",
headers: {
Authorization: "Bearer " + token,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
}

Managing requests during fast typing

Fast keystrokes can fire requests faster than the API can respond. The SDKs handle three concerns in concert; HTTP-direct callers should too.

  • Debounce keystrokesWait briefly after the last keystroke before sending — long enough to collapse rapid bursts, short enough that the user doesn't perceive the delay. Tune to your UX; 100–200ms works well for most autocomplete interfaces — lean toward 100ms when you want a snappier, more responsive feel, and toward 200ms to trim request volume during fast typing.
  • Cancel in-flightWhen a newer request is about to fire, abort any in-flight request first. Use whichever request-cancellation mechanism your platform provides, or a request-tag scheme that lets you ignore late completions.
  • Discard stale responsesTrack the request_id (or request_at) you sent on the most recent call. When a response arrives, compare its meta.request_id — if it doesn't match the latest, drop the response without applying it. Protects against network reordering even when cancellation works correctly.
request-scheduler.js
let debounceTimer = null;
let inFlight = null; // AbortController for the current request
let latestRequestId = null;
// Debounce: wait 150ms after the last keystroke before firing.
function onKeystroke(rawQuery, completedParams) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => send(rawQuery, completedParams), 150);
}
async function send(rawQuery, completedParams) {
inFlight?.abort(); // cancel any in-flight request
inFlight = new AbortController();
const requestId = crypto.randomUUID();
latestRequestId = requestId;
const res = await fetch("https://api.ai-autocomplete.com/api/suggest", {
method: "POST",
headers: {
Authorization: "Bearer " + (await getAccessToken()),
"Content-Type": "application/json",
},
body: JSON.stringify({
data: { raw_query: rawQuery, completed_params: completedParams },
meta: { request_id: requestId, request_at: new Date().toISOString() },
}),
signal: inFlight.signal,
});
const json = await res.json();
// Discard stale responses that arrive out of order.
if (json.meta.request_id !== latestRequestId) return;
render(json.data.suggestions);
}

Relaying on-screen suggestions

When the user answers a suggestion by typing instead of tapping, the request carries the words but nothing about what was on screen while they were typed. data.recently_suggested closes that gap — it relays the suggestions the user could see while typing the current unresolved trailing text, so those words can be recognized as an answer to one of them. Text that already matches a parameter or a known option is unaffected; the relay only changes the outcome for words that match nothing else, which is what lets "no special instructions" count as an answer to the special instructions suggestion instead of that field being suggested again. The field is optional and omitting it changes nothing else about the response.

recently-suggested.json
{
"data": {
"raw_query": "Create a {{TYPE_1}} no special instructions",
"completed_params": [
{ "placeholder": "{{TYPE_1}}", "type": "type", "text": "email" }
],
"recently_suggested": [
{ "type": "special_instructions", "text": "special instructions" }
]
},
"meta": {
"request_id": "5a0d1c1e-1b3f-4a9e-8a4f-001a1c3b7d20",
"request_at": "2026-05-26T18:30:00Z",
"session_id": "9e5b7c0e-2a1b-4f8e-9c2d-3e4f5a6b7c8d"
}
}
  • When to startOn the first keystroke of text that isn't already covered by a completed param — the point where the user starts answering in prose. Snapshot the suggestions on screen at that moment and keep sending them even after later responses replace what's displayed, since the words being typed were prompted by the snapshot.
  • What to sendThe snapshot plus whatever suggestions are on screen now, snapshot entries first, deduplicated by type. Send each suggestion's own type and text — not the words the user typed. Skip "placeholder" suggestions; they're prompt hints, not answerable fields.
  • When to stopAs soon as the trailing text resolves — it becomes a completed param, the user deletes it back to where they started typing, or they pick an option instead. Omit the field entirely from then on rather than relaying a hint the user has moved past.

Reporting dismissed suggestions

Users dismiss suggestions as well as answer them — the → key in the SDKs, whatever decline affordance you build. Report a dismissal and that parameter counts as handled, so it stops coming back in the next response's suggestions; leave it out and the user gets asked the same thing again. The SDKs do this on every request and on the submitted result, so a hand-rolled client should match them if you want the same behavior.

skipped.json
{
"data": {
"raw_query": "Create a {{TYPE_1}}",
"completed_params": [
{ "placeholder": "{{TYPE_1}}", "type": "type", "text": "email" },
{ "placeholder": "", "type": "goal", "text": "skipped" }
]
},
"meta": {
"request_id": "5a0d1c1e-1b3f-4a9e-8a4f-001a1c3b7d20",
"request_at": "2026-05-26T18:30:00Z",
"session_id": "9e5b7c0e-2a1b-4f8e-9c2d-3e4f5a6b7c8d"
}
}
  • What to sendAppend an entry to completed_params carrying the suggestion's type, the literal text "skipped", and an empty placeholder — nothing was substituted into raw_query, so there is no token to name. Put these after your real params, which do have positions in the query.
  • When to drop itLeave the dismissal out once the user fills that parameter after all — an entry that is both answered and declined contradicts itself. Re-derive the list on each request rather than discarding the dismissal for good: if the user deletes that value again, it should come back.

Personalizing suggestions with additional_context

Your app usually knows more about the request than the query alone — who the user is, what they have ordered or built before, what is open on screen. Send that as data.additional_context, a free-form JSON object, and the server tailors the parameters and option values it suggests to it: if the context names a value for a field it is about to suggest, that value is listed first. The server never interprets the object itself — only you know what is worth conditioning on — and it is not remembered between requests, so include it on every request you want personalized.

additional-context.json
{
"data": {
"raw_query": "I want a",
"additional_context": {
"tier": "gold",
"home_store": "Gangnam Station",
"allergies": ["peanut"],
"preferences": { "milk": "oat milk", "size": "grande" },
"favorites": ["iced americano", "cold brew"],
"recent_orders": [
{ "drink": "iced caramel macchiato", "size": "grande", "at": "2026-08-18" }
]
}
},
"meta": {
"request_id": "0c6a3b8e-5d2f-4e1a-9b7c-2d4e6f8a0b1c",
"request_at": "2026-08-19T09:15:00Z",
"session_id": "9e5b7c0e-2a1b-4f8e-9c2d-3e4f5a6b7c8d"
}
}
  • What to sendKeys that match the product's catalog field names (e.g. size, milk) are honoured most reliably; free-form prose is weaker. Nest as you like — the shape is yours. Omit the field entirely when there is nothing to add; an empty object is treated the same as absent.
  • Size cap2000 bytes, measured on the compacted JSON (whitespace is free), roughly a full profile plus a couple dozen history entries. Non-ASCII text costs 3 bytes per character. Anything past the cap is truncated with a trailing "…" rather than rejected, so lead with the most useful keys.
  • It is data, not instructionsThe server treats the object strictly as data. Instructions, role changes, or prompt-like text inside it are ignored, and its values are never written into the query on the user's behalf — they only steer which options are offered.
  • Personal dataSend only what you want the model to condition on. The same masking rule as completed_params applies: leave out names, emails, and other identifiers that add nothing to the suggestions.

PII masking

By default, each completed_params[].text carries the literal value the user picked (e.g. "alex@example.com"). To keep that out of server logs — for compliance or privacy — omit the text field on entries you want to mask. The request still validates with the placeholder and type intact, but the server no longer sees the specific value, which can reduce suggestion quality. Use selectively where the privacy benefit outweighs the ranking cost.

masked-request.json
{
"data": {
"raw_query": "Draft an email to {{CONTACT_1}}",
"completed_params": [
{ "placeholder": "{{CONTACT_1}}", "type": "contact" }
]
},
"meta": {
"request_id": "5a0d1c1e-1b3f-4a9e-8a4f-001a1c3b7d20",
"request_at": "2026-05-26T18:30:00Z",
"session_id": "9e5b7c0e-2a1b-4f8e-9c2d-3e4f5a6b7c8d"
}
}

Session lifecycle

A session_id groups one user's autocomplete activity for analytics and event recording. Reuse the same value across keystrokes within one autocomplete interaction; mint a new one when:

  • The user commits — presses Enter, taps a Submit button, or any equivalent finalize action in your UI.
  • The user navigates away, closes the surface, or otherwise abandons the current input.
  • A long idle period elapses — there's no server-enforced TTL, so pick a window that matches your UX (e.g. 10–15 minutes since the last keystroke).
session.js
// Reuse one session_id across a user's keystrokes; rotate when they finalize.
let sessionId = crypto.randomUUID();
function metaForKeystroke() {
return {
request_id: crypto.randomUUID(), // fresh per request
request_at: new Date().toISOString(),
session_id: sessionId, // stable across the interaction
};
}
function onSubmit() {
// User committed (Enter / Submit) — start a new session for the next input.
sessionId = crypto.randomUUID();
}

Choosing an auth mode

Pick the credential that matches where the call originates:

  • Public key — drop straight into a client application. Scoped and rate-limited; safe to ship in client bundles.
  • Secret key + access token — your server holds the secret, mints short-lived tokens, and the client only ever sees the token. Use for production: token leaks have a small blast radius.
  • Server-to-server — call /api/suggest directly with the secret key when there's no end-user client in the loop (back-office, batch jobs).