---
type: note
alexandria_core: true
tags: [contacts, reference]
author: alexandria
---

# Importing macOS Contacts (local, read-only against Contacts)

The `contacts` module lets the agent bring the owner's **macOS Contacts** into the
vault on demand — four distinct jobs, each explicit, none of them a bulk dump:

| The owner says | The job | What changes |
|---|---|---|
| *"put names on my texts / WhatsApp"*, *"import my contacts"* | **Handle map** | `Agent/scripts/contacts/handles.json` — phone/e-mail → name, read by the iMessage and WhatsApp readers. PII, gitignored. |
| *"fill in my People notes from Contacts"* | **Enrich** | Empty `email / phone / company / role / location / linkedin` fields on **existing** `People/<Name>.md` notes, from the matching card. Filled fields are never touched. |
| *"what's X's number / e-mail"*, *"who do I know at Acme"* | **Lookup** | Nothing — the agent answers from the cards. |
| *"add X from my contacts"* | **Create** | `People/<Full Name>.md` from `People/_template.md`, for the people the owner **names**. |

The importer (`alexandria_vault.engine.contacts_import`) is **plumbing**. The owner
never runs it or sees a command — they ask in plain language and the agent does the
rest, then answers in the owner's terms.

## Where it runs — locally, not on the automation host
Contacts is a per-machine, TCC-protected store on the owner's **Mac**. Like the
iMessage and WhatsApp readers, this runs *in-session on the local machine* — never
on a runner, which has no address book. Every `AddressBook-v22.abcddb` under
`~/Library/Application Support/AddressBook/` is read (the local store plus one per
synced source — iCloud, Exchange, Google) and merged: same card id → one card; same
name sharing a phone or e-mail → one card.

## Prerequisite — Full Disk Access (one-time)
macOS blocks reading the AddressBook folder until the **app running the assistant**
has Full Disk Access: **System Settings → Privacy & Security → Full Disk Access →
enable it → re-open the app.** Until then the importer prints that fix and stops. It
is a permission grant, not a stored credential. **Without it**, an exported vCard
works: *Contacts → File → Export → Export vCard…* then `--vcf <file> --no-store`.

## Read-only against Contacts — by construction
The live databases are **never opened**: each store (db + `-wal` + `-shm`) is copied
to a temp dir and the *copy* is queried read-only, then deleted. Nothing in Contacts
is edited, tagged, grouped, or deleted, and there is no write path to improvise.

## The four jobs, precisely

### 1. Handle map — `--handles`
Writes the **shared** map `Agent/scripts/contacts/handles.json`: every phone and
e-mail on every card (except the owner's own "me" card) → the card's name. Both
readers load it *underneath* their own module-specific `contacts.json`, so a
hand-written label there still wins. Re-running refreshes only the entries the
importer wrote (listed under `_imported`); anything hand-added to the file is kept
and beats an import on the same handle. The file is PII: it is gitignored (the
importer ensures the `.gitignore` beside it) and **is never committed**.

### 2. Enrich — `--enrich [--dry-run]`
For each existing `People/<Name>.md` (the template and folder note excluded), the
card is matched **in tiers**: first by the note's `name` or filename against card
names; only if nothing matches, by the note's `aliases` against card names and
nicknames (all case- and accent-insensitive) — so an alias like "Alex" can never pull
in a second person beside the exact-name card. A matching card fills **only blank**
frontmatter fields (empty, or a `<placeholder>`; a value written in block form on
the following indented lines counts as filled): `email`, `phone` (one value each — the work e-mail and the mobile number when the
card labels them, else the first), `company`, `role`, `location` (city, country), `linkedin`.
More than one distinct card in the winning tier → the note is skipped as *ambiguous*
and reported, whatever their names. Always show the owner the `--dry-run` plan before
applying.

### 3. Lookup — `--search <query>` (`--json`)
Substring match on name, nickname, company, title, e-mail, or the last digits of a
number. The agent answers the question; it does not paste the whole card list.

### 4. Create — `--create "<Full Name>"` (repeatable) · `--create-all`
A new note in the vault's own People shape (the frontmatter keys are read from
`People/_template.md`, so a vault that customised its template is honoured), filled
from the card: name, nickname → `aliases`, the six fields above, `tags: [contact]`,
`status: active`; extra numbers/e-mails/addresses, social handles, birthday, and the
Contacts note go under `## Notes`; `## Context` carries a one-line *imported on*
stamp for the agent to replace with real context. **Mind the Contacts note field:**
it is copied verbatim, and people keep gate codes, door PINs, and the like there —
a created People note is git-tracked content, so check `## Notes` before committing
and cut anything that is a secret rather than a fact about the person. An existing note is **never
replaced**. A `--create` name that matches several cards is refused with the
candidates; `--create-all` refuses above `contacts.max_create` (default 200) without
`--yes` — and the skill only reaches for it when the owner explicitly asked for
their whole address book, having been told how many notes that is. Company-only
cards are skipped unless `--include-orgs`.

**A People note is for someone the owner deals with** — the direct-dealing test in
[[AGENTS#Note types]]. An address book is mostly *not* that: old vendors, one-off
numbers, restaurants. Create notes for the people the owner names; sweep nothing
in unasked.

## The importer (flags, for the agent)
```sh
P="${ALEXANDRIA_PYTHON:-python3}"   # the interpreter with the alexandria-vault package
"$P" -m alexandria_vault.engine.contacts_import --vault .                          # list every card
"$P" -m alexandria_vault.engine.contacts_import --vault . --search rivera --json   # lookup
"$P" -m alexandria_vault.engine.contacts_import --vault . --handles                # names for the readers
"$P" -m alexandria_vault.engine.contacts_import --vault . --enrich --dry-run       # what would be filled
"$P" -m alexandria_vault.engine.contacts_import --vault . --enrich                 # fill the blanks
"$P" -m alexandria_vault.engine.contacts_import --vault . --create "Alex Rivera" --create "Jordan Lee"
"$P" -m alexandria_vault.engine.contacts_import --vault . --vcf ~/Downloads/contacts.vcf --no-store --handles
```
Every run prints one coverage line to stderr — `sources N · cards M (people, orgs,
me-card skipped) · with phone / e-mail` — so `--json` stdout stays parseable. A store
that is missing or unreadable is a hard stop with the reason (no Contacts on this
machine, or the Full Disk Access message), never a quiet `sources 0`.

## What the agent commits
Enriched and created People notes are vault content: one commit per job
(`contacts: enrich (3 notes)`, `contacts: add Alex Rivera`). The handle map is
gitignored PII and never enters git.

## Config (`Agent/config.json` → `contacts`, all optional)
- `max_create` — the `--create-all` cap without `--yes` (default 200).
- `sources` — extra `.abcddb` or `.vcf` paths to read every run.
- `handles_file` — where the shared map lives (default `Agent/scripts/contacts/handles.json`; a
  relative path is relative to the vault). The importer writes there and **both readers read
  there** — the one setting moves all three together.
