# Archive group Source: https://docs.xtrace.ai/api-reference/groups/archive-group https://api.staging.xtrace.ai/openapi.public.json delete /v1/groups/{group_id} Soft-delete: flips `status` to `archived`. The group row is retained so memory rows tagged with this id remain searchable; new ingests reject the id with `422 group_archived`. Restore by `PATCH`ing `status` back to `active`. Idempotent. # Create group Source: https://docs.xtrace.ai/api-reference/groups/create-group https://api.staging.xtrace.ai/openapi.public.json post /v1/groups Register a new group on the org. Returns the persisted row including the server-generated `id`. # Get group Source: https://docs.xtrace.ai/api-reference/groups/get-group https://api.staging.xtrace.ai/openapi.public.json get /v1/groups/{group_id} # List groups Source: https://docs.xtrace.ai/api-reference/groups/list-groups https://api.staging.xtrace.ai/openapi.public.json get /v1/groups List groups for the calling org. Active-only by default. # Update group Source: https://docs.xtrace.ai/api-reference/groups/update-group https://api.staging.xtrace.ai/openapi.public.json patch /v1/groups/{group_id} Patch `name`, `prompt`, and/or `status`. Changing `prompt` does not retroactively re-tag memory rows that were tagged under the previous prompt. # Agentic memory search Source: https://docs.xtrace.ai/api-reference/memories/agentic-memory-search https://api.staging.xtrace.ai/openapi.public.json post /v1/memories/search Agentic search over the memory pool. Pipeline: per-corpus vector retrieval → (``mode=compose`` only) LLM context selection. ``data: list[Memory]`` is always returned; ``mode=compose`` additionally assembles a markdown block into ``context: str``, while ``mode=retrieve`` stops after retrieval and leaves ``context`` null. Scope is enforced server-side from the request's scope axes (``user_id`` / ``group_ids`` / ``agent_id`` / ``app_id``); no caller-supplied filter DSL. # Delete memory Source: https://docs.xtrace.ai/api-reference/memories/delete-memory https://api.staging.xtrace.ai/openapi.public.json delete /v1/memories/{memory_id} Hard-delete a memory — fact, artifact, or episode. Removes the record outright (no soft-delete / tombstone). A deleted fact disappears from supersede chains; the revision walker (``GET /{id}/revisions``) tolerates the missing node and simply stops there. Idempotent: the first delete returns 204, subsequent deletes return 404 (the record is gone, so the existence check below fails). # Get memory Source: https://docs.xtrace.ai/api-reference/memories/get-memory https://api.staging.xtrace.ai/openapi.public.json get /v1/memories/{memory_id} Get one memory by id. Works for facts, artifacts, episodes. Always returns the full representation — artifacts include ``details.full_content``. # Get revision chain Source: https://docs.xtrace.ai/api-reference/memories/get-revision-chain https://api.staging.xtrace.ai/openapi.public.json get /v1/memories/{memory_id}/revisions Return the revision chain for a memory. Facts → supersede chain (oldest → newest). Artifacts → version chain (v1 → vN). Episodes → single-element list (episodes have no revisions today). Full chain in one response. ``has_more`` is always ``false``; cursor envelope kept for shape consistency. # Ingest memories Source: https://docs.xtrace.ai/api-reference/memories/ingest-memories https://api.staging.xtrace.ai/openapi.public.json post /v1/memories Async ingest. Returns ``202 + job_id`` by default; the extraction runs in the background and the caller polls ``GET /v1/memories/jobs/{job_id}``. With ``?wait=true`` the server holds the connection up to ~30 seconds waiting for extraction to terminate. If it finishes in time, the response is ``200 OK`` with the terminal job inline (``status: "succeeded"`` or ``"failed"``). If the wait window elapses first, the response falls back to ``202 + pending`` and the caller resumes the normal polling pattern. # List memories Source: https://docs.xtrace.ai/api-reference/memories/list-memories https://api.staging.xtrace.ai/openapi.public.json get /v1/memories List memories with flat-equality filters and cursor pagination. # Poll ingest job Source: https://docs.xtrace.ai/api-reference/memories/poll-ingest-job https://api.staging.xtrace.ai/openapi.public.json get /v1/memories/jobs/{job_id} Return the current state of an ingest job. Terminal states (``succeeded`` / ``failed``) remain queryable after the job completes. ``result.memories_created`` and ``result.memories_updated`` carry thin references (``{id, type, text}``); fetch ``GET /v1/memories/{id}`` for the full row. Returns ``404 job_not_found`` for unknown ids or ids belonging to a different org. # Procedural-memory recall (pre-tool-call hook) Source: https://docs.xtrace.ai/api-reference/memories/procedural-memory-recall-pre-tool-call-hook https://api.staging.xtrace.ai/openapi.public.json post /v1/memories/trigger Procedural-memory recall for a pre-tool-call hook. Fires the symbol tripwire on the in-flight ``action`` (or explicit ``entities``) and returns the ``lesson``/``procedure`` insights past sessions recorded about those symbols — advisory, not a mandate. ``data: list[Memory]`` carries the matched rows (``type: "lesson" | "procedure"``); ``mode=compose`` additionally runs an LLM relevance gate over ``task`` and assembles a markdown block into ``context``, while ``mode=retrieve`` returns the raw matched rows and leaves ``context`` null. Scope is enforced server-side from the request's scope axes (``user_id`` / ``group_ids`` / ``agent_id`` / ``app_id``). Unlike ``POST /v1/memories/search``, this endpoint is **not** metered against the monthly quota — the hook is meant to be called freely before every tool use. # Update memory group_ids Source: https://docs.xtrace.ai/api-reference/memories/update-memory-group_ids https://api.staging.xtrace.ai/openapi.public.json patch /v1/memories/{memory_id} Add or remove ``group_ids`` on a single memory — the sharing axis. Group tags control who can reach a row via ``filters: {group_ids: }`` on search / list, so this is how a memory becomes shared (or un-shared) after ingest. Scope is group tags only: ``text`` and the entity ids (``user_id`` / ``agent_id`` / ``conv_id`` / ``app_id``) are immutable post-ingest and are not editable here. The operation is set-based and idempotent — re-sending the same patch is a no-op and skips the write entirely. See :class:`MemoryGroupPatchRequest` for the full contract. # Get usage Source: https://docs.xtrace.ai/api-reference/usage/get-usage https://api.staging.xtrace.ai/openapi.public.json get /v1/usage Aggregate the org's memory usage for the current quota period — a calendar month, or your billing period on plans that meter per billing period — plus a per-day breakdown if requested, plus an inline storage snapshot. - ``operations`` totals ``messages_ingested`` / ``searches`` / ``requests`` across every API key for the period. - ``quota.monthly`` pairs those totals against the plan caps resolved from the org's subscription tier; ``limit: null`` means the tier is uncapped (enterprise). - ``quota.rate_limit_req_per_min`` is the per-API-key request ceiling enforced across all memory endpoints. # Delete webhook config Source: https://docs.xtrace.ai/api-reference/webhooks/delete-webhook-config https://api.staging.xtrace.ai/openapi.public.json delete /v1/webhooks Remove the org's webhook config. Idempotent — returns `204` whether or not a config existed. Stops all deliveries for the org. # Get webhook config Source: https://docs.xtrace.ai/api-reference/webhooks/get-webhook-config https://api.staging.xtrace.ai/openapi.public.json get /v1/webhooks Read the org's current webhook config. The `secret` is masked — the full value is only ever shown at create / rotate time. # Set webhook config Source: https://docs.xtrace.ai/api-reference/webhooks/set-webhook-config https://api.staging.xtrace.ai/openapi.public.json put /v1/webhooks Create or replace the org's webhook config. Idempotent on `url` / `events` / `enabled`. The full signing `secret` is returned here — on first create, or whenever `rotate_secret=true`. Store it: `GET` only ever returns it masked. # Authentication Source: https://docs.xtrace.ai/guides/authentication How API keys work — and how to wire them into the SDK. Every request needs one piece: an **API key**. The key is the sole identity — your organization is derived server-side from it, and every row is scoped to the calling org. ## Get your credentials 1. Sign in at **[app.xtrace.ai](https://app.xtrace.ai)** 2. Open **Settings → API Keys** 3. Create a new **API key** (`xtk_…`) Treat the API key like a password — anyone with it can read and write memories under your org. Store it in a secrets manager or environment variable, never in source control. ## Headers ```http theme={null} Authorization: Bearer xtk_... ``` Required on every request. Missing or invalid values: | Error | Cause | | ----- | -------------------------- | | `401` | Missing or invalid API key | **Upgrading from an older integration?** The `X-Org-Id` header is no longer needed and is deprecated. It's still accepted during a compatibility window — if sent, it must match the key's org (a mismatch returns `403 org_mismatch`) — but it will be removed in a future release. New integrations must not send it. ## Using the SDK The SDK builds the header from a single constructor option: ```ts theme={null} import { MemoryClient } from '@xtraceai/memory'; const client = new MemoryClient({ apiKey: process.env.XTRACE_API_KEY!, }); ``` That's it — every method call on the client carries the right headers. ## Storing credentials Never commit API keys to source control. Use environment variables, a secrets manager (AWS Secrets Manager, GCP Secret Manager, 1Password CLI), or a `.env` file that's in `.gitignore`. A typical setup: ```bash .env theme={null} XTRACE_API_KEY=xtk_... ``` ```ts theme={null} import 'dotenv/config'; import { MemoryClient } from '@xtraceai/memory'; const client = new MemoryClient({ apiKey: process.env.XTRACE_API_KEY!, }); ``` ## Rotating a key If a key leaks, treat it like any other credential incident: 1. Issue a new key from your org admin tool 2. Roll the new key into your environment / secrets manager 3. Revoke the old key Keys are long-lived; there is no automatic expiry in v1. ## Browser vs server The SDK works in both Node 18+ and modern browsers (it uses native `fetch`). **Don't ship API keys to a browser** — proxy memory-API calls through your own backend so the key never leaves the server. # Groups Source: https://docs.xtrace.ai/guides/groups Share memory across users. Register a group — prompted or catch-all — tag memories to it at ingest, and let every member search them. By default a memory is scoped to the `user_id` that ingested it. **Groups** let you share memory *across users*: tag a memory to a group at ingest, and anyone who searches that group can see it. The motivating case is collaborative agents — e.g. a **travel-planning** assistant where each trip is a group. Every traveler's AI tags trip-relevant facts to the trip's group, so the whole party shares one evolving picture (hotels, restaurants, dates) while each person's unrelated personal memories stay private. ## The model * A **group** is a registry entry with a `name` and an *optional* `prompt`. The prompt determines the group's mode: * **Prompted** — the group has a `prompt` describing *what belongs* in it. The ingest classifier reads it and tags a memory only when the prompt clearly applies. * **Catch-all** — the group has no `prompt`. It receives **every** extracted memory judged shareable, with no per-group matching. * **The personal gate applies to every group.** Before any tagging, the classifier judges each extracted memory personal vs. shareable; personal memories are never group-tagged, whatever the group's mode. See [The personal gate](#the-personal-gate). * Group ids are server-generated, unguessable handles (`grp_…`). **Knowing the id is the access boundary** — your client decides which users belong to which groups and which ids to send; the memory service doesn't track membership. * Tagging is **additive**: tagging a memory to a group never removes it from the author's own scope. ## 1. Register a group **Prompted** — pass a `prompt` describing what belongs: ```ts theme={null} const trip = await client.groups.create({ name: 'Tokyo trip 2026', prompt: 'Facts about the Tokyo trip in May 2026: flights, hotels, restaurants, ' + 'reservations, and dietary needs for this trip.', }); console.log(trip.id); // "grp_4f14…" ``` Write the `prompt` the way you'd brief the classifier: concrete about what to include. A sharp prompt ("facts about the **Tokyo** trip") tags precisely; a vague one over- or under-tags. **Catch-all** — omit the `prompt` entirely (or send `null`): ```ts theme={null} const workspace = await client.groups.create({ name: 'Trip workspace', // no prompt → catch-all }); ``` Only *omitting* `prompt` (or sending `null`) creates a catch-all. An empty string is rejected with `422` — a blank field from a buggy client can't silently turn a group into a catch-all. ## 2. Tag memories at ingest Pass the group ids in `group_ids`. At extraction time the classifier tags each extracted memory: ```ts theme={null} await client.memories.ingest({ messages: [ { role: 'user', content: "When I'm in Tokyo I always stay near Shibuya station." }, { role: 'assistant', content: 'Noted.' }, ], user_id: 'alice', conv_id: 'conv_2026_05_16', group_ids: [trip.id, workspace.id], }); ``` * Each **shareable** extracted memory is tagged with the prompted groups its content matches **plus every requested catch-all group** — several, one, or none. ("Stays near Shibuya" → the Tokyo trip *and* the workspace; an off-topic but shareable aside → the workspace only.) * **Personal** memories are never tagged to any group — see [the personal gate](#the-personal-gate). * Unknown or archived ids are **soft-skipped** (never fail the ingest) and returned in `result.ignored_group_ids`. * Up to **20** group ids per ingest; more returns `422`. ## The personal gate Every extracted memory is first judged **personal vs. shareable**, and personal memories are *never* group-tagged — not by prompted groups, not by catch-alls. This is the privacy backstop that makes catch-alls safe: passing a group id doesn't mean everything in the conversation crosses to the group. * **Personal** means private or sensitive circumstances: health, family matters, private feelings, finances, credentials. * Merely being *about* the user does **not** make a memory personal. "Alice is vegetarian" on a shared-trip ingest is shareable — the group needs it to plan dinner. "Alice's father is in the hospital" is personal, even if it came up while discussing trip dates. * Personal memories are **not dropped**. They're stored in the authoring user's personal scope like any untagged memory and remain fully retrievable there (`search({ user_id })`, `recall`). The gate only controls *sharing*. * The gate **fails closed**: if the personal-vs-shareable verdict is unavailable, the memory is left untagged rather than shared. ## 3. Read shared memories back **The whole group, across every member** — omit `user_id`, pass `group_ids`: ```ts theme={null} const shared = await client.memories.search({ query: 'where is everyone staying?', group_ids: [trip.id], }); ``` **A user's own memories *plus* the group's shared memories**, in one ready-to-inject prompt — use `recall`: ```ts theme={null} const { prompt } = await client.memories.recall({ query: 'what should we plan for dinner?', pools: [ { user_id: 'alice' }, // her dietary prefs { group_ids: [trip.id] }, // the trip's shared restaurant picks ], }); ``` `recall` sections the prompt by **Personal** + the group's name and attributes each shared line to its author (`you:` for the caller, `:` for fellow members). See [Searching memories](/guides/searching-memories#personal-and-shared-with-recall) for the full picture, including how AND-scoping makes `{ user_id, group_ids }` on a plain `search` an *intersection* rather than a union. Group membership matching is **any-of**: pass `group_ids: [tripA, tripB]` to search across both trips at once (a user can belong to many groups). ## Managing groups ```ts theme={null} await client.groups.list(); // all groups (active + archived) await client.groups.get(trip.id); await client.groups.update(trip.id, { prompt: '…' }); // re-prompt the classifier await client.groups.archive(trip.id); // soft-archive ``` Prompt edits are a one-way door: * **Adding** a prompt to a catch-all group is allowed — it becomes a *prompted* group from then on, and new ingests match against the prompt. * **Removing** a prompt is not: `null` on update means "keep the existing prompt", and an empty string is a `422`. Once a group is prompted, it stays prompted. * Prompt changes never retroactively re-tag existing memories — they only steer future ingests. Archiving is soft (`status` becomes `"archived"`): the group stays readable, but it's **dropped from future ingest tagging** — a stale id passed on ingest just lands in `ignored_group_ids`. Re-activate by `update(id, { status: 'active' })`. ## Best practices **Pick the mode that matches the ingest.** A **catch-all** fits a shared workspace or trip bucket where the conversations you ingest are *already about* the shared context — everything shareable from them belongs to the group, so per-memory matching would only lose facts. A **prompted** group fits a curated topical slice — the conversations mix concerns and only some of it belongs (e.g. a team channel's ingests feeding a narrow "on-call runbook facts" group). **Expect lower search precision on catch-alls — by design.** A catch-all trades precision for recall: it accumulates *everything* shareable from its ingests, so a group-scoped search over it surfaces more loosely related neighbors than a search over a sharply prompted group. If group-search quality matters more than capture-everything coverage, use a prompted group. **Model a group around a real shared boundary.** A group should map to something multiple users genuinely share — a trip, a project, a workspace channel — not a single user (that's what `user_id` is for) and not a throwaway topic. If only one person will ever read it, it doesn't need to be a group. **For prompted groups, write the `prompt` like a brief to the classifier.** It's the *only* signal used to decide what gets tagged. Name the subject so unrelated facts don't leak in: | | Prompt | | ------- | ---------------------------------------------------------------------------------------------------------------------------- | | ✅ Sharp | "Facts about the **Tokyo trip in May 2026**: flights, hotels, restaurants, reservations, and dietary needs for *this trip*." | | ❌ Vague | "Travel stuff." — over- or under-tags | **Send only the groups the user is actually in.** Your app owns membership; pass the `group_ids` relevant to *this* conversation, not every group in the org. This matters doubly for catch-alls: every shareable memory lands in *every* catch-all you pass, so a stray catch-all id pollutes that group with an unrelated conversation. (Max 20 per ingest.) **Don't lean on the personal gate for topical scoping.** The gate keeps private and sensitive content (health, family, finances, credentials) out of *all* groups server-side — but it's a privacy backstop, not a relevance filter. An off-topic but shareable fact still lands in a catch-all. Scope which ids you send — and prompted groups' prompts — to control *topic*; let the gate handle *privacy*. **Pick the right read for the job:** * An agent serving **one user inside a group** → `recall({ pools: [{ user_id }, { group_ids }] })` — their own context *plus* the group's, deduped and attributed. * A **whole-group overview** ("what does the trip know?") → `search({ group_ids })`, omit `user_id`. * Don't expect `search({ user_id, group_ids })` to union — it's an **intersection** (see [Searching memories](/guides/searching-memories#personal-and-shared-with-recall)). **Treat tagging as best-effort.** Whether a fact lands in a group is an LLM relevance call, not a guarantee — and the personal gate fails closed, leaving a memory untagged when its verdict is unavailable. Don't build logic that *requires* a specific fact to be tagged. Always read `result.ignored_group_ids` to catch unknown or archived ids. **Group ids are the access boundary.** They're unguessable (`grp_…`) and the service doesn't track membership — your app decides who can read a group. Don't expose a group id to anyone who shouldn't see the group, and don't tag anything you wouldn't want *every* member to read — especially a catch-all, where everything shareable crosses over. **Archive groups when they're done.** A finished trip shouldn't keep collecting tags; archive it to stop new tagging while keeping its history readable. ## See also * **[Ingesting memories](/guides/ingesting-memories#tagging-memories-to-groups)** — the `group_ids` tagging path * **[Searching memories](/guides/searching-memories)** — scoping, `recall`, search modes * **[TypeScript SDK](/guides/typescript-sdk)** — `client.groups` method reference # Ingesting memories Source: https://docs.xtrace.ai/guides/ingesting-memories Send conversation turns, get back extracted facts. Async by default, with a sync escape hatch. Ingest is the write path. You send conversation messages; the server runs LLM-based extraction to pull out facts (and, when relevant, artifacts and episodes), embeds each one, and stores them in your org's vector index. ## The mental model Ingest is **asynchronous by default**. Extraction is LLM-bound — typically 3–10 seconds — so the API returns a job immediately and does the work in the background. Your code polls or opts into sync mode. ``` ┌──────────┐ ┌───────────────┐ │ Client │ POST /v1/memories ──────► │ Memory API │ │ │ ◄──── IngestJob (pending) │ (returns 1s) │ └──────────┘ └───────────────┘ │ │ extraction (3–10s) ▼ status: succeeded result.memories_created: [...] ``` ## Required fields Every ingest needs: * `messages` — array of `{ role, content }`. Empty array → 400. * `user_id` — keys the per-user session namespace * `conv_id` — anchors every extracted memory to a conversation (for replay, export, bulk retract) Optional: `agent_id`, `app_id`, `group_ids` (tag the extracted memories to shared [groups](/guides/groups) — memories judged personal, e.g. health or finances, are never group-tagged), `timestamp_format` (a `strptime` format for parsing dated turns on the batch path), `extract_artifacts` (defaults to `true` — pass `false` to skip the artifact-extraction stage, the most expensive part of the pipeline). ## Async ingest (default) ```ts theme={null} const job = await client.memories.ingest({ messages: [ { role: 'user', content: 'My favorite food is pad see ew.' }, { role: 'assistant', content: 'Noted — Thai food.' }, ], user_id: 'alice', conv_id: 'conv_2026_05_16', }); // pollUntilDone handles exponential backoff (500ms → 5s) and timeout. const done = await client.memories.jobs.pollUntilDone(job.id); if (done.status === 'failed') { throw new Error(`Ingest failed: ${done.error?.message}`); } console.log('Created', done.result?.memories_created.length, 'memories'); ``` ## Sync ingest (`wait: true`) Useful for demos, one-shot scripts, or any code where you want the result inline: ```ts theme={null} const job = await client.memories.ingest( { messages: [{ role: 'user', content: 'I am vegetarian.' }], user_id: 'alice', conv_id: 'conv_2026_05_16', }, { wait: true }, ); if (job.status === 'succeeded') { console.log('Inline result:', job.result?.memories_created); } else if (job.status === 'failed') { console.error('Extraction failed:', job.error); } else { // Sync budget elapsed (30s) — fell back to async; poll job.id as above. console.log('Polling required:', job.id); } ``` The server holds the connection for **up to 30 seconds**. If extraction finishes in that window the response is terminal (`succeeded` or `failed`). If the budget elapses, you get a pending/running job back and have to poll — same as async mode. Use sync mode for interactive demos and CLI tools; use async mode for production agent loops where you want to dispatch ingest and continue working. ## What gets extracted You pass messages; you don't pre-decide what's a fact vs an artifact vs an episode. The server's extraction pipeline decides: | Type | Triggered when | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Fact** | The default. A semantic claim in a turn ("User likes X", "User works at Y"). | | **Artifact** | The conversation references a structured object — a doc, code snippet, summary — that's worth storing standalone. Extracted by default; pass `extract_artifacts: false` to skip this stage. | | **Episode** | A stretch of turns gets summarized into a session-level memory. Server-driven; no client knob. | The `result.memories_created` array tells you what landed; each entry is a thin reference (`{id, type, text}`). For the full row, call `client.memories.get(id)`. ## Tagging memories to groups Pass `group_ids` to associate this ingest with one or more **groups** — shared tagging targets you register up front (see [Groups](/guides/groups)). At extraction time a classifier tags each extracted memory: **prompted** groups get the memories their `prompt` matches, and **catch-all** groups (registered without a prompt) get every shareable memory. Other members of the group can then surface those memories with a [group search](/guides/searching-memories#scoping-a-search). ```ts theme={null} await client.memories.ingest({ messages: [ { role: 'user', content: "When I'm in Tokyo I always stay near Shibuya station." }, { role: 'assistant', content: 'Noted.' }, ], user_id: 'alice', conv_id: 'conv_2026_05_16', group_ids: ['grp_tokyo2026'], }); ``` * The classifier tags each extracted memory with the **subset** of `group_ids` it belongs to — a memory can land in several groups, one, or none. Untagged extraction still happens as usual; tagging is additive. * Memories judged **personal** (private/sensitive: health, family matters, finances, credentials) are never group-tagged — they stay in the author's personal scope, fully retrievable there. See [the personal gate](/guides/groups#the-personal-gate). * Unknown or archived ids are **soft-skipped** — they never fail the ingest, and come back in `result.ignored_group_ids` so you can prune stale ids client-side. * Up to **20** group ids per ingest; more returns `422`. Groups are how you share memory **across users**. A fact Alice ingests with `group_ids: ['grp_tokyo2026']` becomes visible to every member of that group via group search — without exposing her untagged personal memories. ## Failure modes Extraction can fail for various reasons — upstream LLM hiccup, content that doesn't yield extractable facts, rate limits. The job lands in `status: "failed"` with an `error.code` and `error.message`. Retry by submitting the same body again; we don't auto-retry server-side. Common failure codes: | Code | Meaning | | --------------------- | ----------------------------------------------- | | `ingest_failed` | Generic extraction error; check `error.message` | | `rate_limit_exceeded` | Org quota hit; wait and retry | ## See also * **[Searching memories](/guides/searching-memories)** — query what you just ingested * **API Reference → Memories → Ingest** — full request/response schemas # Quickstart Source: https://docs.xtrace.ai/guides/quickstart Install the SDK, ingest a conversation turn, and search it back — in under five minutes. End-to-end in 5 minutes. By the end you'll have a memory ingested and retrieved via vector search. ## 1. Install ```bash theme={null} npm install @xtraceai/memory ``` Node 18 or newer. Works in the browser too. ## 2. Get credentials Sign in at **[app.xtrace.ai](https://app.xtrace.ai)** and grab your **API key** (`xtk_…`) from **Settings → API Keys**. The key is the sole identity — your org is derived server-side from it. See [Authentication](/guides/authentication) for the full credential setup, including storage best practices. ## 3. Ingest a memory ```ts theme={null} import { MemoryClient } from '@xtraceai/memory'; const client = new MemoryClient({ apiKey: process.env.XTRACE_API_KEY!, }); const job = await client.memories.ingest({ messages: [ { role: 'user', content: 'My favorite food is pad see ew. I love Thai cuisine.' }, { role: 'assistant', content: 'Got it — pad see ew noted.' }, ], user_id: 'alice', conv_id: 'conv_2026_05_16', }); console.log('Ingest job:', job.id, 'status:', job.status); ``` The call returns immediately with a job in `status: "pending"` or `"running"`. Extraction runs server-side (3–10 seconds typical). ## 4. Wait for extraction to finish ```ts theme={null} const done = await client.memories.jobs.pollUntilDone(job.id); console.log('Memories created:', done.result?.memories_created); ``` The SDK's `pollUntilDone` helper handles exponential backoff (500ms → 5s) and a configurable timeout. You can also opt into **synchronous mode** by passing `{ wait: true }` on ingest — the server holds the connection up to 30 seconds. See [Ingesting memories](/guides/ingesting-memories) for the full pattern. ## 5. Search the memory back ```ts theme={null} const results = await client.memories.search({ query: 'what does the user like to eat?', user_id: 'alice', limit: 5, }); for (const m of results.data) { console.log(m.score?.toFixed(2), '·', m.text); } // 0.87 · User loves Thai cuisine. // 0.82 · User's favorite food is pad see ew. ``` ## What's next * **[Authentication](/guides/authentication)** — get the credentials and use them * **[Ingesting memories](/guides/ingesting-memories)** — async/sync trade-offs, polling, what gets extracted * **[Searching memories](/guides/searching-memories)** — scoping, search modes, `recall` * **[Groups](/guides/groups)** — share memory across users * **API Reference** tab — every endpoint, every response shape # Searching memories Source: https://docs.xtrace.ai/guides/searching-memories Vector search scoped by what you pass — a user's own memories, shared groups, or both. Once a memory is ingested and the job reaches `succeeded`, it's immediately queryable. There are three read paths: * **`search`** — vector-ranked retrieval, scoped by the ids you pass. Use when you have a natural-language query. * **`list`** — paginated browse by scope, no query. Use for "all of Alice's memories". * **`recall`** — convenience over `search` that combines a user's own memories **and** a shared group's into one ready-to-inject prompt (see [below](#personal-shared-with-recall)). ## Vector search ```ts theme={null} const results = await client.memories.search({ query: 'what does the user like to eat?', user_id: 'alice', limit: 10, }); for (const m of results.data) { console.log(m.score?.toFixed(2), '·', m.text); } ``` `query` is required (non-empty). The server embeds it and ranks by cosine similarity; `data` is a list of `Memory` rows with `.score` populated. ## Scoping a search There is **no filter DSL**. Search is *"scope by what you pass"*: supply any combination of the axes below and each one **AND-narrows** the result. An omitted axis is unconstrained, and **at least one** axis is required — an unscoped search returns `422`. | Axis | Scopes to | | ----------- | ------------------------------------------------------- | | `user_id` | one user's memories | | `group_ids` | memories tagged to **any** of these groups (cross-user) | | `agent_id` | one agent | | `app_id` | one app | ```ts theme={null} // Alice's memories only await client.memories.search({ query, user_id: 'alice' }); // Alice's memories, narrowed to one agent await client.memories.search({ query, user_id: 'alice', agent_id: 'planner' }); ``` Everything you pass is AND'd together; your `org` is always implicit (from the API key). `user_id` is **required on ingest** but **optional on search** — omit it and pass `group_ids` to read a whole group across users (next section). ## Reading shared memories (groups) Memories [tagged to a group](/guides/ingesting-memories#tagging-memories-to-groups) at ingest are visible to anyone who searches that group. `group_ids` matches **any-of** — a row tagged to *any* of the requested groups qualifies. ```ts theme={null} // The whole Tokyo trip, across every member — omit user_id: await client.memories.search({ query, group_ids: ['grp_tokyo2026'] }); // Alice's OWN slice of the trip (intersection: user_id AND group): await client.memories.search({ query, user_id: 'alice', group_ids: ['grp_tokyo2026'] }); ``` Because scoping is AND-everything, `{ user_id, group_ids }` is an **intersection** (Alice's rows that are *also* tagged to the trip), not a union. For *"Alice's own memories **plus** the trip's shared memories"* in one call, use `recall`. ## Personal and shared with recall `recall` runs the personal and shared scopes in parallel, dedupes, and returns a single context block — the combined read that AND-scoping can't express in one `search`. ```ts theme={null} const { prompt, memories } = await client.memories.recall({ query: 'what should we plan for dinner on the trip?', pools: [ { user_id: 'alice' }, // her dietary prefs { group_ids: ['grp_tokyo2026'] }, // the trip's shared restaurant picks ], }); // inject `prompt` into your agent's context ``` `prompt` is sectioned by **Personal** + each group's name, with shared lines attributed to their author (`you:` / `:`). It resolves group names automatically and won't bleed in the user's *other* groups. Full options in the [SDK reference](/guides/typescript-sdk) and the [Groups guide](/guides/groups). ## Search modes: `retrieve` vs `compose` `mode` controls whether the server's LLM context-selection pass runs: | `mode` | `data` | `context` | Use when | | --------------------- | ------------------------------ | ------------------------ | ----------------------------------------------------------- | | `compose` *(default)* | the **agent-selected** subset | assembled markdown block | you want a ready-to-inject context prompt | | `retrieve` | the **raw** vector-ranked rows | `null` | you want pure results to process yourself — cheaper, no LLM | ```ts theme={null} // Raw rows, no LLM pass: const raw = await client.memories.search({ query, user_id: 'alice', mode: 'retrieve' }); // Assembled context for a prompt (default): const composed = await client.memories.search({ query, user_id: 'alice', mode: 'compose' }); console.log(composed.context); // "Relevant memories about the user:\n- ..." ``` ## Cursor pagination `search` and `list` return `has_more` and `next_cursor`. The SDK's `list` is an async iterator that paginates automatically: ```ts theme={null} for await (const memory of client.memories.list({ user_id: 'alice', limit: 50 })) { // …each memory across all pages } ``` For cursor-level control use `listPage`: ```ts theme={null} const page = await client.memories.listPage({ user_id: 'alice', limit: 50, cursor }); ``` `list` accepts the same scope keys plus `type` (`fact` | `artifact` | `episode`) and `order`. Cursors are tenant-scoped — a cursor from one `(org, key)` pair can't be used by another. ## Retrieving a single memory ```ts theme={null} const memory = await client.memories.get('5b0d0f7d-d502-45d5-847d-e19512b5c517'); ``` Returns the full row — including `details.full_content` for artifacts, which search and list omit by default (it can be large). 404s if the id doesn't exist or was deleted. ## See also * **[Ingesting memories](/guides/ingesting-memories)** — what gets stored, and tagging to groups * **[Groups](/guides/groups)** — register groups and share memory across users * **API Reference → Memories → Search** — every field, generated from the live spec # TypeScript SDK Source: https://docs.xtrace.ai/guides/typescript-sdk Reference for the @xtraceai/memory package — every class, method, type, and error. The `@xtraceai/memory` package is the primary supported client. It's a hand-written wrapper over the HTTP API with idiomatic TypeScript types, an exponential-backoff polling helper, async-iterator pagination, and a typed error hierarchy. ```bash theme={null} npm install @xtraceai/memory ``` Zero runtime dependencies. Node 18+ (native `fetch`). Works in the browser too. ## `MemoryClient` The entry point. One client serves the whole org. ```ts theme={null} import { MemoryClient } from '@xtraceai/memory'; const client = new MemoryClient({ apiKey: process.env.XTRACE_API_KEY!, }); ``` ### Constructor options | Option | Type | Required | Default | Notes | | ------------------ | -------------- | -------- | ---------------------------------- | ---------------------------------------------------------------------------------- | | `apiKey` | `string` | ✓ | — | `xtk_...` API key — the org is derived server-side from it | | `baseUrl` | `string` | — | `https://api.production.xtrace.ai` | Override for staging or self-hosted: e.g. `https://api.staging.xtrace.ai` | | `fetch` | `typeof fetch` | — | `globalThis.fetch` | Inject a custom `fetch` (tests, polyfills, instrumentation) | | `maxRetries` | `number` | — | `2` | Retries on `5xx` (idempotent methods) and `429` (any method); honors `Retry-After` | | `requestIdFactory` | `() => string` | — | `req_` | Override the `X-Request-Id` generator | ## `client.memories` All memory operations live here. Returns a `Memories` instance. ### `ingest(body, options?)` Submit conversation messages for extraction. Returns an `IngestJob`. ```ts theme={null} const job = await client.memories.ingest({ messages: [ { role: 'user', content: 'My favorite food is pad see ew.' }, { role: 'assistant', content: 'Noted.' }, ], user_id: 'alice', conv_id: 'conv_2026_05_16', }); ``` **Required body fields**: `messages`, `user_id`, `conv_id`. Optional: `agent_id`, `app_id`, `group_ids` (tag the extracted memories to [groups](/guides/groups)), `timestamp_format`, `extract_artifacts` (defaults to `true` — pass `false` to skip the artifact-extraction stage). **Options**: * `wait?: boolean` — if true, the server holds the connection up to 30s and returns a terminal job inline. Falls back to async if extraction is still running at 30s. * `signal?: AbortSignal` * `requestId?: string` ### `list(query?)` → `AsyncIterable` Auto-paginating async iterator over memories matching the query. ```ts theme={null} for await (const memory of client.memories.list({ user_id: 'alice' })) { console.log(memory.text); } ``` Filter keys (all optional): `user_id`, `agent_id`, `conv_id`, `app_id`, `type`, `limit`, `order`, `include`. ### `listPage(query?)` → `Promise>` Single-page version of `list`. Use when you need cursor-level control. ```ts theme={null} const page = await client.memories.listPage({ user_id: 'alice', limit: 50, cursor }); // page.data, page.has_more, page.next_cursor ``` ### `get(id)` → `Promise` Fetch a single memory by id. Returns the full row, including `details.full_content` for artifacts. ```ts theme={null} const memory = await client.memories.get('5b0d0f7d-d502-...'); ``` ### `delete(id)` → `Promise` **Hard delete.** Removes the point outright — afterwards `get` 404s, it's gone from list/search, and a second delete 404s (idempotent by absence). There is no `update` method: corrections flow through ingest (re-ingesting the corrected statement supersedes the old one). ```ts theme={null} await client.memories.delete('5b0d0f7d-d502-...'); ``` ### `search(body)` → `Promise` Vector search, scoped by what you pass (`user_id` / `group_ids` / `agent_id` / `app_id` — all AND-narrow; at least one required). `mode` defaults to `compose`. ```ts theme={null} const results = await client.memories.search({ query: 'what does the user like to eat?', user_id: 'alice', limit: 10, }); // results.data — ranked rows; results.context — assembled prompt when mode==='compose' ``` See [Searching memories](/guides/searching-memories) for scoping, modes, and groups. ### `retrieve(body)` → `Promise` Sugar over `search` that forces `mode: 'compose'` — the response's `context` carries the LLM-assembled, ready-to-inject prompt. ### `recall(params, options?)` → `Promise` Personal **+** shared (group) read in one call. Fans out a `search` per scope, dedupes by id, and renders a single prompt sectioned by Personal + group name (shared lines attributed to their author). This is the combined read that AND-scoping can't express in a single `search`. ```ts theme={null} const { prompt, memories, scopes } = await client.memories.recall({ query: 'what should we plan for dinner on the trip?', pools: [ { user_id: 'alice' }, // personal scope { group_ids: ['grp_tokyo2026'] }, // shared scope (any-of) ], }); // inject `prompt`; `memories` is the deduped, score-ranked union; `scopes` is per-pool counts ``` Axes **AND** within a pool; pools **OR**. So `[{ user_id }, { app_id: 'product-kb' }]` reads "alice's memories OR the product KB," while `{ user_id, app_id }` in *one* pool would AND them. **Params**: `query` (required) and `pools` (≥1 pool — each a `ScopePool` of `{ user_id?, group_ids?, agent_id?, app_id? }`); optional `mode` (default `compose`), `limit` (default 10). **Options**: `template?` (override the prompt format — see `PromptTemplate`), `render?` (supply your own renderer), `signal?`, `requestId?`. `renderMemoriesPrompt(memories, opts?)` is also exported standalone if you want to format a memory list yourself. ## `client.memories.jobs` ### `get(jobId)` → `Promise` Poll a single ingest job. Use `pollUntilDone` instead for normal flows. ### `pollUntilDone(jobId, options?)` → `Promise` Polls a job until it reaches `succeeded` or `failed`. Exponential backoff starting at 500ms, capped at 5s, default 60s timeout. ```ts theme={null} const done = await client.memories.jobs.pollUntilDone(job.id); if (done.status === 'failed') throw new Error(done.error?.message); console.log(done.result?.memories_created); ``` **Options**: * `timeoutMs?: number` — default 60\_000 * `initialIntervalMs?: number` — default 500 * `maxIntervalMs?: number` — default 5\_000 * `backoffFactor?: number` — default 1.5 * `signal?: AbortSignal` ## `client.groups` Register and manage **groups** — shared tagging targets. Register a group, then pass its id in `ingest({ group_ids })` and read it back with `search`/`recall`. See the [Groups guide](/guides/groups). ```ts theme={null} const trip = await client.groups.create({ name: 'Tokyo trip 2026', prompt: 'Facts about the Tokyo trip: hotels, restaurants, dates, dietary needs.', }); // trip.id === "grp_…" ``` | Method | Returns | Notes | | ----------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `create({ name, prompt? })` | `Group` | `prompt` tells the ingest classifier what belongs to this group; omit it for a [catch-all](/guides/groups#the-model) that receives every shareable memory | | `list()` | `Group[]` | all groups (active + archived) | | `get(id)` | `Group` | | | `update(id, { name?, prompt?, status? })` | `Group` | edit / re-prompt; `status: 'archived'` archives | | `archive(id)` | `Group` | soft-archive — drops the group from future ingest tagging | ## Error classes Every API failure is a subclass of `MemoryError`. Match on the class for HTTP-status handling and on `.code` for stable machine-readable error codes from the server. ```ts theme={null} import { MemoryNotFound, RateLimited, MemoryError } from '@xtraceai/memory'; try { await client.memories.get('does-not-exist'); } catch (err) { if (err instanceof MemoryNotFound) { // 404 } else if (err instanceof RateLimited) { console.log('retry after', err.retryAfter, 'seconds'); } else if (err instanceof MemoryError) { console.log(err.status, err.code, err.message); } } ``` | Class | HTTP status | | ---------------- | -------------------------------- | | `BadRequest` | 400 | | `Unauthorized` | 401 | | `Forbidden` | 403 | | `MemoryNotFound` | 404 | | `Conflict` | 409 | | `Unprocessable` | 422 | | `RateLimited` | 429 (adds `.retryAfter: number`) | | `ServerError` | 5xx | Common fields on every error: * `status: number` — HTTP status code * `code: string` — stable machine-readable error code (e.g. `memory_not_found`, `org_mismatch`) * `errorType: string` — category (e.g. `invalid_request_error`) * `requestId: string | undefined` — propagate to support / logs * `details: Record | undefined` — error-specific extras ## Type exports The full set of exported types: ```ts theme={null} import type { // Resources Memory, FactMemory, ArtifactMemory, EpisodeMemory, FactDetails, ArtifactDetails, EpisodeDetails, MemoryRef, MemoryType, MemoryStatus, // Ingest Message, Role, IngestRequest, IngestJob, IngestJobResult, JobStatus, // Read ListQuery, ListEnvelope, SearchRequest, SearchListEnvelope, SearchMode, // Recall + prompt rendering RecallParams, RecallResult, RecallScopeStat, ScopePool, PromptTemplate, // Groups Group, GroupStatus, GroupCreateRequest, GroupUpdateRequest, GroupListEnvelope, // Errors ApiErrorBody, } from '@xtraceai/memory'; ``` `renderMemoriesPrompt` and `DEFAULT_PROMPT_TEMPLATE` are exported as values (not types): ```ts theme={null} import { renderMemoriesPrompt, DEFAULT_PROMPT_TEMPLATE } from '@xtraceai/memory'; ``` `Memory` is a discriminated union — `m.type === 'fact'` narrows `m.details` to `FactDetails`, etc. ## Retries and timeouts | What | Default | Override | | ----------------------------- | ------------------------------------------------ | -------------------------------------------- | | Max retries | 2 | `new MemoryClient({ maxRetries: 5 })` | | Retry-eligible methods on 5xx | `GET`, `HEAD` only | n/a | | Retry-eligible methods on 429 | any | n/a — always retried, honoring `Retry-After` | | Backoff | 250ms · 500ms · 1s · … capped at 5s, with jitter | n/a | Network errors on idempotent methods also retry. ## Request ids Every request carries an `X-Request-Id` header (auto-generated `req_` by default). The server echoes it; the SDK surfaces it on errors via `err.requestId`. Use it when filing support tickets — it pins down the exact request in our logs. ## See also * **[Quickstart](/guides/quickstart)** — install and run end-to-end * **[Authentication](/guides/authentication)** — credentials, headers, environment selection * **[Ingesting memories](/guides/ingesting-memories)** — async/sync, polling, group tagging * **[Searching memories](/guides/searching-memories)** — scoping, modes, `recall` * **[Groups](/guides/groups)** — share memory across users * **API Reference** tab — the underlying HTTP endpoints # Webhooks Source: https://docs.xtrace.ai/guides/webhooks Get a signed push notification the moment memory learning finishes for a conversation — instead of polling the job endpoint. Ingest is [asynchronous](/guides/ingesting-memories): you send conversation turns and the server extracts memories in the background. Normally you'd poll `GET /v1/memories/jobs/{job_id}` to find out when it's done. **Webhooks** flip that around — register one URL and XTrace POSTs to it the moment learning **completes or fails** for a conversation. The motivating case is a "memory is ready" gate: an app that wants to tell its user *"we've learned from that conversation"* before letting them continue. Polling makes that laggy and chatty; a webhook makes it a push. ## The model * **One endpoint per org.** You register a single subscriber URL — there are no per-conversation subscriptions to manage. Every terminal ingest job for the org fires to that one URL. * **Two events.** `memory.learning.completed` and `memory.learning.failed` (see [Events](#events)). * **Signed.** Every request carries an `X-Webhook-Signature` HMAC so you can verify it really came from XTrace (see [Verifying the signature](#verifying-the-signature)). * **Correlated by your own ids.** The payload echoes back the `conv_id` and `user_id` you sent at ingest, so you can match a delivery to the originating conversation. * **Best-effort.** Delivery is at-most-once with a few quick retries. Treat the job-polling endpoint as your fallback (see [Delivery semantics](#delivery-semantics)). ## 1. Register your endpoint `PUT /v1/webhooks` with the URL XTrace should call. It must be **HTTPS** and resolve to a public address. ```bash theme={null} curl -X PUT https://api.production.xtrace.ai/v1/webhooks \ -H "Authorization: Bearer $XTRACE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.com/hooks/xtrace" }' ``` ```json Response theme={null} { "object": "webhook", "url": "https://your-app.com/hooks/xtrace", "events": ["memory.learning.completed", "memory.learning.failed"], "enabled": true, "secret": "whsec_9638f6cfeff8bae3f4899ab979cd66b0", "created_at": "2026-06-24T00:59:32Z", "updated_at": "2026-06-24T00:59:32Z" } ``` The full `secret` is returned **only here** — on first create, or when you rotate it. Store it now. Every later `GET` returns it masked (`whsec_••••66b0`). You need the full value to verify signatures. `PUT` is create-or-replace: call it again to change the URL or events. By default it **keeps the existing secret** (so an edit doesn't break verification); pass `?rotate_secret=true` to mint a fresh one. To subscribe to only one event, pass `events`: ```json theme={null} { "url": "https://your-app.com/hooks/xtrace", "events": ["memory.learning.completed"] } ``` ## Events | Event | When it fires | Carries | | --------------------------- | ------------------------------------------------------------------------------------- | ------------------------------ | | `memory.learning.completed` | The ingest job succeeded. **Fires even when nothing was extracted** (`memories: []`). | `memories`, `memories_updated` | | `memory.learning.failed` | Extraction errored. | `error` | `completed` is a **"this conversation has been processed"** signal, not a "we found something" one. A conversation that yields no new memory still fires `completed` with an empty `memories` array — so an app gating on "memory is ready" always gets unblocked. Don't treat `memories: []` as an error. ### Payload ```json memory.learning.completed theme={null} { "event": "memory.learning.completed", "job_id": "job_2f0c0e181daa4a2bae4324c69c22b1fb", "conv_id": "conv_2026_05_16", "user_id": "alice", "memories": [ { "id": "68a38338-a9a3-469d-abe9-c93e140ee40f", "type": "fact" } ], "memories_updated": [], "timestamp": "2026-06-24T00:59:38Z" } ``` ```json memory.learning.failed theme={null} { "event": "memory.learning.failed", "job_id": "job_…", "conv_id": "conv_2026_05_16", "user_id": "alice", "error": { "type": "server_error", "code": "ingest_failed", "message": "Memory extraction failed" }, "timestamp": "2026-06-24T00:59:38Z" } ``` * `conv_id` / `user_id` are the exact values you passed to [`POST /v1/memories`](/guides/ingesting-memories) — use them to correlate the delivery back to the originating conversation. * `memories` are thin refs (`{ id, type }`). Fetch the full row with `GET /v1/memories/{id}` when you need the content. ## Verifying the signature Every delivery includes: ```http theme={null} X-Webhook-Event: memory.learning.completed X-Webhook-Signature: sha256=3f05a813ac1161f0870eef6a18894c3da236e8da7e7dea04fa7862794d3e4134 ``` The signature is `sha256=` + an HMAC-SHA256 of the **raw request body bytes**, keyed by your webhook `secret`. Recompute it over the bytes you received (before any JSON parsing) and constant-time compare. Verify against the **raw body**, not a re-serialized object. Re-encoding the JSON can reorder keys or change whitespace and the signature won't match. ```ts Node / Express theme={null} import crypto from 'node:crypto'; import express from 'express'; const app = express(); const SECRET = process.env.XTRACE_WEBHOOK_SECRET!; // Capture the RAW body for signature verification. app.post('/hooks/xtrace', express.raw({ type: 'application/json' }), (req, res) => { const sig = req.header('X-Webhook-Signature') ?? ''; const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(req.body).digest('hex'); if ( sig.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)) ) { return res.status(401).end(); } const event = JSON.parse(req.body.toString()); // event.conv_id, event.user_id, event.memories … res.status(200).end(); }); ``` ```python Python / Flask theme={null} import hashlib import hmac from flask import Flask, request, abort app = Flask(__name__) SECRET = os.environ["XTRACE_WEBHOOK_SECRET"] @app.post("/hooks/xtrace") def hook(): raw = request.get_data() # raw bytes expected = "sha256=" + hmac.new(SECRET.encode(), raw, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, request.headers.get("X-Webhook-Signature", "")): abort(401) event = request.get_json() # event["conv_id"], event["user_id"], event["memories"] … return "", 200 ``` Respond with any `2xx` to acknowledge. A non-`2xx` (or a timeout) is treated as a failed delivery and retried (see below). ## Delivery semantics Delivery is **best-effort, at-most-once**: * On a non-`2xx` or a network error, XTrace retries a few times with a short backoff, then gives up. A `4xx` (other than `408`/`429`) is treated as a permanent rejection and **not** retried. * Deliveries are independent of the ingest job. A dropped webhook never changes the job's outcome — the memories are already stored. * **Your fallback is the job endpoint.** If a delivery is ever lost, `GET /v1/memories/jobs/{job_id}` still returns the terminal result. For anything you can't afford to miss, reconcile against it rather than relying on the webhook alone. Deliveries are **not** strictly ordered — use `timestamp` if you need to reason about ordering. ## Managing the webhook ```bash theme={null} # Read current config (secret masked) curl https://api.production.xtrace.ai/v1/webhooks \ -H "Authorization: Bearer $XTRACE_API_KEY" # Rotate the signing secret (returns the new full secret once) curl -X PUT "https://api.production.xtrace.ai/v1/webhooks?rotate_secret=true" \ -H "Authorization: Bearer $XTRACE_API_KEY" \ -H "Content-Type: application/json" -d '{ "url": "https://your-app.com/hooks/xtrace" }' # Pause without losing the URL curl -X PUT https://api.production.xtrace.ai/v1/webhooks \ -H "Authorization: Bearer $XTRACE_API_KEY" \ -H "Content-Type: application/json" -d '{ "url": "https://your-app.com/hooks/xtrace", "enabled": false }' # Remove it entirely (idempotent — always 204) curl -X DELETE https://api.production.xtrace.ai/v1/webhooks \ -H "Authorization: Bearer $XTRACE_API_KEY" ``` ## Best practices **Always verify the signature.** Your endpoint is public; the signature is what proves a request is from XTrace and wasn't tampered with. Reject anything that doesn't verify. **Verify against the raw body.** Capture the bytes before parsing (`express.raw`, `request.get_data()`), or you'll re-serialize and break the comparison. **Treat `completed` as "processed," not "found."** An empty `memories` array is a normal success — it means the conversation produced no new memory, and your "ready" gate should still release. **Acknowledge fast, work async.** Return `2xx` quickly and do any heavy work after. A slow handler looks like a failed delivery and gets retried. **Make your handler idempotent.** Retries (and at-most-once semantics) mean you should key on `job_id` so a re-delivery doesn't double-process. **Reconcile critical flows against the job endpoint.** Webhooks are best-effort; if a step truly cannot be missed, fall back to `GET /v1/memories/jobs/{job_id}`. **Keep the secret server-side.** It's an org-wide signing key — never ship it to a browser. Rotate it (`?rotate_secret=true`) if it leaks. ## See also * **[Ingesting memories](/guides/ingesting-memories)** — the async job lifecycle webhooks notify on * **[Authentication](/guides/authentication)** — the `Authorization` header used to manage the webhook * **API Reference** — `PUT` / `GET` / `DELETE /v1/webhooks` request and response schemas # Introduction Source: https://docs.xtrace.ai/introduction Hosted memory for AI agents. Send conversation turns, get back searchable facts. **XTrace Memory Manager** is a hosted memory service for AI agents. You send conversation messages; we extract structured **facts**, **artifacts**, and **episodes** from them, embed each one, and store them in a per-org vector index. You retrieve them later with natural-language search — scoped to a single user, or shared across a **group**. ## Concepts in 30 seconds | Term | What it is | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | | **Fact** | A single semantic claim extracted from a conversation turn (e.g. *"User is vegetarian"*). Most ingested memory is facts. | | **Artifact** | A structured object referenced by the conversation (e.g. a doc, code snippet, summary). Server-extracted when content warrants it. | | **Episode** | A session-scoped summary spanning a stretch of turns. Bounds the temporal scope of facts. | | **Memory** | Umbrella term — every Fact / Artifact / Episode is a Memory, distinguished by its `type` field. | | **Group** | A shared tagging target. Tag memories to a group at ingest, and every member can search them — memory shared *across users*, not just within one. | You don't have to pre-classify anything when you ingest. The server decides what becomes a fact vs an artifact vs an episode. ## Five-minute path Install the SDK, ingest a turn, search the result API keys, org headers, environment setup Async vs sync ingest, polling for completion Scope by user, group, agent, or app — plus `recall` Share memory across users with group tagging For the full endpoint and type reference, see the **API Reference** tab. The TypeScript SDK ([@xtraceai/memory](https://www.npmjs.com/package/@xtraceai/memory)) is the primary supported client today. A Python SDK is on the roadmap. All examples below use the TS SDK; the underlying HTTP API is documented in the **API Reference** tab if you need to wire something yourself. # CLI quickstart Source: https://docs.xtrace.ai/x-vec/cli Set up the xtrace CLI and run the init workflow — create an execution context, configure embeddings, and go from an empty knowledge base to search results in four commands. This page walks you through setting up your environment and running the `init` workflow for a first-time setup. The CLI is the quickest way to create an execution context, configure your embedding model, and save the required settings to a `.env` file so the SDK and tools work together out of the box. ## Install dependencies Create a virtual environment for SDK dependencies and install the CLI: ```bash theme={null} python -m venv .venv source .venv/bin/activate pip install -e ".[cli]" ``` ## Using the CLI Commands are invoked directly from the terminal: ```bash theme={null} xtrace [ARGS] ``` Run `xtrace --help` to view all available commands: ```bash theme={null} xtrace --help ``` You can also start an interactive shell with tab completion and command history: ```bash theme={null} xtrace shell ``` Inside the shell, omit the `xtrace` prefix (e.g. `xvec load ...` instead of `xtrace xvec load ...`). ## Initialize the SDK Run `init` to configure the SDK: ```bash theme={null} xtrace init ``` The `init` command configures your local SDK repo in the following ways: * connects your XTrace account via your `API_KEY` and `ORG_ID` * creates or loads a local **execution context**, which is a unique fingerprint from which to interact with your stored data * loads an **embedding model**, which embeds data for storage and retrieval in a vector space You only need to run `init` once to persist your configuration, with information automatically being stored in a `.env` file and a `/data` directory. ## Key concepts * **Execution context** — your private cryptographic state. It holds a Paillier key pair (for encrypting vectors) and an AES key (for encrypting content), all locked by a passphrase you choose during `init`. Every chunk you store and every query you run uses this context. Losing the passphrase means losing the ability to decrypt your data. * **Embedding model** — converts text into binary vectors for encrypted storage and search. The model you select during `init` must be the same one used for both uploading and querying. Changing models later requires re-uploading your data. * **Knowledge base** — a namespace on XTrace where your encrypted chunks live. Create one with `xtrace kb create` before loading data. For a deeper look at how the encryption works, see the [Quickstart](/x-vec/quickstart) (Python SDK tutorial). ## Your first query After running `init`, four commands take you from an empty knowledge base to search results: ```bash theme={null} # 1. Create a knowledge base (note the KB ID in the output) xtrace kb create my-first-kb # 2. Load documents from a local folder xtrace xvec load ./my-docs/ # 3. Search xtrace xvec retrieve "your query here" # 4. (Optional) Search with LLM synthesis xtrace xvec retrieve "your query" --inference openai --model gpt-4o ``` ## Command groups Commands are organized by submodule. All subgroups and shared commands are accessible from the same `xtrace` CLI entry point. ### Shared | Command | Description | | --------- | ----------------------------------------------------------- | | `init` | Initialize the SDK (credentials, execution context, model). | | `version` | Print the installed SDK version. | | `shell` | Start an interactive CLI shell. | ### Knowledge base admin — `xtrace kb ` | Command | Description | | ---------- | ----------------------------------------------- | | `create` | Create a new knowledge base. | | `delete` | Delete one or more knowledge bases by ID. | | `list` | List knowledge bases available to your API key. | | `describe` | Describe one or more knowledge bases by ID. | KB admin commands require `ADMIN_KEY` input, entered once per shell session. To save the key to your `.env` for implicit admin access, run `xtrace init --admin`. ### x-vec — `xtrace xvec ` | Command | Description | | ------------- | ------------------------------------------------------- | | `load` | Load data from a folder into a knowledge base. | | `retrieve` | Retrieve from a knowledge base using a text query. | | `query` | Alias of `retrieve`. Same signature and behavior. | | `head` | Preview vectors in a knowledge base. | | `fetch` | Fetch specific vectors by ID from a knowledge base. | | `upsert` | Upsert a single text chunk into a knowledge base. | | `upsert-file` | Upsert chunks from a single file into a knowledge base. | **x-mem** CLI commands (`xtrace xmem `) are coming soon. For full usage details for each command, run `xtrace xvec --help` or see the [full CLI reference](/x-vec/cli-reference). # CLI command reference Source: https://docs.xtrace.ai/x-vec/cli-reference Full usage details for every xtrace CLI command — shared commands, knowledge base admin, and x-vec data commands. This page provides usage details for all CLI commands. See the [CLI quickstart](/x-vec/cli) for installation and first-time setup. Commands are grouped by submodule and invoked as: ```bash theme={null} xtrace [ARGS] # subgroup command xtrace [ARGS] # top-level command ``` Inside the interactive shell (`xtrace shell`), omit the `xtrace` prefix: ```bash theme={null} > xvec load /path/to/data/ KB_ID > kb create my-kb ``` ## Shared commands ### `init` ```bash theme={null} xtrace init [--env-file {path/to/env-file}] [--admin] [--inference] [--help] ``` `init` sets up your local SDK repo by connecting your XTrace credentials, creating or loading an execution context, and loading an embedding model. Must be run once before any data commands. `--env-file` / `-f` sets a custom path for the generated `.env` file (default: `.env`). `--admin` saves your admin key to `.env` for implicit admin access. `--inference` saves an inference API key. ### `version` ```bash theme={null} xtrace version ``` Prints the installed SDK version. ### `shell` ```bash theme={null} xtrace shell ``` Starts an interactive CLI shell with tab completion and command history. Inside the shell, run commands without the `xtrace` prefix. ## Knowledge base admin — `xtrace kb` KB commands require `ADMIN_KEY` input, entered once per session. To avoid repeated prompts, run `init --admin` to save the key to your `.env`. ### `create` ```bash theme={null} xtrace kb create {NAME} [-d "{description}"] [-p {permission}] [--json] [-a {API_KEY}] [--help] ``` Creates a knowledge base named `NAME`. Use `-d` for an optional description (wrap in quotes). Control access with `-p {permission}`: `read`, `write`, `delete`, or `none` (default: `write`). The permission applies to the API key in your `.env` unless overridden with `-a {API_KEY}`. `--json` returns the raw API response. ### `delete` ```bash theme={null} xtrace kb delete {KB_ID...} [--json] [--help] ``` Permanently deletes one or more space-separated knowledge bases. Prompts for confirmation. `--json` outputs raw JSON results. ### `list` ```bash theme={null} xtrace kb list [--all] [--json] [-a {api_key}] [--help] ``` Lists all knowledge bases accessible to your current API key. By default, only KBs with explicit permissions are shown. `--all` also shows KBs with no permissions (displayed as `NONE`). `--json` returns raw JSON including numeric `permissionLabel` values. Override the API key with `-a {api_key}`. ### `describe` ```bash theme={null} xtrace kb describe {KB_ID ...} [--json] [-a {api_key}] [--help] ``` Prints details for one or more knowledge bases. `--json` returns raw JSON (list when multiple IDs are given). Override the API key with `-a {api_key}`. ## x-vec commands — `xtrace xvec` ### `load` ```bash theme={null} xtrace xvec load {/path/to/dir/} {KB_ID} [-f {file-types,...}] [--max-chunk-chars {N}] [--max-parallel-embeddings {N}] [--help] ``` Loads data from a directory into a knowledge base with id `KB_ID`, processing files of type `.txt`, `.md`, `.json`, and `.csv`. To filter by file type, use `-f` with a comma-separated list (e.g. `txt,json`). `--max-chunk-chars {N}` sets a character limit per chunk. Chunks that exceed this limit (e.g. large JSON array elements) are split to fit. Useful when your embedding model has a small context window. `--max-parallel-embeddings {N}` limits the number of concurrent embedding requests. Useful when your embedding provider has concurrency limits (e.g. a local Ollama instance). ### `retrieve` ```bash theme={null} xtrace xvec retrieve {KB_ID} {"query"} [-k {integer}] [--inference {provider} --model {"model"}] [--json] [-a {api_key}] [--help] ``` Returns the `k` most similar vectors to the query (default `k=3`). `--inference {provider} --model {"model"}` runs an LLM over the retrieved context (requires an inference key from `init --inference`). `--json` returns raw chunk data. Override the API key with `-a {api_key}`. ### `query` ```bash theme={null} xtrace xvec query {KB_ID} "{query}" [-k {integer}] [--inference {provider} --model {"model"}] [--json] [-a {api_key}] [--help] ``` Alias of `retrieve`. Same signature and behavior. ### `head` ```bash theme={null} xtrace xvec head {KB_ID} [--all] [--fullChunks] [--json] [-a {api_key}] [--help] ``` Previews vectors in a knowledge base. Shows up to 25 vectors with truncated content by default. `--all` shows every vector. `--fullChunks` disables content truncation. `--json` returns a raw JSON array. Override the API key with `-a {api_key}`. ### `fetch` ```bash theme={null} xtrace xvec fetch {KB_ID} {VECTOR_ID...} [--fullChunks] [--json] [-a {api_key}] [--help] ``` Fetches one or more vectors by ID. `--fullChunks` disables content truncation. `--json` returns a raw JSON list of `{id, content}` objects. Override the API key with `-a {api_key}`. ### `upsert` ```bash theme={null} xtrace xvec upsert {KB_ID} "{text}" [--help] ``` Inserts a single text chunk into a knowledge base. Wrap `text` in quotes if it contains spaces. ### `upsert-file` ```bash theme={null} xtrace xvec upsert-file {/path/to/file} {KB_ID} [--max-chunk-chars {N}] [--max-parallel-embeddings {N}] [--help] ``` Inserts and chunks the contents of a single file into a knowledge base. Supported types: `.txt`, `.md`, `.json`, `.csv`. For multiple files, use `load`. `--max-chunk-chars {N}` and `--max-parallel-embeddings {N}` behave the same as in `load` (see above). ## x-mem commands — `xtrace xmem` x-mem CLI commands are coming soon. # Configuration Source: https://docs.xtrace.ai/x-vec/configuration Configure the x-vec SDK via environment variables or constructor parameters — cryptography backends, key providers, DataLoader/Retriever options, and AWS KMS. The x-vec SDK is designed to be highly configurable. You can configure it via environment variables or by passing parameters directly to classes and methods. ## Cryptography configuration The SDK supports two production-ready homomorphic encryption schemes and one experimental scheme. Both `PaillierClient` and `PaillierLookupClient` run on CPU by default. **GPU backend (internal testing phase).** XTrace maintains a GPU-accelerated implementation of the homomorphic encryption layer that is approximately **20× faster** than the CPU path for large embedding collections. It is available as a compiled extension that slots into the same `DEVICE=gpu` switch — no application code changes required. The GPU implementation is not open-sourced at this time as it is under internal testing. Contact us at [liwen@xtrace.ai](mailto:liwen@xtrace.ai) if you are interested in access. Set `DEVICE=gpu` to activate the GPU backend once the compiled extension is in place. **Paillier** — standard Paillier encryption: ```python theme={null} from xtrace_sdk.x_vec.crypto.paillier_client import PaillierClient paillier_client = PaillierClient(embed_len=512, key_len=1024) ``` **Paillier-Lookup** — optimised Paillier variant using precomputed tables for faster encryption. Recommended for large collections: ```python theme={null} from xtrace_sdk.x_vec.crypto.paillier_lookup_client import PaillierLookupClient paillier_client = PaillierLookupClient(embed_len=512, key_len=1024) ``` `embed_len` must be strictly less than `key_len`. Use at least `key_len=1024` for security, as it is the bit-length for the prime modulus. **Goldwasser-Micali** (`xtrace_sdk.x_vec.crypto.goldwasser_micali_client`) is included for research purposes and is **experimental** — it is not supported by `DataLoader` or `Retriever` and should not be used in production. ## Execution context configuration `ExecutionContext` bundles the homomorphic client and AES encryption under a single key-provider-protected object. A **key provider** supplies the AES key used to encrypt the homomorphic secret key at rest. The SDK ships two providers: * `PassphraseKeyProvider` — derives a 256-bit AES key from a passphrase using scrypt. * `AWSKMSKeyProvider` — envelope encryption via AWS KMS (the data encryption key is generated and wrapped by KMS and never persisted in plaintext). The key provider encrypts the secret homomorphic key at rest. There is no way to recover it through the SDK — manage your passphrase or KMS key securely. ### Passphrase key provider The simplest option — derive an AES key from a passphrase: ```python theme={null} from xtrace_sdk.x_vec.utils.execution_context import ExecutionContext from xtrace_sdk.x_vec.crypto.key_provider import PassphraseKeyProvider provider = PassphraseKeyProvider("your-secret-passphrase") ctx = ExecutionContext.create( key_provider=provider, homomorphic_client_type="paillier", embedding_length=512, key_len=1024, path="data/exec_context", # optional: save immediately ) ``` You can also pass an explicit `salt` to `PassphraseKeyProvider` for additional key-derivation control. ### AWS KMS key provider For production workloads, use envelope encryption via AWS KMS. The data encryption key (DEK) is generated by KMS and never stored in plaintext: ```python theme={null} import boto3 from xtrace_sdk.x_vec.utils.execution_context import ExecutionContext from xtrace_sdk.x_vec.crypto.key_provider import AWSKMSKeyProvider kms = boto3.client("kms") provider = AWSKMSKeyProvider.create(kms, "alias/xtrace") ctx = ExecutionContext.create( key_provider=provider, homomorphic_client_type="paillier_lookup", embedding_length=512, key_len=1024, ) ``` When loading a context that was saved with KMS, reconstruct the provider from the stored encrypted DEK (EDEK): ```python theme={null} import base64, json, boto3 from xtrace_sdk.x_vec.crypto.key_provider import AWSKMSKeyProvider # Read the EDEK from the saved context with open("data/exec_context") as f: obj = json.load(f) edek = base64.b64decode(obj["wrapped_key"]) kms = boto3.client("kms") provider = AWSKMSKeyProvider.from_wrapped(edek, kms_client=kms, key_id="alias/xtrace") ctx = ExecutionContext.load_from_disk(path="data/exec_context", key_provider=provider) ``` ### Persisting and reloading You can persist and reload the execution context: ```python theme={null} # Save to disk execution_context.save_to_disk("data/exec_context") # Load from disk (passphrase-based context) execution_context = ExecutionContext.load_from_disk("your-secret-passphrase", "data/exec_context") ``` Or store/load it remotely via XTrace: ```python theme={null} from xtrace_sdk.integrations.xtrace import XTraceIntegration xtrace = XTraceIntegration(org_id="your_org_id", api_key="your_api_key") # Save to remote await execution_context.save_to_remote(xtrace) # Load from remote execution_context = await ExecutionContext.load_from_remote( "your-secret-passphrase", "ctx_id", xtrace ) ``` Each `ExecutionContext` has a unique `id` attribute you can use to reference it later. ## DataLoader configuration `DataLoader` requires an execution context and an XTrace integration instance: ```python theme={null} from xtrace_sdk.x_vec.data_loaders.loader import DataLoader from xtrace_sdk.integrations.xtrace import XTraceIntegration xtrace = XTraceIntegration(org_id="your_org_id", api_key="your_api_key") data_loader = DataLoader(execution_context, xtrace) ``` To reconstruct a `DataLoader` from a saved execution context: ```python theme={null} from xtrace_sdk.x_vec.utils.execution_context import ExecutionContext ctx = ExecutionContext.load_from_disk("your-secret-passphrase", "data/exec_context") data_loader = DataLoader(ctx, xtrace) ``` ## Retriever configuration `Retriever` mirrors the `DataLoader` setup. Pass `parallel=True` to decode Hamming distances using multiprocessing (useful for large KBs): ```python theme={null} from xtrace_sdk.x_vec.retrievers.retriever import Retriever retriever = Retriever(execution_context, xtrace) # parallel decoding mode retriever = Retriever(execution_context, xtrace, parallel=True) ``` ## Environment variables The following environment variables are read automatically: | Variable | Description | | ------------------------------- | ------------------------------------------------------------------------------------- | | `XTRACE_API_KEY` | XTrace API key (used by `XTraceIntegration` when `api_key` is not passed explicitly). | | `XTRACE_ORG_ID` | Organisation ID (used by `XTraceIntegration` when `org_id` is not passed explicitly). | | `XTRACE_API_URL` | API base URL — defaults to `https://api.production.xtrace.ai`. | | `XTRACE_EXECUTION_CONTEXT_PATH` | Default path to a saved execution context. | | `INFERENCE_API_KEY` | API key for your inference provider (OpenAI, Redpill, etc.). | # Embedding models Source: https://docs.xtrace.ai/x-vec/embedding-models Convert text into binary vectors for encrypted storage and search using Ollama, Sentence Transformers, OpenAI, or your own float vectors. `Embedding` converts text into binary vectors for encrypted storage and search. The `embed_len` dimension must match the value set on your `ExecutionContext`. Supported providers: * **Ollama** — local, no API key required * **Sentence Transformers** — local, models downloaded from Hugging Face (requires the `[embedding]` extra) * **OpenAI** — cloud-based For end-to-end privacy, run Ollama or Sentence Transformers locally. OpenAI can be used when privacy is not a concern. The `INFERENCE_API_KEY` environment variable is read automatically when `api_key` is not passed explicitly. ## Ollama ```python theme={null} from xtrace_sdk.x_vec.inference.embedding import Embedding embed = Embedding("ollama", "mxbai-embed-large", 1024) vector = await embed.bin_embed("some text") ``` For Ollama setup instructions, see the [Ollama installation docs](https://ollama.com/docs/installation). ## Sentence Transformers Models are downloaded from Hugging Face on first use. See the [pretrained models list](https://www.sbert.net/docs/pretrained_models.html) for available models. Requires `pip install "xtrace-ai-sdk[embedding]"`. ```python theme={null} from xtrace_sdk.x_vec.inference.embedding import Embedding embed = Embedding("sentence_transformer", "mixedbread-ai/mxbai-embed-large-v1", 512) vector = await embed.bin_embed("some text") ``` ## OpenAI Set your OpenAI API key via the `INFERENCE_API_KEY` environment variable or pass it directly as `api_key`. ```python theme={null} from xtrace_sdk.x_vec.inference.embedding import Embedding embed = Embedding("openai", "text-embedding-3-small", 1536) vector = await embed.bin_embed("some text") ``` ## Bring your own vectors If you already have float vectors from another source, convert them to the binary format XTrace expects using `Embedding.float_2_bin`. The length of the resulting list must match `embed_len` on your homomorphic client. ```python theme={null} from xtrace_sdk.x_vec.inference.embedding import Embedding your_vector = [0.1, -0.2, 0.3, ...] # list of floats, length = embed_len binary_vector = Embedding.float_2_bin(your_vector) ``` # Installation Source: https://docs.xtrace.ai/x-vec/installation Install the x-vec Python SDK and optional extras for local embeddings and the CLI. Requires Python 3.11 or later. ## Base install The base package includes the full x-vec SDK — encrypted vector storage, retrieval, execution context management, and the Ollama and OpenAI embedding providers: ```bash theme={null} pip install xtrace-ai-sdk ``` ## Optional extras ### `[embedding]` — Sentence Transformers Adds local embedding support via [Sentence Transformers](https://www.sbert.net). Models are downloaded from Hugging Face on first use. Ollama and any OpenAI API-compatible embedding service (including OpenAI, Redpill, and Anthropic) are supported out of the box in the base install — no extra flag needed. Only install this extra if you want to use Sentence Transformers directly: ```bash theme={null} pip install "xtrace-ai-sdk[embedding]" ``` ### `[cli]` — Interactive CLI Adds the `xtrace` command-line interface — an interactive shell for managing knowledge bases, loading data, and running queries without writing Python: ```bash theme={null} pip install "xtrace-ai-sdk[cli]" ``` See the [CLI quickstart](/x-vec/cli) for setup and usage. ### Combined Extras can be combined: ```bash theme={null} pip install "xtrace-ai-sdk[embedding,cli]" ``` ## Install from source ```bash theme={null} git clone https://github.com/XTraceAI/xtrace-vec-sdk.git cd xtrace-vec-sdk pip install -e . ``` To include extras: ```bash theme={null} pip install -e ".[embedding,cli]" ``` # XTrace Vector DB — Encrypted vector search Source: https://docs.xtrace.ai/x-vec/introduction Semantic search with the privacy guarantees of keeping your data on your own machine. Content is AES-encrypted and embedding vectors are homomorphically encrypted before they ever leave your laptop. Traditional vector databases require you to hand your data to a third party in plaintext. **x-vec** is different: your document content is AES-encrypted and your embedding vectors are homomorphically encrypted **before they leave your machine**. The server stores and searches over ciphertexts — computing nearest-neighbor Hamming distances directly on encrypted vectors — without ever seeing the underlying data. When results come back, you decrypt them locally. You get semantic search with the same privacy guarantees as if the data never left your laptop. **Looking for hosted agent memory?** x-vec is the low-level encrypted vector database. If you want managed memory for AI agents — send conversation turns, get back searchable facts — see the [Memory docs](/introduction) instead. ## Get started **Create a free account** at [app.xtrace.ai](https://app.xtrace.ai) to get your API key and org ID. The free tier is rate-limited but fully functional. Install the Python SDK and optional extras Concept-first walkthrough with full code examples Terminal-first workflow — querying in four commands Ollama, Sentence Transformers, OpenAI, or your own vectors ## Reference `XTraceIntegration` — security model, chunk operations, context management Crypto backends, key providers, AWS KMS, environment variables Full usage for every `xtrace` command Filter syntax, operators, and privacy trade-offs # LLM inference Source: https://docs.xtrace.ai/x-vec/llm-inference Run an LLM over retrieved context for RAG pipelines — with OpenAI, Anthropic, private TEE inference via Redpill, or fully local Ollama. `InferenceClient` runs an LLM over retrieved context — useful for RAG pipelines where you want a synthesized answer rather than raw chunk results. Supported providers: * **OpenAI** * **Anthropic (Claude)** — via OpenAI-compatible API * **Redpill** — private inference in TEE GPUs * **Ollama** — fully local For end-to-end privacy, run Ollama locally. If that is not feasible, Redpill provides private inference via GPU Trusted Execution Environments (TEE). If inference privacy is not a concern, OpenAI or Anthropic can be used. The `INFERENCE_API_KEY` environment variable is read automatically when `api_key` is not passed explicitly. ## OpenAI ```python theme={null} from xtrace_sdk.x_vec.inference.llm import InferenceClient inference = InferenceClient(inference_provider="openai", model_name="gpt-4o", api_key="your_api_key") inference.query("How many r's are in the word strawberry?") ``` For supported models, refer to the [OpenAI documentation](https://platform.openai.com/docs/models). ## Anthropic (Claude) Uses Anthropic's OpenAI-compatible API endpoint. ```python theme={null} from xtrace_sdk.x_vec.inference.llm import InferenceClient inference = InferenceClient(inference_provider="claude", model_name="claude-sonnet-4-6", api_key="your_api_key") inference.query("How many r's are in the word strawberry?") ``` For supported models, refer to the [Anthropic documentation](https://docs.anthropic.com/en/docs/about-claude/models). ## Redpill Redpill provides private inference with models running in TEE (Trusted Execution Environment) GPUs, ensuring your queries remain secure during inference. This is an ideal middle ground when you need privacy protection but cannot run models locally. Key features: * **Private inference**: Models run in TEE GPU environments * **Unified API**: Access to 200+ AI models through a single API * **Cost-effective**: Transparent per-token pricing ```python theme={null} from xtrace_sdk.x_vec.inference.llm import InferenceClient inference = InferenceClient(inference_provider="redpill", model_name="deepseek/deepseek-v3-0324", api_key="your_api_key") inference.query("How many r's are in the word strawberry?") ``` Create an API key at [redpill.ai](https://redpill.ai/). For the full model list and pricing, see the [Redpill docs](https://docs.redpill.ai/). ## Ollama Ollama runs entirely locally, providing the strongest inference privacy guarantee. ```python theme={null} from xtrace_sdk.x_vec.inference.llm import InferenceClient inference = InferenceClient(inference_provider="ollama", model_name="llama3.3", api_key="ollama") inference.query("How many r's are in the word strawberry?") ``` For Ollama setup instructions, see the [Ollama installation docs](https://ollama.com/docs/installation). # Managed service Source: https://docs.xtrace.ai/x-vec/managed-service XTraceIntegration is the single entry point for the XTrace API — security model, connecting, loading, querying, metadata search, chunk operations, and execution context management. `XTraceIntegration` is the single entry point for all communication with the XTrace API. It handles chunk storage, encrypted Hamming distance computation, metadata search, and execution context management. ## Security model Understanding what XTrace can and cannot see is the core guarantee of this SDK. **What XTrace cannot see** | Item | How it is protected | | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Chunk content | AES-encrypted on the client before upload. The server stores only ciphertext. The AES key is supplied by a key provider (passphrase-derived or AWS KMS envelope encryption) and never leaves your environment. | | Embedding vectors | Encrypted with Paillier homomorphic encryption on the client before being sent. The server computes nearest-neighbor Hamming distances directly on the ciphertexts — it never sees the original binary vectors, not even during search. | | Query vectors | Same as stored vectors: homomorphically encrypted on the client before the search request is transmitted. | | Paillier private key | Never transmitted in plaintext. See [Execution context](#execution-context) below. | **What XTrace can see** | Item | Notes | | --------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | Metadata tags (`tag1`–`tag5`, `facets`) | Stored and indexed in plaintext. See [Metadata filtering](/x-vec/metadata-filtering) for mitigation strategies. | | Paillier public key | Stored in plaintext by design — public keys are not secret. | | Collection structure | Number of chunks, their `kb_id` assignments, and chunk-level metadata are visible. | | Encrypted blobs | The server stores AES ciphertexts and Paillier ciphertexts, but cannot decrypt them. | ## Execution context The `ExecutionContext` bundles the Paillier key pair and the AES key under a single protected object. A **key provider** controls how the AES key is generated and protected. The SDK ships two providers: * `PassphraseKeyProvider` — derives a 256-bit AES key from a passphrase via scrypt. Simple and self-contained — no cloud dependencies. * `AWSKMSKeyProvider` — generates a data encryption key (DEK) via AWS KMS envelope encryption. The DEK is never stored in plaintext — only the KMS-wrapped ciphertext (EDEK) is persisted. When stored remotely on XTrace (`execution_context.save_to_remote(xtrace)`), only the following is transmitted: * The **public key** in plaintext (intentional — it is not secret). * The **secret key** encrypted with the AES key supplied by the key provider. * Non-sensitive configuration (key length, embedding length). Neither your passphrase nor your KMS plaintext DEK are ever transmitted. Without the corresponding key provider, the stored blob cannot be decrypted — XTrace cannot recover the secret key and cannot decrypt your chunk content or vectors. See [Configuration](/x-vec/configuration) for the full key provider reference. #### Passphrase-based context ```python theme={null} from xtrace_sdk.x_vec.utils.execution_context import ExecutionContext from xtrace_sdk.x_vec.crypto.key_provider import PassphraseKeyProvider provider = PassphraseKeyProvider("your-secret-passphrase") ctx = ExecutionContext.create( key_provider=provider, homomorphic_client_type="paillier_lookup", embedding_length=512, key_len=1024, path="data/exec_context", # optional: save immediately ) # Restore from disk ctx = ExecutionContext.load_from_disk("your-secret-passphrase", "data/exec_context") # Or save/restore via XTrace await ctx.save_to_remote(xtrace) ctx = await ExecutionContext.load_from_remote("your-secret-passphrase", ctx.id, xtrace) ``` #### AWS KMS-based context ```python theme={null} import boto3 from xtrace_sdk.x_vec.utils.execution_context import ExecutionContext from xtrace_sdk.x_vec.crypto.key_provider import AWSKMSKeyProvider kms = boto3.client("kms") provider = AWSKMSKeyProvider.create(kms, "alias/xtrace") ctx = ExecutionContext.create( key_provider=provider, homomorphic_client_type="paillier_lookup", embedding_length=512, key_len=1024, ) # Restore: reconstruct the provider from the stored EDEK import base64, json with open("data/exec_context") as f: edek = base64.b64decode(json.load(f)["wrapped_key"]) provider = AWSKMSKeyProvider.from_wrapped(edek, kms_client=kms, key_id="alias/xtrace") ctx = ExecutionContext.load_from_disk(path="data/exec_context", key_provider=provider) ``` ## Connecting ```python theme={null} from xtrace_sdk.integrations.xtrace import XTraceIntegration xtrace = XTraceIntegration( org_id="your_org_id", api_key="your_api_key", # or set XTRACE_API_KEY env var api_url="https://api.production.xtrace.ai", # default ) ``` Use it as an async context manager to manage the HTTP session automatically: ```python theme={null} async with XTraceIntegration(org_id="your_org_id", api_key="your_api_key") as xtrace: ... ``` ## Loading data Use `DataLoader` with an `XTraceIntegration` to encrypt and store documents: ```python theme={null} from xtrace_sdk.x_vec.data_loaders.loader import DataLoader from xtrace_sdk.x_vec.inference.embedding import Embedding embed = Embedding("sentence_transformer", "mixedbread-ai/mxbai-embed-large-v1", 512) data_loader = DataLoader(execution_context, xtrace) # Provide your documents as a list of chunk dicts collection = [ { "chunk_content": "...", "meta_data": { "tag1": "user_123", "tag2": "my-project", "tag5": "doc1.txt", "facets": ["finance", "q1"], }, }, ] vectors = [embed.bin_embed(item["chunk_content"]) for item in collection] # coroutines, awaited by loader index, db = await data_loader.load_data_from_memory(collection, vectors) await data_loader.dump_db(db, index=index, kb_id="your_kb_id") ``` ## Querying Use `Retriever` to run encrypted nearest-neighbor search: ```python theme={null} from xtrace_sdk.x_vec.retrievers.retriever import Retriever from xtrace_sdk.x_vec.inference.embedding import Embedding embed = Embedding("ollama", "mxbai-embed-large", 1024) retriever = Retriever(execution_context, xtrace) vec = await embed.bin_embed("What is XTrace?") ids = await retriever.nn_search_for_ids(vec, k=3, kb_id="your_kb_id") results = await retriever.retrieve_and_decrypt(ids, kb_id="your_kb_id") ``` ## Metadata search See [Metadata filtering](/x-vec/metadata-filtering) for the full filter syntax, operator reference, and performance guidance. Search and filter chunks by metadata without running a vector query: ```python theme={null} results = await xtrace.meta_search( kb_id="your_kb_id", meta_filter={"tag1": "user_123", "tag2": "my-project"}, context_id=execution_context.id, ) # Paginated variant page = await xtrace.meta_search_paginated( kb_id="your_kb_id", context_id=execution_context.id, meta_filter={"tag1": "user_123"}, limit=20, offset=0, return_content=True, # include encrypted chunk_content in results ) ``` ## Chunk operations ```python theme={null} # Delete specific chunks await xtrace.delete_chunks(chunk_ids=[0, 1, 2], kb_id="your_kb_id") # Delete by metadata filter await xtrace.delete_chunks_by_meta( kb_id="your_kb_id", context_id=execution_context.id, meta_filter={"tag1": "user_123", "tag2": "old-project"}, ) # Patch metadata fields on matching chunks await xtrace.patch_chunks_by_meta( kb_id="your_kb_id", context_id=execution_context.id, meta_filter={"tag1": "user_123", "facets": {"$contains": "draft"}}, patch={"facets": ["published"]}, ) ``` ## Execution context management Use the `ExecutionContext` helpers rather than calling the low-level API directly: ```python theme={null} # Save to XTrace (preferred — secret key is encrypted by the key provider before upload) ctx_id = await execution_context.save_to_remote(xtrace) # Restore from XTrace (passphrase-based) execution_context = await ExecutionContext.load_from_remote( "your-secret-passphrase", ctx_id, xtrace ) # Restore from XTrace (KMS-based — reconstruct the provider first) provider = AWSKMSKeyProvider.from_wrapped(edek, kms_client=kms, key_id="alias/xtrace") execution_context = await ExecutionContext.load_from_remote( key_provider=provider, context_id=ctx_id, integration=xtrace ) # List all stored context IDs for your org ctx_ids = await xtrace.list_exec_contexts() # Delete a context await xtrace.delete_exec_context(ctx_id) ``` ## Notes * `kb_id` and `org_id` are available from the XTrace dashboard. * Metadata fields use the fixed schema: `tag1`–`tag5` and `facets`. See [Metadata filtering](/x-vec/metadata-filtering) for field semantics and operator reference. * The `concurrent=True` flag on `store_db` enables parallel batch ingestion — useful for large loads. # Metadata filtering Source: https://docs.xtrace.ai/x-vec/metadata-filtering Filter encrypted search by plaintext metadata tags — field schema, supported operators, query examples, and the privacy trade-offs. Metadata filters can be applied during nearest-neighbor search or used standalone via `meta_search` / `meta_search_paginated`. ## Privacy notice Metadata fields (`tag1`–`tag5`, `facets`) are **stored in plaintext** and are not encrypted by default. Chunk content and embedding vectors are end-to-end encrypted — metadata is the only part of a chunk that the XTrace server can read. If metadata privacy is a requirement, consider the following mitigation until native support is available: * **Store only opaque identifiers** in metadata tags (e.g. a hashed or randomly assigned `user_id` rather than a readable name), and keep the mapping in your own system. * **Restrict filters to equality checks** (`$eq` / `$in`) on those opaque values. Range operators (`$gt`, `$lte`, `$begins_with`, etc.) leak ordering information and should be avoided when the tag value itself is sensitive. XTrace plans to support encrypted metadata indexes natively in a future release. ## Metadata fields Each chunk has five indexed scalar tags and one multi-value field: | Field | Type | Recommended use | | -------- | ------------------------- | ------------------------------------------------------ | | `tag1` | String | High-cardinality identifier (e.g. `user_id`, `org_id`) | | `tag2` | String | Collection / project / knowledge base | | `tag3` | Zero-padded number string | Numeric ranges (e.g. score, price, count) | | `tag4` | ISO 8601 date string | Temporal ranges (e.g. `created_at`) | | `tag5` | String | Source or namespace | | `facets` | List of strings | Labels, categories, ad-hoc metadata | All tags are compared as **strings**. For correct range ordering, numeric values must be zero-padded to a fixed width and dates must use ISO 8601 UTC. ```python theme={null} # Numeric — zero-pad so "0000000010" > "0000000002" "tag3": "0000000010" # Date — ISO 8601 UTC "tag4": "2024-01-01T00:00:00Z" ``` ## Supported operators ### Scalar tags (`tag1`–`tag5`) | Operator | Description | | -------------------------------- | ------------------------------------ | | `"value"` (raw string) | Exact match | | `{"$eq": "value"}` | Exact match | | `{"$ne": "value"}` | Not equal | | `{"$gt": "v"}` / `{"$gte": "v"}` | Greater than / greater than or equal | | `{"$lt": "v"}` / `{"$lte": "v"}` | Less than / less than or equal | | `{"$gte": "a", "$lte": "b"}` | Range (between) | | `{"$begins_with": "prefix"}` | Prefix match | | `{"$in": ["a", "b"]}` | Value is in list | | `{"$nin": ["a", "b"]}` | Value is not in list | | `{"$contains": "substr"}` | Substring match | | `{"$exists": True}` | Field is present and non-empty | ### `facets` (multi-value) | Operator | Description | | ------------------------- | ------------------------------------ | | `{"$subset": ["a", "b"]}` | Contains all provided tokens | | `{"$any": ["a", "b"]}` | Contains at least one provided token | | `{"$none": ["a", "b"]}` | Contains none of the provided tokens | | `{"$contains": "token"}` | Contains the given token | | `{"$size": N}` | Facets list has exactly N tokens | ## Query examples **Nearest-neighbor search with a filter:** ```python theme={null} ids = await retriever.nn_search_for_ids( query_vector, k=5, kb_id="your_kb_id", meta_filter={"tag1": "org_772", "tag2": "invoices"}, ) ``` **Range filter on a numeric tag:** ```python theme={null} meta_filter = { "tag1": "org_772", "tag3": {"$gt": "000000100000"}, } ``` **Date range with facet refinement:** ```python theme={null} meta_filter = { "tag4": { "$gte": "2024-01-01T00:00:00Z", "$lte": "2024-03-31T23:59:59Z", }, "facets": {"$contains": "finance"}, } ``` **Subset facet filter:** ```python theme={null} meta_filter = { "tag1": "org_772", "facets": {"$subset": ["finance", "tax_audit"]}, } ``` **Standalone metadata search:** ```python theme={null} results = await xtrace.meta_search( kb_id="your_kb_id", meta_filter={"tag1": "org_772", "tag2": "invoices"}, context_id=execution_context.id, ) ``` **Paginated metadata search:** ```python theme={null} page = await xtrace.meta_search_paginated( kb_id="your_kb_id", context_id=execution_context.id, meta_filter={"tag1": "org_772", "tag2": "invoices"}, limit=20, offset=0, return_content=True, # include encrypted chunk_content in results ) ``` # Quickstart Source: https://docs.xtrace.ai/x-vec/quickstart A complete end-to-end walkthrough of the x-vec Python SDK — create an execution context, encrypt and store documents, and run an encrypted search. This page walks through the Python SDK for x-vec with a complete end-to-end example. See [Installation](/x-vec/installation) first if you haven't installed the SDK yet. **Prefer the command line?** The [CLI quickstart](/x-vec/cli) gets you from zero to querying in four terminal commands — no Python required. ## How it works XTrace stores your documents as encrypted chunks inside a **knowledge base**. Before anything leaves your machine, the SDK does two things: it AES-encrypts the chunk content using a key derived from your passphrase, and it encodes the embedding vector into a binary format and encrypts it with Paillier homomorphic encryption. When you query, the query vector is encrypted the same way and sent to the server. XTrace computes nearest-neighbor Hamming distances directly on the ciphertexts — without decrypting them — and returns the closest chunk IDs. You decrypt the content locally. The server never sees your documents, your vectors, or your query intent, even during active search. ## Core concepts Five objects make up the SDK. Understanding how they relate to each other makes everything else straightforward. * **ExecutionContext** is your private cryptographic state. It holds a Paillier key pair (for encrypting vectors) and an AES key (for encrypting content), all locked by a passphrase. Every chunk you store and every query you run must use the same execution context. Create it once and save it — losing it means losing the ability to decrypt anything stored with it. * **Embedding** converts text to a binary vector. The output dimension (`embed_len`) must match the value you set when creating the execution context. The SDK supports Sentence Transformers, Ollama, and OpenAI; you can also convert existing float vectors with `Embedding.float_2_bin`. * **XTraceIntegration** is the async HTTP client that communicates with XTrace. It authenticates with your API key and org ID but never transmits plaintext data — only ciphertexts. * **DataLoader** orchestrates encryption and upload. It accepts a list of chunk dicts, encrypts each one using the execution context, and stores them in a knowledge base via `XTraceIntegration`. * **Retriever** orchestrates search. It encrypts a query vector, asks XTrace to compute Hamming distances over all stored ciphertexts, fetches the top-k chunk IDs, and decrypts the results. A **knowledge base** (KB) is a namespace on XTrace where your encrypted chunks live. Create one from the XTrace dashboard or with `xtrace kb create`. ## Prerequisites You need an XTrace account with an API key, an org ID, and at least one knowledge base. Create a knowledge base from the dashboard, or via the CLI after running `xtrace init`: ```bash theme={null} xtrace kb create my-kb ``` ## Step 1 — Create an execution context The execution context is the cryptographic core of the system. It holds a Paillier key pair and an AES key, protected by a **key provider**. Create it once and save it to disk. In future sessions you reload it with your key provider instead of generating a new one. ```python theme={null} from xtrace_sdk.x_vec.utils.execution_context import ExecutionContext from xtrace_sdk.x_vec.crypto.key_provider import PassphraseKeyProvider provider = PassphraseKeyProvider("your-secret-passphrase") ctx = ExecutionContext.create( key_provider=provider, homomorphic_client_type="paillier_lookup", # fastest CPU option embedding_length=512, # must match your embedding model key_len=1024, # Paillier key size in bits (>= 1024) path="data/exec_context", # saved to disk immediately ) print("Context ID:", ctx.id) # note this — you need it to reload from XTrace ``` For production workloads, `AWSKMSKeyProvider` provides envelope encryption via AWS KMS — the data encryption key is generated and wrapped by KMS and never persisted in plaintext. See [Configuration](/x-vec/configuration) for setup details. To reload in a future session: ```python theme={null} from xtrace_sdk.x_vec.utils.execution_context import ExecutionContext ctx = ExecutionContext.load_from_disk("your-secret-passphrase", "data/exec_context") ``` You can also back the context up to XTrace so you can restore it from any machine: ```python theme={null} from xtrace_sdk.integrations.xtrace import XTraceIntegration xtrace = XTraceIntegration(org_id="your_org_id", api_key="your_api_key") await ctx.save_to_remote(xtrace) # secret key is encrypted by the key provider before upload # Restore ctx = await ExecutionContext.load_from_remote("your-secret-passphrase", ctx.id, xtrace) ``` ## Step 2 — Set up embedding and connect to XTrace The embedding model converts text to binary vectors. Use Sentence Transformers for a fully local setup. See [Embedding models](/x-vec/embedding-models) for Ollama and OpenAI options. ```python theme={null} from xtrace_sdk.x_vec.inference.embedding import Embedding from xtrace_sdk.integrations.xtrace import XTraceIntegration embed = Embedding("sentence_transformer", "mixedbread-ai/mxbai-embed-large-v1", dim=512) xtrace = XTraceIntegration(org_id="your_org_id", api_key="your_api_key") ``` `XTraceIntegration` reads `XTRACE_API_KEY` from the environment if `api_key` is omitted. ## Step 3 — Encrypt and store documents `DataLoader` encrypts your chunks and sends them to a knowledge base. Each chunk is a dict with a `chunk_content` string and an optional `meta_data` dict. Metadata is stored in plaintext — see [Metadata filtering](/x-vec/metadata-filtering) for the field schema and privacy implications. ```python theme={null} from xtrace_sdk.x_vec.data_loaders.loader import DataLoader loader = DataLoader(ctx, xtrace) docs = [ { "chunk_content": "XTrace encrypts vectors with Paillier homomorphic encryption.", "meta_data": {"tag1": "user_123", "tag2": "intro", "facets": ["security"]}, }, { "chunk_content": "The server computes nearest-neighbor search on ciphertexts.", "meta_data": {"tag1": "user_123", "tag2": "intro", "facets": ["search"]}, }, ] vectors = [embed.bin_embed(d["chunk_content"]) for d in docs] # coroutines, awaited by loader index, db = await loader.load_data_from_memory(docs, vectors) await loader.dump_db(db, index=index, kb_id="your_kb_id") ``` ## Step 4 — Query `Retriever` encrypts a query vector, asks XTrace to find the nearest neighbors, then decrypts and returns the matching chunks. ```python theme={null} from xtrace_sdk.x_vec.retrievers.retriever import Retriever retriever = Retriever(ctx, xtrace) vec = await embed.bin_embed("How does XTrace protect my data?") ids = await retriever.nn_search_for_ids(vec, k=3, kb_id="your_kb_id") results = await retriever.retrieve_and_decrypt(ids, kb_id="your_kb_id") for r in results: print(r["chunk_content"]) ``` ## Next steps * [Embedding models](/x-vec/embedding-models) — choose your embedding provider (Ollama, OpenAI, Sentence Transformers) * [LLM inference](/x-vec/llm-inference) — add LLM synthesis over retrieved results * [Metadata filtering](/x-vec/metadata-filtering) — filter search results by metadata tags; understand the privacy trade-offs * [Managed service](/x-vec/managed-service) — full `XTraceIntegration` reference (chunk operations, context management, pagination) * [Configuration](/x-vec/configuration) — crypto backends, key providers, AWS KMS, and environment variables