LLM and transactional events
Pharos keeps product facts deterministic and uses the LLM only for bounded language generation. Source applications calculate recipients, rankings, prices, URLs, and other trusted values. Pharos validates those values, asks the model for copy when enabled, renders trusted templates, and delivers through SES.
Project brand brain
Each project can configure:
- LLM enabled/disabled
- provider and model (Gemini is currently supported)
- brand voice
- target audience
- additional vocabulary and safety instructions
All model calls use lib/ai.ts. The gateway enforces structured JSON output and
records model, prompt version, latency, status, and a SHA-256 input hash in
ai_generations. Raw prompts and recipient PII are not stored in the audit.
Project API keys
Create a scoped key after the project exists:
pnpm api-key:create:prod chutometro "Chutometro production" events:write,events:send
events:write submits and previews events; events:send is what actually
delivers. The split mirrors campaigns:preview vs campaigns:send — agent
keys carry events:write only, so an agent can render any transactional email
and cannot deliver one. A server that sends real mail needs both.
The raw key is printed once. Pharos stores only its SHA-256 hash. Put the raw
value in the source application's secret store as PHAROS_API_KEY. Add
--expires-days <n> for a key that should self-revoke.
Competition-finished event
POST /api/v1/events defaults to preview mode. Set deliveryMode: "send" only
after reviewing a preview.
{
"projectId": "chutometro",
"eventType": "competition.finished",
"idempotencyKey": "WC:2026:final-ranking:user-id",
"notificationCategory": "competition_results",
"deliveryMode": "preview",
"recipient": {
"email": "person@example.com",
"firstName": "Dan",
"language": "pt-BR"
},
"data": {
"competition": "Copa do Mundo 2026",
"season": 2026,
"pools": [
{
"name": "Família",
"url": "https://chutometro.com.br/b/ABC123?aba=ranking",
"position": 2,
"memberCount": 8,
"points": 24,
"exactScores": 4,
"winnerName": "João",
"winnerPoints": 28
}
]
}
}
Authentication is Authorization: Bearer phk_... (a project key), or an
organization key (pha_...) whose organization owns the project. Preview
requires events:write; send mode requires events:send, persists the event
under the (project, idempotencyKey) unique key and queues delivery through
QStash. Repeating the same request does not send twice.
Participants are attached to the product-notifications list on first event.
An existing unsubscribe is honored and never silently reversed.
Generic events
Any eventType other than competition.finished goes down the generic path:
the caller supplies finished copy and Pharos owns delivery, idempotency,
suppression, and recording.
One asymmetry, on purpose: on the generic path a missing deliveryMode
means send — generic callers predate the field and their contact-form and
alert mail must keep flowing. An explicit deliveryMode: "preview" echoes the
composed email back without persisting or delivering anything. Delivering
requires events:send either way; preview needs only events:write.
{
"projectId": "d-fit",
"eventType": "contact.form_submitted",
"idempotencyKey": "sha256-of-payload-and-time-bucket",
"notificationCategory": "operational",
"recipient": { "email": "contato@dfit.app" },
"subject": "✔ Contato do D-Fit — Maria",
"html": "<p>…</p>",
"text": "…",
"replyTo": "maria@example.com"
}
notificationCategory decides both list handling and delivery mode:
product— mail to a user. Subscription status is honored, a List-Unsubscribe URL is attached, and delivery is queued through QStash (responsestatus: "queued"); QStash retries transient failures.operational— mail to a team inbox, triggered by a human waiting on an interactive action. No contact record, no unsubscribe handling, and delivery is synchronous: a 2xx withstatus: "sent"means SES accepted the message, and a 502 withstatus: "failed"means it did not go out — surface that to the person instead of acknowledging the send. A stray unsubscribe must never be able to mute an internal alert, which is why operational mail skips contact handling entirely.
Every delivered event is recorded in send_events with
source: "transactional" and the event type as its grouping key, so it appears
in the project's Sends view with delivery and open state.