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"],