Guides · How Voci is built
Under the hood

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, not middleware.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-5 throughout, 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.tsx wraps the app in ClerkProvider + ConvexProviderWithClerk; components call Convex through useQuery / useMutation.
Server
auth()getToken() from @clerk/nextjs/server, passed as { token } to fetchQuery / fetchMutation. Canonical example: app/app/new/actions.ts.
Inside Convex
convex/auth.config.ts reads CLERK_FRONTEND_API_URL, set on the Convex deployment rather than in .env.local.
The gate
proxy.ts protects /app/* and the named AI and data /api/* routes. Everything else, including /docs and /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 issuesstepsdrafts; sharing hangs off issueMembers and issueInvites.

TableWhat it holds
usersClerk id, lowercased email, UI language, and the two monthly counters (projects, weighted AI tokens) with the month bucket they belong to.
issuesOne plan: raw input, summary, status, scope, visibility and share token, view/share counts, and when the updates agent last checked it.
stepsOrdered actions under an issue: kind, title, description, target and target contact details, done flag, send record, signature count, outcome.
draftsOne editable draft per step.
issueMembersMembership and role (owner / collaborator). The authorization layer reads this, not issues.userId.
issueInvitesCollaboration invites held by email until the invitee first signs in with that address.
signaturesOne row per (petition step, account); the unique index is what enforces one signature per user. steps.signatureCount is the denormalized copy.
petitionInvitesInvited email plus a status — never deleted. Statuses are what keep declines invisible to the owner and block re-invite spam.
userProfilesAddress, normalized address, and the reps array resolved from it. The array is overwritten wholesale on every lookup.
contactsHand-entered local officials. A separate table precisely because userProfiles.reps is replaced on each lookup and these must survive.
officialsCanonical elected officials, deduplicated across all users by externalId — bioguide id for federal, otherwise a level:state:chamber:district seat key.
repAccountsLinks a Clerk account to an official it represents, with a pending / verified / revoked status. Gates the reps surface.
researchPer-issue brief: status, start time, summary, sources, error.
messagesPer-issue chat turns.
issueUpdatesDevelopments found by the daily monitoring agent, with a seen flag driving the dashboard badge.
responsesReplies 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.
rateLimitsFixed-window counters keyed by (key, action).
aiUsageAudit 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/draft streams Claude tokens straight to the client as text/plain; the editor renders them live.
  • api/draft-questions is blocking JSON, and best-effort — an empty or failed round falls through to instant drafting rather than blocking the user.
  • api/response-triage is 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/chat streams 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) and api/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.

Email

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.
  • renderPlainEmailunbranded, 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_PREFIXES in lib/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 repAccounts row. Only verified sees 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:3000

The 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.

Something here wrong or missing? Tell us.