# AGENTS.md — Relationship Radar

Relationship Radar (`relradar.ai`) is an internal Capital Factory tool. It
synthesizes ~70 data sources (Gmail, Calendar, HubSpot, Asana, Airtable,
Crunchbase, court records, etc.) into relationship-intelligence briefs for
meeting prep and contact research.

This file is the contract for **agent integrators** (Mikey, Doorman, the
`/relationship-radar` skill, anything else hitting our endpoints from a
non-browser context). Humans use the web UI at `/`.

---

## Quick start (agents)

**Easy path — install the MCP server.** If your host speaks MCP (Claude
Code, Claude Desktop, Cursor, any `@modelcontextprotocol/sdk`-compatible
runtime), wire it once and call typed tools instead of hand-rolling HTTP:

```bash
claude mcp add relationship-radar npx -y @capitalthought/relationship-radar-mcp \
  --env RADAR_TOKEN=$DASHBOARD_TOKEN
```

The MCP server exposes 5 verb-first tools: `query_person`, `suggest_identities`,
`investigate_person`, `get_health`, `get_source_health`. Source code +
install instructions: <https://github.com/capitalthought/relationship-radar-mcp>.
Discovery card: <https://relradar.ai/.well-known/mcp/server.json>.

**Direct HTTP — for callers without MCP.** Plain Bearer auth + JSON:

```bash
curl -X POST https://relradar.ai/query \
  -H "Authorization: Bearer $DASHBOARD_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"name":"Patrick Vogt","company":"Capital Factory"}'
```

That's the canonical call shape. The remainder of this file documents the
auth model, the endpoints, the role-gated source set, the rate-limit
semantics, and the error shape.

---

## Authentication

Two paths. Pick the one that matches your caller shape.

### 1. `Authorization: Bearer <DASHBOARD_TOKEN>` (service-to-service)

For agent / cron / cross-worker callers. `DASHBOARD_TOKEN` is a single
shared secret rotated atomically across the worker, the enrichment worker,
and 1Password — see `scripts/rotate-dashboard-token --check` for drift
detection. Bearer-authed callers are treated as `role: "owner"` and have
full source access.

This is what Mikey, Doorman, and the `/relationship-radar` skill use.

#### Rotation impact on MCP / external callers

The `@capitalthought/relationship-radar-mcp` package reads its bearer
from the `RADAR_TOKEN` env var the host (Claude Code, Cursor, etc.)
passes to the stdio process. **A `DASHBOARD_TOKEN` rotation invalidates
every external `RADAR_TOKEN` until each consumer is updated.** The
worker-side rotate script handles the worker + enrichment worker + 1P,
but it does NOT push to MCP-host configs — those are off-network.

After running `scripts/rotate-dashboard-token`:

1. Pull the fresh value from 1P (item `72nihele3tekhod267gsq4xkqy` in
   the Relationship Radar vault, field `credential`).
2. Update each MCP host's config:
   - Claude Code: `claude mcp update relationship-radar --env RADAR_TOKEN=<new>`
     (or `claude mcp remove` + `claude mcp add ... --env RADAR_TOKEN=<new>`).
   - Other hosts: edit `mcpServers.relationship-radar.env.RADAR_TOKEN` in
     the host's MCP config file, restart.
3. Verify with: `echo '{"name":"Patrick Vogt","company":"Capital Factory"}' | curl -X POST https://relradar.ai/query -H "Authorization: Bearer $NEW_TOKEN" -H "Content-Type: application/json" -d @-` returns 200, not 401.

Until step 2 lands, the MCP server returns `401 Please sign in to continue`
on every tool call. The error shape is the same as a missing-auth direct
call (see § Error shape) so agents that retry on auth failures will at
least surface a clear signal.

**Future:** consider standing up a token-refresh endpoint or short-lived
JWT issuance so MCP hosts can self-rotate. Until then, treat
`DASHBOARD_TOKEN` rotations as a coordinated event across all consumers,
not a unilateral worker-side action.

### 2. `Cookie: radar_session=...` (browser sessions)

For human callers via Google SSO. Set by `/login` → `/callback`. Resolves
to a `session.email` + role inferred from the sign-in domain
(capitalfactory.com → owner; staff/team/social roles configured per email
in worker env vars). Browser-only — agents should not maintain session
cookies.

**Both paths support every public endpoint.** Pick based on whether you
have a real user (cookie) or a service identity (bearer).

---

## Multi-tenant authentication & tenancy (preview — Phase A in progress)

Relationship Radar is rolling out from single-tenant (Capital Factory only) to multi-tenant SaaS. The transition is gated by the worker-level `MULTI_TENANT_ENABLED` flag (currently `false` in production — CF behavior is unchanged). Once that flips, this section becomes load-bearing for any agent acting on a tenant's behalf.

**Org-scoped tokens (forthcoming).** Per-org API keys live in the `org_api_keys` table (Phase A.4 mint endpoint not yet live). When minted, they take the shape `Authorization: Bearer rk_<hash>`. The shared `DASHBOARD_TOKEN` continues to work and resolves to the Capital Factory org (`org_id=00000000-0000-0000-0000-000000000000`) — it cannot read other tenants' data even after the flag flips.

**Org-id parameter.** Every endpoint accepts an optional `?org_id=<uuid>` query param. When omitted, the worker resolves the bearer's default org. Cookie-authed users always operate within the org their session resolves to (one user, one active org).

**Plan-tier source restrictions.** Each org subscribes to a plan that gates which of the ~70 sources its queries can fan out to. Sources outside the plan are silently skipped (`status: "skipped", skip_reason: "plan_source_not_allowed"`). The four plans:

| Plan | Sources allowed | Members |
|---|---|---|
| OneOff ($4 / query) | Public-only (web, news, crunchbase, linkedin_company, twitter, youtube, substack) | 1 |
| Pro ($29 / mo, BYOK) | All API-key sources (Apollo, HubSpot, Asana, Airtable, etc.) | 1 |
| Organization ($99 / seat / mo, BYOK) | All sources, including OAuth-bound (Gmail, GDrive) | unlimited |
| Capital Factory (anchor tenant) | All | unlimited |

**Daily spend caps (soft).** Pro and Organization tenants have a daily $-spend ceiling on LLM synthesis costs (~$50/org-day for Pro, $50/seat-day for Org). Hitting the cap returns HTTP 402 with `code: "plan_quota_exceeded"`. OneOff is naturally per-call.

**Onboarding sequence (forthcoming).** Once Phase B ships:

1. `radar_create_org({name, owner_email, internal_domain, plan_id})` — creates org + Stripe Checkout session
2. `radar_connect_source({org_id, source, credential_type, credential_blob})` — connects Gmail/HubSpot/etc. via OAuth or API key
3. `radar_test_source({org_id, source})` — confirms the credential works
4. `query_person({org_id, name, ...})` — first dossier

Until then, only the OneOff anonymous path and the Capital Factory legacy bearer path are wired.

---

## Endpoints

### `POST /query` — full dossier

Headers: `Authorization: Bearer` OR `Cookie`, `Content-Type: application/json`,
`Accept: application/json` (JSON) or `Accept: text/event-stream` (SSE).

Body:

```json
{
  "name": "Patrick Vogt",
  "company": "Capital Factory",
  "email": "patrick@example.com",
  "phone": "+15125550100"
}
```

Only `name` is required. `email` and `phone` improve identity
disambiguation. `company` is optional but strongly recommended.

Useful query params:

| Param | Effect |
|---|---|
| `prefetch=1` | Skip LLM synthesis. Source collection + cache write only. Used by /suggest's speculative pre-fetch. Bearer auth required. |
| `skip_budget=0` | Disable LL#4 latency-budget gate. Use only when you know the source is worth the wait. |
| `skip_deprioritize=0` | Disable LL#3 hit-rate-based source skipping. |

JSON response (truncated):

```json
{
  "query": { "name": "Patrick Vogt", "company": "Capital Factory" },
  "generated_at": "2026-05-06T22:00:00Z",
  "role": "owner",
  "sources_used": ["hubspot", "gmail", "calendar", ...],
  "sources_skipped": ["dappier"],
  "sources_failed": [],
  "confidence": "high",
  "report": "...",
  "partial": false
}
```

`partial: true` is set when sources timed out or skipped — treat the
report as incomplete and consider retrying.

**Implementer gotchas (learned building clients — saves hours):**

- **Cold-cache `/query` takes ~20s, not <5s.** First-time queries fan out
  across ~60 sources + LLM synthesis. Set client timeouts to **≥30s** and
  expect a 15–25s wait on a cache miss; warm-cache hits return in ~1s. Use
  `?prefetch=1` to pre-warm without paying for synthesis.
- **The JSON example above is authoritative over the OpenAPI/JSON-schema** —
  field names in code (e.g. `sources_used` / `source_results` /
  `generated_at`) are the source of truth; trust a live response over the
  spec if they disagree.
- **Mint a service-scoped API key with a Bearer `DASHBOARD_TOKEN`, not just
  the cookie UI.** `POST /account/api-keys` with
  `Authorization: Bearer <DASHBOARD_TOKEN>` and body
  `{"label":"my-service"}` returns `{"token":"rk_<43char>", ...}` — the
  right pattern for service A to provision its own per-org key without sharing
  the shared dashboard token. (Despite the auth docs framing this endpoint as
  cookie-session-only.)

### `POST /suggest` — identity disambiguation

Same auth + body shape as `/query`. Returns candidate identities:

```json
{
  "query": "Patrick Vogt",
  "candidates": [
    { "name": "Patrick Vogt", "email": "patrick@cf.com", "company": "...", "confidence": 95, "source": "hubspot · gmail" }
  ],
  "auto_proceed": true
}
```

When `auto_proceed: true`, /suggest also kicks off a speculative
`?prefetch=1` /query in the background — the next /query for the same
person hits a warm cache.

### `POST /api/investigate` — PI agent (deep mode)

Same auth. Body:

```json
{
  "name": "...",
  "company": "...",
  "depth": "quick" | "standard" | "thorough"
}
```

Iterative tool-use loop. Bounded by depth ($0.10 / $0.30 / $0.75 spend
caps). Streams via SSE when `Accept: text/event-stream`.

### `POST /retry` — single-source retry

After a /query, agents can retry one source with the same query identity.
Body: `{ "source": "hubspot" }`. Useful for sources that flake on the
initial fan-out.

### `GET /admin/source-health` — last-status snapshot

Bearer-auth only. Returns each source's most recent /query result
(`status`, `error`, `last_success`). NOT an aggregate; one row per
source. See the source-health docs at `scripts/relationship-radar-check`
for interpretation.

### `GET /admin/source-latency.csv` / `/admin/source-hit-rates.csv`

Bearer-auth only. CSV. Per-source p50/p95 latency (LL#4) and synth
citation rate (LL#3) over the last 14 days.

### `GET /health`

Public, unauthenticated. Returns `{ "status": "ok" }`. Use this for
liveness probes; don't use it to detect partial degradation.

### `GET /health/detail`

Bearer-auth or session-required. Returns module health snapshot
(cron last-run timestamps, processed-event counts).

---

## Source role-gating

Each source is tagged with the minimum role allowed to consume it.
Bearer-authed callers are `owner` and see everything. Browser-authed
callers see whatever their email/role permits. The matrix is in
`src/radar/types.ts` (`SOURCE_ACCESS`) — these are the buckets:

| Role | Access |
|---|---|
| `owner` | All ~70 sources. Bearer auth defaults here. |
| `staff` | Most CF-internal sources. Excludes some PI/court/SEC paths. |
| `team` | CF team members; reduced source set. |
| `social` | Public-only sources (Crunchbase, Twitter, news). No internal CF data. |

Check a source's role before assuming it'll appear in a response. Sources
the role can't access are silently omitted from `sources_used`.

---

## Rate limits

Per-user (by `session.email` for cookies, by a synthetic ID for bearer):

| Window | Limit | Surface |
|---|---|---|
| 24h | 200 /query (owner) / 50 (others) | All /query and /suggest count toward the same daily counter. |
| Per-source retry | 5 /retry per source per day | Prevents stuck retry loops. |

Hitting a limit returns HTTP 429 with `{"status":"error","message":"..."}`.
Wait until midnight UTC for reset. Bearer-authed callers do NOT bypass —
this is intentional, to detect runaway agent loops.

---

## Cost model

LLM synthesis is cap-tracked per-caller via the
`@capitalthought/anthropic` cost-tracker. Soft-cap defaults: $5/user-day,
$50/user-month, $1000/day-global. Cap breaches log
`cost-tracker.cap-exceeded` to Axiom but do NOT block the call (yet).
A /query making 2 synth calls + cache lookup typically lands ~$0.005.

---

## Error shape

All endpoints return JSON (or SSE for streaming):

```json
{ "status": "error", "message": "Human-readable reason", "code": "optional_machine_code" }
```

HTTP status codes:

| Code | Meaning |
|---|---|
| 200 | Success (or partial — check `partial: true`) |
| 400 | Validation error (missing name, malformed body) |
| 401 | No auth or invalid bearer / session |
| 403 | Authenticated but role lacks access |
| 429 | Rate limited |
| 500 | Worker error (rare; retry once, then escalate) |
| 504 | LLM synthesis timed out — partial dossier returned where possible |

Don't hardcode message strings — they evolve. Hardcode against status code
+ `code` when present.

---

## What this product does NOT do

- No write paths to Gmail / Calendar / HubSpot. Read-only across all sources.
- No public registration. Bearer tokens are minted by Josh out-of-band.
- No webhook subscriptions. Pull only.
- No bulk export. Per-query results, not data dumps.
- No streaming `/query` over HTTP today (the worker streams internally to bound the 29.5s wall-clock; the public surface is JSON). MCP tool callers also get the synchronous JSON shape.

---

## Reporting issues / drift

If a source you depend on starts failing, hit `/admin/source-health` first
to confirm it's a known failure (not your call shape). Then file via the
`/bugfile relationship-radar` skill or DM Josh on iMessage.

---

Last updated: 2026-05-06 (in tandem with shipping this file).
Source: `src/static/agents-md.ts`.
