Pharos
Agents.md

MCP server

Pharos exposes its project-scoped API to coding agents (Claude Code, Cursor, Codex) through an MCP server. The goal is that wiring a new app into Pharos is one step for an agent rather than a re-derivation from the docs each time.

This is the productized version of the Chutometro integration — see docs/decisions.md, "The product surface is the API, not the dashboard."


Why there is prerequisite work

Pharos has two authentication paths, and until now almost nothing was on the one an agent can use.

Helper Credential Routes
authenticateProjectApiRequest (lib/project-api-auth.ts) Bearer phk_…, project-scoped, with scopes (historical) /api/v1/events, /api/v1/contacts/sync
requireProjectApiAccess (lib/authz.ts) browser session cookie only everything else

An agent holding a project API key could sync contacts and fire a transactional event, and nothing else — no lists, no templates, no campaign preview.

lib/api-access.ts closes the gap with requireProjectAccessOrKey, which accepts either credential and requires a scope on the API-key path. A browser session implies every scope, so dashboard behaviour is unchanged. Every key-authenticated route goes through it now, including the two v1 routes that predate it — which is what makes organization keys work everywhere. It is also where per-key rate limiting lives: over-budget requests get 429 with Retry-After (lib/rate-limit.ts).

There are two kinds of key. A project key (phk_…) is pinned to one project — authenticateProjectApiRequest joins the key's own project against projects.slug, so it can never reach another. An organization key (pha_…) reaches every project in its organization, resolving by id or slug; slugs are globally unique, so this stays unambiguous. The org key is what lets a single global MCP registration serve every repo on a machine instead of pinning it to one project.

A project outside the key's organization returns 404, not 403 — an org key must not be usable to probe for project names elsewhere.

Routes must also be exempted from the proxy session gate. proxy.ts rejects any /api/* request without a session cookie before it ever reaches the route, so every key-authenticated path has to be listed in SESSION_GATE_EXEMPT_PREFIXES (lib/admin-auth.ts). Those paths are not public — each route still authorizes itself — but forgetting one shows up as a bare {"error":"Unauthorized"} that the route never sees.

POST /api/contacts/bulk-delete stays session-only. Mass deletion is not something an agent should reach even with a write scope, and carving out a contacts:delete scope for one route is not worth the vocabulary. Delete from the dashboard.

Scopes

Scopes follow the resource:action convention already established by contacts:write and events:write. Existing keys keep working.

Scope Grants
projects:read enumerate projects and read the key's own organization (organization keys only)
projects:write create projects in the key's organization (organization keys only)
contacts:read list and export contacts
contacts:write create, update, import, sync contacts
lists:read list lists and subscriber counts
lists:write create and update lists
templates:read read email templates
templates:write create and update email templates
campaigns:read list campaigns with delivery/engagement stats
campaigns:preview render a campaign and resolve its recipient set
campaigns:send actually send
events:write submit and preview transactional events
events:send actually deliver a transactional event
automations:read list automations, steps and run history
insights:read mentions (beacons), revenue metrics, store reviews
* everything

One endpoint requires two scopes at once: GET /api/sends returns contact identities crossed with delivery activity, so it demands campaigns:read and contacts:readcampaigns:read alone must not become a back door to recipient emails. requireProjectAccessOrKey accepts an array for exactly this shape.

Every key-authenticated request is recorded in api_key_audit (key, route, scope, outcome; 90-day retention) — pnpm key-audit [days] [key-id] reads it. Scopes are fixed at creation, so keys issued before campaigns:read / automations:read existed will 403 on the new read tools until re-issued.

The split that matters

Preview and send are separate on purpose, twice: campaigns:preview vs campaigns:send, and events:write vs events:send. The second exists because a key that can loop a per-recipient transactional send over the contact list is a key that can send a campaign — one email at a time. An agent key is provisioned with everything except the two send scopes:

Create one under Settings → API keys in the dashboard — the agent preset selects exactly this scope set — or from the CLI:

pnpm org-key:create <org-slug> "Claude Code" --agent   # every project
pnpm api-key:create <project> "Claude Code" --agent    # one project

The model can draft a campaign, resolve exactly who would receive it, and render the final HTML — and it cannot send, regardless of what it decides to do. The guarantee lives in the credential, not in a prompt.

Granting send is a deliberate, separate act:

pnpm api-key:create <project> "CI release mailer" campaigns:send
pnpm api-key:create <project> "App server" events:write,events:send

Preview

POST /api/send accepts mode: "preview". It resolves the recipient set and renders the email exactly as the send path would — same locale resolution, same token interpolation, same template — then returns instead of sending. No campaign row is written, no QStash batches are queued, no send_events rows are created.

// → POST /api/send   { "projectId": "mooni", "template": "newsletter",
//                      "subject": "…", "content": "…",
//                      "recipientMode": "list:newsletter", "mode": "preview" }
{
  "mode": "preview",
  "recipientCount": 412,
  "sampleRecipient": { "email": "…", "language": "pt-BR" },
  "subject": "…",
  "html": "<!doctype html>…",
  "text": "…"
}

The sample recipient is the first of the resolved set, so the preview shows the locale a real recipient would get rather than a hardcoded en.

Tool surface

What the server can reach follows from the key it holds. With a project key (phk_…) it is scoped to one project — an agent works in one repo, which is one app — and a project argument naming anything else is refused before the request leaves the machine. With an organization key (pha_…) every project-scoped tool takes an optional project, defaulting to PHAROS_PROJECT when set, and three organization-level tools appear that a project key does not get at all.

Tool Scope Backed by
list_organizations projects:read GET /api/v1/organizations — org keys only
list_projects projects:read GET /api/v1/projects — org keys only
create_project projects:write POST /api/v1/projects — org keys only
list_contacts contacts:read GET /api/contacts
create_contact contacts:write POST /api/contacts
update_contact contacts:write PATCH /api/contacts/:id, resolved by email
sync_contacts contacts:write POST /api/v1/contacts/sync
list_lists lists:read GET /api/lists
create_list lists:write POST /api/lists
list_templates templates:read GET /api/email-templates
create_template templates:write POST /api/email-templates
update_template templates:write PATCH /api/email-templates/:id
list_tokens lib/email-render.ts
list_campaigns campaigns:read GET /api/campaigns, with send/open/click/bounce stats
list_sends campaigns:read + contacts:read GET /api/sends, per-recipient delivery records
preview_campaign campaigns:preview POST /api/send with mode: "preview"
send_event events:write preview / events:send deliver POST /api/v1/events (preview-default)
list_automations automations:read GET /api/automations
get_automation automations:read GET /api/automations/:id, steps + run history
list_mentions insights:read GET /api/mentions/hits, the beacons inbox
revenue_summary insights:read GET /api/revenue/summary, monthly rows + latest snapshot
list_reviews insights:read GET /api/reviews, maxRating narrows to complaints

Automation editing stays session-only, like contacts/bulk-delete: a drip sequence is a standing decision about who gets mailed when, and an agent rewriting one is a send-adjacent act that belongs in the dashboard.

There is no send_campaign tool. The agent key already lacks campaigns:send, so exposing one would only produce a 403 — but not exposing it means the model never reaches for a lever it must not pull. Defense in depth, and one less thing to reason about. Sending happens in the dashboard.

list_contacts takes limit / search / status, which GET /api/contacts now applies server-side — handing an agent five thousand rows is not useful, and a filtered request should not ship the whole contact table over the wire. (The client re-applies the filters as a no-op backstop against an older deployment.) preview_campaign takes includeHtml (default false) for the same reason — the recipient count and subject come back either way, and the full rendered email only when asked for.

list_tokens matters more than it looks, and carries a caveat that is easy to get wrong. lib/email-render.ts defines the tokens, but interpolateEmail only runs on the snapshot HTML path — a stored template's HTML carried onto the campaign. When a campaign uses a built-in React template with raw content, renderEmail renders that copy literally, so a {{contact.email}} written there ships as those exact characters. The tool says so explicitly; without it an agent would reasonably assume tokens work everywhere.

Resources

The server serves the existing docs as MCP resources:

  • docs/contact-sync-api.md
  • docs/llm-and-transactional-events.md
  • docs/pharos-automations.md

That is how an agent learns the contract without a bespoke system prompt, and it means the docs stay the single source of truth for both humans and models.

Packaging

Lives at packages/mcp (the workspace already has @pharos/observability) and builds with tsup to a single ESM entry with a shebang:

pnpm --filter @pharos/mcp build

The package is private: true — with no external clients there is nothing to publish yet, so registration points at the built file directly.

Register once for every repo on the machine, using an organization key:

claude mcp add pharos -s user \
  -e PHAROS_API_KEY=pha_… \
  -- node /Users/danvilela/Code/Pharos/packages/mcp/dist/index.js

-s user writes to ~/.claude.json. Without PHAROS_PROJECT, tools take a project argument and list_projects shows what the key can reach. An individual repo can still pin itself with a project-scoped .mcp.json:

{
  "mcpServers": {
    "pharos": {
      "command": "node",
      "args": ["/Users/danvilela/Code/Pharos/packages/mcp/dist/index.js"],
      "env": {
        "PHAROS_API_KEY": "pha_…",
        "PHAROS_PROJECT": "mooni"
      }
    }
  }
}

PHAROS_BASE_URL defaults to https://pharosbase.com; point it at http://localhost:4326 to work against a local dev server.

Tradeoff worth naming: an org key in ~/.claude.json is readable by anything running as you and grants write access to contacts, lists and templates across every project at once. It still cannot send. That is a fine trade for a solo studio and would need revisiting with customers on the platform.

Dependencies stay external rather than bundled, so the server runs out of this repo's node_modules. Publishing as npx -y @pharos/mcp is the step to take when there is someone outside this machine to install it — at which point docs/ needs copying into the package at build time, since the doc resources currently resolve relative to the repo root.

Hosted HTTP endpoint

The same tool surface is served over Streamable HTTP at /api/mcp (app/api/mcp/route.ts), which is the zero-install registration:

claude mcp add pharos -s user -t http https://pharosbase.com/api/mcp \
  -H "Authorization: Bearer pha_…"

Both transports register through one registerPharosTools in packages/mcp/src/server.ts — stdio builds one client from the environment for the life of the process, the route builds one per request from the presented key. The endpoint is stateless (no MCP sessions), and the MCP layer holds no authority of its own: every tool call is forwarded to the HTTP API with the caller's key, where scopes, rate limits, expiry and the audit log apply exactly as if the caller had hit the API directly. A project key's pinned project — PHAROS_PROJECT over stdio — is derived from the key itself over HTTP.

Auth for the endpoint is the API key itself; MCP OAuth 2.1 is the step to take when external customers exist and keys-in-config stops being acceptable.

Bootstrap

Creating a project used to be the one step that forced an agent back into this repo, and the reason was real: a project key is pinned to a project that already exists, so it can never be the credential that brings one into being.

The organization key dissolves that. Its reach is the organization, so a project created inside that organization is within what the caller already holds — it is not an escalation, it is the same boundary. POST /api/v1/projects takes the organization from the key, never from the request body, which makes "create into someone else's organization" unexpressible rather than merely rejected.

# → POST /api/v1/projects   { "name": "Acme App", "sesFromEmail": "hi@acme.dev" }
{
  "project": { "id": "prj_…", "slug": "acme-app", "name": "Acme App",  },
  "defaultList": { "slug": "newsletter", "name": "Newsletter" }
}

The same key reaches the new project immediately, so no credential is provisioned during bootstrap — that is what makes the whole flow one step. The default newsletter list is seeded so the project can accept subscribers before anyone opens the dashboard.

Both create paths run through createProject in lib/projects.ts. That sharing is load-bearing rather than tidiness: child tables (lists, contacts, campaigns) key on projects.slug, not on the row id, so a second implementation that normalized slugs differently would produce a project whose own data does not resolve.

list_organizations returns an array holding exactly one organization — the key's. The shape is what a user-scoped credential would return several of, so keeping it plural now means adding one later is not a breaking change. It reads on projects:read rather than a scope of its own: an org key already is the organization, and reading back its own name discloses nothing the holder did not need to know to present the key. The slug is worth having because dashboard URLs are /<team>/<project>/… where team is that slug — it is how an agent hands a human a link.

projects:write is newer than any key issued before it, and scopes are fixed at creation time. An existing agent key gets list_organizations (it already carries projects:read) but 403s on create_project until re-issued:

pnpm org-key:create <org-slug> "Claude Code" --agent

Then update PHAROS_API_KEY wherever the server is registered. The client says as much on a 403 for any scope other than campaigns:send — that one is the design working as intended, and re-issuing will never change it.

Still deliberately absent: deleting a project, and provisioning API keys over the API. Creation is additive and cheap to undo by hand; deletion cascades across every table and is a dashboard action. A key that can mint keys is a key that can escape its own scopes, including the campaigns:send omission the whole model rests on.