Algolia Search skill for Codex
Relevance settings in git, zero-downtime reindexes. Codex designs Algolia records for search rather than mirroring database rows, keeps searchableAttributes, customRanking and faceting in a reviewed settings script, and rebuilds indexes atomically so users never hit an empty index. Tenant isolation uses server-generated secured API keys, and React InstantSearch pages ship with URL routing and Insights events wired up.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- algolia-search Installs to ~/.agents/skills/algolia-search/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install algolia-search The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project algolia-search Installs to .agents/skills/algolia-search/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/algolia-search.zipunzip -o algolia-search.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) algolia-search PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $algolia-search, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds Algolia search end to end - record design, indexing with the v5 algoliasearch client, index settings (searchableAttributes, customRanking, faceting, replicas), atomic reindexing, secured API keys for tenants, and React InstantSearch UIs with facets, routing and Insights events. Use when adding Algolia to an app, syncing a database to an index, fixing irrelevant results or missing facets, building an InstantSearch page, or scoping search per user or tenant.
Use it when
- Adding hosted search to a site or app with Algolia
- Syncing database records into an Algolia index or rebuilding it
- Results feel irrelevant, facets are missing or sort orders are wrong
- Search must be restricted per user or tenant
Use something else when
- You want self-hosted full-text search in Postgres or Meilisearch: use full-text-search
- You are tuning Elasticsearch or OpenSearch mappings: use elasticsearch-mappings
How to check the result
getSettings matches the settings file, a fixed list of real queries returns the expected first hit, and a secured key for one tenant finds nothing from another.
The mistake it prevents
Indexing without a stable objectID, so every resync silently duplicates the catalog.
Done well means: records are small and shaped for search (not copies of DB rows), relevance is set explicitly in index settings that live in version control, reindexing never shows users an empty index, and no browser ever receives a key that can write.
Before touching code
- Read
AGENTS.mdfor the package manager, env var conventions and where background jobs live. - Find existing usage:
rg -n "algoliasearch|instantsearch|ALGOLIA" --glob '!node_modules'. - Check the client major version in the lockfile. v5 (
algoliasearch@5) uses a single client withindexNamein every call; v4 usedclient.initIndex(name). Do not mix the two styles. - Confirm which keys exist: Application ID, a Search-only key (public), an Admin key or a scoped write key (server only). Network calls to Algolia need sandbox network approval.
Key handling
| Key | Where it may live | Use |
|---|---|---|
| Search-only API key | Browser, mobile apps | Queries only |
| Secured API key (derived) | Browser, per user | Search restricted by filters / expiry |
Write key with ACL addObject, deleteObject, settings | Server, CI | Indexing jobs |
| Admin key | Secrets manager only | Key management, never in app runtime |
Create a narrow write key for the indexer instead of shipping the Admin key to workers.
Record design
- One record per searchable thing. Include only attributes you search, filter, rank or display.
- Stable
objectIDequal to the DB primary key, so updates overwrite instead of duplicating. - Precompute ranking signals as numbers (
popularity,sales_30d,created_at_tsas Unix seconds). - Keep records small; plans enforce a per-record size limit (10 KB on entry plans). Split long
text (docs, articles) into paragraph records sharing a
group_id, then setattributeForDistinct: "group_id"anddistinct: trueso one hit per page appears. - Store tenant or permission data as filterable attributes (
tenant_id,visible_to).
{
"objectID": "sku_18342",
"name": "Trail running shoe",
"brand": "Norda",
"categories": ["Shoes", "Shoes > Running"],
"price": 189,
"in_stock": true,
"popularity": 8421,
"tenant_id": "store_7",
"url": "/p/sku_18342",
"image": "https://cdn.example.com/sku_18342.jpg"
}
Index settings as code
Keep settings in a file and apply them from a script, so relevance changes are reviewed in PRs.
// scripts/algolia-settings.ts
import { algoliasearch } from 'algoliasearch';
const client = algoliasearch(process.env.ALGOLIA_APP_ID!, process.env.ALGOLIA_WRITE_KEY!);
const { taskID } = await client.setSettings({
indexName: 'products',
indexSettings: {
searchableAttributes: ['name', 'brand', 'unordered(categories)', 'unordered(description)'],
attributesForFaceting: ['searchable(brand)', 'categories', 'in_stock', 'filterOnly(tenant_id)'],
customRanking: ['desc(in_stock)', 'desc(popularity)'],
attributesToSnippet: ['description:20'],
replicas: ['products_price_asc'],
},
forwardToReplicas: true,
});
await client.waitForTask({ indexName: 'products', taskID });
Settings decisions:
| Need | Setting |
|---|---|
| Title matches beat description matches | Order in searchableAttributes; most important first |
| Word position inside a long field should not matter | unordered(attr) |
| Business signal breaks ties | customRanking with numeric attributes only |
| Sort by price | Replica with ranking starting asc(price); use a virtual replica if your plan supports it |
| Filter but never show as facet | filterOnly(attr) |
| Search inside a long facet list | searchable(attr) |
| Typos hurt SKUs or codes | disableTypoToleranceOnAttributes: ['sku'] |
Never put a timestamp or free text in customRanking; a unique value per record turns the
tie-breaker into the only criterion that matters.
Indexing
Incremental updates from the app or a queue worker:
await client.saveObjects({ indexName: 'products', objects: records }); // upsert
await client.partialUpdateObjects({ indexName: 'products', objects: [{ objectID: 'sku_18342', in_stock: false }], createIfNotExists: false });
await client.deleteObjects({ indexName: 'products', objectIDs: ['sku_99'] });
Full rebuilds use replaceAllObjects, which writes to a temporary index and moves it over the
live one atomically, so search never sees a half-built index:
await client.replaceAllObjects({ indexName: 'products', objects: allRecords, batchSize: 1000 });
Rules for sync jobs:
- Send changes from the same transaction boundary as the DB write (outbox table or job queue), not from a request handler that may fail after the commit.
- Batch at about 1,000 records; each batch counts as operations against your quota.
- For very large catalogs, stream from the DB with a cursor and call
saveObjectsper chunk into a fresh index, then useoperationIndexwithoperation: 'move'to swap. replaceAllObjectsandmoveoverwrite the live index. Confirm the target index name with the user before running a full rebuild against production.
Python (v4 client) has the same shape: SearchClientSync(app_id, key).save_objects(index_name=..., objects=...).
Multi-tenant and per-user access
Generate a secured key on the server for each session; never filter tenants in the browser only.
const securedKey = client.generateSecuredApiKey({
parentApiKey: process.env.ALGOLIA_SEARCH_KEY!,
restrictions: {
filters: `tenant_id:${session.tenantId}`,
validUntil: Math.floor(Date.now() / 1000) + 3600,
restrictIndices: ['products'],
},
});
The parent must be a search-only key. Return securedKey to the client and initialise
InstantSearch with it; refresh before validUntil.
React InstantSearch
import { liteClient as algoliasearch } from 'algoliasearch/lite';
import { InstantSearch, SearchBox, Hits, RefinementList, Pagination, Configure, Highlight } from 'react-instantsearch';
import { history } from 'instantsearch.js/es/lib/routers';
const searchClient = algoliasearch(APP_ID, SEARCH_KEY);
function Hit({ hit }) {
return <a href={hit.url}><Highlight attribute="name" hit={hit} /></a>;
}
export function ProductSearch() {
return (
<InstantSearch searchClient={searchClient} indexName="products" routing={{ router: history() }} insights>
<Configure hitsPerPage={24} />
<SearchBox placeholder="Search products" />
<RefinementList attribute="brand" searchable />
<Hits hitComponent={Hit} />
<Pagination />
</InstantSearch>
);
}
- Facet widgets only work for attributes listed in
attributesForFaceting. routingputs query and refinements in the URL so results are shareable and back-button safe.insightssends click and view events; send conversions (Added to cart,Purchased) with thequeryIDso Click and Conversion Analytics, Dynamic Re-Ranking and Recommend have data.- Next.js App Router: use
react-instantsearch-nextjs(InstantSearchNext) for SSR. - Do not render a custom
<input>that fires a search per keystroke without debouncing; the built-inSearchBoxalready handles query hooks.
Verification
-
client.getSettings({ indexName })output matches the settings file. -
client.searchSingleIndex({ indexName, searchParams: { query: '<known product>' } })returns it in the top three; record five real queries and their expected first hit as a smoke test. - Facet counts in the UI match a
facets: ['brand']query. - Browser network tab shows only the search-only or secured key.
- A secured key for tenant A returns zero hits for a record owned by tenant B.
- Rebuild job run twice leaves the record count unchanged (
nbHitswith empty query).
If relevance is off, open the ranking info (getRankingInfo: true) for the expected and the
actual first hit and compare criterion by criterion before changing settings.
Gotchas
saveObjectswithoutobjectIDlets Algolia generate IDs; every resync then duplicates records.- Changing settings on a primary without
forwardToReplicasleaves sort replicas stale. filterssyntax needs quotes for values with spaces:brand:"New Balance".- Numeric filters need numbers in the record;
"189"as a string will not matchprice < 200. - Deleting a record in the DB but not in Algolia leaves dead links; handle deletes in the same sync.
- Empty-query browsing returns records in
customRankingorder; check that it looks sensible.
Output
Report: index names touched, settings diff, records indexed or removed, keys created (by ACL, never the value), and the smoke-test queries with their first hits.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Algolia Search"
short_description: "Relevance settings in git, zero-downtime reindexes"
default_prompt: "$algolia-search Add Algolia search to our product catalog and keep it in sync with Postgres" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.