Embeddable widget
Add a full search UI with one script tag and a short-lived session token — the session contract, every data attribute, events, theming and CSP notes.
aacsearch.js is a self-contained InstantSearch bundle. Drop two elements into a page and it renders a search box, result count, hit list, facet checkboxes, sorting, pagination and recent queries — talking to your index through the AACSearch embed proxy without ever exposing a platform API key.
The page never holds the API key. Your server exchanges the key for a short-lived, search-only session token with POST /api/v1/embed/session and renders that token into the page as data-aac-token.
The script tag
Mint a token on your backend first (see Embed session):
curl -sS -X POST "$AAC_API/embed/session" \
-H "Authorization: Bearer $AAC_KEY" \
-H "Content-Type: application/json" \
-d '{ "index": "documentation", "expires_in": 900 }'Then put the returned token in the page:
<script
src="https://search.example.com/embed/v1/aacsearch.js"
data-aac-token="aac_sess_…"
data-aac-target="#aacsearch"
></script>
<div id="aacsearch"></div>That is the whole integration on the page side. The script finds its target, posts the token to /api/v1/embed/config, injects its stylesheet and mounts the UI — no build step, no framework, no npm package.
| Attribute | Required | Default | Meaning |
|---|---|---|---|
data-aac-token | yes | — | Embed session token from POST /api/v1/embed/session. Without it the script does not auto-mount |
data-aac-target | no | #aacsearch | CSS selector of the mount point |
data-aac-endpoint | no | derived from script.src | Base URL of the deployment, e.g. https://search.example.com |
data-aac-placeholder | no | from session settings | Overrides the input placeholder |
data-aac-client-id | no | anonymous UUID in localStorage | Stable visitor id (for example your authenticated user id) so searches and clicks build one history across devices |
There is no data-aac-key: a platform API key must never appear in HTML, a data- attribute or a URL. The token is search-only, tied to one index and expires on its own.
When data-aac-endpoint is omitted, the script derives it from its own src by stripping everything from /embed/ onwards — so copying the tag from the console's embed panel works on any domain.
If the tag sits in <head>, the widget waits for DOMContentLoaded; if the script runs after the DOM, it mounts immediately. Either way the target element must exist by the time the mount runs.
Versioned versus unversioned URL
| URL | Cache | Use |
|---|---|---|
/embed/v1/aacsearch.js | max-age=31536000, immutable | Production. The version is in the path: v1 never changes |
/embed/aacsearch.js | max-age=300 | Staging and previews; picks up fixes within five minutes |
Both are served with access-control-allow-origin: * and cross-origin-resource-policy: cross-origin, so they work from any domain. Pin the versioned URL in production — an unversioned script that upgrades itself is a surprising dependency.
Manual initialisation
For a single-page app, or when you need to mount after a route change, call the global instead of using data attributes:
<script src="https://search.example.com/embed/v1/aacsearch.js"></script>
<script>
window.AACSearch.init({
token: "aac_sess_…", // minted server-side, never the API key
target: "#docs-search",
endpoint: "https://search.example.com",
placeholder: "Search the docs…",
clientId: "user_123", // optional
});
</script>interface InitOptions {
token: string; // embed session token, NOT the API key
target: string | HTMLElement;
endpoint?: string;
placeholder?: string;
clientId?: string;
}init returns a promise that resolves once the widget has been set up. It does not throw on failure; it renders an inline message instead:
| Situation | What the user sees |
|---|---|
No token | Search is not configured for this page. (plus a console.error pointing at POST /api/v1/embed/session) |
Token expired (401) | Search session expired — reload the page. |
Any other failure of /embed/config | Search is unavailable right now (503). (the status code varies) |
If init is called before the target exists it logs [AACSearch] target not found and returns.
window.AACSearch also exposes version (1.0.0 in the current build — the quickest way to answer "is the page running the build I deployed?"), trackConversion() and getRecommendations().
The session contract
Every call the bundle makes goes to your deployment's /api/v1/embed/* proxy and authenticates with the session token, sent in a JSON body — never in the URL, so it stays out of browser history, referrers and access logs.
| Call | Endpoint | When |
|---|---|---|
| Settings | POST /api/v1/embed/config | Once at mount: returns index, scope, expires_at and settings |
| Search | POST /api/v1/embed/search | Every query, page change, facet toggle or sort change |
| Events | POST /api/v1/embed/events | view when a hit is first rendered, click on a hit, conversion from trackConversion() |
| Recommendations | POST /api/v1/embed/recommendations | When showRelated is on, and from getRecommendations() |
Search does not go from the browser straight to the search service. The proxy re-checks the token on every request, applies the tenant filter server-side and runs the same pipeline as the public API (query logs, rules, defaults, relaxation, did-you-mean), so widget traffic shows up in analytics. The browser never sees an engine host, key or collection name. Each proxied search counts against the searches quota; a single proxy request carries at most 5 searches, page ≤ 20 and per_page is clamped to 50.
Session lifetime is 900 seconds by default (3600 at most). The widget does not refresh its token: once it expires, requests return 401 and the widget shows The search session expired — reload the page. For pages that stay open, mint a fresh token before expiry and call init again — or reload the page.
Events
The widget reports behaviour automatically, best-effort and with keepalive, so a tracking failure never breaks the host page:
// Called from your own code, e.g. after checkout:
window.AACSearch.trackConversion({
documentId: "sku-123",
valueCents: 4900,
query: "running shoes",
});Events carry the session token, a visitor session_id (the widget's client id), the document_id, result position and query. They land in the same behaviour tables as the REST events API, with per-widget attribution when the token came from a saved widget.
Recommendations
const { items, mode } = await window.AACSearch.getRecommendations({
seedDocumentId: "sku-123",
limit: 4,
});getRecommendations returns { items: [], mode: "unavailable" } before the widget has mounted and { items: [], mode: "error" } when the request fails. The built-in “For you” strip appears only when showRelated is enabled for the index and recommendations are enabled there.
Theming and layout
The palette comes from the index's embed settings, editable in the console under Search → Embed with a live preview — changes apply on the next page load (and next token mint), no redeploy.
| Setting | Default | Effect |
|---|---|---|
accent | #2563eb | Buttons, focus ring, active page, link colour, mark background |
dark | false | Switches to the dark palette (#0b1220 background) |
perPage | 8 | Hits per page |
placeholder | Search the docs… | Input placeholder |
showStats | true | Result count line |
showRecent | true | Recent-query chips |
showVoice | false | Browser speech input in the search box |
showImage | false | Hit image when the document has one |
showRelated | false | “For you” strip under results |
facets | [] | Facet fields; the console caps the selection at 6 |
sortOptions | relevance + created_at:desc | Sort dropdown, hidden when there is only one option |
queryBy | title,content | Fields searched |
CSS custom properties are set on the root element, so a site stylesheet can override them without patching the bundle:
.aac-ui {
--aac-accent: #0f766e;
--aac-bg: #ffffff;
--aac-fg: #0f172a;
--aac-muted: #64748b;
--aac-border: #e2e8f0;
--aac-soft: #f8fafc;
border-radius: 14px;
}The layout is a 200 px facet column plus results, collapsing to a single column below 720 px. Hit rendering uses title, content, url and an optional category field for the chip; a hit without url renders as a <div> instead of a link.
What it does at runtime
- Search as you type with an explicit submit button and a reset button.
- Facets as checkbox lists (up to 8 values each), built on the InstantSearch refinement-list connector so empty facet values do not throw during render.
- Sorting through
setQueryParameter("sortBy", …); the first option must bedefault. - Recent queries stored in
localStorageunderaac:recent:<index>, at most 5 entries, only for queries of 3+ characters that returned results. Clearing site data clears them. - Visitor id kept in
localStorage(aac_client_id) unless you passdata-aac-client-id; it keys search history and click attribution. No cookies are set. - Voice via InstantSearch's
voiceSearchwidget when enabled — the browser's own speech recognition, so no audio is uploaded to your backend for this path. - Skeletons while the first query runs, and a friendly empty state:
No resultswith a suggestion to try fewer words. - Localisation of the few built-in strings (search, clear, no results, results) for
enandru, chosen fromnavigator.language; other languages fall back to English. Everything else comes from your documents.
Limitations
- No URL state. The widget does not sync the query or filters to the address bar, so results are not shareable by link and the back button does not restore a search. Add your own routing if that matters.
- One widget per page is the documented configuration. The stylesheet is injected once and the CSS variables are global, so a second instance would inherit the first one's palette.
- No result templating. Hit markup is fixed; for custom cards, either override the CSS or build your own UI on
/embed/search. - No automatic token refresh. Sessions last 15 minutes by default and 1 hour at most; re-mint and re-
initfor long-lived pages. - Facet labels are field names, not humanised titles — name fields accordingly.
Content Security Policy
All widget traffic goes to one origin, your deployment: the script itself and the /api/v1/embed/* calls. The browser does not talk to the search engine, so no engine origin is needed.
script-src 'self' https://search.example.com;
connect-src 'self' https://search.example.com;
style-src 'self' 'unsafe-inline';The bundle injects its stylesheet inline, so style-src needs 'unsafe-inline' (or a nonce-aware override if you re-implement the styles). connect-src is the directive people forget: the script loads fine, but the /embed/config, /embed/search and /embed/events calls fail in the network tab without it. If your API is served from a different host than the script, allow both origins.