FounderVoice
AI personal-branding SaaS that learns a founder's "Voice DNA" and auto-writes on-brand content with RAG over pgvector.
- Role
- Founder & Full Stack Engineer
- Timeline
- 2024 – present
- Team
- Solo build
- Year
- 2024
- Next.js
- TypeScript
- Supabase
- Google Gemini
- pgvector
- Vercel
Problem
Founders know their business cold, but almost none of them can consistently write about it. The result is a content graveyard: a LinkedIn profile last touched eight months ago, a Notion doc of half-drafted posts, and the nagging guilt that "building in public" is happening to everyone else. Generic AI writing tools do not fix this because they all sound the same — the moment a founder posts obviously-GPT prose, their credibility drops.
Target user: early-to-growth-stage founders and solo operators who have a strong point of view but no time (or appetite) to write daily.
Constraints that shaped the system:
- The output has to sound like them, not like a language model. That is the entire product.
- It has to be a real SaaS: authenticated, multi-tenant, billed, and safe with third-party OAuth tokens (LinkedIn) that can post on the user's behalf.
- It is a solo build, so every subsystem has to be operable by one person and cheap enough to run on usage-based infrastructure.
Business goal: convert "I should post more" into a background process — the user drops in raw thoughts, the system returns publish-ready content in their voice, and (optionally) publishes it automatically on a schedule.
Architecture
FounderVoice is a Next.js app on Vercel with Supabase (Postgres + Auth + Storage + Edge Functions) as the backend of record. The interesting part is not the CRUD — it is the Voice DNA pipeline and the security posture around autonomous publishing.
At a high level:
- Next.js (App Router) renders the dashboard and the composer, and calls Supabase Edge Functions for anything AI- or secret-adjacent.
- 10 Deno Edge Functions own generation, retrieval, OAuth exchange, publishing, and webhook verification. They stream tokens back to the client.
- Postgres (20+ RLS tables) stores users, voice samples, embeddings, drafts, schedules, billing state, and OAuth connections. Row-Level Security is the tenancy boundary — there is no "trust the app server" layer.
- pgvector stores per-user embeddings of writing samples and powers
similarity search via a
match_voice_chunksRPC. - pg_cron drives auto-publishing: scheduled posts are dequeued and pushed to LinkedIn without a human in the loop.
Voice DNA data flow
- The user pastes writing samples (past posts, emails, voice-memo transcripts).
- Each sample is chunked and embedded; chunks land in a
voice_chunkstable with avectorcolumn. - On generation, the user's prompt is embedded, and
match_voice_chunksdoes a cosine-similarity search to pull the most representative chunks of their voice. - Those chunks become the style context in a RAG prompt to Gemini.
- Every 5 new samples, a trigger re-runs embedding maintenance so the Voice DNA sharpens as the user writes more.
Engineering Decisions
pgvector instead of a dedicated vector database. A separate vector store (Pinecone/Weaviate) would have meant a second system to secure, bill, and keep consistent with Postgres. Because voice chunks are per-user and gated by RLS, keeping embeddings inside Postgres let me reuse the exact same tenancy model for vectors as for every other row. Tradeoff: pgvector's ANN indexing is less tunable than a purpose-built engine, but at per-user chunk counts (hundreds, not millions) an IVFFlat index is comfortably sub-100ms. Revisit only if a single user's corpus explodes.
Supabase Edge Functions (Deno) for anything touching a secret or a model.
The browser never sees a model key or an OAuth token. Alternatives were Next.js
route handlers (fine, but co-locating with Supabase Auth/JWT and pg was
cleaner) or a separate Node service (more ops for a solo build). Deno's
web-standard fetch/streaming made token streaming trivial.
AES-256-GCM encryption of OAuth tokens at rest. LinkedIn tokens can post as the user. Storing them plaintext — even behind RLS — was unacceptable. They are encrypted application-side before insert and decrypted only inside the edge function that publishes. Tradeoff: key management complexity, mitigated by keeping the data-encryption key out of the database entirely.
HMAC-signed OAuth state + webhook verification. OAuth state is
HMAC-signed so a forged callback cannot complete a connection, and inbound
webhooks (billing) are verified by signature before any state change. This
closes CSRF-on-OAuth and spoofed-webhook classes of bug.
Dual payment providers (Lemon Squeezy + Dodo). Merchant-of-record coverage across regions and a fallback if one provider has issues. Tradeoff: two webhook formats to normalize into one internal billing state machine.
Source Code Walkthrough
The voice-similarity retrieval RPC
The core of Voice DNA is a single Postgres function that takes a query
embedding and returns the closest chunks for the calling user only. RLS plus
an explicit user_id filter means retrieval can never leak one founder's voice
into another's output.
-- match_voice_chunks: cosine-similarity search over a single user's corpus.
-- Why it exists: RAG needs the most "on-voice" passages as style context.
-- Why written this way: the <=> operator uses the IVFFlat index; ordering by
-- distance and capping with match_count keeps prompt context small and cheap.
create or replace function match_voice_chunks(
p_user_id uuid,
query_embedding vector(768),
match_count int default 6,
min_similarity float default 0.5
)
returns table (id uuid, content text, similarity float)
language sql stable
as $$
select
vc.id,
vc.content,
1 - (vc.embedding <=> query_embedding) as similarity
from voice_chunks vc
where vc.user_id = p_user_id
and 1 - (vc.embedding <=> query_embedding) >= min_similarity
order by vc.embedding <=> query_embedding
limit match_count;
$$;Performance considerations: the function is stable (safe to cache within
a statement), returns a bounded match_count, and pushes the distance filter
into SQL so we never ship low-relevance chunks into the model prompt (tokens
cost money and dilute style). Possible improvements: switch IVFFlat to HNSW
for higher recall as corpora grow, and pre-warm the index on cold connections.
The streaming generation edge function
Generation runs in a Deno edge function so the model key stays server-side and tokens stream to the UI as they are produced.
// supabase/functions/generate/index.ts
// Why: stream first token fast; assemble Voice DNA context before calling Gemini.
Deno.serve(async (req) => {
const { userId, prompt } = await req.json();
// 1) Embed the prompt and pull the user's most on-voice chunks.
const queryEmbedding = await embed(prompt);
const { data: chunks } = await supabase.rpc("match_voice_chunks", {
p_user_id: userId,
query_embedding: queryEmbedding,
match_count: 6,
});
// 2) Build a RAG prompt: their voice as style context, prompt as intent.
const style = chunks.map((c) => c.content).join("\n---\n");
const stream = await gemini.generateContentStream({
contents: buildRagPrompt({ style, prompt }),
});
// 3) Stream tokens straight to the browser (no buffering the full response).
return new Response(
new ReadableStream({
async start(controller) {
for await (const chunk of stream) {
controller.enqueue(new TextEncoder().encode(chunk.text()));
}
controller.close();
},
}),
{ headers: { "content-type": "text/plain; charset=utf-8" } },
);
});Why written this way: streaming makes latency feel like ~200–300ms even when full generation takes seconds; the user watches their voice appear. Improvements: cache the assembled style context per user for a short TTL so back-to-back generations skip the retrieval round-trip.
AES-256-GCM token encryption (encrypt-before-insert)
LinkedIn tokens can post as the user, so they never touch Postgres in
plaintext. The data-encryption key lives only in the edge function's
environment — never in a column, never in a migration — so a database dump
or a rogue select * yields ciphertext, not credentials. GCM (an AEAD cipher)
gives confidentiality and tamper-detection: if the stored blob is modified,
decrypt throws instead of returning garbage that we might act on.
// supabase/functions/_shared/crypto.ts
// Why: OAuth tokens are "post-as-the-user" capabilities. Encrypt at rest with
// an authenticated cipher; keep the key out of the DB entirely.
// Why GCM: AEAD — the 16-byte auth tag detects tampering on decrypt.
// Format: base64(iv).base64(ciphertext+tag) — self-describing, no side table.
const KEY = await crypto.subtle.importKey(
"raw",
decodeBase64(mustEnv("TOKEN_ENC_KEY")), // 32 bytes = AES-256; fail fast if unset
{ name: "AES-GCM" },
false, // non-extractable: the CryptoKey can't be read back out
["encrypt", "decrypt"],
);
export async function encryptToken(plaintext: string): Promise<string> {
// A fresh 96-bit IV per encryption is mandatory for GCM — IV reuse under the
// same key is catastrophic (it leaks the auth key). crypto.getRandomValues
// is a CSPRNG, so we never derive IVs from counters or timestamps.
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
KEY,
new TextEncoder().encode(plaintext),
);
return `${encodeBase64(iv)}.${encodeBase64(new Uint8Array(ct))}`;
}
export async function decryptToken(blob: string): Promise<string> {
const [ivB64, ctB64] = blob.split(".");
if (!ivB64 || !ctB64) throw new Error("malformed_ciphertext"); // defensive: never feed junk to subtle
const pt = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: decodeBase64(ivB64) },
KEY,
decodeBase64(ctB64),
); // throws on tag mismatch — tampered data can't silently pass through
return new TextDecoder().decode(pt);
}Performance considerations: importKey runs once per cold start and the
CryptoKey is reused; per-call cost is a single AES-GCM op (microseconds) —
negligible next to the network hop to LinkedIn. Edge cases handled: missing
key env fails the function on boot rather than silently storing plaintext;
malformed ciphertext and tag-mismatch both throw before any downstream use.
Possible improvements: wrap the format in a v1. version prefix now so key
rotation (envelope encryption with a per-token DEK) is a non-breaking change
later; today rotation would require a re-encrypt migration.
Idempotent dual-provider billing webhook normalizer
Two merchants-of-record (Lemon Squeezy + Dodo) send different payloads for the same conceptual events, and both retry aggressively. The normalizer collapses them into one internal entitlement event and makes processing idempotent on the provider's event id, so a retry (or a duplicate from an at-least-once delivery) can never double-provision or double-refund.
// supabase/functions/billing-webhook/normalize.ts
// Why: two providers, one entitlement state machine. Normalize first, then
// dedupe on (provider, event_id) so retries are safe by construction.
type EntitlementEvent =
| { kind: "activate"; userId: string; plan: Plan }
| { kind: "deactivate"; userId: string };
function normalize(provider: Provider, raw: unknown): NormalizedEvent {
// Parse at the boundary with a per-provider Zod schema — never trust the
// shape of an inbound webhook, even a signature-verified one.
const evt = providerSchema[provider].parse(raw);
return {
// Composite key is the idempotency anchor. Same provider event delivered
// twice → same key → second INSERT is a no-op.
dedupeKey: `${provider}:${evt.id}`,
entitlement: mapToEntitlement(provider, evt), // provider-specific → internal
};
}
async function handle(provider: Provider, raw: unknown) {
const { dedupeKey, entitlement } = normalize(provider, raw);
// Claim the event atomically. ON CONFLICT DO NOTHING means the winner of a
// race processes; every duplicate short-circuits. This is the whole trick:
// idempotency lives in a UNIQUE constraint, not in application if-checks.
const { rowCount } = await sql`
insert into billing_events (dedupe_key, provider, payload)
values (${dedupeKey}, ${provider}, ${raw as object})
on conflict (dedupe_key) do nothing
`;
if (rowCount === 0) return; // already processed — safe to ack and drop
await applyEntitlement(entitlement); // only ever runs once per real event
}Why written this way: idempotency is enforced by a UNIQUE(dedupe_key)
constraint rather than a read-then-write check, so it is correct even under
concurrent duplicate deliveries (the classic TOCTOU bug in naive webhook
handlers). Security: signature verification happens before this, on the
raw body; normalize still re-validates shape with Zod because a valid
signature only proves origin, not that the payload matches our assumptions.
Possible improvements: the billing_events row is the natural audit log
and replay source — a nightly reconciliation job could diff it against provider
APIs to catch any event we never received at all.
Technical Challenges
- Autonomous publishing without a server the user trusts. pg_cron dequeues
scheduled posts and invokes the publish function, which decrypts the LinkedIn
token in memory only. Getting the failure semantics right mattered more
than the happy path, because "post to the world on the user's behalf" is not
a safely-retryable operation. The classification I settled on: a network
timeout after LinkedIn may have accepted the post is ambiguous, so publish
is made idempotent with a client-generated dedupe token per scheduled post —
a retry with the same token can't produce a double-post. A
401means the OAuth token was revoked → don't retry, mark the connection stale and notify the user to reconnect. A429is honored with backoff. Only genuinely transient5xx/timeouts retry (bounded), and anything that exhausts retries lands in a dead-letter state that surfaces in the UI rather than failing silently — a silently-dropped scheduled post erodes trust faster than a visible error. - Secret leakage into the client bundle. With a Next.js app calling edge functions, it is easy to accidentally import a server-only constant into a client component. I wrote a build-time client-bundle secret scanner that greps the emitted client chunks for key-shaped strings and fails the build.
- Rate limiting a single-tenant-feeling but multi-tenant product. Generation is expensive; a 30 req/min/user limit protects cost and providers while staying invisible to normal use.
- Normalizing two billing webhooks (Lemon Squeezy + Dodo) into one entitlement state without double-provisioning on retries — solved with idempotency keys keyed on provider event id.
Code Quality & Testing
For a solo build, tests are not about coverage vanity — they are the only thing standing in for the code reviewer I do not have. The strategy is deliberately weighted toward the two places a bug is expensive: the RAG boundary and the secret/tenancy boundary.
Type safety. TypeScript runs in strict mode (strictNullChecks,
noUncheckedIndexedAccess) with no implicit any allowed to survive CI. But
types only prove internal consistency — they say nothing about what a model,
a webhook, or a browser actually sends. So every trust boundary is re-validated
at runtime with Zod v4: inbound webhook payloads, edge-function request
bodies, and the JSON shape Gemini returns for structured generations are all
parsed, not cast. A z.infer keeps the static type and the runtime schema from
drifting apart. The rule is simple: as is a smell at a boundary; parse is
the default.
Testing strategy.
- Unit + property-based (Vitest + fast-check). Pure logic — the billing entitlement state machine, chunking, prompt assembly — is tested with property-based tests rather than a handful of examples. fast-check found the bugs that mattered: e.g. that duplicate webhook deliveries in any interleaving must converge to the same entitlement (idempotency as an invariant, not a lucky path).
// billing.property.test.ts — idempotency is a property, not a test case.
import { fc, test } from "@fast-check/vitest";
test.prop([fc.array(billingEventArb, { minLength: 1, maxLength: 20 })])(
"applying any permutation of the same events yields one entitlement",
async (events) => {
const a = await runNormalizer(events);
const b = await runNormalizer(shuffle(events)); // reordered + duplicated
expect(a.entitlement).toEqual(b.entitlement); // order/dupes must not matter
},
);- API mocking (MSW). Gemini, LinkedIn, and both billing providers are
mocked at the network layer with MSW, so tests exercise real
fetchcode paths — including timeouts, 429s, and malformed responses — without hitting a provider. This is where fallback and retry semantics get proven. - RLS cross-tenant isolation probes. An integration suite authenticates as
user A and asserts that no query — direct table access or
match_voice_chunksRPC — can return user B's voice chunks. This runs against a real Postgres with policies applied, because RLS correctness is a property of the database, not the app. It is the single most important test in the repo: a leak here is the product's worst failure mode.
Error handling & observability. Edge functions log structured events
({ event, userId, latencyMs, provider }) rather than free-text strings, so
generation latency percentiles and provider error rates are queryable. Failures
are typed (UnauthorizedWebhookError, ProviderTimeoutError) and mapped to
deliberate HTTP semantics — a provider timeout is a 503 the client can retry,
a bad signature is a 401 it must not.
Module & service boundaries. A hard no-server-actions policy: anything
touching a secret or a model runs in a Deno edge function, never in a Next.js
Server Action or route handler co-located with client code. This keeps the
secret surface in one auditable place and makes the client bundle structurally
incapable of importing a key. Shared crypto/validation lives in
functions/_shared, imported by function, never by the client.
What CI enforces (fail-the-build gates).
tsc --noEmitin strict mode, ESLint, and the full Vitest suite.- The build-time client-bundle secret scanner (below) — the load-bearing
gate. It runs after
next buildand greps the emitted client chunks for key-shaped strings and known env-var names. If a server-only secret ever leaks into a client chunk, the build fails before it can ship.
# scripts/scan-client-bundle.sh — runs in CI after `next build`.
# Why: a single stray `import` of a server constant into a client component
# can ship a secret to every browser. This is the backstop for that mistake.
set -euo pipefail
CHUNKS=".next/static/chunks"
# Patterns: provider key prefixes + our own server-only env var names.
PATTERNS='sk-[A-Za-z0-9]{20,}|AIza[0-9A-Za-z_-]{35}|TOKEN_ENC_KEY|SUPABASE_SERVICE_ROLE'
if grep -REn --include='*.js' "$PATTERNS" "$CHUNKS"; then
echo "::error::secret-shaped string found in client bundle — failing build"
exit 1
fi
echo "client bundle clean"Honest limitation: this scanner is a blunt regex, not an AST check, so it can false-positive on a variable literally named like an env var and false-negative on a secret that has been transformed before emission. It is a backstop, not a proof — the real defense is the no-server-actions boundary above. Moving it to an AST-based check on the module graph is queued tech debt.
Benchmarks
Streaming latency is the number that defines the product feel. Cold edge
invocations sit around 800ms to first token; warm invocations drop to ~310ms;
and when the assembled voice context is cached, first token lands near 190ms.
Retrieval via match_voice_chunks is comfortably sub-100ms at p95, which keeps
it off the critical path. The Voice DNA auto-retrain fires every 5 new samples,
trading a small background embedding cost for continuously sharper style
matching. See the charts above for the full series.
Lessons Learned
- What succeeded: keeping vectors in Postgres. Reusing RLS for tenancy on embeddings removed an entire class of "did I leak a user's data" anxiety.
- What failed first: my initial non-streaming generation felt broken even though it was fast — perceived latency is a feature, and streaming fixed it.
- What I would redesign: the billing normalization grew organically across two providers; I would model the entitlement state machine explicitly up front next time.
- Remaining tech debt: IVFFlat index tuning is manual; HNSW migration is queued. The secret scanner is a blunt regex and should move to an AST-based check.
- Future improvements: multi-platform publishing (X, threads), and a "voice drift" detector that flags when generated output strays from the DNA.
Media
- Product walkthrough recording of the composer streaming on-voice output.
- Screenshots of the Voice DNA training screen and the auto-publish scheduler.
- Architecture diagram of the RAG + edge-function + pg_cron pipeline.
Documentation Links
- Live product: thefoundervoice.com
- FounderVoice is a closed-source commercial SaaS; internal architecture and runbooks are maintained privately.
Build Logs
- Requirements & planning. Framed the entire product as one bet: output must sound like the user. Everything else (billing, publishing) is table stakes. Chose Supabase to compress backend surface area for a solo build.
- Architecture. Decided vectors live in Postgres; edge functions own secrets and models; RLS is the tenancy boundary.
- Implementation. Built the sample → chunk → embed pipeline, then
match_voice_chunks, then streaming generation. Added LinkedIn OAuth with HMAC-signed state and AES-256-GCM token encryption. - Testing. Verified RLS isolation with cross-tenant probes; added the client-bundle secret scanner to the build.
- Deployment. Vercel for the app, Supabase for backend, pg_cron for auto-publishing. Wired dual billing webhooks with idempotency.
- Monitoring. Track generation latency percentiles and provider error rates; rate-limit at 30 req/min/user.
- Retrospective. The vector-in-Postgres decision paid for itself; the billing layer is the next refactor.