On this page

kurn search

kurn search is a collection of example search adapter implementations for the kurn engine, with TypeScript and Python clients. Use them as reference designs for building search into your own application.

Start by running the parts-catalog suggestion example below: load sample records, try a mistyped part number, filter the results and call the adapter from a client. Then substitute your own records or adapt an implementation to your application.

Run the example → · Browse the adapter implementations → · Use the clients →

What you’ll run

A local kurnd backend, an example adapter service and a client query, using fictional data. Keep both services on loopback; the sample authentication is for local demonstration.

New to the engine? Try the engine quickstart first. For a smaller adapter example, use the one-record identifier lookup.

The Search source distribution includes AGPL-3.0-only service and adapter code, and MIT-licensed TypeScript and Python clients. These steps use source checkouts; no npm or PyPI installation is promised.

How it fits

request path
application → TypeScript or Python client → kurn-searchd → kurnd
                  fixed Search API          adapter plan      list snapshot

kurnd owns matching, typed payload filtering and versioned list snapshots. The adapter owns source projection, bounded probes, evidence checks and result ranking. The service supplies a trusted tenant scope and either returns a complete result or refuses. kurn search connects directly to kurnd.

Shared setup: build the local tools once

Use the current kurn and kurn search source checkouts with Go 1.27, Node.js, Python and curl. Use absolute physical checkout paths (pwd -P) and a fresh private run root outside both checkouts. Choose unused loopback ports.

Tested source and toolchain references

This walkthrough was checked with Node.js 25.6.1, Python 3.14.5, this Search source revision and this engine source revision. The Search checkout's docs/user-manual.md records its compatible engine identity. Preparation verifies that the tools match that contract.

shell · setup
export KURN_REPO=/absolute/path/to/kurn
export KURN_SEARCH_REPO=/absolute/path/to/kurn-search
export KURN_SEARCH_RUN_ROOT="$(cd "$(mktemp -d)" && pwd -P)"
chmod 700 "$KURN_SEARCH_RUN_ROOT"
umask 077
mkdir -p "$KURN_SEARCH_RUN_ROOT/bin"
(cd "$KURN_REPO" && go build -o "$KURN_SEARCH_RUN_ROOT/bin/kurn" ./cmd/kurn)
(cd "$KURN_REPO" && go build -o "$KURN_SEARCH_RUN_ROOT/bin/kurnd" ./cmd/kurnd)
(cd "$KURN_SEARCH_REPO" && go build -o "$KURN_SEARCH_RUN_ROOT/bin/kurn-searchd" ./cmd/kurn-searchd)
(cd "$KURN_SEARCH_REPO" && go build -o "$KURN_SEARCH_RUN_ROOT/bin/kurn-search-local" ./cmd/kurn-search-local)

Keep the same checkout paths and run root in every terminal; do not run mktemp again there. Continue with the prepared journey below. It does not require the ordinary bundle build, prepare, or mutable-store startup commands in the alternative example.

Prepared suggestions: source → version → query

First complete Shared setup once: it provides both checkout paths, the private run root and all four executables. Then follow this section through client integration; skip the mutable-store alternative. The checked source contains 24 fictional records, including a unique readiness record for tenant alpha, partition north. Your source must have exactly one record in its chosen readiness scope.

These rows from source-a.ndjson make the lookup visible. They are a subset of the supplied 24-row source, not a replacement input file:

IDIdentifierManufacturerStatusTenant / partition
A01ZS-901Northstaractivealpha / north
A02AX-100Northstaractivealpha / south
A03AX-100Northstaractivealpha / south
A04AX-100northstardiscontinuedalpha / south
B13AX-100Northstaractivebeta / north
shell · fresh immutable version
export PREPARED_VERSION="$KURN_SEARCH_RUN_ROOT/version-a"
export KURND_ADDR=127.0.0.1:18080 KURN_SEARCHD_ADDR=127.0.0.1:18081
"$KURN_SEARCH_RUN_ROOT/bin/kurn-search-local" prepare-artifact \
  -profile parts-catalog-suggest \
  -source "$KURN_SEARCH_REPO/examples/parts-catalog-suggest-prepared/source-a.ndjson" \
  -kurn "$KURN_SEARCH_RUN_ROOT/bin/kurn" \
  -kurnd "$KURN_SEARCH_RUN_ROOT/bin/kurnd" \
  -searchd "$KURN_SEARCH_RUN_ROOT/bin/kurn-searchd" \
  -search-checkout "$KURN_SEARCH_REPO" \
  -build-dir "$KURN_SEARCH_RUN_ROOT/build-a" -out "$PREPARED_VERSION" \
  -readiness-tenant alpha -readiness-partition north \
  -kurnd-addr "$KURND_ADDR" -searchd-addr "$KURN_SEARCHD_ADDR"
"$KURN_SEARCH_RUN_ROOT/bin/kurn-search-local" verify-version -out "$PREPARED_VERSION"

Both targets must be absent and their parents physical, private 0700 directories. Require successful exits and verification reporting status: "VERIFIED", startable: true. Do not start a partial, uncertain or refused publication. Runtime controls contain absolute paths: do not relocate the version or rewrite controls. Keep logs outside it; only state/ is mutable. Serving no longer needs the source or mapping.

Keep these environment values in every terminal. Start the bundled backend in terminal 1:

terminal 1 · backend
"$PREPARED_VERSION/bin/kurnd" -addr "$KURND_ADDR" \
  -artifact "$PREPARED_VERSION/data/parts-catalog-suggest-v1.kurn" \
  -api-keys-file "$PREPARED_VERSION/kurnd-api-keys"

In terminal 2, require HTTP 200 and Kurn-Artifact-Verification: full before starting Search. This composition refuses trusted verification at readiness.

terminal 2 · readiness, then Search
curl --fail --silent --show-error --include "http://$KURND_ADDR/readyz"
"$PREPARED_VERSION/bin/kurn-searchd" -config "$PREPARED_VERSION/kurn-searchd.json"

In terminal 3, wait for $PREPARED_VERSION/state/search-status.json while Search remains running, then query with the newly generated tenant handle. This dependency-free example reads the handle internally, without printing it or putting it in command arguments:

terminal 3 · authenticated sentinel
PYTHONPATH="$KURN_SEARCH_REPO/clients/python" python3 - <<'PY'
import json
import os
from pathlib import Path
from kurn_search_client import KurnSearchClient, PartsCatalogSuggestRequest

version = Path(os.environ["PREPARED_VERSION"])
credentials = json.loads((version / "client-credentials.json").read_text())
handle = next(c["handle"] for c in credentials["credentials"] if c["tenant"] == "alpha")
client = KurnSearchClient("http://" + os.environ["KURN_SEARCHD_ADDR"], lambda: handle)
result = client.search_parts_catalog_suggest(
    PartsCatalogSuggestRequest("ZS-901", 1, partition_id="north"),
    timeout_seconds=5,
)
assert result["complete"] is True
assert [hit["entry_id"] for hit in result["hits"]] == ["A01"]
assert result["hits"][0]["result"]["score"] == 100
print("prepared sentinel: PASS (A01, score 100)")
PY

Try a mistyped part number

Keep both prepared processes running. AX-010 swaps two adjacent digits in AX-100. Run this in terminal 3 with the same environment and alpha handle. The first query shows candidates, the second filters to active Northstar parts, and the last checks a no-match:

terminal 3 · typo, filter and no-match
PYTHONPATH="$KURN_SEARCH_REPO/clients/python" python3 - <<'PY'
import json
import os
from pathlib import Path
from kurn_search_client import KurnSearchClient, PartsCatalogSuggestRequest

version = Path(os.environ["PREPARED_VERSION"])
credentials = json.loads((version / "client-credentials.json").read_text())
handle = next(c["handle"] for c in credentials["credentials"] if c["tenant"] == "alpha")
client = KurnSearchClient("http://" + os.environ["KURN_SEARCHD_ADDR"], lambda: handle)
for label, query, filters, expected in [
    ("typo", "AX-010", None, ["A02", "A03", "A04"]),
    ("filtered", "AX-010", {"manufacturer": "Northstar", "status": "active"}, ["A02", "A03"]),
    ("no-match", "QQQQQQ", None, []),
]:
    result = client.search_parts_catalog_suggest(
        PartsCatalogSuggestRequest(query, 10, partition_id="south", filters=filters),
        timeout_seconds=5,
    )
    assert result["complete"] is True
    assert [hit["entry_id"] for hit in result["hits"]] == expected
    print(label + ":")
    for hit in result["hits"]:
        row = hit["result"]
        print(" ", hit["entry_id"], row["identifier"], row["manufacturer"], row["status"])
    if not result["hits"]:
        print("  no matches")
PY
expected output · supplied source and default policy
typo:
  A02 AX-100 Northstar active
  A03 AX-100 Northstar active
  A04 AX-100 northstar discontinued
filtered:
  A02 AX-100 Northstar active
  A03 AX-100 Northstar active
no-match:
  no matches

The result preserves separate record IDs even when identifiers repeat. Tenant scope comes from the handle, so beta's B13 is not a candidate. Manufacturer filters are case-sensitive. A generated adjacent-transposition variant can yield an exact companion hit; its score of 100 is whole-key membership, not confidence. See score origins and policy defaults before interpreting scores on other data.

API and clients

The Search HTTP API is distinct from the kurnd API. Its canonical contract is maintained in the Search repository. The generated TypeScript and Python clients call fixed routes and validate responses. The examples below import the clients directly from the source checkout; no package installation is needed.

Keep the prepared service running. This Node.js example uses the source-checkout TypeScript client's generated runtime, the same version's tenant handle and the suggestion adapter you just prepared. No client package installation is needed:

terminal 3 · JavaScript / TypeScript client
node --input-type=module <<'JS'
import { readFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
const { KurnSearchClient } = await import(pathToFileURL(
  process.env.KURN_SEARCH_REPO + "/clients/typescript/generated/client.mjs"
));
const credentials = JSON.parse(await readFile(
  process.env.PREPARED_VERSION + "/client-credentials.json", "utf8"
));
const handle = credentials.credentials.find(c => c.tenant === "alpha").handle;
const client = new KurnSearchClient({
  endpoint: "http://" + process.env.KURN_SEARCHD_ADDR,
  getToken: () => handle,
});
const result = await client.searchPartsCatalogSuggest(
  { query: "AX-010", limit: 10, partitionId: "south",
    filters: { manufacturer: "Northstar", status: "active" } },
  { timeoutMs: 5_000 },
);
for (const hit of result.hits) {
  console.log(hit.entry_id, hit.result.identifier, hit.result.manufacturer);
}
JS

Expected identifiers: A02 AX-100 Northstar and A03 AX-100 Northstar. Discovery is a different recipe: prepare its own service before calling searchPartsCatalogDiscover. Calling it on the suggestion service returns 404 adapter_not_found.

Stop and restart cleanly

The handle supplies tenant scope; do not send tenant_id or reuse an ordinary example's fixed fixture handle. Stop Search with Ctrl-C in its owning terminal and wait for exit, then stop the backend and wait. Clean Search shutdown removes its status file. Run "$KURN_SEARCH_RUN_ROOT/bin/kurn-search-local" verify-version -out "$PREPARED_VERSION" again and require startable: true before restarting the unchanged version with its bundled executables. A status-file warning is not proof that a process stopped; inspect the owning terminals and never delete a live process's status file.

Use your own data or adapt an implementation

For the same part-number lookup, start by changing the source records as described below. For a different search behavior, choose the closest example implementation and use its source projection, query plan and result handling as a starting point for your own adapter. Keep the example’s tests as a reference and add cases for your own data and expected results.

Use the same NDJSON fields as the source example: id, identifier, manufacturer, category, status, tenant_id and optional partition_id. Follow that profile's validation rules; do not silently drop source fields or invent a readiness record in customer data.

Change -source to your file, select a tenant/partition containing exactly one readiness record, and use fresh build and version targets plus unused ports. Repeat verification and authenticated queries with that version's newly generated handle. Judge results on your own labelled typos and no-matches before integrating. Keep your authoritative source separately; replace a published version rather than editing its artifact or controls.

Alternative: mutable-store identifier example

This is a separate one-record example, not a prerequisite for prepared suggestions. Complete Shared setup, then use unused ports and the following commands instead of prepare-artifact. Its mutable bundle/update operations do not apply to read-only prepared serving.

shell · alternative addresses
export KURND_ADDR=127.0.0.1:8080 KURN_SEARCHD_ADDR=127.0.0.1:8081
shell · build and prepare
"$KURN_SEARCH_RUN_ROOT/bin/kurn" build \
  -in "$KURN_SEARCH_REPO/adapters/catalogid/testdata/list-feed.ndjson" \
  -mapping "$KURN_SEARCH_REPO/adapters/catalogid/testdata/list-mapping.json" \
  -out "$KURN_SEARCH_RUN_ROOT/bundle"
"$KURN_SEARCH_RUN_ROOT/bin/kurn-search-local" prepare \
  -bundle "$KURN_SEARCH_RUN_ROOT/bundle" -out "$KURN_SEARCH_RUN_ROOT/local" \
  -kurnd-addr "$KURND_ADDR" -searchd-addr "$KURN_SEARCHD_ADDR"

Start each foreground process in its own terminal:

terminal 1 · terminal 2 · terminal 3
"$KURN_SEARCH_RUN_ROOT/bin/kurnd" -addr "$KURND_ADDR" -data "$KURN_SEARCH_RUN_ROOT/local/data"
"$KURN_SEARCH_RUN_ROOT/bin/kurn-searchd" -config "$KURN_SEARCH_RUN_ROOT/local/kurn-searchd.json"
node "$KURN_SEARCH_REPO/examples/catalog-identifier/search.mjs"

Start terminal 2 after kurnd listens, then run terminal 3 after the Search status file appears. The checked example returns only config-proof for identifier ABC-123. Stop Search first, then kurnd. The prepared handle is fixture data, not a production credential.

Different paths, different policy defaults

Path or policySimilarity generationAcceptance and exact transposition step
Ordinary bundle suggest / legacy0.65Legacy acceptance; transposition only on an empty pool.
Prepared default: osa1-0600.60Optimal-string-alignment distance at most one; transposition if no candidate is accepted.
Opt-in osa1-060-union0.60Distance-one acceptance; always combine and deduplicate both branches.
Experimental osa10.45Distance-one acceptance; transposition if nothing is accepted.

Set -policy NAME during prepare-artifact. New preparation without it writes osa1-060 explicitly; an existing bound configuration with no policy member stays legacy. Non-legacy policies are named in config, manifest and diagnostic; -policy legacy deliberately omits those members to preserve legacy identities. Policy and diagnostic identities must agree.

There are two score origins. Similarity hits report rounded corpus-known n-gram coverage; unknown query grams are excluded from its denominator, so an inexact identifier can score 100. Exact companion hits instead receive 100 from whole-key membership of a generated transposition variant, with transposition: "yes" in their evidence. Under the union policy, transposition evidence wins when both branches find the same entry. Neither score is probability, confidence or edit distance; calibrate on your own labels.

-max-inflight accepts 1–64 (default 4); -max-queue accepts 1–4096 (default four times max-inflight). A full queue returns 503 service_queue_full with Retry-After, before adapter preparation but after HTTP parsing and authentication. A queued request waits at most five seconds for admission, then gets 503 service_admission_unavailable. These bound admission slots, not all HTTP work, process memory or end-to-end latency, and do not promise tenant fairness. Both clients expose the validated retry hint without automatically retrying.

Example adapter implementations

ExampleWhat it demonstrates
catalog-identifierSmallest exact identifier path.
parts-catalogPart identifiers with scalar manufacturer, category and status filters.
parts-catalog-selectBounded multi-select values within fields.
parts-catalog-discoverOne query over identifiers and names, with filters.
parts-catalog-discover-facetsExact counts over the complete matched set before the caller limit.
parts-catalog-discover-facet-alternativesFacet counts that show alternatives after a selection.
parts-catalog-suggestScored part-number suggestions from a dedicated n-gram list.

Each implementation has fictional source data and a focused example guide. One local kurn-searchd process loads one configured adapter. Run separate local processes for different examples.

Current limits

Engine guide · Search source