How Voci is built
For developers: the stack, the data model, the AI patterns, and how to run it locally.
The stack
Voci is one Next.js application with a Convex backend and Anthropic Claude doing the writing. Three pillars, and the boundaries between them are the thing to hold onto:
- Next.js 16 App Router
- React 19, Tailwind v4. Server actions and route handlers are the AI entry points. Note this is Next 16 — the request-level gate is
proxy.ts, notmiddleware.ts. - Convex
- Database, realtime queries, and serverless mutations. The source of truth — there is no SQL and no ORM. Clients subscribe, so most UI updates without a refetch.
- Anthropic Claude
claude-sonnet-4-5throughout, one model constant. System blocks use ephemeral prompt caching.
Around the edges: Clerk v7 for accounts, Geocodio for address → legislator lookup, Resend for outgoing mail, and Vercel for hosting.
Repo layout
app/ Next.js App Router — pages, route handlers, UI components api/ AI and data endpoints, all runtime = "nodejs" app/ the signed-in product (dashboard, issue, step editor) p/[token]/ public read-only plan pages docs/ these guides reps/ the Voci Reps surface (internal prefix — see below) convex/ schema, queries, mutations, crons, access control lib/ ai/ Anthropic client, prompts, and one helper per AI surface civic/ Geocodio lookup and local-target matching email/ Resend client and the two email shells i18n/ English source dictionary + eight committed translations docs/ the /docs page registry proxy.ts auth gate and reps-host rewriting
@/ is the path alias for the project root. Never hand-edit convex/_generated/ — it is rewritten by convex dev.
Auth: Clerk to Convex
Clerk's native Convex integration customizes the session token itself. There is no JWT template named convex — calling getToken({ template: "convex" }) returns a 404. Server code that needs a fresh token after a long-running call uses getToken({ expiresInSeconds }), which forces a Backend-API mint of a standard session token.
- Client
app/providers.tsxwraps the app inClerkProvider+ConvexProviderWithClerk; components call Convex throughuseQuery/useMutation.- Server
auth()→getToken()from@clerk/nextjs/server, passed as{ token }tofetchQuery/fetchMutation. Canonical example:app/app/new/actions.ts.- Inside Convex
convex/auth.config.tsreadsCLERK_FRONTEND_API_URL, set on the Convex deployment rather than in.env.local.- The gate
proxy.tsprotects/app/*and the named AI and data/api/*routes. Everything else, including/docsand/p/*, is public.
app/user-bootstrap.tsx materializes the Convex users row as soon as the client is authenticated. That is what claims pending issue invites and petition requests — without it, someone signing up from an invite email lands on an empty dashboard.
The data model
Everything is in convex/schema.ts. The spine is issues → steps → drafts; sharing hangs off issueMembers and issueInvites.
| Table | What it holds |
|---|---|
users | Clerk id, lowercased email, UI language, and the two monthly counters (projects, weighted AI tokens) with the month bucket they belong to. |
issues | One plan: raw input, summary, status, scope, visibility and share token, view/share counts, and when the updates agent last checked it. |
steps | Ordered actions under an issue: kind, title, description, target and target contact details, done flag, send record, signature count, outcome. |
drafts | One editable draft per step. |
issueMembers | Membership and role (owner / collaborator). The authorization layer reads this, not issues.userId. |
issueInvites | Collaboration invites held by email until the invitee first signs in with that address. |
signatures | One row per (petition step, account); the unique index is what enforces one signature per user. steps.signatureCount is the denormalized copy. |
petitionInvites | Invited email plus a status — never deleted. Statuses are what keep declines invisible to the owner and block re-invite spam. |
userProfiles | Address, normalized address, and the reps array resolved from it. The array is overwritten wholesale on every lookup. |
contacts | Hand-entered local officials. A separate table precisely because userProfiles.reps is replaced on each lookup and these must survive. |
officials | Canonical elected officials, deduplicated across all users by externalId — bioguide id for federal, otherwise a level:state:chamber:district seat key. |
repAccounts | Links a Clerk account to an official it represents, with a pending / verified / revoked status. Gates the reps surface. |
research | Per-issue brief: status, start time, summary, sources, error. |
messages | Per-issue chat turns. |
issueUpdates | Developments found by the daily monitoring agent, with a seen flag driving the dashboard badge. |
responses | Replies the user logged against a step — pasted mail, or notes from a call — each with Claude's triage of it. Intake is manual: Voci never receives mail on the user's behalf. seen drives the dashboard badge, same as issueUpdates. |
rateLimits | Fixed-window counters keyed by (key, action). |
aiUsage | Audit trail: one row per completed AI call. The authoritative monthly counter lives on the users row — this table is the breakdown, not the quota. |
AI invocation patterns
Prompts live in one place, lib/ai/prompts.ts, with one helper per surface in lib/ai/. There are two shapes, chosen by how long the call takes:
1. Blocking server action
analyzeProblem (lib/ai/analyze.ts) is called from the createIssue server action. Claude returns JSON inside <result>…</result>, parsed with zod; invalid output throws AnalyzeError and the form shows a friendly message. The prompt also has an explicit refusal path returning { refused: true, reason }.
2. Streaming and long route handlers
All under app/api/* with runtime = "nodejs":
api/draftstreams Claude tokens straight to the client astext/plain; the editor renders them live.api/draft-questionsis blocking JSON, and best-effort — an empty or failed round falls through to instant drafting rather than blocking the user.api/response-triageis blocking JSON too. It takes only a response id — everything the prompt sees is re-read from Convex with the caller's token — and writes Claude's verdict back onto the row. The response is already stored before it runs, so every failure leaves an untriaged record and a retry button rather than losing what the user pasted.api/chatstreams too, and additionally persists each turn into Convex (research.appendMessage) so the conversation survives a reload. It also re-mints the Clerk token mid-stream — session tokens live about sixty seconds, and the stream can outlast one.api/research(maxDuration = 300) andapi/local-officials(120) are the long ones. Research writes status and results back into Convex (research.start/complete/fail) rather than returning them over the response body, so the panel fills in reactively and a run survives the user navigating away.
The one tool loop
runWebSearchAgent in lib/ai/loop.ts is the only agent loop, shared by research, the daily updates agent, and the local-official finder. It uses Anthropic's server-side web_search tool and handles stop_reason: "pause_turn" by re-sending with the assistant content appended, bounded by maxContinuations. It collects text from every chunk of the turn — the final message may hold only the tail — and sums usage across continuations. Written with relative imports only, so it runs inside both a Next route handler and a Convex "use node" action.
Quotas and rate limits
Two independent mechanisms, both in convex/limits.ts.
Monthly allowances. MONTHLY_PROJECT_LIMIT = 10 and MONTHLY_TOKEN_LIMIT = 3_000_000 billable tokens. “Billable” is a cost proxy: raw counts are normalized to input-token equivalents by TOKEN_WEIGHTS (input 1, output 5, cacheCreation 1.25, cacheRead 0.1), tracking Sonnet's price ratios so one number governs cheap and expensive calls alike.
The pattern is check before, charge after: assertAiBudget(token) → make the call → recordAiUsage(token, feature, usage). A single call can overshoot, and the next one is refused — a token count is not knowable until the model has answered. Background cron work has no signed-in caller, so it pre-checks the owner's budget and charges them through internal.limits.recordAiUsageFor.
Per-action rate limits. Fixed windows in ACTION_LIMITS, keyed by the caller's users id — except the anonymous share endpoint, which is keyed by a server-computed HMAC of the caller IP so nobody can burn another user's budget.
Emails are composed as blocks, never as raw markup: lib/email/template.ts derives the multipart/alternative HTML and text parts from one block list, so the two cannot drift, and everything interpolated is escaped — the unauthenticated share route takes attacker-controlled fromName and note values.
Two shells, and the split is deliberate:
renderEmail— branded (wordmark, card, accent button) for citizen-facing mail: invites, shares, petition asks.renderPlainEmail— unbranded, typography only, and what constituent letters to officials use. Branding is what makes a letter read as a campaign blast in a staffer's inbox. Do not brand the letters.
lib/email/client.ts owns the single call that reaches Resend. Email copy is English-only, same precedent as the public share page. Delivery events come back through a Resend webhook handled in convex/http.ts.
Internationalization
lib/i18n/strings.ts is the English source of truth for every UI string, and its Dict type makes a missing key a compile error in all eight committed dictionaries under lib/i18n/dictionaries/. Never hardcode user-facing text in a component — add a key and use useT().
Language lives in the voci_lang cookie (read server-side in app/layout.tsx for translated SSR) and in users.language for signed-in users. Keep lib/i18n/languages.ts in sync with LANG_CODES in convex/users.ts. AI output follows the user's language through languageDirective().
Some surfaces are English-only by design, and adding useT() to them piecemeal is not an improvement: these docs, /p/[token], /app/admin, the reps surface, all email copy, and the local-official finder. Long-form legal pages take the other route — pre-translated static HTML in public/ (terms.html, guidelines.html, repinfo.html plus per- language copies).
Two surfaces, one deployment
Voci Reps is the same app, same deployment, same Convex, same Clerk instance — not a separate project and not a branch. proxy.ts reads the Host header and rewrites the reps hostname onto the internal /reps route tree; lib/hosts.ts owns that logic.
/reps/*is an internal prefix only. It 404s on the reps host and redirects there from the apex, so every page has one address.SHARED_PREFIXESinlib/hosts.ts—/sign-in,/sign-up,/docs,/api— are not rewritten. Sign-in especially: everything else on the reps host is gated, so rewriting it would deadlock the auth gate.- A rep is an ordinary Clerk user plus a
repAccountsrow. Onlyverifiedsees anything. Claims auto-verify only when the Clerk-verified email is on the office's own domain; everything else waits in a pending queue reviewed from the CLI. - The whole surface is behind
NEXT_PUBLIC_REPS_ENABLED. Unset, the reps host redirects to the apex.
Locally, http://reps.localhost:3000 works with no /etc/hosts entry in Chrome, Edge and Firefox. Safari needs one.
Running it locally
npm install
npx convex dev # terminal 1 — run CONCURRENTLY.
# Watches convex/, regenerates convex/_generated/,
# pushes the schema.
npm run dev # terminal 2 — http://localhost:3000The first convex dev run creates a Convex project and writes NEXT_PUBLIC_CONVEX_URL and CONVEX_DEPLOYMENT into .env.local. Leave it running.
npm run lint # ESLint — lint errors block `next build` npx tsc --noEmit # type-check npm run build # production build npx convex run migrations:backfillMemberships # one-time upgrade migration npx convex run repAccounts:pendingClaims # rep claim review queue
Environment variables
In .env.local
- NEXT_PUBLIC_CONVEX_URL, CONVEX_DEPLOYMENT
- Written for you by npx convex dev.
- NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, CLERK_SECRET_KEY
- From the Clerk dashboard.
- NEXT_PUBLIC_CLERK_SIGN_IN_URL / SIGN_UP_URL
- /sign-in and /sign-up.
- …SIGN_IN_FALLBACK_REDIRECT_URL / SIGN_UP_…
- /app and /app/new.
- ANTHROPIC_API_KEY
- Read implicitly by the Anthropic SDK.
- GEOCODIO_API_KEY
- Address → federal and state legislators. Recommended; the free tier is generous.
- RESEND_API_KEY, RESEND_FROM
- Outgoing mail. The resend.dev sandbox sender only delivers to the address on your Resend account — verify a domain for production.
- NEXT_PUBLIC_SITE_URL
- Absolute URLs in emails and share links, no trailing slash. Falls back to the request host in dev.
- NEXT_PUBLIC_TURNSTILE_SITE_KEY, TURNSTILE_SECRET_KEY
- Optional bot check on the anonymous share-by-email form. Unset means rate limiting only.
- NEXT_PUBLIC_REPS_ENABLED, NEXT_PUBLIC_REPS_HOST
- Open the reps surface, and override its hostname.
- FEEDBACK_EMAIL_TO
- Where /feedback delivers.
- AUTH_DEBUG
- Set to 1 for JWT diagnostics from lib/auth/jwt-debug.ts.
On the Convex deployment
Set with npx convex env set NAME value, not in .env.local:
- CLERK_FRONTEND_API_URL
- Read by
convex/auth.config.ts. This is the variable the code actually uses. - ADMIN_EMAILS
- Comma-separated. Gates /app/admin, decided server-side — non-admins get a not-found shell.
- RESEND_API_KEY, RESEND_WEBHOOK_SECRET
- For Convex-side sending and the delivery webhook in convex/http.ts.
Deployment
Vercel. vercel.json runs npx convex deploy --cmd-url-env-var-name NEXT_PUBLIC_CONVEX_URL --cmd 'next build' on production so the schema and functions ship with the frontend; previews run a plain next build against the dev deployment.
The daily updates cron is registered in convex/crons.ts and runs on the Convex deployment, so it is live wherever that deployment is — not tied to the Vercel build.