Home Playground Docs hub

Guide G-32 · Users & Profile Matching

Finding matches between profiles

Verified against local · 2026-09-25 · profile-matching-service 0.1.0 (workspace HEAD)
Journey. As a developer, I want my app to match people or things to each
other — caretakers to households, mentors to mentees, applicants to openings —
by indexing each side as a JSON profile and ranking them against a query,
without running my own vector database.

The Profile Matching service turns arbitrary JSON payloads into ranked similarity searches. You create a collection (a named vector space with a vectorizer configuration), index a profile — any JSON object — into it, and then search the collection by posting another JSON object as the query. Every profile is scored for cosine similarity against the query and the top-K come back ranked, each with its similarity score so your app can apply its own threshold. This guide walks create → index → search → threshold → cleanup, verified against the local stack (profile-matching :45093); the shared dev subdomain behaves identically.

Prerequisites

subdomain).

in TOKEN (obtain one via guide G-01; the full example below fetches one via the QA sign-in shortcut). The POST routes require the key pair + JWT; only /v1/health is public.

same calls translate directly).

Concepts (only what this journey needs)

settings every profile indexed into it will use: vectorizer_kind (simple_json_v1 is the only kind today) and embedding_dim (default 256). Pick one collection per matching domain — "caretakers", "mentors", "listings" — not one for the whole app.

path=value features and feature-hashed into the collection's embedding at index time. Shared key=value pairs are what make two profiles similar; numbers contribute by key path (so the same numeric key reinforces), booleans by true/false, arrays by position (keep array shapes consistent across profiles or indexes misalign), and null contributes nothing.

results sorted by descending cosine similarity, each with its similarity score, plus total_candidates (how many profiles were scored). There is no server-side threshold parameter — apply your own cutoff on the score (similarity >= 0.5, say) once results are back.

external_key — your app's own id for the entity (a user id, a listing id). It is echoed back in every result so you can join matches to your own records.

one and DELETE the old; there is intentionally no PUT. DELETE is idempotent (204 even for an already-gone id).

"list profiles" route — your app remembers the ids it created (or stores them via external_key). Collections cannot be deleted in V1; only their profiles can. Treat collection creation as provisioning, not churn.

Step 1 — Create a collection

One POST provisions the match space. The response echoes the id every later call needs:

bash
curl -s -X POST "${PM_URL}/v1/collections" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"name": "g32-caretakers", "embedding_dim": 256}' \
  | tee /tmp/g32-collection.json \
  | python3 -c 'import json,sys; c=json.load(sys.stdin); print("collection:", c["id"], c["vectorizer_kind"], c["embedding_dim"])'

Expected response — 201 with the collection: id, name, vectorizer_kind: "simple_json_v1", embedding_dim, created_at. Both fields are optional; omitting them gives the defaults (simple_json_v1 / 256).

Step 2 — Index profiles

Index each candidate as a JSON payload with your own external_key. Here we seed three caretakers — two shepherds and an accountant — so the search in Step 3 has something meaningful to rank:

bash
COLLECTION_ID=$(python3 -c 'import json; print(json.load(open("/tmp/g32-collection.json"))["id"])')
AUTH=(-H "Authorization: Bearer ${TOKEN}"
      -H "x-api-key: ${INAYA_API_KEY}"
      -H "x-client-app-key: ${INAYA_APP_KEY}")

for profile in \
  '{"external_key": "caretaker-amina", "payload": {"role": "shepherd", "experience_years": 5, "looking_for": "pasture-work"}}' \
  '{"external_key": "caretaker-bo",    "payload": {"role": "shepherd", "experience_years": 3, "looking_for": "pasture-work"}}' \
  '{"external_key": "caretaker-cy",    "payload": {"role": "accountant", "experience_years": 10, "looking_for": "desk-work"}}'
do
  curl -s -o /dev/null -w "indexed -> HTTP %{http_code}\n" \
    -X POST "${PM_URL}/v1/collections/${COLLECTION_ID}/profiles" "${AUTH[@]}" \
    -H "Content-Type: application/json" -d "$profile"
done

Expected response — each call returns 201 with the stored profile: a server-generated id, the collection_id, your external_key, the payload as stored, and timestamps. Keep the profile id (or your external_key) — that is how you delete the profile later.

Step 3 — Search and rank

Post a query payload in the same shape as your profiles. The service vectorizes it exactly like an indexed profile and scores every profile in the collection; the top-K come back ranked with their similarity:

bash
COLLECTION_ID=$(python3 -c 'import json; print(json.load(open("/tmp/g32-collection.json"))["id"])')

curl -s -X POST "${PM_URL}/v1/search" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -H "Content-Type: application/json" \
  -d "{\"collection_id\": \"${COLLECTION_ID}\", \"query\": {\"role\": \"shepherd\", \"looking_for\": \"pasture-work\"}, \"top_k\": 3}" \
  | python3 -c 'import json,sys
r = json.load(sys.stdin)
print("candidates:", r["total_candidates"])
for x in r["results"]:
    print("%s: %.3f" % (x["profile"]["external_key"], x["similarity"]))'

Expected response — 200 with total_candidates: 3 and the two shepherds ranked above the accountant (caretaker-amina and caretaker-bo both share the query's role and looking_for features; the accountant shares none and scores 0.0). A profile identical to the query scores 1.0; completely disjoint payloads score 0.0. top_k defaults to 5 when omitted.

Step 4 — Apply your threshold

The API stops at ranking; the cutoff is your product decision. Fetch the top-K you can afford, then keep only what clears your bar — here 0.2:

bash
COLLECTION_ID=$(python3 -c 'import json; print(json.load(open("/tmp/g32-collection.json"))["id"])')

curl -s -X POST "${PM_URL}/v1/search" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -H "Content-Type: application/json" \
  -d "{\"collection_id\": \"${COLLECTION_ID}\", \"query\": {\"role\": \"shepherd\", \"looking_for\": \"pasture-work\"}, \"top_k\": 10}" \
  | python3 -c 'import json,sys
r = json.load(sys.stdin)
THRESHOLD = 0.2
matches = [x["profile"]["external_key"] for x in r["results"] if x["similarity"] >= THRESHOLD]
print("matched:", matches)'

Expected response — matched: ['caretaker-amina', 'caretaker-bo'] (both shepherds clear 0.2; the accountant at 0.0 does not). Tune THRESHOLD to your domain: with the V1 vectorizer, well-matched categorical profiles land around 0.3–0.7, partial overlaps lower, disjoint payloads at 0.0. Validate against your own data before shipping a cutoff.

NOTE: total_candidates tells you how many profiles were actually
scored — if it approaches top_k, raise top_k (or accept you are only
seeing the best of a small pool).

Step 5 — Remove a profile (and why re-index beats update)

DELETE /v1/profiles/{id} removes a single profile and is idempotent — a repeat delete (or an unknown id) still returns 204. To "edit" a profile, index the replacement and delete the original:

bash
COLLECTION_ID=$(python3 -c 'import json; print(json.load(open("/tmp/g32-collection.json"))["id"])')
AUTH=(-H "Authorization: Bearer ${TOKEN}"
      -H "x-api-key: ${INAYA_API_KEY}"
      -H "x-client-app-key: ${INAYA_APP_KEY}")

# Bo gained experience: index the updated profile, then drop the stale one.
NEW_ID=$(curl -s -X POST "${PM_URL}/v1/collections/${COLLECTION_ID}/profiles" "${AUTH[@]}" \
  -H "Content-Type: application/json" \
  -d '{"external_key": "caretaker-bo", "payload": {"role": "shepherd", "experience_years": 6, "looking_for": "pasture-work"}}' \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
echo "re-indexed as ${NEW_ID}"

curl -s -o /dev/null -w "delete stale -> HTTP %{http_code}\n" \
  -X DELETE "${PM_URL}/v1/profiles/${NEW_ID}" "${AUTH[@]}"
curl -s -o /dev/null -w "delete again (idempotent) -> HTTP %{http_code}\n" \
  -X DELETE "${PM_URL}/v1/profiles/${NEW_ID}" "${AUTH[@]}"

Expected response — re-indexed as <new id>, then delete stale -> HTTP 204 and delete again (idempotent) -> HTTP 204. (We delete the fresh copy so the demo data stays stable for re-runs — in a real app you would delete the old profile id here and keep the new one.)

Error contract

Three failures worth asserting in your client: a malformed collection body, a nonexistent collection, and a malformed profile id.

bash
AUTH=(-H "Authorization: Bearer ${TOKEN}"
      -H "x-api-key: ${INAYA_API_KEY}"
      -H "x-client-app-key: ${INAYA_APP_KEY}")

# embedding_dim must be positive -> 400 with a JSON error body.
curl -s -w "\ncreate bad dim -> HTTP %{http_code}\n" \
  -X POST "${PM_URL}/v1/collections" "${AUTH[@]}" \
  -H "Content-Type: application/json" \
  -d '{"name": "g32-bad", "embedding_dim": 0}'

# Unknown collection id -> 404 on search.
curl -s -w "\nsearch unknown collection -> HTTP %{http_code}\n" \
  -X POST "${PM_URL}/v1/search" "${AUTH[@]}" \
  -H "Content-Type: application/json" \
  -d '{"collection_id": "00000000-0000-0000-0000-000000000000", "query": {"role": "shepherd"}}'

# Malformed profile id -> 400 (ids must be UUIDs).
curl -s -w "\ndelete malformed id -> HTTP %{http_code}\n" \
  -X DELETE "${PM_URL}/v1/profiles/not-a-uuid" "${AUTH[@]}"

Expected response — create bad dim -> HTTP 400 with {"error":"Invalid input: embedding_dim must be positive"}; search unknown collection -> HTTP 404 with {"error":"Collection not found"}; delete malformed id -> HTTP 400 with {"error":"Invalid profile id"}.

Full working example

A complete create → index → search → threshold → cleanup cycle (profile_matching.sh) — self-contained, fetching its JWT via the QA shortcut (local/QA environments only; production uses the G-01 flows):

bash
#!/usr/bin/env bash
set -euo pipefail

# 0. Obtain a user JWT (QA shortcut — see G-01 for production flows)
TOKEN=$(curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" \
  -H "Content-Type: application/json" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -d "{\"device_id\": \"g32-demo\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"g32-demo-run\"}" \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
export TOKEN

AUTH=(-H "Authorization: Bearer ${TOKEN}"
      -H "x-api-key: ${INAYA_API_KEY}"
      -H "x-client-app-key: ${INAYA_APP_KEY}")

# 1. Create the collection
COLLECTION_ID=$(curl -sf -X POST "${PM_URL}/v1/collections" "${AUTH[@]}" \
  -H "Content-Type: application/json" \
  -d '{"name": "g32-demo-caretakers", "embedding_dim": 256}' \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
echo "collection: ${COLLECTION_ID}"

# 2. Index profiles
for profile in \
  '{"external_key": "caretaker-amina", "payload": {"role": "shepherd", "experience_years": 5, "looking_for": "pasture-work"}}' \
  '{"external_key": "caretaker-bo",    "payload": {"role": "shepherd", "experience_years": 3, "looking_for": "pasture-work"}}' \
  '{"external_key": "caretaker-cy",    "payload": {"role": "accountant", "experience_years": 10, "looking_for": "desk-work"}}'
do
  curl -sf -o /dev/null -X POST "${PM_URL}/v1/collections/${COLLECTION_ID}/profiles" \
    "${AUTH[@]}" -H "Content-Type: application/json" -d "$profile"
done
echo "3 profiles indexed"

# 3. Search and apply a threshold
curl -sf -X POST "${PM_URL}/v1/search" "${AUTH[@]}" \
  -H "Content-Type: application/json" \
  -d "{\"collection_id\": \"${COLLECTION_ID}\", \"query\": {\"role\": \"shepherd\", \"looking_for\": \"pasture-work\"}, \"top_k\": 10}" \
  | python3 -c 'import json,sys
r = json.load(sys.stdin)
keys = [x["profile"]["external_key"] for x in r["results"] if x["similarity"] >= 0.2]
assert set(keys) == {"caretaker-amina", "caretaker-bo"}, keys
print("matched:", keys)'

# 4. Cleanup: delete every profile we created via the collection-wide
#    ranking (search always scores every profile, so top_k >= count
#    returns them all regardless of the query shape).
IDS=$(curl -sf -X POST "${PM_URL}/v1/search" "${AUTH[@]}" \
  -H "Content-Type: application/json" \
  -d "{\"collection_id\": \"${COLLECTION_ID}\", \"query\": {\"role\": \"shepherd\"}, \"top_k\": 100}" \
  | python3 -c 'import json,sys
r = json.load(sys.stdin)
print(" ".join(x["profile"]["id"] for x in r["results"]))')
for id in ${IDS}; do
  curl -sf -o /dev/null -X DELETE "${PM_URL}/v1/profiles/${id}" "${AUTH[@]}"
done
echo "cleaned up profiles; the collection itself remains (V1 has no delete-collection)"

(AUTH_URL is the Auth Service base URL — http://127.0.0.1:40004 on the local stack; PM_URL is the Profile Matching base URL — http://127.0.0.1:45093 locally; TENANT_ID is your tenant's id.)

Try it live

Open the Playground → Users & Profile Matching — the Profile Matching console exercises the same create → index → match cycle against the live service.

Copy agent prompt

Agent prompt — copy and hand to your agent
You are working with the Inaya Labs Profile Matching service. The base URL is
in PM_URL; the tenant key pair is in INAYA_API_KEY / INAYA_APP_KEY and
TENANT_ID; the Auth Service is at AUTH_URL. Obtain a user JWT via POST
/v3/auth/qa/login {"device_id","locale","tenant_id","username"} if TOKEN is
not already set. Steps: (1) POST /v1/collections {"name":"agent-caretakers",
"embedding_dim":256} (expect 201) and remember the id. (2) Index three
profiles into it via POST /v1/collections/{id}/profiles: caretaker-amina
{"role":"shepherd","experience_years":5,"looking_for":"pasture-work"},
caretaker-bo {"role":"shepherd","experience_years":3,"looking_for":
"pasture-work"}, caretaker-cy {"role":"accountant","experience_years":10,
"looking_for":"desk-work"} — each with an external_key and expect 201. (3)
Search via POST /v1/search {"collection_id": id, "query": {"role":"shepherd",
"looking_for":"pasture-work"}, "top_k": 10} and report each result's
external_key and similarity; confirm both shepherds outrank the accountant and
note total_candidates. (4) Repeat the search keeping only similarity >= 0.2
and report the matched external_keys. (5) Delete one profile by id (expect
204), then delete the same id again and report that it is still 204
(idempotent). Never log the JWT; report status codes per step.

API reference

Next journeys