{"id":"53fbb838-4f40-4022-9d3c-abf1032b1369","slug":"contactgraph","kind":"skill","name":"contactgraph","summary":"Agent-native personal graph","owner":"ContactGraph","repoUrl":"https://github.com/ContactGraph/contactgraph","license":"Apache-2.0","licenseTier":"permissive","trust":"community","stars":3,"match":null,"fewStars":true,"format":"skill","createdAt":"2026-05-21T23:22:27.000Z","addedAt":"2026-10-21T17:24:05.891Z","logoUrl":"https://avatars.githubusercontent.com/u/287500537?v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["writing"],"tags":[],"runtime":"binary","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-26T02:21:09.966Z","sample":false,"url":"https://conduits.dev/tools/contactgraph","language":"Python","transports":["stdio"],"install":{},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"# ContactGraph\n\nYour phone contacts are your real professional network — but they're trapped on your phone with no company info, no titles, and no way to search them. ContactGraph fixes that.\n\n1. **Upload phone contacts** — your `.vcf` file becomes your authoritative network in the cloud\n2. **Link with LinkedIn** — upload your LinkedIn Connections CSV to match contacts with current company, title, and LinkedIn profile\n3. **See your strong professional ties** — people in both your phone and LinkedIn, with accurate employer info, all in one place\n4. **Take action** — use your enriched network for job searching, warm intros, fundraising, and recruiting\n5. **Connect graphs** — share your ContactGraph with trusted friends to extend your network beyond first degree\n\nThe current product focus is **job search**: discover relevant open roles at companies where you have warm connections. Other use cases (fundraising, recruiting, networking) are planned.\n\n**Product (web app):** [https://www.contactgraph.ai](https://www.contactgraph.ai)\n**API host:** `https://api.contactgraph.ai` (MCP, OAuth, and skill file are served here — `www` does not proxy these paths)\n**MCP endpoint:** `https://api.contactgraph.ai/mcp`\n**Agent skill file:** `https://api.contactgraph.ai/skill.md`\n\n## Quick start for humans\n\n### Claude\n\n1. Claude.ai → **Customize** (top of left sidebar) → **Connectors** → **+** → **Add custom connector**\n2. **Name:** `ContactGraph`\n3. **Remote MCP server URL:** `https://api.contactgraph.ai/mcp`\n4. Leave Client ID / Secret empty (Dynamic Client Registration).\n5. Click **Connect** → sign in with Google → return to Claude.\n6. Start a **new chat**, enable the ContactGraph connector.\n7. Ask the agent to run **`sync_source`**, wait until sync finishes, then try *\"What VCs do I know?\"* or *\"Who do I know at Sticker VC?\"*\n\nClaude uses redirect URI `https://claude.ai/api/mcp/auth_callback` — handled automatically.\n\n**Note:** If tools stop working after ~15 minutes, disconnect/reconnect the connector (token refresh). For testing, set `JWT_ACCESS_TOKEN_EXPIRE_MINUTES=1440` on Railway.\n\n### OpenClaw\n\n[OpenClaw](https://docs.openclaw.ai) is a self-hosted AI agent gateway (WhatsApp, Telegram, Control UI, etc.). ContactGraph is a **remote Streamable HTTP MCP server** at `https://api.contactgraph.ai/mcp` with **OAuth 2.1** (same flow as Claude — dynamic client registration, Google sign-in).\n\nYou need two things: **MCP tools** (ContactGraph server + auth) and **agent instructions** (the skill file).\n\n#### 1. Install the ContactGraph skill\n\nOpenClaw loads skills from `~/.openclaw/skills`. Copy the published skill so your agent knows the `connect_source` → `sync_source` → `query_network` workflow:\n\n```bash\nmkdir -p ~/.openclaw/skills/contactgraph\ncurl -s https://api.contactgraph.ai/skill.md -o ~/.openclaw/skills/contactgraph/SKILL.md\n```\n\nAlternatively, add `https://api.contactgraph.ai/skill.md` to the agent system prompt.\n\n#### 2. Connect ContactGraph (pick one auth method)\n\n**Option A — OAuth plugin (recommended)**\n\nNative OpenClaw MCP config only supports static `Authorization: Bearer …` headers today; ContactGraph expects a full OAuth flow. The [openclaw-mcp-bridge](https://github.com/fsaint/openclaw-mcp-bridge) plugin handles PKCE, dynamic client registration, and token refresh for you.\n\n```bash\nnpm install openclaw-mcp-bridge\n```\n\nAdd to `~/.openclaw/openclaw.json`:\n\n```json\n{\n  \"plugins\": {\n    \"entries\": {\n      \"plugin-mcp-client\": {\n        \"enabled\": true,\n        \"config\": {\n          \"servers\": {\n            \"contactgraph\": {\n              \"url\": \"https://api.contactgraph.ai/mcp\",\n              \"auth\": {\n                \"scopes\": [\"contactsafe:read\", \"contactsafe:write\"]\n              }\n            }\n          }\n        }\n      }\n    }\n  }\n}\n```\n\nRestart the gateway (`openclaw gateway restart` — plugin changes require a restart). In chat, run **`/mcp auth contactgraph`**. Your browser opens → sign in with Google → return to OpenClaw.\n\nTools appear namespaced, e.g. `contactgraph__sync_source`, `contactgraph__query_network`. Use `/mcp tools` to verify.\n\n**Option B — Built-in MCP config (manual Bearer token)**\n\nIf you already have a ContactGraph access token (from any MCP client that completed OAuth), register the server via CLI. OpenClaw substitutes `${CONTACTGRAPH_MCP_TOKEN}` from your environment at runtime:\n\n```bash\nopenclaw mcp set contactgraph '{\"url\":\"https://api.contactgraph.ai/mcp\",\"transport\":\"streamable-http\",\"headers\":{\"Authorization\":\"Bearer ${CONTACTGRAPH_MCP_TOKEN}\"}}'\nexport CONTACTGRAPH_MCP_TOKEN=\"your-access-token\"\nopenclaw mcp list\n```\n\nChanges under `mcp.*` hot-apply without a gateway restart. Tokens expire (~15 minutes by default); reconnect or use Option A for automatic refresh. For longer-lived testing tokens, raise `JWT_ACCESS_TOKEN_EXPIRE_MINUTES` on the ContactGraph deployment.\n\n#### 3. Use it\n\nIn whatever channel your OpenClaw agent runs on:\n\n> Connect my Gmail through ContactGraph, run sync, then tell me what investors I know.\n\nThe agent should call `connect_source` (you open the Google consent URL once if Gmail is not connected yet), then `sync_source`, poll `get_source_status` until sync completes, and answer via `query_network`.\n\n**Docs:** [OpenClaw MCP CLI](https://docs.openclaw.ai/cli/mcp) · [Configuration reference](https://docs.openclaw.ai/gateway/configuration-reference)\n\n### Gemini (CLI agent)\n\nThe [Gemini CLI](https://github.com/google-gemini/gemini-cli) is Google's open-source terminal agent. It supports remote **Streamable HTTP** MCP servers with automatic OAuth discovery — ContactGraph uses the same OAuth 2.1 + DCR flow as Claude.\n\n#### 1. Add ContactGraph as an MCP server\n\n**Via CLI** (writes to `~/.gemini/settings.json`):\n\n```bash\ngemini mcp add -s user --transport http contactgraph https://api.contactgraph.ai/mcp\n```\n\n**Or edit `~/.gemini/settings.json` directly:**\n\n```json\n{\n  \"mcpServers\": {\n    \"contactgraph\": {\n      \"httpUrl\": \"https://api.contactgraph.ai/mcp\"\n    }\n  }\n}\n```\n\nOAuth endpoints are discovered from `/.well-known/oauth-protected-resource` — no Client ID or Secret needed.\n\n#### 2. Authenticate\n\nStart the Gemini CLI, then run:\n\n```\n/mcp auth contactgraph\n```\n\nYour browser opens → sign in with Google (ContactGraph OAuth) → return to the terminal. Tokens are stored in `~/.gemini/mcp-oauth-tokens.json` and refreshed automatically.\n\nVerify with `/mcp` — you should see ContactGraph tools listed (namespaced as `mcp_contactgraph_*`).\n\n**Headless environments:** OAuth requires a local browser and redirect to `http://localhost:7777/oauth/callback`. It will not work over plain SSH without port forwarding.\n\n#### 3. Teach the agent\n\nPaste `https://api.contactgraph.ai/skill.md` into your first message, or save it locally and `@`-reference it:\n\n> Read https://api.contactgraph.ai/skill.md, then connect my Gmail via ContactGraph, sync, and tell me what investors I know.\n\n#### Gemini Enterprise (org admins)\n\nIf you use [Gemini Enterprise](https://cloud.google.com/gemini-enterprise) Agent Designer instead of the CLI, add ContactGraph as a **Custom MCP Server** data store in the Google Cloud console ([docs](https://docs.cloud.google.com/gemini/enterprise/docs/connectors/custom-mcp-server/set-up-custom-mcp-server)):\n\n| Field | Value |\n|-------|-------|\n| MCP Server URL | `https://api.contactgraph.ai/mcp` |\n| Authorization URL | `https://api.contactgraph.ai/oauth/authorize` |\n| Token URL | `https://api.contactgraph.ai/oauth/token` |\n| Scopes | `contactsafe:read contactsafe:write` |\n\nGemini Enterprise requires a **pre-registered OAuth client** (not DCR). Register one via `POST https://api.contactgraph.ai/oauth/register` with redirect URI `https://vertexaisearch.cloud.google.com/oauth-redirect`, then enter the returned `client_id` (and `client_secret` if applicable) in the data store config. Enable actions on the data store, connect it to your agent, and authorize Gemini Enterprise when prompted.\n\n**Docs:** [Gemini CLI MCP servers](https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md)\n\n### ChatGPT (Developer Mode apps)\n\nChatGPT supports remote MCP servers as **custom apps** via [Developer Mode](https://developers.openai.com/api/docs/guides/developer-mode) (Plus, Pro, Business, Enterprise, and Education on the web). ContactGraph uses Streamable HTTP + OAuth 2.1 with dynamic client registration — same pattern as Claude.\n\n#### 1. Enable Developer Mode\n\n1. Go to [ChatGPT Settings → Apps](https://chatgpt.com/#settings/Connectors)\n2. Open **Advanced settings → Developer mode** and turn it **ON**\n\nBusiness/Enterprise admins may need to enable **Create custom MCP connectors** under workspace permissions first.\n\n#### 2. Create a ContactGraph app\n\n1. In Apps settings, click **Create app** (visible only in Developer Mode)\n2. Fill in:\n   - **Name:** `ContactGraph`\n   - **MCP server URL:** `https://api.contactgraph.ai/mcp`\n   - **Authentication:** **OAuth**\n   - Leave Client ID / Client Secret empty — ChatGPT registers via DCR automatically\n3. Check **I trust this application**\n4. Click **Create** → complete the OAuth sign-in (Google) when redirected\n\nThe app appears under **Drafts**. Use the app details page to toggle individual tools on/off and **Refresh** after ContactGraph deploys new tools.\n\nChatGPT redirect URIs (`https://chatgpt.com/connector_platform_oauth_redirect` or per-app `https://chatgpt.com/connector/oauth/{callback_id}`) are registered automatically through DCR.\n\n#### 3. Use it in a chat\n\n1. Start a **new chat**\n2. From the **+** menu, choose **Developer mode** and select your ContactGraph app\n3. Prompt explicitly so ChatGPT picks the right tools:\n\n> Using ContactGraph only: read https://api.contactgraph.ai/skill.md, connect my Gmail if needed, run sync_source, wait for sync to complete, then tell me what VCs I know.\n\nWrite actions (`sync_source`, `connect_source`) require confirmation by default — review each tool call before approving.\n\n**Tips:** Developer Mode does not work in Agent mode (only Deep Research can use custom apps, read-only). For workspace-wide rollout, admins publish the app from **Workspace Settings → Apps → Drafts**.\n\n**Docs:** [ChatGPT Developer mode](https://developers.openai.com/api/docs/guides/developer-mode) · [Apps SDK authentication](https://developers.openai.com/apps-sdk/build/auth)\n\n---\n\n## Quick start for agents\n\n1. Read the skill file: **`https://api.contactgraph.ai/skill.md`**\n2. MCP server: **`https://api.contactgraph.ai/mcp`** (Streamable HTTP; trailing slash OK)\n3. Authenticate via OAuth 2.1 Bearer token (see **MCP workflow** below).\n4. Typical flow:\n   - `connect_source` (`source_type`: `google_mail`) → user opens `oauth_url` → Google consent\n   - `sync_source` → poll `get_source_status` until `sync_state` is `partial` or `complete`\n   - `start_enrichment` → poll `get_enrichment_status` until complete\n   - `query_network` with e.g. `\"Who do I know at Sticker VC?\"`\n\nAfter deploys or schema changes, run **`sync_source`** then **`start_enrichment`** again to refresh.\n\nLocal development URLs: `http://localhost:8000/mcp`, `http://localhost:8000/skill.md`.\n\n---\n\n## Quick start (developers)\n\n```bash\nuv sync --package contactsafe-server --extra dev\n\ncp .env.example .env\n# Fill in DATABASE_URL, TOKEN_ENCRYPTION_KEY, SESSION_SECRET, Google OAuth\n\nmake migrate\nmake dev       # API + MCP on :8000\nmake web       # Web app on :3000 (separate terminal)\n```\n\n| Endpoint | Local |\n|----------|-------|\n| Health | http://localhost:8000/health |\n| MCP | http://localhost:8000/mcp |\n| Skill | http://localhost:8000/skill.md |\n| OAuth metadata | http://localhost:8000/.well-known/oauth-authorization-server |\n| Web app | http://localhost:3000 |\n\n---\n\n## Data sources\n\n| Source | Type | What it does |\n|--------|------|-------------|\n| **Phone contacts** | `.vcf` upload | Your authoritative network — names, phone numbers, emails |\n| **LinkedIn Connections** | `.csv` upload | Matches contacts by name/email/URL; adds current company, title, and LinkedIn profile link |\n| **Gmail** | OAuth | Email metadata (headers only) for tie strength, relationship signals, and additional contact matching. Google Contacts (People API) are also ingested during Gmail sync. |\n| **Google Calendar** | OAuth | Calendar event metadata (attendees, titles, times). Created alongside Gmail on OAuth; must be synced separately with explicit `source_id`. |\n| **LinkedIn profile** | PDF upload | Your own work history for your profile |\n\n**Phone contacts are your network. Everything else is enrichment.** The People page shows your phone contacts; LinkedIn and Gmail add company, title, and tie strength data to those contacts. A **strong professional tie** is someone who appears in both your phone and your LinkedIn connections.\n\n### External enrichment APIs\n\n| Provider | Env var | What it does |\n|----------|---------|-------------|\n| **Exa** | `EXA_API_KEY` | Primary web search enrichment — resolves employer, role, social URLs for contacts via `people` + `personal_site` search |\n| **Tavily** | `TAVILY_API_KEY` | Web search fallback when Exa returns no hits |\n| **Serper** | `SERPER_API_KEY` | Cheap Google SERP fallback when Exa/Tavily miss |\n| **TheirStack** | `THEIRSTACK_API_KEY` | Job discovery — finds open roles at companies where you have connections |\n| **ScrapingDog** | `SCRAPINGDOG_API_KEY` | Live LinkedIn profile scraping for strong ties — refreshes employer data beyond the CSV |\n| **OpenAI** | `OPENAI_API_KEY` | LLM-powered query plans, richer enrichment synthesis, semantic excerpt search |\n\n---\n\n## Web app\n\nThe web dashboard lives in `apps/web` (Next.js) and runs as a separate service.\n\n| Page | Path | Description |\n|------|------|-------------|\n| Setup | `/setup` | Guided onboarding: upload phone contacts, LinkedIn, connect Gmail |\n| People | `/people` | Browse and search your contact graph; CSV export |\n| Organizations | `/organizations` | Company-level view with industry, size, enrichment; CSV export |\n| Graph | `/graph` | Visual network graph |\n| Jobs | `/jobs` | Job discovery — open roles at companies where you have connections (TheirStack) |\n| Target Companies | `/target-companies` | Curated org lists for focused job search |\n| Sharing | `/sharing` | Trust list management — invite friends to share networks |\n| Profile | `/profile` | Your work history, social links, account settings, account deletion |\n\n---\n\n## MCP tools\n\n| Tool | Auth | Description |\n|------|------|-------------|\n| `connect_source` | Optional | Connect a data source (`google_mail`, `google_calendar`). Returns `oauth_url` when browser consent is needed. |\n| `upload_source` | Bearer | Upload file content for `phone_contacts_upload`, `linkedin_connections_upload`, or `linkedin_profile_upload` |\n| `list_sources` | Bearer | List connected sources for the user |\n| `get_source_status` | Bearer | Connection + sync progress (`pending` \\| `syncing` \\| `partial` \\| `complete` \\| `failed`) |\n| `sync_source` | Bearer | Import or refresh graph from connected source(s). Without `source_id`, syncs all Gmail sources. |\n| `start_enrichment` | Bearer | Kick off background enrichment (web search, employer resolution, role extraction) |\n| `get_enrichment_status` | Bearer | Poll enrichment progress |\n| `query_network` | Bearer | Natural-language search over the user's graph. Returns `matches` and `second_degree_matches`. |\n| `describe_graph` | Bearer | High-level graph summary (contact count, org count, enrichment status) |\n| `view_trusted_users` | Bearer | View trust list members, inbound/outbound invites |\n| `edit_trusted_users` | Bearer | Add/remove trust list members, accept/decline invites, set privacy labels |\n| `dedup_persons` | Bearer | Merge duplicate person records by name |\n\n---\n\n## Testing locally\n\n1. `make dev`\n2. Health: `curl -s http://localhost:8000/health`\n3. MCP without auth (expect **401**):\n\n```bash\ncurl -i -X POST http://localhost:8000/mcp/ \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"params\":{},\"id\":1}'\n```\n\n4. [MCP Inspector](https://github.com/modelcontextprotocol/inspector) → `http://localhost:8000/mcp` → OAuth or Bearer token\n5. Flow: `connect_source` → Google OAuth → `sync_source` → `start_enrichment` → `get_source_status` / `get_enrichment_status` → `query_network`\n6. Tests: `make test`\n\n---\n\n## Testing production\n\nProduction runs on **Railway** as two services: the web app at **https://www.contactgraph.ai** and the API/MCP backend at **https://api.contactgraph.ai** (both CNAME → Railway). Always use **`api.contactgraph.ai`** for OAuth, MCP, and JWT audience — not `www` (which only serves the web app) and not the raw Railway hostname.\n\n```bash\ncurl -s https://api.contactgraph.ai/health\ncurl -s https://api.contactgraph.ai/.well-known/oauth-protected-resource | jq\ncurl -s https://api.contactgraph.ai/.well-known/oauth-authorization-server | jq\ncurl -i -X POST https://api.contactgraph.ai/mcp/ \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"params\":{},\"id\":1}'\n# Expect 401 + WWW-Authenticate\n```\n\nAfter connecting Gmail, run **`sync_source`** then **`start_enrichment`** once (or again after upgrades), then query e.g. *\"What VCs do I know?\"*\n\nRailway fallback hostname: `https://contactgraph-production.up.railway.app` — avoid for OAuth/MCP clients.\n\nMCP Inspector production URL: `https://api.contactgraph.ai/mcp`\n\n---\n\n## MCP workflow (OAuth 2.1)\n\n1. Discover: `GET /.well-known/oauth-protected-resource` and `GET /.well-known/oauth-authorization-server`\n2. Authorize with PKCE: `GET /oauth/authorize?...`\n3. User completes Google sign-in (grants Gmail, Contacts, and Calendar scopes)\n4. Exchange code: `POST /oauth/token` (`grant_type=authorization_code`, PKCE verifier)\n5. Call MCP tools with `Authorization: Bearer <access_token>`\n6. `connect_source` → `sync_source` → `start_enrichment` → poll status → `query_network`\n\nRefresh: `POST /oauth/token` with `grant_type=refresh_token`.\n\nDynamic Client Registration: `POST /oauth/register` (RFC 7591).\n\n---\n\n## Graph model and query engine\n\nThe schema uses a **three-layer entity-claim graph**:\n\n1. **Entities (global)** — `persons` and `orgs` tables with derived/cached columns. Deduplicated via `person_aliases` and `org_aliases` (email, LinkedIn URL, GitHub URL, domain).\n2. **Claims (global, with provenance)** — append-only assertions: `employment_claims`, `relationship_claims`, `person_attribute_claims`, `org_attribute_claims`. Each claim records who contributed it, from what source, when, and at what confidence. Re-syncing upserts in place (idempotent on unique keys).\n3. **User observations (per-user)** — `user_person_observations`, `user_relationship_observations`, `user_org_observations` — per-user rollups of email volume, tie strength, and classification flags.\n\nDerived columns on `persons` (current org, role, categories, social profiles, bio) are **recomputed from claims** at the end of each enrichment run.\n\n**Enrichment** uses a queue-based strategy pipeline (`start_enrichment` / `get_enrichment_status`). Strategies include heuristic classification, email-signature parsing, web search (Exa/Tavily/Serper), ScrapingDog LinkedIn scraping, and LLM synthesis. Contacts enriched within `WEB_ENRICHMENT_TTL_DAYS` (default 30) are skipped, making repeat enrichment near-free for API costs.\n\n**`query_network`** accepts a natural-language `question` and returns `matches` (including `also_known_as` aliases), `second_degree_matches` (from trust list connections), and `applied_plan`.\n\n| Config | Effect |\n|--------|--------|\n| No `OPENAI_API_KEY` | Heuristic query planner + email-domain/name category tags |\n| `EXA_API_KEY` | Web search enrichment — employer, role, social URLs for top contacts |\n| `TAVILY_API_KEY` | Web search fallback when Exa returns no hits |\n| `SERPER_API_KEY` | Cheap Google SERP fallback when Exa/Tavily miss |\n| `OPENAI_API_KEY` | LLM query plans, richer enrichment synthesis, semantic excerpt search |\n| `THEIRSTACK_API_KEY` | Job discovery — open roles at companies in your network |\n| `SCRAPINGDOG_API_KEY` | Live LinkedIn profile scraping for strong ties |\n\nBy default, queries **exclude automated senders and newsletters** (`exclude_automated`, `exclude_broadcast`).\n\nExample questions: *\"Who do I know named Chris?\"*, *\"What VCs do I know?\"*, *\"Who do I know at AIX?\"*, *\"Who did I talk to about hiring?\"* (semantic needs OpenAI + excerpts).\n\n---\n\n## Network sharing\n\nUsers can share their professional network with trusted friends via **trust lists**. Sharing is mutual — both sides can browse each other's contacts (names and roles only; emails, phone numbers, and notes are never shared).\n\n- **Invite flow:** enter a friend's email → get a copyable invite message → they sign up (or accept if already on the platform)\n- **Second-degree results:** `query_network` returns `second_degree_matches` from trust list members' networks\n- **Privacy labels:** per-contact visibility controls (public name+role, or hidden entirely)\n- **MCP tools:** `view_trusted_users`, `edit_trusted_users`\n- **Web:** `/sharing` page\n\n---\n\n## Job discovery\n\nContactGraph monitors open roles at companies where you have connections, powered by **TheirStack**.\n\n- **Automatic discovery:** after phone + LinkedIn imports complete, org enrichment runs automatically; job discovery follows for orgs in your target lists\n- **Target companies:** curate org lists on the `/target-companies` page to focus discovery\n- **Job relevance:** LLM-based filtering scores roles against your work history and preferences (title, location, seniority)\n- **Web:** `/jobs` page with inline contact lists showing who you know at each company\n\n---\n\n## Security, privacy, and account deletion\n\n### Data handling\n\n- **OAuth tokens** (Google) are encrypted at rest with Fernet symmetric encryption (`TOKEN_ENCRYPTION_KEY`)\n- **JWT Bearer tokens** authenticate all MCP and API requests; signed with `JWT_SIGNING_KEY`\n- **Session secrets** protect web app sessions (`SESSION_SECRET`)\n- **Email content is never stored** — only message metadata (From, To, Cc, Date headers) for tie strength signals\n- **Database** uses Supabase (managed Postgres) with SSL in production (`DATABASE_SSL=true`)\n\n### Privacy model\n\n- Each user's **observations** (tie strength, notes, email counts) are private — other users cannot see them\n- **Global entities** (person names, orgs) and **claims** (employment, attributes) are shared across users for deduplication, but queried only through each user's own observation edges\n- **Trust list sharing** exposes only names and roles — never emails, phone numbers, or notes\n- Per-contact **privacy labels** let users hide specific contacts from shared views\n\n### Account deletion\n\nUsers can delete their account from the **Profile** page or via `POST /api/delete-user-account`. Deletion is **immediate** and cascades:\n\n- All user-scoped data is deleted: observations, interaction excerpts, sources, OAuth credentials, trust list memberships, org lists, job preferences, enrichment runs\n- Global `persons` and `orgs` entities survive if other users still observe them\n- Claims contributed by the deleted user persist with anonymized provenance (`contributor_user_id → NULL`)\n- Sessions are orphaned (`user_id → NULL`)\n\n### Data export\n\n- **People** and **Organizations** views support **CSV export** from the web app\n- All data originates from user-provided files (VCF, CSV, PDF) and OAuth APIs — users retain their original exports\n\n---\n\n## Google OAuth (Gmail, Contacts, and Calendar)\n\n1. [Google Cloud Console](https://console.cloud.google.com/) → enable **Gmail API**, **People API**, and **Calendar API**\n2. OAuth client (Web) → redirect URIs:\n   - Local: `http://localhost:8000/oauth/callback`\n   - Production: `https://api.contactgraph.ai/oauth/callback`\n3. Set `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI` in `.env` / Railway\n\nRequested scopes: `openid`, `email`, `profile`, `gmail.readonly` (Gmail source), `contacts.readonly` (Google Contacts, ingested during Gmail sync), `calendar.readonly` (Calendar source).\n\n---\n\n## Deploy on Railway\n\nTwo services from this repo:\n\n| Service | Root directory | Config file | Builder |\n|---------|----------------|-------------|---------|\n| API | `/` (default) | `/railway.api.toml` | Dockerfile |\n| WWW | `apps/web` | `/apps/web/railway.toml` | Railpack |\n\nDo **not** use a root `railway.toml` — Railway applies it to every service and\nwill force the API Dockerfile on the web app.\n\n### API\n\n1. Create a Railway service from this repo (`Dockerfile` + `railway.api.toml`).\n2. Postgres or Supabase `DATABASE_URL`.\n3. **Required env vars:**\n   - `APP_ENV=production`\n   - `DATABASE_URL` (Postgres connection string)\n   - `TOKEN_ENCRYPTION_KEY` (Fernet key for OAuth token encryption)\n   - `SESSION_SECRET`\n   - `BASE_URL=https://api.contactgraph.ai`\n   - `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`\n   - `GOOGLE_REDIRECT_URI=https://api.contactgraph.ai/oauth/callback`\n   - `JWT_SIGNING_KEY` (separate from `SESSION_SECRET`)\n4. **Optional env vars** (see `.env.example`):\n   - `OPENAI_API_KEY`, `EXA_API_KEY`, `TAVILY_API_KEY`, `SERPER_API_KEY`\n   - `THEIRSTACK_API_KEY`, `SCRAPINGDOG_API_KEY`\n   - `WEB_BASE_URL` (web app origin, for upload redirect URLs)\n   - `ADMIN_EMAILS` (comma-separated, enables admin impersonation)\n   - `JWT_ACCESS_TOKEN_EXPIRE_MINUTES` (default 15)\n5. Google Cloud redirect URI must match production callback.\n6. CNAME **api.contactgraph.ai** → this Railway API service (the web app uses **www.contactgraph.ai**).\n7. **Before deploying** schema changes, run migrations manually against production:\n\n```bash\nDATABASE_URL='postgresql+asyncpg://...' DATABASE_SSL=true make migrate\n```\n\n8. Deploy — the container starts uvicorn only; it does **not** run Alembic automatically.\n\n### WWW\n\n1. Railway builds from `apps/web` using Railpack.\n2. **Required env vars:**\n   - `CONTACTGRAPH_API_URL` (e.g. `https://api.contactgraph.ai` — the API origin)\n   - `SESSION_SECRET`\n\n---\n\n## Database\n\n### Supabase (recommended)\n\n1. [supabase.com](https://supabase.com/dashboard) → **Database** → Direct connection URI (port 5432)\n2. `.env`:\n\n```env\nDATABASE_URL=postgresql+asyncpg://postgres:PASSWORD@db.PROJECT_REF.supabase.co:5432/postgres\nDATABASE_SSL=true\n```\n\n3. `make migrate` — extensions (`vector`, `pg_trgm`) + OAuth tables + graph schema\n\n### Local Docker Postgres\n\n```bash\nmake docker-up\n```\n\nDefault `DATABASE_URL` from `.env.example` (no SSL).\n\n---\n\n## Secrets\n\n```bash\nuv run python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\"\nuv run python -c \"import secrets; print(secrets.token_urlsafe(32))\"\n```\n\nSet `TOKEN_ENCRYPTION_KEY`, `SESSION_SECRET`, and optionally `JWT_SIGNING_KEY` (falls back to `SESSION_SECRET` in dev).\n\n---\n\n## Commands\n\n| Command | Purpose |\n|---------|---------|\n| `make dev` | API + MCP on port 8000 (reload) |\n| `make web` | Next.js web app on port 3000 |\n| `make migrate` | Alembic upgrade head |\n| `make migrate-new` | Generate new Alembic migration |\n| `make test` | Server tests |\n| `make coverage` | Tests with coverage report |\n| `make lint` | Ruff check + format |\n| `make typecheck` | Pyright |\n| `make docker-up` | Local Postgres |\n| `make docker-down` | Stop local containers |\n| `make sync` | `uv sync` |\n| `make web-install` | `pnpm install` for web app |\n"}