AACSearch

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.

AttributeRequiredDefaultMeaning
data-aac-tokenyes—Embed session token from POST /api/v1/embed/session. Without it the script does not auto-mount
data-aac-targetno#aacsearchCSS selector of the mount point
data-aac-endpointnoderived from script.srcBase URL of the deployment, e.g. https://search.example.com
data-aac-placeholdernofrom session settingsOverrides the input placeholder
data-aac-client-idnoanonymous UUID in localStorageStable 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

URLCacheUse
/embed/v1/aacsearch.jsmax-age=31536000, immutableProduction. The version is in the path: v1 never changes
/embed/aacsearch.jsmax-age=300Staging 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:

SituationWhat the user sees
No tokenSearch 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/configSearch 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.

CallEndpointWhen
SettingsPOST /api/v1/embed/configOnce at mount: returns index, scope, expires_at and settings
SearchPOST /api/v1/embed/searchEvery query, page change, facet toggle or sort change
EventsPOST /api/v1/embed/eventsview when a hit is first rendered, click on a hit, conversion from trackConversion()
RecommendationsPOST /api/v1/embed/recommendationsWhen 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.

SettingDefaultEffect
accent#2563ebButtons, focus ring, active page, link colour, mark background
darkfalseSwitches to the dark palette (#0b1220 background)
perPage8Hits per page
placeholderSearch the docs…Input placeholder
showStatstrueResult count line
showRecenttrueRecent-query chips
showVoicefalseBrowser speech input in the search box
showImagefalseHit image when the document has one
showRelatedfalse“For you” strip under results
facets[]Facet fields; the console caps the selection at 6
sortOptionsrelevance + created_at:descSort dropdown, hidden when there is only one option
queryBytitle,contentFields 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 be default.
  • Recent queries stored in localStorage under aac: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 pass data-aac-client-id; it keys search history and click attribution. No cookies are set.
  • Voice via InstantSearch's voiceSearch widget 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 results with a suggestion to try fewer words.
  • Localisation of the few built-in strings (search, clear, no results, results) for en and ru, chosen from navigator.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-init for 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.

On this page