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

# Metadata

> Declare a metadata key like email, and search one person's context — without losing the notes that never mentioned them.

Say you're building a support bot. Every conversation you save carries a bit
of metadata — who it was about, which order:

```python theme={"dark"}
client.vaults.push(vault_id,
    "user: my order is late\nagent: refunded, arrives in 3 days",
    metadata={"session_id": "chat-1", "email": "sara@shop.com"})

client.vaults.push(vault_id,
    "user: I can't log in\nagent: reset link sent",
    metadata={"session_id": "chat-2", "email": "tom@shop.com"})

client.vaults.push(vault_id,
    "user: what's our refund policy?\nagent: 30 days, no questions asked",
    metadata={"session_id": "chat-3"})   # no email — a general note
```

(`session_id` is special: it groups pushes into one source. It's not a
filterable key — the ones you declare are.)

Now Sara messages again. You want her history — not Tom's. Two steps.

## Step 1: declare the key (once)

```python theme={"dark"}
client.vaults.declare_meta_key(vault_id, "email")
```

This tells xysq "the `email` field matters — index it." Everything already
in the vault is indexed in the background (seconds for typical vaults), and
every future push indexes on arrival.

**Where keys can live**: the registry is an agent/team-vault feature. Agent
vaults declare via the SDK (above) or the vault's Settings; team vaults via
**Settings → Filterable metadata** in the app. The personal vault has no
registry — metadata still rides on personal pushes (like `session_id`),
it just isn't filterable there.

**Declare exact-value keys you'll filter by**: `email`, `customer_id`,
`ticket`, `order_id`, `repo`. Don't declare topical labels (`topic:
infra`) — grouping by theme is what [tags](/sdk/tags) are for. Up to 16
keys per vault; values must be text/numbers/booleans (or arrays of them);
reserved keys like `session_id` are refused.

## Step 2: filter your search

```python theme={"dark"}
hits = client.vaults.pull(vault_id, "order problems",
                          filters={"meta": {"email": "sara@shop.com"}})
```

| saved chat             | in the search? | why                                |
| ---------------------- | -------------- | ---------------------------------- |
| chat-1 (Sara's order)  | ✅ yes          | email matches                      |
| chat-2 (Tom's login)   | ❌ no           | different email — filtered out     |
| chat-3 (refund policy) | ✅ yes          | has no email, so it stays in scope |

Call this rule **match-or-absent**: a source without the key is never
excluded. The policy note isn't *about* Sara, but it isn't about anyone
else either — so it stays available when answering her. Only data that
*contradicts* the filter is dropped. (Case doesn't matter; lists work on
both sides; multiple keys combine.)

## Adding metadata later

Metadata usually rides in at push time — but you can attach or fix it on an
existing source too:

```python theme={"dark"}
res = client.vaults.push(vault_id, "user: escalation call notes...")  # forgot the email
client.vaults.update_source_meta(vault_id, res.id,
                                 set={"email": "sara@shop.com"})
```

Only declared keys are writable this way (unknown keys are echoed back,
not written), and the filter index updates immediately. This is also the
path for file uploads, which carry no metadata at upload time.

## Filtering examples

One person's context (their data + everything unlabeled, never anyone else's):

```python theme={"dark"}
client.vaults.pull(vault_id, "order problems",
                   filters={"meta": {"email": "sara@shop.com"}})
```

Two keys at once (both must not be contradicted):

```python theme={"dark"}
client.vaults.pull(vault_id, "escalations",
                   filters={"meta": {"email": "sara@shop.com", "ticket": "T-88"}})
```

Either of two values for one key:

```python theme={"dark"}
client.vaults.pull(vault_id, "billing",
                   filters={"meta": {"email": ["sara@shop.com", "sam@shop.com"]}})
```

## Tags or metadata keys?

One question decides it: *should a source that says nothing about this
still show up?* Yes → metadata key (forgiving, match-or-absent). No, only
my curated set → [tag](/sdk/tags) (hard scope).

|                 | Tags                                                                           | Metadata keys                              |
| --------------- | ------------------------------------------------------------------------------ | ------------------------------------------ |
| What            | labels **you** curate                                                          | values the data **carries**                |
| Set when        | any time, any source                                                           | at push, or later via `update_source_meta` |
| Who sees it     | scope members (personal/agent-global: just you; team/project: the whole scope) | the whole vault                            |
| Filtering by it | **only** tagged sources                                                        | drops **contradictions**; keyless stays in |
| Good for        | working sets, review queues, compliance labels                                 | `email`, `customer_id`, `order_id`, `repo` |

## Scoped pull\_context

MCP agents get the same filter on `pull_context` — for **team** scopes,
since that's where MCP-reachable declared keys live (teams declare them in
the team's Settings; the personal vault has no registry):

```python theme={"dark"}
pull_context(query="order problems",
             scope="support-team",          # filters need ONE vault
             filters={"meta": {"email": "sara@shop.com"}})
```

Worth knowing on either surface:

* **Single vault.** Filters apply to one vault per call (a named team on
  MCP, the addressed vault on the SDK); shared context isn't searched while
  a metadata filter is active — the `coverage_note` says so.
* **Typos fail loud.** Filtering on an undeclared key is a 400 on the SDK
  (listing the declared keys); on MCP it's `items: []` with the reason in
  `coverage_note` — read it before concluding "no memory".
* **Combines with tags**: `filters={"tags": ["launch"], "meta": {"email":
  ...}}` = your curated set, minus anyone else's data.

## Best practices

* **Canonicalize values before pushing.** Matching is exact (trimmed,
  case-insensitive) — `ORD-123` never matches `123`.
* **Stamp the key consistently** if exclusion matters: keyless sources
  always stay in scope, which is a feature for shared notes and a foot-gun
  for data you forgot to label. `update_source_meta` fixes stragglers.
* **Put metadata on the first push of a session.** Later pushes in the
  same `session_id` extend the content, not the metadata.
* **Use arrays for multi-party sources** — `"email": ["a@…", "b@…"]`
  matches either.
* **Deleting a key is a light switch, not a shredder.** Removing a
  declared key only drops the index; the metadata stays on every source,
  and re-declaring rebuilds it identically.
* **Need only-matching results, keyless excluded too?** That's not a
  metadata filter — that's a curated set. Use a tag.
* **Storing names or emails in the content itself, not just metadata?**
  See [PII scrubbing](/sdk/pii-scrub) to strip that at ingest.
