> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Cross-channel agent memory plane

> How Orbit resolves a caller's identity across voice, SMS, and chat, retrieves their unified agent memory, and exposes it through the CDP Profile API with role-based PII reveal.

# Cross-channel agent memory plane

Agent memory is a per-contact store of facts, preferences, goals, and conversation summaries that your agents learn and recall. The memory plane is the layer that makes that store usable when the customer shows up on **any** channel — voice, SMS, WhatsApp, or chat — instead of only re-reading the current conversation thread.

Two services sit under the plane:

* **Resolution** — at the start of a turn, the plane maps whatever identifier the channel carries (E.164 phone, contact id, unified identity id) to a single resolved scope: the contact plus their unified identity.
* **Retrieval** — the resolved scope builds a tenant-scoped vector filter; candidate items are deduplicated and recency-ranked before the top-K are rendered into the agent's context.

Without the plane, a voice caller is anonymous until they re-identify themselves, and an SMS conversation that already captured their preferences is invisible to a later phone call.

## Identity resolution flow

Each channel hands a different identifier to the runtime, so the plane resolves in up to three steps:

1. **E.164 phone only (voice turns)** — resolve `phone → contact_id` through the registered lookup. When nothing matches, the turn proceeds with whatever scope was already supplied.
2. **Contact id present (SMS / WhatsApp / chat)** — resolve `contact_id → unified_identity_id` so a contact merge can't strand the turn on the pre-merge id (merges re-point memory in the same transaction).
3. **Caller already supplied a unified identity —** that short-circuits both lookups.

Every lookup step is **fail-open**: a database blip, a missing row, or a lookup that isn't registered never blocks the turn — the agent just retrieves with the fragments it had. Identity resolution never introduces a blocking call into the turn-critical path.

```text theme={null}
voice turn      → phone
sms/chat turn   → contact id
merged contact  → unified identity (from step 1 or 2, or supplied up-front)
```

A contact-to-identity resolution is cached in-process for 5 minutes, so the same contact doesn't re-query within a conversation.

## Retrieval: filter assembly, dedupe, recency

Retrieval runs against the tenant-scoped vector store that backs all agent memory, filtered and ranked in this order:

1. **Tenant scope first.** The filter asserts the tenant id before any contact-level clause; the platform's strict-tenancy check rejects a filter that lacks it, and malformed tenant or contact shapes abort retrieval with an empty result rather than crossing a tenant boundary.
2. **Contact + unified identity.** When a resolved identity is present, the filter matches a point by **either** `contact_id` **or** `unified_identity_id` — so memory re-pointed by a mid-conversation merge stays retrievable, and pre-merge legacy points (which only carry `contact_id`) are still matched.
3. **Narrowing.** Optional `agent_id` and `unified_conversation_id` clauses restrict retrieval to one agent's or one conversation's notes.
4. **Over-fetch and dedupe.** The query fans out for roughly `limit × 3` candidates, then drops content-identical items (normalised on trim/lower/first-160-chars) so a fact can't double-occupy the top-K.
5. **Recency decay + rerank.** Scores decay \~5 % per week of age (floored so old items don't vanish), and an optional reranker re-orders the pool before the top-K are returned.

If anything in steps 1–4 fails or returns nothing, retrieval collapses to an empty list rather than a runtime error.

## Rendering memory into the turn

When retrieval produced items, the plane renders them as a single system-level block and hands it back to the agent executor. The block is prefixed with the constant marker

```text theme={null}
Customer memory (from past interactions):
```

followed by one `- <item>` line per memory. The executor uses that marker the same way it uses its RAG-context prefix: on every new turn it strips the previously attached memory block before appending the fresh one, so memory never accumulates across a conversation even when the conversation state is persisted between turns.

When resolution can't identify a contact, or retrieval produced no items, no block is attached on that turn — the agent simply runs with the context it already has.

## Reading memory through the CDP Profile API

Four endpoints expose a contact's plane to your systems, one per identifier family that the Profile API already resolves. All four return the same shape, with PII masked by default, rate-limited at 60 requests per minute per tenant.

<CardGroup cols={2}>
  <Card title="By user id" icon="id-badge">`GET /api/v1/cdp/profiles/by-user-id/:user_id/memory`</Card>
  <Card title="By email" icon="envelope">`GET /api/v1/cdp/profiles/by-email/:email/memory`</Card>
  <Card title="By phone" icon="phone">`GET /api/v1/cdp/profiles/by-phone/:phone/memory`</Card>
  <Card title="By anonymous id" icon="user-secret">`GET /api/v1/cdp/profiles/by-anonymous-id/:anonymous_id/memory`</Card>
</CardGroup>

Auth: `owner` / `admin` / `developer` role with the `contacts:read` scope. Query params:

| Parameter | Behaviour                                                                         |
| --------- | --------------------------------------------------------------------------------- |
| `q`       | Optional semantic prompt that steers retrieval (default: a profile-facts prompt). |
| `limit`   | Items 1..20 (default 20).                                                         |
| `reveal`  | `reveal=true` unmasks PII per item; each reveal is audit-logged.                  |

```text theme={null}
{
  "contact_id": "cnt_…",
  "unified_identity_id": "uid_…",
  "memory_enabled": true,
  "count": 2,
  "items": [
    { "memory_type": "fact",       "content": "…", "created_at": "…" },
    { "memory_type": "preference", "content": "…", "created_at": "…" }
  ]
}
```

`memory_type` is one of `fact`, `preference`, `goal`, `summary`. When the contact's GDPR opt-out flag is set (`memory_enabled=false`), the response is an explicit empty shape — never a leak of the opted-out plane:

```json theme={null}
{ "contact_id": "cnt_…", "unified_identity_id": null, "memory_enabled": false, "count": 0, "items": [] }
```

Unknown identifiers return 404 `PROFILE_NOT_FOUND`. Malformed identifiers return a 422 validation error per the Profile API conventions.

## Role-gated PII reveal

Item `content` is PII-masked by default. Passing `?reveal=true` unmasks it only when the caller has `owner`, `admin`, or `developer` role; every reveal writes an audit-log entry so operators can see who unmasked what.

* **401** — the request didn't authenticate (missing/expired JWT).
* **403** — an authenticated caller without one of the three roles or without `contacts:read`. Reveal is silently ignored for them: the route returns successfully with the content still masked rather than leaking or hard-failing.

## The pre-turn identity bridge (internal)

The agent runtime needs to lift a caller's phone into a contact before the executor turn runs, so it queries one internal endpoint and hands the result to the plane.

```
GET /api/v1/internal/memory/identity
      ?tenant_schema=<tenant namespace>
      [&phone=<e164> | &contact_id=<id>]
      [&include_memory_enabled=true]

Headers: x-internal-token: <rotated internal token>
```

Returning:

```json theme={null}
{
  "contact_id": "cnt_…",
  "unified_identity_id": "uid_…",
  "memory_enabled": true
}
```

Phone-only turns supply `phone`; unmatched phones return `200` with null fields so the runtime proceeds in contact-only retrieval — `404` is deliberately never used for an unmatched row. `contact_id`-only turns resolve the unified identity. `include_memory_enabled=true` also returns the contact's opt-out flag so the runtime can log the gate before retrieval. Server-to-server only: the internal token gate rejects external callers.

## Browsing memory in the dashboard

Open **AI Agents → Customer Memory** (`/agents/memory`). Pick a contact, and the page runs the same plane surface as the CDP endpoints — you can read items, add a fact manually, and delete items. When a contact has the GDPR opt-out flag set, the dashboard shows the opted-out state explicitly rather than listing items.

## GDPR consent and erasure

The plane honours the per-contact `memory_enabled` flag (settable from the contact's record or the consent surfaces) **before** any retrieval fan-out. The gate is consulted on every read and write:

* An opted-out contact returns an empty plane from the CDP endpoints, the dashboard shows the opt-out state, and the executor attaches no memory block.
* A contact-level erasure (`DELETE /api/v1/contacts/:contact_id/memory`) purges every item the tenant holds on the contact.
* An organisation-level offboard purges every item for the tenant.

The opt-out check is fail-open by design: if the lookup errors it treats the contact as opted-in rather than blocking the turn.

## Example: fetch plus reveal behaviour

Fetch a contact's memory by email, then unmask when you're an owner:

```bash theme={null}
# 1. Masked by default
curl -sG 'https://api.orbit.devotel.io/api/v1/cdp/profiles/by-email/alice%40example.com/memory' \
  -H 'Authorization: Bearer '$(get_jwt) \
  -H 'X-API-Key: dv_live_…'

# 2. As owner, unmask — every item's reveal is audit-logged
curl -sG 'https://api.orbit.devotel.io/api/v1/cdp/profiles/by-email/alice%40example.com/memory?reveal=true' \
  -H 'Authorization: Bearer '$(get_jwt) \
  -H 'X-API-Key: dv_live_…'
```

Sample response:

```json theme={null}
{
  "contact_id": "cnt_9x7f…",
  "unified_identity_id": "uid_az41…",
  "memory_enabled": true,
  "count": 2,
  "items": [
    {
      "memory_type": "fact",
      "content": "prefers SMS callbacks over email",
      "created_at": "2026-08-12T14:03:11Z"
    },
    {
      "memory_type": "preference",
      "content": "asked for a late-evening callback window",
      "created_at": "2026-08-20T09:44:52Z"
    }
  ]
}
```

Error shapes you should expect:

```http theme={null}
HTTP/1.1 401 Unauthorized
{ "error": { "code": "UNAUTHORIZED", "message": "Authentication required", "status": 401 } }

HTTP/1.1 403 Forbidden
{ "error": { "code": "FORBIDDEN", "message": "Role or scope missing", "status": 403 } }

HTTP/1.1 404 Not Found
{ "error": { "code": "PROFILE_NOT_FOUND", "message": "No contact matches that email.", "status": 404 } }
```

## Next steps

* [Cost controls](/agents/cost-controls) — memory retrieval adds embedding spend; budgets still apply.
* [Tenant isolation](/concepts/tenant-isolation) — the tenant-first filter contract the plane enforces.
* [Consent management](/compliance/consent-management) — how `memory_enabled` maps to consent states and erasure.
* [AI Agents](/agents/overview) — the runtime that attaches the plane's context block per turn.
