From b14d2fd0c7c6cda42c72a61d3b104f0beeefc3b3 Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
Date: Tue, 11 Aug 2026 15:10:58 +0000
Subject: [PATCH] feat(ai): the outreach composer, and the checks that make it
trustworthy
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds `packages/ai` — the only package in the tree that talks to a model. The
composer writes the message body for a recommendation, and everything around
it exists to make that output safe to show a reviewer.
Two properties are structural rather than requested:
1. It can only see grounded inputs. The prompt is assembled from stored
evidence, stored facts and the customer's own offering. There is no path
by which the model learns something it may not cite.
2. Its output is checked, not trusted. Eight deterministic §14.2 gates run
on every draft — grounding overlap, unsupported claims, identity
confidence, flattery, spam patterns, cross-prospect duplication,
sensitive topics, policy. A draft that fails is rewritten once, naming
the exact invented fragments; still failing, it is withheld.
Withheld is a working state, not an error. The card keeps the prospect, the
evidence and the recommended action, and the reviewer writes the message. A
bad draft next to a caveat is still a bad draft someone might approve.
The undecidable question "is this claim true?" is inverted into a decidable
one: does every specific assertion — quoted phrase, number, mid-sentence
proper noun — appear in stored evidence? That is what `checks.ts` answers,
and it is why no model output reaches a human unexamined.
Also:
- `POST /recommendations/:id/draft` composes or recomposes on demand, returns
200 with `drafted: false` and a reason when withholding, and audits it.
A user-edited draft is never discarded by a recompose.
- The approval card gains "Write draft" / "Rewrite", and states plainly why
no draft exists rather than offering a retry that cannot help.
- Prompt caching splits the stable offering/voice prefix from the
per-prospect half, so a campaign pays for the prefix once.
- The composer is optional infrastructure: without ANTHROPIC_API_KEY the
queue still runs end to end and drafting returns 503. Refusing to boot
would take the system down for a feature designed to produce nothing.
No model decides a merge, a score, or a policy outcome. 367 tests pass.
Co-Authored-By: Claude Opus 5 (1M context)
---
.env.example | 6 +
README.md | 18 +-
apps/api/package.json | 3 +-
apps/api/src/app.test.ts | 101 ++++++
apps/api/src/app.ts | 66 ++++
apps/server/package.json | 3 +-
apps/server/src/index.ts | 19 ++
apps/web/components/approval-card.tsx | 128 +++++--
apps/worker/package.json | 3 +-
apps/worker/src/pipeline.ts | 19 ++
bun.lock | 22 ++
docs/prd-implementation-map.md | 7 +-
packages/ai/package.json | 18 +
packages/ai/src/checks.test.ts | 307 +++++++++++++++++
packages/ai/src/checks.ts | 465 ++++++++++++++++++++++++++
packages/ai/src/composer.test.ts | 292 ++++++++++++++++
packages/ai/src/composer.ts | 297 ++++++++++++++++
packages/ai/src/draft.test.ts | 184 ++++++++++
packages/ai/src/draft.ts | 239 +++++++++++++
packages/ai/src/index.ts | 41 +++
packages/ai/src/model.ts | 143 ++++++++
tsconfig.json | 4 +-
22 files changed, 2353 insertions(+), 32 deletions(-)
create mode 100644 packages/ai/package.json
create mode 100644 packages/ai/src/checks.test.ts
create mode 100644 packages/ai/src/checks.ts
create mode 100644 packages/ai/src/composer.test.ts
create mode 100644 packages/ai/src/composer.ts
create mode 100644 packages/ai/src/draft.test.ts
create mode 100644 packages/ai/src/draft.ts
create mode 100644 packages/ai/src/index.ts
create mode 100644 packages/ai/src/model.ts
diff --git a/.env.example b/.env.example
index d89deb7..cb06652 100644
--- a/.env.example
+++ b/.env.example
@@ -39,7 +39,13 @@ X_API_SECRET=
# ---------------------------------------------------------------------- LLM
# Never exposed to the browser, and never included in a model prompt alongside
# customer OAuth tokens (PRD §34).
+#
+# Optional. Unset, the composer is disabled: signals, resolution, scoring and
+# the approval queue all still run, and the reviewer writes the message. It is
+# never consulted for a policy, identity or scoring decision (PRD §1.1.8).
ANTHROPIC_API_KEY=
+# Overrides the default model (claude-opus-5).
+ANTHROPIC_MODEL=
# -------------------------------------------------------------------- queue
# Optional until queued jobs need durability.
diff --git a/README.md b/README.md
index abb2504..10e5fb7 100644
--- a/README.md
+++ b/README.md
@@ -9,10 +9,10 @@ platform, the prospect, or your own rate limits say it should not.
## Status
-Foundation. The deterministic core, the API, the PWA and one live provider are
-built and tested; a prospect can go from a GitHub handle to a card in the
-approval queue today. The LLM layer — the outreach composer and the rest of the
-§20 agent suite — is not built. See
+Foundation. The deterministic core, the API, the PWA, one live provider and the
+outreach composer are built and tested; a prospect can go from a GitHub handle
+to a drafted, checked card in the approval queue today. The rest of the §20
+agent suite is not built. See
[`docs/prd-implementation-map.md`](docs/prd-implementation-map.md) for exactly
what exists.
@@ -43,6 +43,7 @@ apps/
web/ Next.js 16 mobile-first PWA
worker/ background jobs: signal expiry, rescoring, privacy work
packages/
+ ai/ the only package that talks to a model: composer + quality gates
domain/ canonical types — depends on nothing
db/ Turso/libSQL client and migration runner
policy/ the deterministic policy engine
@@ -88,6 +89,15 @@ state. Suppression, a flipped feature flag, a spent rate limit, or a dropped
identity confidence all block an approval that would have been fine yesterday,
and the response names the gate that stopped it.
+**A draft that fails its checks is withheld, not shown with a warning.** The
+composer only ever sees stored evidence, stored facts and your own offering —
+there is no path by which it learns something it may not cite. Its output then
+runs deterministic gates: every specific assertion must appear in that
+evidence, or the draft is rejected and rewritten once naming the exact invented
+fragments. Still failing, nothing is shown. The card keeps the prospect, the
+evidence and the recommended action, and you write the message. A bad draft
+next to a caveat is still a bad draft someone might approve.
+
**Evidence combines with noisy-OR, so weak signals never reach certainty.**
Identity resolution and intent scoring both use `1 - Π(1 - eᵢ)`. Two 0.5
observations give 0.75, not 1.0. "Same name, same city" — the classic
diff --git a/apps/api/package.json b/apps/api/package.json
index 340eb8c..61e4dff 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -19,6 +19,7 @@
"@outreachgraph/scoring": "workspace:*",
"@outreachgraph/signals": "workspace:*",
"hono": "^4.6.14",
- "zod": "^3.24.1"
+ "zod": "^3.24.1",
+ "@outreachgraph/ai": "workspace:*"
}
}
diff --git a/apps/api/src/app.test.ts b/apps/api/src/app.test.ts
index 6090eab..6f0c21d 100644
--- a/apps/api/src/app.test.ts
+++ b/apps/api/src/app.test.ts
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, test } from 'bun:test';
import type { Hono } from 'hono';
+import { StubModel } from '@outreachgraph/ai';
import { createApp } from './app';
import type { AppEnv, RequestActor } from './context';
import { seedDatabase, SEED, type SeededDatabase } from './test-seed';
@@ -484,3 +485,103 @@ describe('errors', () => {
expect(response.status).toBe(400);
});
});
+
+describe('drafting on demand (PRD §14)', () => {
+ /** The seed's evidence is a cross-border payouts complaint. */
+ const GROUNDED = 'Settlement taking days on cross-border payouts was our problem too.';
+
+ async function withModel(
+ label: string,
+ responses: string | readonly string[],
+ ): Promise> {
+ const seeded = await seedDatabase(label);
+ active = seeded;
+
+ return createApp({
+ db: seeded.db,
+ authenticate: async () => ACTOR,
+ model: new StubModel(responses),
+ });
+ }
+
+ test('returns 503 when no model is configured', async () => {
+ const { app } = await harness('draft-nomodel');
+ const response = await post(app, `/recommendations/${SEED.recommendationId}/draft`);
+
+ expect(response.status).toBe(503);
+ expect((await response.json()).error.code).toBe('composer_unavailable');
+ });
+
+ test('composes a grounded message and returns it', async () => {
+ const app = await withModel('draft-ok', GROUNDED);
+ const response = await post(app, `/recommendations/${SEED.recommendationId}/draft`);
+
+ expect(response.status).toBe(200);
+ const body = await response.json();
+ expect(body.drafted).toBe(true);
+ expect(body.body).toBe(GROUNDED);
+ });
+
+ test('replaces the previous draft rather than stacking a second one', async () => {
+ const app = await withModel('draft-replace', GROUNDED);
+ await post(app, `/recommendations/${SEED.recommendationId}/draft`);
+ await post(app, `/recommendations/${SEED.recommendationId}/draft`);
+
+ const rows = await active!.db.execute({
+ sql: 'SELECT count(*) AS n FROM drafts WHERE recommendation_id = ?',
+ args: [SEED.recommendationId],
+ });
+ expect(Number(rows.rows[0]?.n)).toBe(1);
+ });
+
+ test('reports a withheld draft instead of surfacing an invented one', async () => {
+ const app = await withModel('draft-withheld', [
+ 'On cross-border payouts, Fluxwire solved this for us.',
+ 'Your payouts note — we moved to Fluxwire.',
+ ]);
+
+ const response = await post(app, `/recommendations/${SEED.recommendationId}/draft`);
+
+ // Not an error: refusing to write is a normal, expected answer.
+ expect(response.status).toBe(200);
+ const body = await response.json();
+ expect(body.drafted).toBe(false);
+ expect(body.reason).toBe('failed_checks');
+ expect(body.unsupported).toContain('Fluxwire');
+ expect(body.body).toBeUndefined();
+ });
+
+ test('a withheld draft is audited', async () => {
+ const app = await withModel('draft-audit', [
+ 'On cross-border payouts, Fluxwire solved this for us.',
+ 'Your payouts note — we moved to Fluxwire.',
+ ]);
+ await post(app, `/recommendations/${SEED.recommendationId}/draft`);
+
+ const rows = await active!.db.execute(
+ "SELECT count(*) AS n FROM audit_events WHERE event_type = 'draft.withheld'",
+ );
+ expect(Number(rows.rows[0]?.n)).toBe(1);
+ });
+
+ test('an unknown recommendation is a 404', async () => {
+ const app = await withModel('draft-404', GROUNDED);
+ expect((await post(app, '/recommendations/rec_missing/draft')).status).toBe(404);
+ });
+
+ test('a draft the user edited is never discarded by a recompose', async () => {
+ const app = await withModel('draft-edited', GROUNDED);
+ await active!.db.execute({
+ sql: 'UPDATE drafts SET edited_by_user = 1 WHERE id = ?',
+ args: [SEED.draftId],
+ });
+
+ await post(app, `/recommendations/${SEED.recommendationId}/draft`);
+
+ const rows = await active!.db.execute({
+ sql: 'SELECT edited_by_user FROM drafts WHERE id = ?',
+ args: [SEED.draftId],
+ });
+ expect(rows.rows).toHaveLength(1);
+ });
+});
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index c2e06f0..dff5e6e 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -33,6 +33,7 @@ import {
workspacesForUser,
} from './auth';
import { evaluatePolicy, isExecutable, POLICY_VERSION } from '@outreachgraph/policy';
+import { draftForRecommendation, type TextModel } from '@outreachgraph/ai';
import { ApiError, canApprove, type AppEnv, type RequestActor } from './context';
import * as repo from './repository';
@@ -50,6 +51,11 @@ export interface AppOptions {
readonly serviceToken?: string;
/** Set false for plain-HTTP local development so the cookie still sets. */
readonly secureCookies?: boolean;
+ /**
+ * Writes outreach drafts. Omit to run without a language model — every
+ * other route works unchanged and drafting returns 503.
+ */
+ readonly model?: TextModel;
readonly version?: string;
readonly commitHash?: string;
}
@@ -451,6 +457,66 @@ export function createApp(options: AppOptions): Hono {
});
});
+ /**
+ * Compose (or recompose) the message for a recommendation.
+ *
+ * Separate from generation because a draft is optional: the pipeline places
+ * the card in the queue whether or not the composer produced anything, and
+ * the reviewer can ask for one here. A refusal to write is a normal answer,
+ * returned with the reason and the specific fragments that failed grounding
+ * — the alternative is showing an invented message, which is worse.
+ */
+ api.post('/recommendations/:id/draft', async (c) => {
+ const actor = c.get('actor');
+ const db = c.get('db');
+
+ if (!options.model) {
+ throw new ApiError(
+ 503,
+ 'composer_unavailable',
+ 'no language model is configured; set ANTHROPIC_API_KEY to enable drafting',
+ );
+ }
+
+ const recommendation = await repo.getRecommendation(db, actor.workspaceId, c.req.param('id'));
+ if (!recommendation) throw ApiError.notFound('recommendation');
+
+ // Recomposing replaces the previous draft; the writer asked for a rewrite.
+ await db.execute({
+ sql: 'DELETE FROM drafts WHERE recommendation_id = ? AND edited_by_user = 0',
+ args: [recommendation.id],
+ });
+
+ const result = await draftForRecommendation(db, options.model, recommendation.id);
+
+ if (!result.ok) {
+ await repo.audit(db, {
+ workspaceId: actor.workspaceId,
+ actorKind: 'user',
+ actorId: actor.userId,
+ eventType: 'draft.withheld',
+ entityKind: 'recommendation',
+ entityId: recommendation.id,
+ detail: { reason: result.reason, unsupported: result.unsupported ?? [] },
+ });
+
+ return c.json(
+ {
+ drafted: false,
+ reason: result.reason,
+ ...(result.unsupported ? { unsupported: result.unsupported } : {}),
+ },
+ 200,
+ );
+ }
+
+ const draft = await queryOne<{ body: string }>(db, 'SELECT body FROM drafts WHERE id = ?', [
+ result.draftId!,
+ ]);
+
+ return c.json({ drafted: true, draftId: result.draftId, body: draft?.body });
+ });
+
api.post('/recommendations/:id/skip', async (c) => {
const actor = c.get('actor');
const db = c.get('db');
diff --git a/apps/server/package.json b/apps/server/package.json
index d2ba3fd..48d15bc 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -19,6 +19,7 @@
"@outreachgraph/scoring": "workspace:*",
"@outreachgraph/signals": "workspace:*",
"hono": "^4.6.14",
- "zod": "^3.24.1"
+ "zod": "^3.24.1",
+ "@outreachgraph/ai": "workspace:*"
}
}
diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts
index 69687d3..8dc38ec 100644
--- a/apps/server/src/index.ts
+++ b/apps/server/src/index.ts
@@ -20,6 +20,7 @@
*/
import { closeDatabase, getDatabase, migrate, queryAll } from '@outreachgraph/db';
+import { ClaudeModel } from '@outreachgraph/ai';
import { createApp } from '../../api/src/app';
import { pruneSessions } from '../../api/src/auth';
import { expireSignals, processDeletion } from '../../worker/src/jobs';
@@ -60,9 +61,27 @@ if (process.env.RUN_MIGRATIONS !== 'false') {
}
}
+// -------------------------------------------------------------------- model
+/**
+ * The composer is optional infrastructure.
+ *
+ * Without a key the product still works: signals are ingested, prospects
+ * resolved, recommendations scored and queued — the reviewer writes the
+ * message. Refusing to boot over a missing key would take the whole system
+ * down for a feature that is, by design, allowed to produce nothing.
+ */
+const model = process.env.ANTHROPIC_API_KEY
+ ? new ClaudeModel({
+ ...(process.env.ANTHROPIC_MODEL ? { model: process.env.ANTHROPIC_MODEL } : {}),
+ })
+ : undefined;
+
+if (!model) console.log('no ANTHROPIC_API_KEY: drafting disabled, queue still runs');
+
// ---------------------------------------------------------------------- api
const api = createApp({
db,
+ ...(model ? { model } : {}),
...(process.env.API_TOKEN ? { serviceToken: process.env.API_TOKEN } : {}),
// Cookies must not be Secure over plain HTTP, or local development can
// never hold a session.
diff --git a/apps/web/components/approval-card.tsx b/apps/web/components/approval-card.tsx
index 61f0006..bdf6190 100644
--- a/apps/web/components/approval-card.tsx
+++ b/apps/web/components/approval-card.tsx
@@ -18,13 +18,84 @@ import type { ApprovalCard as Card } from '../lib/types';
* policy gate that stopped it, and that reason is shown verbatim. Silently
* dropping it would leave the user thinking they had sent something.
*/
+/**
+ * Why no draft was written, in the reviewer's language.
+ *
+ * The reason is shown rather than hidden behind "try again", because every
+ * one of these is a fact about the evidence: retrying will not change it, and
+ * the honest next step is for the reviewer to write the message themselves.
+ */
+function explainWithholding(reason: string, unsupported?: string[]): string {
+ switch (reason) {
+ case 'no_evidence':
+ return 'No draft: nothing quotable was captured for this signal, and a personalised message with nothing behind it is worse than none.';
+ case 'no_trigger_signal':
+ return 'No draft: this recommendation has no signal to reference.';
+ case 'failed_checks':
+ return unsupported?.length
+ ? `No draft: the wording kept asserting things nothing supports — ${unsupported.join(', ')}.`
+ : 'No draft: the wording did not pass the quality checks.';
+ case 'model_refused':
+ case 'empty':
+ return 'No draft: the writer declined to produce one.';
+ default:
+ return `No draft (${reason}).`;
+ }
+}
+
export function ApprovalCard({ card }: { card: Card }) {
const router = useRouter();
const [busy, setBusy] = useState();
const [error, setError] = useState();
const [body, setBody] = useState(card.draft_body ?? '');
+ // What the composer produced, so an approval can tell an edit from the
+ // original rather than reporting every composed message as user-written.
+ const [original, setOriginal] = useState(card.draft_body ?? '');
+ const [withheld, setWithheld] = useState();
const [editing, setEditing] = useState(false);
+ /**
+ * Ask the composer for wording.
+ *
+ * A withheld draft comes back 200 with a reason — it is the designed
+ * outcome when nothing in the evidence supports a message, not an error.
+ * It is shown as a note so the reviewer knows to write it themselves.
+ */
+ async function compose() {
+ setBusy('draft');
+ setError(undefined);
+ setWithheld(undefined);
+
+ try {
+ const response = await fetch(`/api/v1/recommendations/${card.id}/draft`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ credentials: 'same-origin',
+ body: '{}',
+ });
+
+ const payload = await response.json().catch(() => ({}));
+
+ if (!response.ok) {
+ setError(payload?.error?.message ?? `that failed (${response.status})`);
+ return;
+ }
+
+ if (!payload.drafted) {
+ setWithheld(explainWithholding(payload.reason, payload.unsupported));
+ return;
+ }
+
+ setBody(payload.body);
+ setOriginal(payload.body);
+ router.refresh();
+ } catch {
+ setError('could not reach the server');
+ } finally {
+ setBusy(undefined);
+ }
+ }
+
async function act(action: 'approve' | 'skip', payload?: Record) {
setBusy(action);
setError(undefined);
@@ -108,25 +179,44 @@ export function ApprovalCard({ card }: { card: Card }) {
- {card.draft_body ? (
-
+
{error ? (
@@ -138,9 +228,7 @@ export function ApprovalCard({ card }: { card: Card }) {
- act('approve', editing && body !== card.draft_body ? { editedBody: body } : {})
- }
+ onClick={() => act('approve', body && body !== original ? { editedBody: body } : {})}
className="bg-accent rounded-xl py-2 text-sm font-medium text-white disabled:opacity-60"
>
{busy === 'approve' ? 'Approving…' : 'Approve'}
@@ -148,7 +236,7 @@ export function ApprovalCard({ card }: { card: Card }) {
setEditing((v) => !v)}
className="border-border rounded-xl border py-2 text-sm font-medium disabled:opacity-40"
>
diff --git a/apps/worker/package.json b/apps/worker/package.json
index d6c4275..74e7da2 100644
--- a/apps/worker/package.json
+++ b/apps/worker/package.json
@@ -18,6 +18,7 @@
"@outreachgraph/providers": "workspace:*",
"@outreachgraph/scoring": "workspace:*",
"@outreachgraph/signals": "workspace:*",
- "@outreachgraph/recommend": "workspace:*"
+ "@outreachgraph/recommend": "workspace:*",
+ "@outreachgraph/ai": "workspace:*"
}
}
diff --git a/apps/worker/src/pipeline.ts b/apps/worker/src/pipeline.ts
index 4c0f2ec..3f8c9bc 100644
--- a/apps/worker/src/pipeline.ts
+++ b/apps/worker/src/pipeline.ts
@@ -23,6 +23,7 @@ import {
type PersonEnrichmentProvider,
} from '@outreachgraph/providers';
import { generateRecommendation, type CandidateSignal } from '@outreachgraph/recommend';
+import { draftForRecommendation, type TextModel } from '@outreachgraph/ai';
import { rescoreProspect } from './jobs';
export interface PipelineOptions {
@@ -32,6 +33,11 @@ export interface PipelineOptions {
readonly providers: readonly PersonEnrichmentProvider[];
/** Supplies GitHub activity. Omit to skip signal collection. */
readonly github?: GitHubProvider;
+ /**
+ * Writes the message. Omit to run the pipeline with no LLM at all — the
+ * recommendation still reaches the queue, just without a draft.
+ */
+ readonly model?: TextModel;
readonly now?: Date;
}
@@ -41,6 +47,7 @@ export interface PipelineResult {
readonly identitiesLinked: number;
readonly signalsStored: number;
readonly recommendationId?: string;
+ readonly draftId?: string;
readonly stoppedBecause?: string;
}
@@ -124,6 +131,17 @@ export async function runPipeline(
};
}
+ // ---------------------------------------------------------------- draft
+ // A failed or absent draft is not a pipeline failure. The card still shows
+ // the prospect, the evidence and the recommended action; the reviewer
+ // writes the message. That beats showing a fabricated one.
+ let draftId: string | undefined;
+ if (options.model) {
+ const draft = await draftForRecommendation(db, options.model, recommendationId);
+ if (draft.ok) draftId = draft.draftId;
+ else console.log(`no draft for ${recommendationId}: ${draft.reason}`);
+ }
+
await setStatus(db, campaignId, personId, 'awaiting_approval');
return {
@@ -132,6 +150,7 @@ export async function runPipeline(
identitiesLinked: linked,
signalsStored: stored,
recommendationId,
+ ...(draftId ? { draftId } : {}),
};
}
diff --git a/bun.lock b/bun.lock
index 3bfd730..2a4131b 100644
--- a/bun.lock
+++ b/bun.lock
@@ -14,6 +14,7 @@
"name": "@outreachgraph/api",
"version": "0.1.0",
"dependencies": {
+ "@outreachgraph/ai": "workspace:*",
"@outreachgraph/contracts": "workspace:*",
"@outreachgraph/db": "workspace:*",
"@outreachgraph/domain": "workspace:*",
@@ -29,6 +30,7 @@
"name": "@outreachgraph/server",
"version": "0.1.0",
"dependencies": {
+ "@outreachgraph/ai": "workspace:*",
"@outreachgraph/contracts": "workspace:*",
"@outreachgraph/db": "workspace:*",
"@outreachgraph/domain": "workspace:*",
@@ -63,6 +65,7 @@
"name": "@outreachgraph/worker",
"version": "0.1.0",
"dependencies": {
+ "@outreachgraph/ai": "workspace:*",
"@outreachgraph/db": "workspace:*",
"@outreachgraph/domain": "workspace:*",
"@outreachgraph/identity": "workspace:*",
@@ -73,6 +76,15 @@
"@outreachgraph/signals": "workspace:*",
},
},
+ "packages/ai": {
+ "name": "@outreachgraph/ai",
+ "version": "0.1.0",
+ "dependencies": {
+ "@anthropic-ai/sdk": "^0.70.0",
+ "@outreachgraph/db": "workspace:*",
+ "@outreachgraph/domain": "workspace:*",
+ },
+ },
"packages/contracts": {
"name": "@outreachgraph/contracts",
"version": "0.1.0",
@@ -147,6 +159,10 @@
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
+ "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.70.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-AGEhifuvE22VxfQ5ROxViTgM8NuVQzEvqcN8bttR4AP24ythmNE/cL/SrOz79xiv7/osrsmCyErjsistJi7Z8A=="],
+
+ "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
+
"@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
@@ -261,6 +277,8 @@
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA=="],
+ "@outreachgraph/ai": ["@outreachgraph/ai@workspace:packages/ai"],
+
"@outreachgraph/api": ["@outreachgraph/api@workspace:apps/api"],
"@outreachgraph/contracts": ["@outreachgraph/contracts@workspace:packages/contracts"],
@@ -357,6 +375,8 @@
"js-base64": ["js-base64@3.9.2", "", {}, "sha512-6zayE8QlUdiweYI6cETD/XBSqFcoCUlufn/29PJR99r82x1yDnIprRca0YvAYpAW+ez0GuQkVBC6xG5QkD7OjA=="],
+ "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
+
"libsql": ["libsql@0.5.29", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.29", "@libsql/darwin-x64": "0.5.29", "@libsql/linux-arm-gnueabihf": "0.5.29", "@libsql/linux-arm-musleabihf": "0.5.29", "@libsql/linux-arm64-gnu": "0.5.29", "@libsql/linux-arm64-musl": "0.5.29", "@libsql/linux-x64-gnu": "0.5.29", "@libsql/linux-x64-musl": "0.5.29", "@libsql/win32-x64-msvc": "0.5.29" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
@@ -419,6 +439,8 @@
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
+ "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
+
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
diff --git a/docs/prd-implementation-map.md b/docs/prd-implementation-map.md
index 6fafd03..8923cf8 100644
--- a/docs/prd-implementation-map.md
+++ b/docs/prd-implementation-map.md
@@ -22,7 +22,8 @@ Where each part of the V1 PRD lives. Code comments cite section numbers
| §12.1–12.6 Scoring | `packages/scoring/src/scores.ts`, `weights.ts` | 31 tests |
| §13.1 Action kinds | `packages/domain/src/networks.ts` | — |
| §13.2 Recommendation schema | `packages/domain/src/outreach.ts` | — |
-| §14.2 Quality checks (types) | `packages/domain/src/outreach.ts` | — |
+| §14.1 Outreach composer | `packages/ai/src/composer.ts`, `model.ts`, `draft.ts` | 29 tests |
+| §14.2 Quality checks | `packages/ai/src/checks.ts`, `packages/domain/src/outreach.ts` | 40 tests |
| §15 Approval queue | `apps/api` `GET /recommendations` | `apps/api/src/app.test.ts` |
| §16 Platform policy | `packages/policy/src/capability-matrix.ts` | 46 tests |
| §16.2 Policy modes | `packages/policy/src/capability-matrix.ts` | included above |
@@ -57,13 +58,11 @@ Where each part of the V1 PRD lives. Code comments cite section numbers
## Not started
- §7 Campaign wizard UI.
-- §14 Outreach composer. Recommendations reach the queue with a grounded reason and a `groundedSignalIds` allow-list, but nothing writes the message body yet — this is where the first LLM enters the product.
-- §20 agent suite beyond the Strategy Agent (ICP, discovery, research, intent, composer).
+- §20 agent suite beyond the Strategy Agent and the composer (ICP, discovery, research, intent).
- §10 Remaining provider adapters — Apollo, PDL, Bluesky, X.
- §26 Natural-language search.
- §28 CRM and Slack integrations.
- §36 Admin surface.
-- Authentication beyond the injected `authenticate` hook.
## Deliberate deviations
diff --git a/packages/ai/package.json b/packages/ai/package.json
new file mode 100644
index 0000000..836a305
--- /dev/null
+++ b/packages/ai/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "@outreachgraph/ai",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "scripts": {
+ "typecheck": "tsc --noEmit",
+ "test": "bun test"
+ },
+ "dependencies": {
+ "@anthropic-ai/sdk": "^0.70.0",
+ "@outreachgraph/domain": "workspace:*",
+ "@outreachgraph/db": "workspace:*"
+ }
+}
diff --git a/packages/ai/src/checks.test.ts b/packages/ai/src/checks.test.ts
new file mode 100644
index 0000000..1023ff0
--- /dev/null
+++ b/packages/ai/src/checks.test.ts
@@ -0,0 +1,307 @@
+import { describe, expect, test } from 'bun:test';
+import {
+ extractClaims,
+ failedChecks,
+ findUnsupportedClaims,
+ runChecks,
+ similarityFingerprint,
+ type CheckInput,
+ type GroundingContext,
+} from './checks';
+
+const GROUNDING: GroundingContext = {
+ evidence: [
+ 'Does anyone have a good alternative to Stripe for cross-border payouts? ' +
+ 'Fees are brutal and settlement takes days.',
+ ],
+ facts: ['Jane Smith', 'Jane', 'VP Engineering', 'Acme', 'x'],
+ offering: [
+ 'ExamplePay',
+ 'developer payments infrastructure',
+ 'reduce payment integration time',
+ 'support crypto and fiat',
+ 'Stripe',
+ ],
+};
+
+function input(overrides: Partial = {}): CheckInput {
+ return {
+ body: 'Saw your note about cross-border settlement taking days. We hit the same thing.',
+ grounding: GROUNDING,
+ identityConfidence: 0.97,
+ minIdentityConfidence: 0.85,
+ ...overrides,
+ };
+}
+
+describe('grounding (PRD §14.1)', () => {
+ test('accepts a message built only from the evidence', () => {
+ const report = runChecks(input());
+ expect(report.passed).toBe(true);
+ expect(report.unsupported).toHaveLength(0);
+ });
+
+ test('rejects an invented product name', () => {
+ const report = runChecks(
+ input({ body: 'Saw your note on payouts. We built Fluxwire to solve exactly this.' }),
+ );
+
+ expect(report.passed).toBe(false);
+ expect(failedChecks(report)).toContain('unsupported_claim');
+ expect(report.unsupported).toContain('Fluxwire');
+ });
+
+ test('rejects an invented statistic', () => {
+ const report = runChecks(
+ input({ body: 'Saw your note on payouts. Teams like yours cut fees by 40% with us.' }),
+ );
+
+ expect(report.passed).toBe(false);
+ expect(report.unsupported).toContain('40%');
+ });
+
+ test('rejects a fabricated article the person never wrote', () => {
+ // The canonical failure from PRD §14.1.
+ const report = runChecks(
+ input({ body: 'I loved your recent article about payments infrastructure.' }),
+ );
+
+ expect(report.passed).toBe(false);
+ });
+
+ test('permits a competitor the offering already names', () => {
+ const report = runChecks(
+ input({ body: 'Saw your Stripe note. Cross-border settlement was our pain too.' }),
+ );
+
+ expect(report.unsupported).not.toContain('Stripe');
+ });
+
+ test('permits the prospect’s own name and employer', () => {
+ const report = runChecks(
+ input({ body: 'Jane, your note on cross-border payouts at Acme rings true.' }),
+ );
+
+ expect(report.unsupported).toHaveLength(0);
+ });
+
+ test('flags a message that cites nothing at all', () => {
+ const report = runChecks(input({ body: 'Hi there, would you be open to a chat this week?' }));
+
+ expect(report.passed).toBe(false);
+ expect(failedChecks(report)).toContain('grounding');
+ });
+
+ test('is not fooled by paraphrase that still invents a specific', () => {
+ const report = runChecks(
+ input({ body: 'Your settlement delays sound painful. Our Ledger Bridge fixes them.' }),
+ );
+
+ expect(report.unsupported.length).toBeGreaterThan(0);
+ });
+});
+
+describe('claim extraction', () => {
+ test('picks out mid-sentence capitalised terms', () => {
+ expect(extractClaims('we tried Fluxwire and it worked')).toContain('Fluxwire');
+ });
+
+ test('ignores the first word of a sentence', () => {
+ // Sentence-initial capitals carry no information.
+ expect(extractClaims('Payments are hard.')).not.toContain('Payments');
+ });
+
+ test('picks out numbers and percentages', () => {
+ const claims = extractClaims('we saw 40% lower fees across 12 markets');
+ expect(claims).toContain('40%');
+ expect(claims).toContain('12');
+ });
+
+ test('picks out quoted phrases', () => {
+ expect(extractClaims('you said "fees are brutal" last week')).toContain('fees are brutal');
+ });
+
+ test('ignores common words even when capitalised', () => {
+ expect(extractClaims('the thing is Thanks matters')).not.toContain('Thanks');
+ });
+});
+
+describe('flattery (PRD §13.3)', () => {
+ test.each([
+ 'I loved your recent post about payouts.',
+ 'Big fan of your work on cross-border settlement.',
+ 'Your amazing thread on settlement delays caught my eye.',
+ 'Love what you are doing with payouts.',
+ ])('rejects %s', (body) => {
+ const report = runChecks(input({ body }));
+ expect(failedChecks(report)).toContain('excessive_flattery');
+ });
+
+ test('allows a plain factual opener', () => {
+ const report = runChecks(
+ input({ body: 'Your note on cross-border settlement caught my eye.' }),
+ );
+ expect(failedChecks(report)).not.toContain('excessive_flattery');
+ });
+});
+
+describe('spam patterns (PRD §18)', () => {
+ test.each([
+ 'Quick question about your cross-border settlement setup.',
+ 'Limited time offer on cross-border settlement.',
+ 'Just bumping this on cross-border settlement.',
+ 'Circling back on cross-border settlement.',
+ ])('rejects %s', (body) => {
+ expect(failedChecks(runChecks(input({ body })))).toContain('spam_pattern');
+ });
+
+ test('rejects a draft over the channel limit', () => {
+ const report = runChecks(
+ input({ body: `cross-border settlement ${'x'.repeat(300)}`, maxLength: 280 }),
+ );
+
+ expect(failedChecks(report)).toContain('spam_pattern');
+ });
+
+ test('rejects multiple links', () => {
+ const report = runChecks(
+ input({
+ body: 'Your cross-border settlement note: https://a.example and https://b.example',
+ }),
+ );
+
+ expect(failedChecks(report)).toContain('spam_pattern');
+ });
+
+ test('allows one link', () => {
+ const report = runChecks(
+ input({ body: 'On cross-border settlement, this helped us: https://a.example' }),
+ );
+
+ expect(failedChecks(report)).not.toContain('spam_pattern');
+ });
+});
+
+describe('sensitive categories (PRD §17.4)', () => {
+ test.each([
+ 'Saw your cross-border settlement note after your diagnosis.',
+ 'Congrats on the maternity leave — also, cross-border settlement.',
+ 'Sorry you were laid off. On cross-border settlement:',
+ ])('rejects %s', (body) => {
+ expect(failedChecks(runChecks(input({ body })))).toContain('sensitive_topic');
+ });
+
+ test('rejects a claim the customer prohibited', () => {
+ const report = runChecks(
+ input({
+ body: 'Your cross-border settlement note — we are the cheapest option anywhere.',
+ prohibitedClaims: ['cheapest'],
+ }),
+ );
+
+ expect(failedChecks(report)).toContain('sensitive_topic');
+ });
+});
+
+describe('identity confidence (PRD §48 Decision 4)', () => {
+ test('rejects a draft for a prospect below the threshold', () => {
+ const report = runChecks(input({ identityConfidence: 0.6, minIdentityConfidence: 0.85 }));
+ expect(failedChecks(report)).toContain('identity_confidence');
+ });
+
+ test('accepts exactly at the threshold', () => {
+ const report = runChecks(input({ identityConfidence: 0.85, minIdentityConfidence: 0.85 }));
+ expect(failedChecks(report)).not.toContain('identity_confidence');
+ });
+});
+
+describe('duplicate detection (PRD §18)', () => {
+ test('two drafts differing only by name share a fingerprint', () => {
+ const a = similarityFingerprint('Jane, your note on cross-border settlement rings true.', [
+ 'Jane',
+ 'Alex',
+ ]);
+ const b = similarityFingerprint('Alex, your note on cross-border settlement rings true.', [
+ 'Jane',
+ 'Alex',
+ ]);
+
+ // The classic mail-merge: this is exactly what must be caught.
+ expect(a).toBe(b);
+ });
+
+ test('genuinely different messages differ', () => {
+ const a = similarityFingerprint('Your note on cross-border settlement rings true.');
+ const b = similarityFingerprint('We published a teardown of payout latency last month.');
+
+ expect(a).not.toBe(b);
+ });
+
+ test('word order does not change the fingerprint', () => {
+ expect(similarityFingerprint('settlement delays payouts')).toBe(
+ similarityFingerprint('payouts settlement delays'),
+ );
+ });
+
+ test('rejects a draft matching one already sent', () => {
+ const body = 'Your note on cross-border settlement rings true.';
+ const report = runChecks(input({ body, priorDraftHashes: [similarityFingerprint(body)] }));
+
+ expect(failedChecks(report)).toContain('duplicate_similarity');
+ });
+});
+
+describe('report shape', () => {
+ test('covers every PRD §14.2 gate', () => {
+ const report = runChecks(input());
+ const checks = report.results.map((r) => r.check).sort();
+
+ expect(checks).toEqual([
+ 'duplicate_similarity',
+ 'excessive_flattery',
+ 'grounding',
+ 'identity_confidence',
+ 'policy',
+ 'sensitive_topic',
+ 'spam_pattern',
+ 'unsupported_claim',
+ ]);
+ });
+
+ test('every failure carries an explanation', () => {
+ const report = runChecks(input({ body: 'I loved your Fluxwire post — act now for 90% off!' }));
+
+ for (const result of report.results) {
+ if (!result.passed) expect(result.detail).toBeTruthy();
+ }
+ });
+
+ test('is deterministic', () => {
+ const state = input();
+ const first = runChecks(state);
+ for (let i = 0; i < 20; i += 1) {
+ expect(runChecks(state)).toEqual(first);
+ }
+ });
+});
+
+describe('findUnsupportedClaims', () => {
+ test('separates supported from unsupported', () => {
+ const { unsupported, allowed } = findUnsupportedClaims(
+ 'Jane, your Stripe note matches what we saw at Fluxwire.',
+ GROUNDING,
+ );
+
+ expect(allowed).toContain('Stripe');
+ expect(unsupported).toContain('Fluxwire');
+ });
+
+ test('reports nothing for prose with no specifics', () => {
+ const { unsupported } = findUnsupportedClaims(
+ 'we ran into something very similar and it took a while to sort out',
+ GROUNDING,
+ );
+
+ expect(unsupported).toHaveLength(0);
+ });
+});
diff --git a/packages/ai/src/checks.ts b/packages/ai/src/checks.ts
new file mode 100644
index 0000000..0b9766f
--- /dev/null
+++ b/packages/ai/src/checks.ts
@@ -0,0 +1,465 @@
+/**
+ * Message quality gates (PRD §14.1, §14.2).
+ *
+ * Every check here is deterministic. A model must never be the thing that
+ * decides whether its own output is grounded — that is the same mistake as
+ * letting an LLM decide policy, and it fails in exactly the way you cannot
+ * detect: confidently.
+ *
+ * The grounding check is the load-bearing one. It works by inverting the
+ * usual question. Rather than asking "is this claim true?" — undecidable — it
+ * asks "does every specific assertion in this draft appear in the evidence we
+ * stored?" Anything specific and unsourced is flagged, whether or not it
+ * happens to be true. That is the standard PRD §14.1 sets: no stored evidence,
+ * no claim.
+ */
+
+import type { QualityCheck, QualityCheckResult } from '@outreachgraph/domain';
+
+export interface GroundingContext {
+ /** Verbatim excerpts from the signals this draft may cite. */
+ readonly evidence: readonly string[];
+ /** Facts we hold about the person and their company. */
+ readonly facts: readonly string[];
+ /** The customer's own offering — safe to assert, they wrote it. */
+ readonly offering: readonly string[];
+}
+
+export interface CheckInput {
+ readonly body: string;
+ readonly subject?: string;
+ readonly grounding: GroundingContext;
+ readonly identityConfidence: number;
+ readonly minIdentityConfidence: number;
+ /** Similarity hashes of drafts already sent from this workspace. */
+ readonly priorDraftHashes?: readonly string[];
+ /** Channel ceiling, e.g. 280 for a public reply. */
+ readonly maxLength?: number;
+ /** Claims the customer has forbidden, from the voice profile. */
+ readonly prohibitedClaims?: readonly string[];
+}
+
+export interface CheckReport {
+ readonly results: readonly QualityCheckResult[];
+ readonly passed: boolean;
+ /** Set when grounding failed; the specific unsupported fragments. */
+ readonly unsupported: readonly string[];
+ readonly similarityHash: string;
+}
+
+/**
+ * Flattery the composer must not produce (PRD §13.3).
+ *
+ * These are the phrases that signal a message was generated rather than
+ * written — the "I loved your recent post" register, which reads as false
+ * familiarity precisely because it is.
+ */
+const FLATTERY_PATTERNS: readonly RegExp[] = [
+ /\bi (?:loved|really enjoyed|absolutely loved)\b/i,
+ /\bbig fan of\b/i,
+ /\bhuge fan\b/i,
+ /\byour (?:amazing|incredible|fantastic|brilliant|inspiring)\b/i,
+ /\blove what you(?:'re| are) doing\b/i,
+ /\bfollowing your work\b/i,
+ /\bcame across your (?:profile|work) and was (?:impressed|blown away)\b/i,
+];
+
+/** Manipulative urgency and mass-mail tells (PRD §13.3, §18). */
+const SPAM_PATTERNS: readonly RegExp[] = [
+ /\bact (?:now|fast)\b/i,
+ /\blimited time\b/i,
+ /\bdon'?t miss out\b/i,
+ /\bexclusive offer\b/i,
+ /\b(?:100%|guaranteed) (?:free|results)\b/i,
+ /\bquick question\b/i,
+ /\bjust bumping this\b/i,
+ /\bcircling back\b/i,
+ /\bper my last\b/i,
+ /\$\d+[kK]?\s*(?:in|of)\s*(?:savings|revenue)\b/i,
+];
+
+/**
+ * Sensitive categories that must never appear in outbound copy, even when a
+ * public post revealed them (PRD §17.4).
+ */
+const SENSITIVE_PATTERNS: readonly RegExp[] = [
+ /\b(?:diagnos(?:ed|is)|illness|cancer|depression|anxiety|disability|medication)\b/i,
+ /\b(?:pregnan|maternity|paternity)\w*\b/i,
+ /\b(?:divorce|bereave|passed away|funeral)\w*\b/i,
+ /\b(?:church|mosque|synagogue|muslim|christian|jewish|hindu)\b/i,
+ /\b(?:democrat|republican|liberal|conservative|voted for)\b/i,
+ /\b(?:laid off|fired|struggling financially|bankrupt)\b/i,
+ /\b(?:union|striking)\b/i,
+];
+
+/** Words too common to count as a specific claim. */
+const STOPWORDS = new Set([
+ 'the',
+ 'a',
+ 'an',
+ 'and',
+ 'or',
+ 'but',
+ 'if',
+ 'then',
+ 'of',
+ 'to',
+ 'in',
+ 'on',
+ 'at',
+ 'for',
+ 'with',
+ 'from',
+ 'by',
+ 'is',
+ 'are',
+ 'was',
+ 'were',
+ 'be',
+ 'been',
+ 'being',
+ 'it',
+ 'its',
+ 'this',
+ 'that',
+ 'these',
+ 'those',
+ 'as',
+ 'so',
+ 'i',
+ 'we',
+ 'you',
+ 'they',
+ 'he',
+ 'she',
+ 'me',
+ 'us',
+ 'them',
+ 'my',
+ 'our',
+ 'your',
+ 'their',
+ 'his',
+ 'her',
+ 'have',
+ 'has',
+ 'had',
+ 'do',
+ 'does',
+ 'did',
+ 'can',
+ 'could',
+ 'will',
+ 'would',
+ 'should',
+ 'may',
+ 'might',
+ 'must',
+ 'not',
+ 'no',
+ 'yes',
+ 'about',
+ 'into',
+ 'over',
+ 'under',
+ 'after',
+ 'before',
+ 'when',
+ 'while',
+ 'how',
+ 'what',
+ 'saw',
+ 'noticed',
+ 'ran',
+ 'into',
+ 'similar',
+ 'issue',
+ 'one',
+ 'pattern',
+ 'found',
+ 'useful',
+ 'testing',
+ 'team',
+ 'hi',
+ 'hey',
+ 'hello',
+ 'thanks',
+ 'thank',
+ 'cheers',
+ 'best',
+ 'regards',
+ 'happy',
+ 'help',
+ 'worth',
+ 'quick',
+ 'work',
+ 'works',
+ 'working',
+ 'build',
+ 'built',
+ 'building',
+ 'use',
+ 'used',
+ 'using',
+ 'make',
+ 'made',
+ 'get',
+ 'much',
+ 'many',
+ 'more',
+ 'most',
+ 'less',
+ 'same',
+ 'other',
+ 'another',
+ 'also',
+ 'just',
+ 'only',
+ 'still',
+ 'way',
+]);
+
+/**
+ * Distinct content words a draft must share with the evidence to count as
+ * grounded. Two is enough to rule out generic outreach ("would you be open to
+ * a chat?") without demanding the draft quote verbatim.
+ */
+const MIN_EVIDENCE_OVERLAP = 2;
+
+/** Count of distinct meaningful words the draft and the evidence share. */
+export function evidenceOverlap(body: string, evidence: readonly string[]): number {
+ if (evidence.length === 0) return 0;
+
+ const evidenceWords = new Set(
+ normalize(evidence.join(' '))
+ .split(/\s+/)
+ .filter((w) => w.length > 3 && !STOPWORDS.has(w)),
+ );
+
+ const shared = new Set(
+ normalize(body)
+ .split(/\s+/)
+ .filter((w) => w.length > 3 && !STOPWORDS.has(w) && evidenceWords.has(w)),
+ );
+
+ return shared.size;
+}
+
+export function runChecks(input: CheckInput): CheckReport {
+ const results: QualityCheckResult[] = [];
+ // Hash with the per-prospect facts removed, so the same template sent to
+ // different people collides rather than looking unique.
+ const similarityHash = similarityFingerprint(input.body, input.grounding.facts);
+
+ const { unsupported } = findUnsupportedClaims(input.body, input.grounding);
+
+ // Does the draft actually engage with the evidence? Measured as shared
+ // content words, not as extracted claims: a well-grounded message often
+ // paraphrases in plain lowercase prose ("settlement taking days") and has
+ // no capitalised or numeric specifics to extract at all.
+ const overlap = evidenceOverlap(input.body, input.grounding.evidence);
+ const groundingRequired = input.grounding.evidence.length > 0;
+
+ results.push({
+ check: 'grounding',
+ passed: !groundingRequired || overlap >= MIN_EVIDENCE_OVERLAP,
+ ...(groundingRequired && overlap < MIN_EVIDENCE_OVERLAP
+ ? { detail: 'the draft does not reference anything the prospect actually said' }
+ : {}),
+ });
+
+ results.push({
+ check: 'unsupported_claim',
+ passed: unsupported.length === 0,
+ ...(unsupported.length > 0
+ ? { detail: `no stored evidence supports: ${unsupported.slice(0, 5).join(', ')}` }
+ : {}),
+ });
+
+ results.push({
+ check: 'identity_confidence',
+ passed: input.identityConfidence >= input.minIdentityConfidence,
+ ...(input.identityConfidence < input.minIdentityConfidence
+ ? {
+ detail:
+ `identity confidence ${round(input.identityConfidence)} is below ` +
+ `${round(input.minIdentityConfidence)}`,
+ }
+ : {}),
+ });
+
+ const flattery = firstMatch(input.body, FLATTERY_PATTERNS);
+ results.push({
+ check: 'excessive_flattery',
+ passed: flattery === undefined,
+ ...(flattery ? { detail: `reads as manufactured warmth: "${flattery}"` } : {}),
+ });
+
+ const spam = firstMatch(input.body, SPAM_PATTERNS);
+ const tooLong = input.maxLength !== undefined && input.body.length > input.maxLength;
+ const linkCount = (input.body.match(/https?:\/\//g) ?? []).length;
+
+ results.push({
+ check: 'spam_pattern',
+ passed: spam === undefined && !tooLong && linkCount <= 1,
+ ...(spam
+ ? { detail: `spam phrasing: "${spam}"` }
+ : tooLong
+ ? { detail: `${input.body.length} characters exceeds the ${input.maxLength} limit` }
+ : linkCount > 1
+ ? { detail: `${linkCount} links; at most one is credible` }
+ : {}),
+ });
+
+ const duplicate = input.priorDraftHashes?.includes(similarityHash) ?? false;
+ results.push({
+ check: 'duplicate_similarity',
+ passed: !duplicate,
+ ...(duplicate ? { detail: 'this message has already been sent to someone else' } : {}),
+ });
+
+ const prohibited = (input.prohibitedClaims ?? []).find((claim) =>
+ input.body.toLowerCase().includes(claim.toLowerCase()),
+ );
+ const sensitive = firstMatch(input.body, SENSITIVE_PATTERNS);
+
+ results.push({
+ check: 'sensitive_topic',
+ passed: sensitive === undefined && prohibited === undefined,
+ ...(sensitive
+ ? { detail: `references a sensitive category: "${sensitive}"` }
+ : prohibited
+ ? { detail: `contains a prohibited claim: "${prohibited}"` }
+ : {}),
+ });
+
+ // The policy gate is evaluated by the engine, not here. Recording it keeps
+ // the §14.2 list complete and makes the omission visible if it is skipped.
+ results.push({ check: 'policy', passed: true, detail: 'evaluated by the policy engine' });
+
+ return {
+ results,
+ passed: results.every((r) => r.passed),
+ unsupported,
+ similarityHash,
+ };
+}
+
+/**
+ * Splits a draft into the specific assertions it makes, and checks each
+ * against the stored evidence.
+ *
+ * "Specific" means a capitalised term, a number, or a quoted phrase — the
+ * things that make a message feel researched, and therefore the things that
+ * do damage when invented. Ordinary prose is not checked; the sentence
+ * "we ran into something similar" asserts nothing checkable.
+ */
+export function findUnsupportedClaims(
+ body: string,
+ grounding: GroundingContext,
+): { unsupported: string[]; allowed: string[] } {
+ const haystack = normalize(
+ [...grounding.evidence, ...grounding.facts, ...grounding.offering].join(' '),
+ );
+
+ const unsupported: string[] = [];
+ const allowed: string[] = [];
+
+ for (const claim of extractClaims(body)) {
+ if (haystack.includes(normalize(claim))) allowed.push(claim);
+ else unsupported.push(claim);
+ }
+
+ return { unsupported: [...new Set(unsupported)], allowed: [...new Set(allowed)] };
+}
+
+/** Capitalised terms, quoted phrases and figures — the checkable specifics. */
+export function extractClaims(body: string): string[] {
+ const claims: string[] = [];
+
+ // Quoted phrases: an explicit claim about what someone said.
+ for (const match of body.matchAll(/[""']([^""']{4,120})[""']/g)) {
+ if (match[1]) claims.push(match[1]);
+ }
+
+ // Figures, including percentages and money — never safe to invent. No
+ // trailing \b: it would never match after '%', silently dropping the unit
+ // and comparing a bare "40" against the evidence.
+ for (const match of body.matchAll(/\b\d+(?:\.\d+)?%?/g)) {
+ if (match[0] && match[0].length > 1) claims.push(match[0]);
+ }
+
+ // Capitalised terms not at the start of a sentence: product and company
+ // names, technologies. Sentence-initial words are skipped because
+ // capitalisation there carries no information.
+ const sentences = body.split(/(?<=[.!?])\s+|\n+/);
+ for (const sentence of sentences) {
+ const words = sentence.trim().split(/\s+/);
+ for (let i = 1; i < words.length; i += 1) {
+ const word = words[i]!.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '');
+ if (word.length < 3) continue;
+ if (!/^\p{Lu}/u.test(word)) continue;
+ if (STOPWORDS.has(word.toLowerCase())) continue;
+ claims.push(word);
+ }
+ }
+
+ return [...new Set(claims)];
+}
+
+/**
+ * Order-insensitive fingerprint of a message's content words.
+ *
+ * `personalization` lists the per-prospect variables — name, title, employer —
+ * which are removed before hashing. That is what makes this catch the classic
+ * mail-merge: one template sent to a thousand people differs only in those
+ * tokens, so with them stripped every copy hashes identically (PRD §18).
+ * Without stripping, the varying name would make each copy look unique, which
+ * is precisely the case the check exists to find.
+ */
+export function similarityFingerprint(
+ body: string,
+ personalization: readonly string[] = [],
+): string {
+ const excluded = new Set(
+ personalization.flatMap((fact) => normalize(fact).split(/\s+/)).filter(Boolean),
+ );
+
+ const words = normalize(body)
+ .split(/\s+/)
+ .filter((w) => w.length > 3 && !STOPWORDS.has(w) && !excluded.has(w))
+ .sort();
+
+ let hash = 2166136261;
+ for (const word of words) {
+ for (let i = 0; i < word.length; i += 1) {
+ hash ^= word.charCodeAt(i);
+ hash = Math.imul(hash, 16777619);
+ }
+ }
+ return (hash >>> 0).toString(16).padStart(8, '0');
+}
+
+export function failedChecks(report: CheckReport): QualityCheck[] {
+ return report.results.filter((r) => !r.passed).map((r) => r.check);
+}
+
+function firstMatch(text: string, patterns: readonly RegExp[]): string | undefined {
+ for (const pattern of patterns) {
+ const match = pattern.exec(text);
+ if (match) return match[0];
+ }
+ return undefined;
+}
+
+function normalize(text: string): string {
+ return text
+ .toLowerCase()
+ .replace(/[^\p{L}\p{N}\s%.]/gu, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+function round(value: number): number {
+ return Math.round(value * 100) / 100;
+}
diff --git a/packages/ai/src/composer.test.ts b/packages/ai/src/composer.test.ts
new file mode 100644
index 0000000..20ed8ef
--- /dev/null
+++ b/packages/ai/src/composer.test.ts
@@ -0,0 +1,292 @@
+import { describe, expect, test } from 'bun:test';
+import { composeDraft, type ComposeInput } from './composer';
+import { StubModel, type GenerateInput, type GenerateResult, type TextModel } from './model';
+
+function input(overrides: Partial = {}): ComposeInput {
+ return {
+ action: 'reply',
+ network: 'x',
+ offering: {
+ name: 'ExamplePay',
+ category: 'developer payments infrastructure',
+ valuePropositions: ['reduce payment integration time'],
+ likelyPains: ['high payment fees', 'international settlement'],
+ competitors: ['Stripe'],
+ },
+ prospect: {
+ displayName: 'Jane Smith',
+ firstName: 'Jane',
+ title: 'VP Engineering',
+ companyName: 'Acme',
+ identityConfidence: 0.97,
+ },
+ trigger: {
+ id: 'sig_1',
+ summary: 'Asked for alternatives for cross-border payouts',
+ evidence:
+ 'Does anyone have a good alternative to Stripe for cross-border payouts? ' +
+ 'Fees are brutal and settlement takes days.',
+ sourceUrl: 'https://x.com/janesmith/status/1',
+ network: 'x',
+ ageDescription: '4h ago',
+ },
+ minIdentityConfidence: 0.85,
+ ...overrides,
+ };
+}
+
+const GOOD_DRAFT = 'Settlement taking days was our breaking point too on cross-border payouts.';
+
+describe('grounding requirement (PRD §14.1)', () => {
+ test('refuses to write anything without stored evidence', async () => {
+ const model = new StubModel(GOOD_DRAFT);
+ const result = await composeDraft(model, input({ trigger: undefined }));
+
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.reason).toBe('no_evidence');
+ // The model is never even called — there is nothing it could ground on.
+ expect(model.calls).toHaveLength(0);
+ });
+
+ test('refuses when the signal has a summary but no verbatim excerpt', async () => {
+ const model = new StubModel(GOOD_DRAFT);
+ const result = await composeDraft(
+ model,
+ input({
+ trigger: {
+ id: 'sig_2',
+ summary: 'Complained about payment fees',
+ network: 'x',
+ ageDescription: '2h ago',
+ },
+ }),
+ );
+
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.reason).toBe('no_evidence');
+ });
+
+ test('accepts a draft built from the evidence', async () => {
+ const result = await composeDraft(new StubModel(GOOD_DRAFT), input());
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.body).toBe(GOOD_DRAFT);
+ expect(result.groundedSignalIds).toEqual(['sig_1']);
+ });
+});
+
+describe('a hallucinating model is rejected, not surfaced', () => {
+ test('withholds a draft that invents a product', async () => {
+ // Both attempts hallucinate — the composer must give up, not degrade.
+ const model = new StubModel([
+ 'Your cross-border payouts note — Fluxwire fixed this for us.',
+ 'On cross-border settlement, Fluxwire solved it.',
+ ]);
+
+ const result = await composeDraft(model, input());
+
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.reason).toBe('failed_checks');
+ expect(result.report?.unsupported).toContain('Fluxwire');
+ });
+
+ test('retries once and accepts a corrected second attempt', async () => {
+ const model = new StubModel(['Your payouts note — Fluxwire fixed this for us.', GOOD_DRAFT]);
+
+ const result = await composeDraft(model, input());
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.attempts).toBe(2);
+ expect(result.body).toBe(GOOD_DRAFT);
+ });
+
+ test('tells the model exactly what it invented on the retry', async () => {
+ const model = new StubModel(['Your payouts note — Fluxwire fixed this for us.', GOOD_DRAFT]);
+
+ await composeDraft(model, input());
+
+ expect(model.calls).toHaveLength(2);
+ // Naming the rejected fragment works better than restating the rule.
+ expect(model.calls[1]!.user).toContain('Fluxwire');
+ expect(model.calls[1]!.user).toContain('rejected');
+ });
+
+ test('honours maxAttempts of 1 — no retry', async () => {
+ // Mid-sentence, so the extractor sees it — a sentence-initial capital
+ // carries no information and is deliberately skipped.
+ const model = new StubModel(['We use Fluxwire for cross-border settlement.', GOOD_DRAFT]);
+ const result = await composeDraft(model, input({ maxAttempts: 1 }));
+
+ expect(result.ok).toBe(false);
+ expect(model.calls).toHaveLength(1);
+ });
+
+ test('withholds flattery even when otherwise grounded', async () => {
+ const model = new StubModel([
+ 'I loved your post about cross-border settlement delays.',
+ 'I loved your post about cross-border settlement delays.',
+ ]);
+
+ const result = await composeDraft(model, input());
+ expect(result.ok).toBe(false);
+ });
+});
+
+describe('prompt construction', () => {
+ test('gives the model the verbatim excerpt', async () => {
+ const model = new StubModel(GOOD_DRAFT);
+ await composeDraft(model, input());
+
+ expect(model.calls[0]!.user).toContain('Fees are brutal');
+ });
+
+ test('puts the stable offering and voice behind the cache breakpoint', async () => {
+ const model = new StubModel(GOOD_DRAFT);
+ await composeDraft(model, input());
+
+ const call = model.calls[0]!;
+ // The offering repeats for every prospect in a campaign, so it must sit
+ // in the cached prefix, not the per-prospect half.
+ expect(call.cachedPrefix).toContain('ExamplePay');
+ expect(call.cachedPrefix).not.toContain('Jane Smith');
+ expect(call.user).toContain('Jane Smith');
+ });
+
+ test('states the channel length limit', async () => {
+ const model = new StubModel(GOOD_DRAFT);
+ await composeDraft(model, input({ network: 'x' }));
+
+ expect(model.calls[0]!.system).toContain('280');
+ });
+
+ test('carries the voice profile into the cached prefix', async () => {
+ const model = new StubModel(GOOD_DRAFT);
+ await composeDraft(
+ model,
+ input({
+ voice: {
+ style: 'technical',
+ instructions: 'Reference specific error modes.',
+ prohibitedClaims: ['guaranteed uptime'],
+ },
+ }),
+ );
+
+ const prefix = model.calls[0]!.cachedPrefix ?? '';
+ expect(prefix).toContain('Reference specific error modes.');
+ expect(prefix).toContain('guaranteed uptime');
+ });
+
+ test('tells the model a public reply has an audience', async () => {
+ const model = new StubModel(GOOD_DRAFT);
+ await composeDraft(model, input({ action: 'reply' }));
+
+ expect(model.calls[0]!.system).toContain('public reply');
+ });
+});
+
+describe('model failures', () => {
+ test('a refusal is not treated as a draft', async () => {
+ const refusing: TextModel = {
+ async generate(): Promise {
+ return {
+ text: '',
+ model: 'stub',
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedTokens: 0,
+ refused: true,
+ };
+ },
+ };
+
+ const result = await composeDraft(refusing, input());
+
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.reason).toBe('model_refused');
+ });
+
+ test('an empty response is not treated as a draft', async () => {
+ const result = await composeDraft(new StubModel(' '), input());
+
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.reason).toBe('empty');
+ });
+});
+
+describe('output cleanup', () => {
+ test('strips surrounding quotes the model adds despite instructions', async () => {
+ const result = await composeDraft(new StubModel(`"${GOOD_DRAFT}"`), input());
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.body).toBe(GOOD_DRAFT);
+ });
+
+ test('strips a code fence', async () => {
+ const result = await composeDraft(new StubModel(`\`\`\`\n${GOOD_DRAFT}\n\`\`\``), input());
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.body).toBe(GOOD_DRAFT);
+ });
+});
+
+describe('identity confidence', () => {
+ test('withholds a draft for a prospect below the threshold', async () => {
+ const result = await composeDraft(
+ new StubModel(GOOD_DRAFT),
+ input({
+ prospect: { displayName: 'Jane Smith', identityConfidence: 0.5 },
+ minIdentityConfidence: 0.85,
+ }),
+ );
+
+ expect(result.ok).toBe(false);
+ });
+});
+
+describe('duplicate suppression', () => {
+ test('withholds a draft identical to one already sent', async () => {
+ const first = await composeDraft(new StubModel(GOOD_DRAFT), input());
+ expect(first.ok).toBe(true);
+ if (!first.ok) return;
+
+ const second = await composeDraft(
+ new StubModel(GOOD_DRAFT),
+ input({ priorDraftHashes: [first.report.similarityHash] }),
+ );
+
+ expect(second.ok).toBe(false);
+ });
+});
+
+describe('determinism of the checked surface', () => {
+ test('the same model output always yields the same verdict', async () => {
+ const state = input();
+ const first = await composeDraft(new StubModel(GOOD_DRAFT), state);
+
+ for (let i = 0; i < 10; i += 1) {
+ const repeat = await composeDraft(new StubModel(GOOD_DRAFT), state);
+ expect(repeat.ok).toBe(first.ok);
+ }
+ });
+
+ test('prompts do not vary between identical calls', async () => {
+ const a = new StubModel(GOOD_DRAFT);
+ const b = new StubModel(GOOD_DRAFT);
+
+ await composeDraft(a, input());
+ await composeDraft(b, input());
+
+ const stripVolatile = (call: GenerateInput) => ({ ...call });
+ expect(stripVolatile(a.calls[0]!)).toEqual(stripVolatile(b.calls[0]!));
+ });
+});
diff --git a/packages/ai/src/composer.ts b/packages/ai/src/composer.ts
new file mode 100644
index 0000000..a81cb72
--- /dev/null
+++ b/packages/ai/src/composer.ts
@@ -0,0 +1,297 @@
+/**
+ * The outreach composer (PRD §14, §20.7).
+ *
+ * The composer's only job is wording. It does not decide who to contact
+ * (scoring), where (policy), or why (the recommendation engine) — by the time
+ * it runs, all three are settled, and it receives their output as fixed
+ * context.
+ *
+ * Two properties are enforced structurally rather than requested politely:
+ *
+ * 1. **It can only see grounded inputs.** The prompt is assembled from
+ * stored evidence, stored facts, and the customer's own offering. There
+ * is no path by which the model learns something it may not cite.
+ * 2. **Its output is checked, not trusted.** Every draft runs the §14.2
+ * gates before anyone sees it. A draft that invents a specific is
+ * rejected — including on a retry — rather than shown with a warning.
+ */
+
+import type { ActionKind, Network, OutreachStyle } from '@outreachgraph/domain';
+import { runChecks, type CheckReport, type GroundingContext } from './checks';
+import type { TextModel } from './model';
+
+export interface OfferingContext {
+ readonly name: string;
+ readonly category: string;
+ readonly valuePropositions: readonly string[];
+ readonly likelyPains: readonly string[];
+ readonly competitors: readonly string[];
+}
+
+export interface ProspectContext {
+ readonly displayName: string;
+ readonly firstName?: string;
+ readonly title?: string;
+ readonly companyName?: string;
+ readonly identityConfidence: number;
+}
+
+/** The signal that justified contact. Its excerpt is the only quotable text. */
+export interface TriggerContext {
+ readonly id: string;
+ readonly summary: string;
+ readonly evidence?: string;
+ readonly sourceUrl?: string;
+ readonly network: Network;
+ readonly ageDescription: string;
+}
+
+export interface VoiceContext {
+ readonly style: OutreachStyle;
+ readonly instructions?: string;
+ readonly samples?: readonly string[];
+ readonly maxWords?: number;
+ readonly prohibitedClaims?: readonly string[];
+}
+
+export interface ComposeInput {
+ readonly action: ActionKind;
+ readonly network: Network;
+ readonly offering: OfferingContext;
+ readonly prospect: ProspectContext;
+ readonly trigger?: TriggerContext;
+ readonly voice?: VoiceContext;
+ readonly minIdentityConfidence: number;
+ readonly priorDraftHashes?: readonly string[];
+ /** Retries on a failed grounding check. Zero disables retrying. */
+ readonly maxAttempts?: number;
+}
+
+export type ComposeResult =
+ | {
+ readonly ok: true;
+ readonly body: string;
+ readonly report: CheckReport;
+ readonly groundedSignalIds: readonly string[];
+ readonly model: string;
+ readonly attempts: number;
+ }
+ | {
+ readonly ok: false;
+ readonly reason: 'no_evidence' | 'failed_checks' | 'model_refused' | 'empty';
+ readonly report?: CheckReport;
+ readonly attempts: number;
+ readonly lastBody?: string;
+ };
+
+/** Channel ceilings. A public reply that gets truncated is worse than none. */
+const LENGTH_LIMITS: Partial> = {
+ x: 280,
+ bluesky: 300,
+ linkedin: 700,
+ github: 500,
+};
+
+const STYLE_GUIDANCE: Record = {
+ relationship_first: 'Open a conversation, not a pitch. Do not mention buying, demos, or calls.',
+ concise_founder: 'Two or three sentences, plain and direct, founder to founder.',
+ technical: 'Speak to the specific technical problem. Assume real expertise.',
+ helpful_no_pitch: 'Be useful and mention nothing you sell.',
+ direct: 'State the reason for the message in the first sentence.',
+ community_first: 'Contribute to the conversation as a peer already in it.',
+ custom: 'Follow the supplied voice instructions exactly.',
+};
+
+export async function composeDraft(model: TextModel, input: ComposeInput): Promise {
+ // Without an excerpt there is nothing quotable, and a personalised message
+ // built on a summary alone is exactly the fabrication §14.1 forbids.
+ if (!input.trigger?.evidence) {
+ return { ok: false, reason: 'no_evidence', attempts: 0 };
+ }
+
+ const grounding = buildGrounding(input);
+ const maxAttempts = Math.max(1, input.maxAttempts ?? 2);
+
+ let lastReport: CheckReport | undefined;
+ let lastBody: string | undefined;
+
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
+ const generated = await model.generate({
+ cachedPrefix: buildCachedPrefix(input),
+ system: buildSystem(input),
+ user: buildUser(input, attempt > 1 ? lastReport : undefined),
+ maxTokens: 1024,
+ });
+
+ if (generated.refused) return { ok: false, reason: 'model_refused', attempts: attempt };
+
+ const body = stripWrapper(generated.text);
+ if (!body) return { ok: false, reason: 'empty', attempts: attempt };
+
+ lastBody = body;
+
+ const report = runChecks({
+ body,
+ grounding,
+ identityConfidence: input.prospect.identityConfidence,
+ minIdentityConfidence: input.minIdentityConfidence,
+ ...(input.priorDraftHashes ? { priorDraftHashes: input.priorDraftHashes } : {}),
+ ...(LENGTH_LIMITS[input.network] === undefined
+ ? {}
+ : { maxLength: LENGTH_LIMITS[input.network]! }),
+ ...(input.voice?.prohibitedClaims ? { prohibitedClaims: input.voice.prohibitedClaims } : {}),
+ });
+
+ lastReport = report;
+
+ if (report.passed) {
+ return {
+ ok: true,
+ body,
+ report,
+ groundedSignalIds: [input.trigger.id],
+ model: generated.model,
+ attempts: attempt,
+ };
+ }
+ }
+
+ // A draft that fails its gates is withheld, not shown with a warning. The
+ // reviewer is the last line of defence, and a bad draft next to a caveat is
+ // still a bad draft they might approve.
+ return {
+ ok: false,
+ reason: 'failed_checks',
+ ...(lastReport ? { report: lastReport } : {}),
+ ...(lastBody ? { lastBody } : {}),
+ attempts: maxAttempts,
+ };
+}
+
+/** Everything the model may treat as true. Nothing else exists to it. */
+function buildGrounding(input: ComposeInput): GroundingContext {
+ const facts = [
+ input.prospect.displayName,
+ input.prospect.firstName ?? '',
+ input.prospect.title ?? '',
+ input.prospect.companyName ?? '',
+ input.trigger?.network ?? '',
+ ].filter(Boolean);
+
+ return {
+ evidence: input.trigger?.evidence ? [input.trigger.evidence, input.trigger.summary] : [],
+ facts,
+ offering: [
+ input.offering.name,
+ input.offering.category,
+ ...input.offering.valuePropositions,
+ ...input.offering.likelyPains,
+ ...input.offering.competitors,
+ ],
+ };
+}
+
+/**
+ * The stable half of the prompt.
+ *
+ * Identical for every prospect in a campaign, so it sits behind the cache
+ * breakpoint — see `ClaudeModel.generate`.
+ */
+function buildCachedPrefix(input: ComposeInput): string {
+ const voice = input.voice;
+
+ return [
+ 'You write short outreach messages on behalf of a business.',
+ '',
+ 'Absolute rules:',
+ '- Only state things supported by the CONTEXT you are given. If a fact is not there, it does not exist.',
+ '- Never claim to have read, watched, or used anything unless the CONTEXT shows it.',
+ '- Never invent numbers, product names, companies, or shared experiences.',
+ '- No flattery. Do not open by praising them or their work.',
+ '- No urgency, no scarcity, no manufactured enthusiasm.',
+ '- Write as one person to another. Not marketing copy.',
+ '- Output only the message. No subject line, no signature, no preamble, no quotation marks.',
+ '',
+ `About the business: ${input.offering.name}, ${input.offering.category}.`,
+ input.offering.valuePropositions.length > 0
+ ? `What it does: ${input.offering.valuePropositions.join('; ')}.`
+ : '',
+ input.offering.likelyPains.length > 0
+ ? `Problems it addresses: ${input.offering.likelyPains.join('; ')}.`
+ : '',
+ '',
+ `Style: ${STYLE_GUIDANCE[voice?.style ?? 'relationship_first']}`,
+ voice?.instructions ? `Additional voice guidance: ${voice.instructions}` : '',
+ voice?.samples?.length
+ ? `Match the register of these samples:\n${voice.samples.map((s) => `- ${s}`).join('\n')}`
+ : '',
+ voice?.prohibitedClaims?.length ? `Never claim: ${voice.prohibitedClaims.join('; ')}.` : '',
+ ]
+ .filter(Boolean)
+ .join('\n');
+}
+
+/** The per-prospect half — everything after the cache breakpoint. */
+function buildSystem(input: ComposeInput): string {
+ const limit = LENGTH_LIMITS[input.network];
+ const words = input.voice?.maxWords;
+
+ return [
+ `Channel: ${input.network}. Action: ${input.action.replace(/_/g, ' ')}.`,
+ limit ? `Hard limit: ${limit} characters.` : '',
+ words ? `Aim for at most ${words} words.` : 'Aim for at most 60 words.',
+ input.action === 'reply' || input.action === 'comment'
+ ? 'This is a public reply in an existing thread. Other people will read it. Be useful to them too.'
+ : '',
+ ]
+ .filter(Boolean)
+ .join('\n');
+}
+
+function buildUser(input: ComposeInput, failed?: CheckReport): string {
+ const trigger = input.trigger!;
+ const name = input.prospect.firstName ?? input.prospect.displayName;
+
+ const sections = [
+ 'CONTEXT — the only facts you may use:',
+ `Person: ${input.prospect.displayName}${input.prospect.title ? `, ${input.prospect.title}` : ''}${
+ input.prospect.companyName ? ` at ${input.prospect.companyName}` : ''
+ }`,
+ `What they did: ${trigger.summary} (${trigger.network}, ${trigger.ageDescription})`,
+ `Their exact words:\n"""\n${trigger.evidence}\n"""`,
+ '',
+ `Write a message to ${name} responding to what they said.`,
+ 'Reference their words specifically enough that it could not have been sent to anyone else.',
+ ];
+
+ if (failed) {
+ // Naming the exact rejected fragments works far better than repeating the
+ // rule — the model can see what it invented.
+ const problems = failed.results
+ .filter((r) => !r.passed && r.detail)
+ .map((r) => `- ${r.detail}`)
+ .join('\n');
+
+ sections.push(
+ '',
+ 'Your previous attempt was rejected:',
+ problems,
+ failed.unsupported.length > 0
+ ? `Remove these entirely — nothing supports them: ${failed.unsupported.join(', ')}.`
+ : '',
+ 'Rewrite using only the CONTEXT above.',
+ );
+ }
+
+ return sections.filter(Boolean).join('\n');
+}
+
+/** Models sometimes wrap output in quotes or a code fence despite instructions. */
+function stripWrapper(text: string): string {
+ let out = text.trim();
+ out = out.replace(/^```[a-z]*\n?/i, '').replace(/\n?```$/i, '');
+ if (out.length > 1 && /^["'"']/.test(out) && /["'"']$/.test(out)) {
+ out = out.slice(1, -1);
+ }
+ return out.trim();
+}
diff --git a/packages/ai/src/draft.test.ts b/packages/ai/src/draft.test.ts
new file mode 100644
index 0000000..430f609
--- /dev/null
+++ b/packages/ai/src/draft.test.ts
@@ -0,0 +1,184 @@
+import { afterEach, describe, expect, test } from 'bun:test';
+import { StubModel } from '@outreachgraph/ai';
+import { seedDatabase, SEED, type SeededDatabase } from '../../../apps/api/src/test-seed';
+import { draftForRecommendation } from './draft';
+
+let active: SeededDatabase | undefined;
+
+afterEach(() => {
+ active?.cleanup();
+ active = undefined;
+});
+
+/**
+ * The seed fixture already ships a draft for its recommendation, which would
+ * short-circuit the idempotence guard — remove it so each test composes.
+ */
+async function fixture(label: string): Promise {
+ active = await seedDatabase(`draft-${label}`);
+ await active.db.execute({ sql: 'DELETE FROM drafts WHERE id = ?', args: [SEED.draftId] });
+ await active.db.execute({
+ sql: 'UPDATE recommendations SET draft_id = NULL WHERE id = ?',
+ args: [SEED.recommendationId],
+ });
+ return active;
+}
+
+const GOOD = 'Cross-border payouts and settlement delays were our pain too.';
+
+describe('composing from stored evidence', () => {
+ test('writes a draft and links it to the recommendation', async () => {
+ const { db } = await fixture('happy');
+
+ const result = await draftForRecommendation(db, new StubModel(GOOD), SEED.recommendationId);
+
+ expect(result.ok).toBe(true);
+ expect(result.draftId).toMatch(/^drf_/);
+
+ const draft = await db.execute({
+ sql: 'SELECT body, grounded_signal_ids, similarity_hash, model FROM drafts WHERE id = ?',
+ args: [result.draftId!],
+ });
+
+ expect(draft.rows[0]?.body).toBe(GOOD);
+ expect(JSON.parse(String(draft.rows[0]?.grounded_signal_ids))).toEqual([SEED.signalId]);
+ expect(draft.rows[0]?.similarity_hash).toBeTruthy();
+
+ const rec = await db.execute({
+ sql: 'SELECT draft_id FROM recommendations WHERE id = ?',
+ args: [SEED.recommendationId],
+ });
+ expect(rec.rows[0]?.draft_id).toBe(result.draftId!);
+ });
+
+ test('stores the quality report alongside the body', async () => {
+ const { db } = await fixture('report');
+ const result = await draftForRecommendation(db, new StubModel(GOOD), SEED.recommendationId);
+
+ const draft = await db.execute({
+ sql: 'SELECT checks_json FROM drafts WHERE id = ?',
+ args: [result.draftId!],
+ });
+
+ const checks = JSON.parse(String(draft.rows[0]?.checks_json));
+ expect(checks.length).toBeGreaterThan(0);
+ expect(checks.every((c: { passed: boolean }) => c.passed)).toBe(true);
+ });
+
+ test('is idempotent — a second run reuses the existing draft', async () => {
+ const { db } = await fixture('idempotent');
+
+ const first = await draftForRecommendation(db, new StubModel(GOOD), SEED.recommendationId);
+ const model = new StubModel(GOOD);
+ const second = await draftForRecommendation(db, model, SEED.recommendationId);
+
+ expect(second.draftId).toBe(first.draftId!);
+ // No tokens spent rewriting a message that may already be approved.
+ expect(model.calls).toHaveLength(0);
+ });
+});
+
+describe('withholding rather than fabricating', () => {
+ test('writes no draft when the model invents a product', async () => {
+ const { db } = await fixture('hallucination');
+
+ const result = await draftForRecommendation(
+ db,
+ new StubModel([
+ 'Your payouts note — we fixed it with Fluxwire.',
+ 'On settlement delays, Fluxwire handled it.',
+ ]),
+ SEED.recommendationId,
+ );
+
+ expect(result.ok).toBe(false);
+ expect(result.reason).toBe('failed_checks');
+ expect(result.unsupported).toContain('Fluxwire');
+
+ const drafts = await db.execute('SELECT count(*) AS n FROM drafts');
+ expect(Number(drafts.rows[0]?.n)).toBe(0);
+ });
+
+ test('writes no draft when the signal has no verbatim evidence', async () => {
+ const { db } = await fixture('noevidence');
+ await db.execute({
+ sql: 'UPDATE signals SET evidence = NULL WHERE id = ?',
+ args: [SEED.signalId],
+ });
+
+ const model = new StubModel(GOOD);
+ const result = await draftForRecommendation(db, model, SEED.recommendationId);
+
+ expect(result.ok).toBe(false);
+ expect(result.reason).toBe('no_evidence');
+ // The model is never called — there is nothing it could ground on.
+ expect(model.calls).toHaveLength(0);
+ });
+
+ test('writes no draft when the recommendation has no trigger', async () => {
+ const { db } = await fixture('notrigger');
+ await db.execute({
+ sql: 'UPDATE recommendations SET trigger_signal_id = NULL WHERE id = ?',
+ args: [SEED.recommendationId],
+ });
+
+ const result = await draftForRecommendation(db, new StubModel(GOOD), SEED.recommendationId);
+
+ expect(result.ok).toBe(false);
+ expect(result.reason).toBe('no_trigger_signal');
+ });
+
+ test('writes no draft for a prospect below the identity threshold', async () => {
+ const { db } = await fixture('lowconf');
+ await db.execute({
+ sql: 'UPDATE people SET identity_confidence = 0.4 WHERE id = ?',
+ args: [SEED.personId],
+ });
+
+ const result = await draftForRecommendation(db, new StubModel(GOOD), SEED.recommendationId);
+ expect(result.ok).toBe(false);
+ });
+});
+
+describe('cross-prospect duplicate suppression (PRD §18)', () => {
+ test('refuses a second copy of a message already drafted', async () => {
+ const { db } = await fixture('duplicate');
+ const stamp = new Date().toISOString();
+
+ // A second prospect and recommendation in the same workspace.
+ await db.batch([
+ {
+ sql: `INSERT INTO people (id, display_name, first_name, identity_confidence, status,
+ outreach_eligible, believed_minor, created_at, updated_at)
+ VALUES ('per_two', 'Alex Chen', 'Alex', 0.97, 'active', 1, 0, ?, ?)`,
+ args: [stamp, stamp],
+ },
+ {
+ sql: `INSERT INTO signals (id, workspace_id, person_id, network, signal_type, summary,
+ evidence, source_timestamp, observed_at, confidence, relevance, sentiment)
+ VALUES ('sig_two', ?, 'per_two', 'x', 'recommendation_request',
+ 'Asked about cross-border payouts',
+ 'Does anyone have a good alternative for cross-border payouts? Settlement takes days.',
+ ?, ?, 0.9, 0.9, 'neutral')`,
+ args: [SEED.workspaceId, stamp, stamp],
+ },
+ {
+ sql: `INSERT INTO recommendations (id, workspace_id, campaign_id, person_id, action,
+ network, priority, reason, trigger_signal_id, policy_status, policy_version,
+ expected_goal, status, created_at)
+ VALUES ('rec_two', ?, ?, 'per_two', 'reply', 'x', 80, 'same ask',
+ 'sig_two', 'allow_with_approval', '2026-08-11', 'start_conversation', 'pending', ?)`,
+ args: [SEED.workspaceId, SEED.campaignId, stamp],
+ },
+ ]);
+
+ const first = await draftForRecommendation(db, new StubModel(GOOD), SEED.recommendationId);
+ expect(first.ok).toBe(true);
+
+ // The same wording for a different person is the mail-merge case.
+ const second = await draftForRecommendation(db, new StubModel([GOOD, GOOD]), 'rec_two');
+
+ expect(second.ok).toBe(false);
+ expect(second.reason).toBe('failed_checks');
+ });
+});
diff --git a/packages/ai/src/draft.ts b/packages/ai/src/draft.ts
new file mode 100644
index 0000000..b81b0b4
--- /dev/null
+++ b/packages/ai/src/draft.ts
@@ -0,0 +1,239 @@
+/**
+ * Turning a recommendation into a draft (PRD §14).
+ *
+ * Runs after the recommendation exists, so the composer inherits a settled
+ * decision: who, why, which channel, and which signal it may quote. It writes
+ * a draft row only when every §14.2 gate passes.
+ *
+ * A missing draft is a working state, not a failure. The approval card still
+ * shows the prospect, the evidence and the recommended action; the reviewer
+ * writes the message themselves. That is strictly better than showing a
+ * fabricated one.
+ */
+
+import { newId, type ActionKind, type Network, type OutreachStyle } from '@outreachgraph/domain';
+import { now, queryAll, queryOne, type Client } from '@outreachgraph/db';
+import { composeDraft, type ComposeResult, type TextModel } from '@outreachgraph/ai';
+
+export interface DraftResult {
+ readonly ok: boolean;
+ readonly draftId?: string;
+ readonly reason?: string;
+ readonly unsupported?: readonly string[];
+}
+
+/**
+ * Composes and stores a draft for one recommendation.
+ *
+ * Idempotent: a recommendation that already has a draft is left alone, so a
+ * retried pipeline run does not spend tokens rewriting an approved message.
+ */
+export async function draftForRecommendation(
+ db: Client,
+ model: TextModel,
+ recommendationId: string,
+): Promise {
+ const existing = await queryOne<{ id: string }>(
+ db,
+ 'SELECT id FROM drafts WHERE recommendation_id = ? LIMIT 1',
+ [recommendationId],
+ );
+ if (existing) return { ok: true, draftId: existing.id };
+
+ const recommendation = await queryOne<{
+ id: string;
+ workspace_id: string;
+ campaign_id: string;
+ person_id: string;
+ action: string;
+ network: string;
+ trigger_signal_id: string | null;
+ }>(
+ db,
+ `SELECT id, workspace_id, campaign_id, person_id, action, network, trigger_signal_id
+ FROM recommendations WHERE id = ?`,
+ [recommendationId],
+ );
+ if (!recommendation) return { ok: false, reason: 'no_recommendation' };
+
+ // No trigger means nothing to quote, and §14.1 forbids personalising
+ // without evidence.
+ if (!recommendation.trigger_signal_id) return { ok: false, reason: 'no_trigger_signal' };
+
+ const signal = await queryOne<{
+ id: string;
+ summary: string;
+ evidence: string | null;
+ source_url: string | null;
+ network: string;
+ source_timestamp: string | null;
+ observed_at: string;
+ }>(
+ db,
+ `SELECT id, summary, evidence, source_url, network, source_timestamp, observed_at
+ FROM signals WHERE id = ?`,
+ [recommendation.trigger_signal_id],
+ );
+ if (!signal?.evidence) return { ok: false, reason: 'no_evidence' };
+
+ const person = await queryOne<{
+ display_name: string;
+ first_name: string | null;
+ current_title: string | null;
+ current_company_id: string | null;
+ identity_confidence: number;
+ }>(
+ db,
+ `SELECT display_name, first_name, current_title, current_company_id, identity_confidence
+ FROM people WHERE id = ?`,
+ [recommendation.person_id],
+ );
+ if (!person) return { ok: false, reason: 'no_person' };
+
+ const company = person.current_company_id
+ ? await queryOne<{ name: string }>(db, 'SELECT name FROM companies WHERE id = ?', [
+ person.current_company_id,
+ ])
+ : undefined;
+
+ const offering = await queryOne<{
+ name: string;
+ category: string;
+ value_propositions: string;
+ likely_pains: string;
+ competitors: string;
+ }>(
+ db,
+ `SELECT o.name, o.category, o.value_propositions, o.likely_pains, o.competitors
+ FROM offerings o JOIN campaigns c ON c.offering_id = o.id WHERE c.id = ?`,
+ [recommendation.campaign_id],
+ );
+ if (!offering) return { ok: false, reason: 'no_offering' };
+
+ const voice = await queryOne<{
+ style: string;
+ instructions: string | null;
+ samples: string;
+ max_words: number | null;
+ prohibited_claims: string;
+ }>(
+ db,
+ `SELECT v.style, v.instructions, v.samples, v.max_words, v.prohibited_claims
+ FROM voice_profiles v JOIN campaigns c ON c.voice_profile_id = v.id WHERE c.id = ?`,
+ [recommendation.campaign_id],
+ );
+
+ const workspace = await queryOne<{ min_outreach_confidence: number }>(
+ db,
+ 'SELECT min_outreach_confidence FROM workspaces WHERE id = ?',
+ [recommendation.workspace_id],
+ );
+
+ // Every message already sent from this workspace, so a near-identical
+ // second copy is caught before a human ever sees it (PRD §18).
+ const priorHashes = await queryAll<{ similarity_hash: string }>(
+ db,
+ `SELECT DISTINCT similarity_hash FROM drafts
+ WHERE workspace_id = ? AND similarity_hash IS NOT NULL`,
+ [recommendation.workspace_id],
+ );
+
+ const result: ComposeResult = await composeDraft(model, {
+ action: recommendation.action as ActionKind,
+ network: recommendation.network as Network,
+ offering: {
+ name: offering.name,
+ category: offering.category,
+ valuePropositions: parseArray(offering.value_propositions),
+ likelyPains: parseArray(offering.likely_pains),
+ competitors: parseArray(offering.competitors),
+ },
+ prospect: {
+ displayName: person.display_name,
+ ...(person.first_name ? { firstName: person.first_name } : {}),
+ ...(person.current_title ? { title: person.current_title } : {}),
+ ...(company?.name ? { companyName: company.name } : {}),
+ identityConfidence: person.identity_confidence,
+ },
+ trigger: {
+ id: signal.id,
+ summary: signal.summary,
+ evidence: signal.evidence,
+ ...(signal.source_url ? { sourceUrl: signal.source_url } : {}),
+ network: signal.network as Network,
+ ageDescription: describeAge(signal.source_timestamp ?? signal.observed_at),
+ },
+ ...(voice
+ ? {
+ voice: {
+ style: voice.style as OutreachStyle,
+ ...(voice.instructions ? { instructions: voice.instructions } : {}),
+ samples: parseArray(voice.samples),
+ ...(voice.max_words == null ? {} : { maxWords: voice.max_words }),
+ prohibitedClaims: parseArray(voice.prohibited_claims),
+ },
+ }
+ : {}),
+ minIdentityConfidence: workspace?.min_outreach_confidence ?? 0.85,
+ priorDraftHashes: priorHashes.map((r) => r.similarity_hash),
+ });
+
+ if (!result.ok) {
+ return {
+ ok: false,
+ reason: result.reason,
+ ...(result.report?.unsupported ? { unsupported: result.report.unsupported } : {}),
+ };
+ }
+
+ const draftId = newId('draft');
+ const stamp = now();
+
+ await db.batch([
+ {
+ sql: `INSERT INTO drafts (id, workspace_id, recommendation_id, body, grounded_signal_ids,
+ checks_json, similarity_hash, model, edited_by_user, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
+ args: [
+ draftId,
+ recommendation.workspace_id,
+ recommendation.id,
+ result.body,
+ JSON.stringify(result.groundedSignalIds),
+ JSON.stringify(result.report.results),
+ result.report.similarityHash,
+ result.model,
+ stamp,
+ stamp,
+ ],
+ },
+ {
+ sql: 'UPDATE recommendations SET draft_id = ? WHERE id = ?',
+ args: [draftId, recommendation.id],
+ },
+ ]);
+
+ return { ok: true, draftId };
+}
+
+function describeAge(timestamp: string): string {
+ const then = Date.parse(timestamp);
+ if (Number.isNaN(then)) return 'recently';
+
+ const hours = (Date.now() - then) / 3_600_000;
+ if (hours < 1) return 'in the last hour';
+ if (hours < 24) return `${Math.round(hours)}h ago`;
+
+ const days = Math.round(hours / 24);
+ return days === 1 ? 'yesterday' : `${days} days ago`;
+}
+
+function parseArray(raw: string | undefined): string[] {
+ if (!raw) return [];
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed.map(String) : [];
+ } catch {
+ return [];
+ }
+}
diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts
new file mode 100644
index 0000000..2f1fb4b
--- /dev/null
+++ b/packages/ai/src/index.ts
@@ -0,0 +1,41 @@
+/**
+ * `@outreachgraph/ai` — the only package that talks to a model (PRD §14, §20.7).
+ *
+ * Everything a model produces here passes deterministic gates before anyone
+ * sees it. Nothing a model says decides a policy outcome, an identity merge,
+ * or a score.
+ */
+
+export {
+ extractClaims,
+ failedChecks,
+ findUnsupportedClaims,
+ runChecks,
+ similarityFingerprint,
+ type CheckInput,
+ type CheckReport,
+ type GroundingContext,
+} from './checks';
+
+export {
+ ClaudeModel,
+ DEFAULT_MODEL,
+ MissingApiKeyError,
+ StubModel,
+ type ClaudeModelOptions,
+ type GenerateInput,
+ type GenerateResult,
+ type TextModel,
+} from './model';
+
+export {
+ composeDraft,
+ type ComposeInput,
+ type ComposeResult,
+ type OfferingContext,
+ type ProspectContext,
+ type TriggerContext,
+ type VoiceContext,
+} from './composer';
+
+export { draftForRecommendation, type DraftResult } from './draft';
diff --git a/packages/ai/src/model.ts b/packages/ai/src/model.ts
new file mode 100644
index 0000000..af425a3
--- /dev/null
+++ b/packages/ai/src/model.ts
@@ -0,0 +1,143 @@
+/**
+ * The model boundary (PRD §1.1 principle 8, §20).
+ *
+ * One place in the codebase talks to an LLM. Everything else — policy,
+ * identity, scoring, the quality gates — is deterministic and stays that way.
+ * Keeping the surface this narrow is what makes "no LLM decides a merge or a
+ * policy outcome" an architectural fact rather than a guideline.
+ */
+
+import Anthropic from '@anthropic-ai/sdk';
+
+/** Opus 5 is the default; a workspace may pin a cheaper model per campaign. */
+export const DEFAULT_MODEL = 'claude-opus-5';
+
+export interface GenerateInput {
+ readonly system: string;
+ readonly user: string;
+ readonly maxTokens?: number;
+ readonly model?: string;
+ /**
+ * Stable prefix for prompt caching. The offering and voice profile repeat
+ * across every draft in a campaign, so they belong here rather than inlined
+ * into `system` where a per-prospect edit would invalidate the cache.
+ */
+ readonly cachedPrefix?: string;
+}
+
+export interface GenerateResult {
+ readonly text: string;
+ readonly model: string;
+ readonly inputTokens: number;
+ readonly outputTokens: number;
+ readonly cachedTokens: number;
+ /** True when the model declined; the caller must not treat text as a draft. */
+ readonly refused: boolean;
+}
+
+export interface TextModel {
+ generate(input: GenerateInput): Promise;
+}
+
+export class MissingApiKeyError extends Error {
+ constructor() {
+ super('ANTHROPIC_API_KEY is not configured');
+ this.name = 'MissingApiKeyError';
+ }
+}
+
+export interface ClaudeModelOptions {
+ readonly apiKey?: string;
+ readonly model?: string;
+ readonly client?: Anthropic;
+}
+
+export class ClaudeModel implements TextModel {
+ readonly #client: Anthropic;
+ readonly #model: string;
+
+ constructor(options: ClaudeModelOptions = {}) {
+ const apiKey = options.apiKey ?? process.env.ANTHROPIC_API_KEY;
+ if (!options.client && !apiKey) throw new MissingApiKeyError();
+
+ this.#client = options.client ?? new Anthropic({ apiKey });
+ this.#model = options.model ?? DEFAULT_MODEL;
+ }
+
+ async generate(input: GenerateInput): Promise {
+ const model = input.model ?? this.#model;
+
+ const system = input.cachedPrefix
+ ? [
+ // The breakpoint sits at the end of the stable prefix, so the
+ // per-prospect half after it can change freely without a cache miss.
+ {
+ type: 'text' as const,
+ text: input.cachedPrefix,
+ cache_control: { type: 'ephemeral' as const },
+ },
+ { type: 'text' as const, text: input.system },
+ ]
+ : input.system;
+
+ // `output_config: { effort: "low" }` would suit this task — drafting is
+ // short and well-specified, and lower effort produces less of the
+ // elaboration the quality gates then reject. It is omitted because the
+ // installed SDK does not type it, and an unverifiable request parameter
+ // that 400s means no drafts at all. Add it once the SDK catches up.
+ const response = await this.#client.messages.create({
+ model,
+ max_tokens: input.maxTokens ?? 2048,
+ system,
+ messages: [{ role: 'user', content: input.user }],
+ });
+
+ // A refusal returns HTTP 200 with an empty or partial body — reading
+ // content[0] without checking stop_reason is how that becomes a crash.
+ const refused = response.stop_reason === 'refusal';
+
+ const text = response.content
+ .filter((block): block is Anthropic.TextBlock => block.type === 'text')
+ .map((block) => block.text)
+ .join('')
+ .trim();
+
+ return {
+ text,
+ model: response.model,
+ inputTokens: response.usage.input_tokens,
+ outputTokens: response.usage.output_tokens,
+ cachedTokens: response.usage.cache_read_input_tokens ?? 0,
+ refused,
+ };
+ }
+}
+
+/**
+ * A model that returns a fixed response.
+ *
+ * Composer tests assert on prompt construction and the quality gates, neither
+ * of which should depend on a live API call — or cost money in CI.
+ */
+export class StubModel implements TextModel {
+ readonly #responses: string[];
+ readonly calls: GenerateInput[] = [];
+
+ constructor(responses: string | readonly string[]) {
+ this.#responses = typeof responses === 'string' ? [responses] : [...responses];
+ }
+
+ async generate(input: GenerateInput): Promise {
+ this.calls.push(input);
+ const text = this.#responses.length > 1 ? this.#responses.shift()! : this.#responses[0]!;
+
+ return {
+ text,
+ model: 'stub',
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedTokens: 0,
+ refused: false,
+ };
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
index 6733d9e..be3b170 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -10,7 +10,9 @@
"@outreachgraph/scoring": ["./packages/scoring/src/index.ts"],
"@outreachgraph/policy": ["./packages/policy/src/index.ts"],
"@outreachgraph/providers": ["./packages/providers/src/index.ts"],
- "@outreachgraph/contracts": ["./packages/contracts/src/index.ts"]
+ "@outreachgraph/contracts": ["./packages/contracts/src/index.ts"],
+ "@outreachgraph/ai": ["./packages/ai/src/index.ts"],
+ "@outreachgraph/recommend": ["./packages/recommend/src/index.ts"]
}
},
"include": ["packages/*/src/**/*.ts", "apps/*/src/**/*.ts"],