Adding a tenant
A tenant is one team's instance: a single Cloudflare Worker + D1 database + queue. The Worker serves both the API and the app, and all bill data flows from your central service — tenants never call LegiScan directly. This page assumes your central service is already running.
Throughout, replace [slug] with a lowercase identifier for the team (e.g. org-nj) and [STATE] with its two-letter abbreviation.
TIP
Shortcut: scripts/new-instance.sh automates every step below — it creates the database, the queue, and the bucket for comment attachments, appends the config block, deploys, sets the secret, binds the tenant on central, registers it, optionally seeds history, and creates the first user, then verifies the instance end to end. Use --from-step N to resume after a failure, and --no-attachments to leave comment attachments off. The manual steps below are the reference, and worth reading once before your first run.
NOTE
Renamed resources? The commands below name every Cloudflare resource floorvote-… — the worker, the D1 database, the queues, and the shared floorvote-dlq. If your deployment uses a different resource prefix (anything other than the default floorvote), substitute it consistently everywhere you see floorvote- below — and make sure it matches the TENANT_QUEUE_PREFIX set on your central service. Otherwise central looks for a queue that doesn't exist, silently creates a phantom with no consumer, and the tenant receives no bills.
Cloudflare credentials
Adding a tenant reuses the account-level credentials you set up once and then reuse for every team:
- Deploy token (
CLOUDFLARE_API_TOKEN) — the one you already created and exported when setting up the central service. Same token here. - AI Gateway — AI runs on the tenant side, routed through a Cloudflare AI Gateway for keyless, unified billing. Create one gateway once and every tenant shares it: in the dashboard, AI → AI Gateway → Create Gateway, name it (e.g.
floorvote), and put the slug in each tenant'sCF_AIG_GATEWAYvar. - AI Gateway token (
CF_AIG_TOKEN) — in your gateway's Settings, Create authentication token (it gets the "Run" permission — copy it now, it's shown once) and toggle Authenticated Gateway on. This is the one secret you set on every tenant, with the same value each time.
NOTE
The AI Gateway token is account-scoped — any "AI Gateway Run" token can send requests through every gateway on the account. That's fine here because all tenants intentionally share one gateway.
IMPORTANT
A gateway and a token are not enough — you also need credits. FloorVote calls Google Gemini, a third-party provider, and sends no provider key of its own. AI Gateway resolves credentials in a fixed order: a provider key on the request, then a stored BYOK key, then Unified Billing against your prepaid credit balance. With no key stored, every request lands on that balance, and an empty balance returns HTTP 402 — Insufficient wholesale credits.
This fails silently from the app's point of view: bills arrive with no summary, no tags, and no relevance score, and nothing in the database records why. Check your Worker logs if AI seems inert.
Two ways to satisfy it:
- Load credits on the Cloudflare dashboard under AI → AI Gateway → Credits, and set an auto top-up so it cannot quietly run dry. Note credits carry a 5% purchase fee — $100 of credit costs $105. Inference itself is passed through at the provider's own per-token rates with no markup.
- Bring your own key (free of Cloudflare credits) — store a Google AI Studio key on the gateway and Google bills you directly. It must be stored under the
defaultalias; a key saved under any other alias is not consulted on this path, and requests still fall through to Unified Billing.
The gateway's Workers AI Billing setting (Standard vs Unified) does not govern this. That setting applies only to Workers AI @cf/… models, so switching it to Standard will not make Gemini requests bill to your card.
For which pieces can be scripted versus created by hand in the dashboard, see docs/internal/tenant-automation.md in the repository.
Prerequisites
- Your central service is deployed and running.
wranglerinstalled and yourCLOUDFLARE_API_TOKENexported (see the central setup).- The repository cloned, with dependencies installed.
- Your AI Gateway created, and its token and slug handy.
Step 1: Create a database
npx wrangler d1 create floorvote-[slug]Save the database_id from the output.
Step 2: Create a queue
npx wrangler queues create floorvote-[slug]-queueEvery tenant's queue consumer names a shared dead-letter queue. It isn't created for you, and a tenant deploy fails outright if it doesn't exist — so create it once, before your first tenant:
npx wrangler queues create floorvote-dlqLater tenants reuse the same one; running this again for a second tenant is harmless but unnecessary.
Step 2b: Create a bucket for comment attachments (optional)
Members can attach files to comments once the instance has a bucket bound. Skip this step, and the bucket block in Step 3, to leave attachments off.
npx wrangler r2 bucket create floorvote-[slug]-filesA new bucket is private, and should stay that way: don't turn on public access or an r2.dev URL. Files go to and from it through the instance's Worker, so it needs no CORS rule and no API token.
Step 3: Add a config block to api/wrangler.toml
Copy the CF_ACCOUNT_ID, CF_AIG_GATEWAY, CENTRAL_API_URL, and SUPERADMIN_JWT_PUBLIC_KEY values from an existing tenant block — they're identical across every tenant. See api/wrangler.example.toml for a documented template.
NOTE
Your first tenant? There's no existing block to copy those four from yet — here's where each comes from:
CENTRAL_API_URL— your central worker's URL, which wrangler printed (and told you to note) when you deployed central. See the central setup. No dashboard needed.CF_ACCOUNT_ID— your Cloudflare account ID.CF_AIG_GATEWAY— the AI Gateway slug you created under Cloudflare credentials above.SUPERADMIN_JWT_PUBLIC_KEY— the public keynode scripts/gen-superadmin-keys.mjsprints when you turn on the superadmin dashboard. Without the dashboard you can leave this var out of a hand-written block, thoughscripts/new-instance.shwon't run until it is set.
IMPORTANT
The shared, top-level [assets] block (inherited by every tenant env) must include run_worker_first = ["/api/*"]:
[assets]
directory = "../web/dist"
binding = "ASSETS"
not_found_handling = "single-page-application"
run_worker_first = ["/api/*"]Without it, a compatibility date of 2025-04-01 or later makes browser navigations to any /api/* URL serve the SPA shell instead of hitting the Worker — so opening a bill text with "Open in new tab" lands on the app feed. Client-side fetch() still reaches the Worker, so the in-page bill-text viewer works either way, which makes the difference easy to miss. This is set once in the top-level block, not per tenant.
[env.[slug]]
name = "floorvote-[slug]"
routes = [{ pattern = "[slug].[your-domain]", custom_domain = true }]
# wrangler doesn't inherit the top-level [define] into named environments, so
# each env needs its own or wrangler warns on every command. BUILD_SHA is
# overridden at deploy; "'dev'" is just the placeholder.
[env.[slug].define]
BUILD_SHA = "'dev'"
[env.[slug].vars]
APP_URL = "https://[slug].[your-domain]"
ASSOCIATION_NAME = "[Full Organization Name]"
# optional — footer credit
OPERATOR_NAME = "Your Operator Name"
# optional — footer link
OPERATOR_URL = "https://example.org"
# optional — feedback recipient(s), and who users are told to ask for source.
# Comma-separated bare addresses. An entry that isn't a valid address is logged
# and skipped, so feedback still reaches the rest.
OPERATOR_CONTACT_EMAILS = "support@example.org"
# optional — where your source is published. If you modify FloorVote and run it
# for other people, AGPLv3 §13 obliges you to offer them the source of your
# version; this is the link the footer offers. Leave it unset and the footer
# points at the upstream repository, which is only accurate while you run the
# code unmodified. Set it to "" if you have no published source — the license
# notice stays, the link goes, and users are pointed at your contact address
# instead.
OPERATOR_SOURCE_URL = "https://github.com/yourorg/floorvote"
# e.g. "NJ"; leave "" for a multi-state team
STATE = "[STATE]"
TENANT_ID = "[slug]"
CENTRAL_API_URL = "https://<your-central>.workers.dev"
AI_GATEWAY_ENABLED = "true"
CF_ACCOUNT_ID = "<your-account-id>"
CF_AIG_GATEWAY = "<your-gateway-slug>"
# optional — the analysis model and its thinking budget. Both have built-in
# defaults; see "Choosing the analysis model" below before setting either.
GEMINI_MODEL = "gemini-2.5-flash"
GEMINI_THINKING_BUDGET = "0"
# which email provider is tried first: "cloudflare" or "resend". With both
# configured, the other is the fallback (see "Optional fallbacks" below).
EMAIL_PROVIDER = "cloudflare"
# who gets cron-failure and email outage alerts. Comma-separated bare addresses
# (not "Name <address>"). An entry that isn't a valid address is logged and
# skipped, so the alert still reaches the rest.
ALERT_EMAILS = "ops@example.org"
# the domain this team is served on
APP_DOMAINS = "[your-domain]"
# sender address (domain must be verified to send)
EMAIL_FROM = "notifications@[your-domain]"
# optional — bulk mail (digest, week-ahead) sender; segments sending reputation
# onto a dedicated subdomain. Falls back to EMAIL_FROM when unset. Domain must be verified.
EMAIL_FROM_BULK = "notifications@mail.[your-domain]"
# copy verbatim from any existing tenant block
SUPERADMIN_JWT_PUBLIC_KEY = '<your-ES256-public-JWK>'
# optional; see the Turnstile page
TURNSTILE_SITE_KEY = "<your-turnstile-site-key>"
[[env.[slug].d1_databases]]
binding = "DB"
database_name = "floorvote-[slug]"
database_id = "<database-id-from-step-1>"
migrations_dir = "migrations"
# Comment attachments: the bucket from Step 2b. Leave this out to keep them off.
[[env.[slug].r2_buckets]]
binding = "ATTACHMENTS_BUCKET"
bucket_name = "floorvote-[slug]-files"
[[env.[slug].queues.producers]]
binding = "BILL_QUEUE"
queue = "floorvote-[slug]-queue"
[[env.[slug].queues.consumers]]
queue = "floorvote-[slug]-queue"
max_batch_size = 10
max_batch_timeout = 30
max_concurrency = 3
dead_letter_queue = "floorvote-dlq"
# Login rate limiter — 10 requests per 60s per IP. Each tenant needs a unique
# namespace_id (any integer, just not shared with another tenant).
[[env.[slug].ratelimits]]
name = "LOGIN_RATE_LIMITER"
namespace_id = "<unique-integer>"
[env.[slug].ratelimits.simple]
limit = 10
period = 60
# One-click mute link in reply emails — 120 requests per 60s per IP. Mail
# providers send many members' unsubscribes from a few IPs, so it gets a higher
# limit than login. Optional: without it the mute link uses LOGIN_RATE_LIMITER.
# Needs its own unique namespace_id, different from the login limiter's.
[[env.[slug].ratelimits]]
name = "MUTE_LINK_RATE_LIMITER"
namespace_id = "<another-unique-integer>"
[env.[slug].ratelimits.simple]
limit = 120
period = 60
# Object-level authorization. Central reads `props.tenantId` — a per-binding value
# Cloudflare sets at deploy time and guarantees is authentic — and rejects any
# tenant-scoped call targeting a DIFFERENT tenant. Omit it and central falls back
# to lenient mode for this tenant. It MUST equal the TENANT_ID var above.
[[env.[slug].services]]
binding = "CENTRAL"
service = "<your-central-worker-name>"
entrypoint = "TenantApi"
props = { tenantId = "[slug]" }
[[env.[slug].send_email]]
name = "EMAIL"
[env.[slug].triggers]
# daily — sends digest and week-ahead emails
# hourly — re-queues bills whose AI analysis the provider gateway shed
crons = ["0 11 * * *", "0 * * * *"]IMPORTANT
Register both crons. The Worker dispatches on the cron string, so a schedule you leave out does not error — it just means that job never runs. Drop the hourly one and bills the AI gateway sheds during a rate-limit window stay un-analyzed until someone finds them by hand. (A demo tenant is the one exception: it takes a reset cron instead of both.)
The entrypoint = "TenantApi" on the CENTRAL binding is what lets outbound tenant-to-central calls authenticate by arrival (that named entrypoint is only reachable over same-account bindings), so the tenant carries no shared secret.
NOTE
Multi-state team: set STATE = "" and store the state list in the database instead (Step 4b). APP_DOMAINS is normally one domain; list two only while migrating domains, then drop back to one.
NOTE
Queue delivery needs no central change. As long as CF_QUEUES_TOKEN is set on central, central finds this tenant's queue at registration and delivers to it. (A static TENANT_QUEUE_ producer binding on central is only worth adding for a very high-volume tenant; most never need it.)
TIP
To show your organization's logo in the app, add a file named exactly web/public/operator-logo.svg before you deploy, since the deploy builds it in. If it's absent, no logo renders (and a harmless 404 for /operator-logo.svg in the browser network log is expected).
The app has a light and a dark color mode, so you can add a second, optional file, web/public/operator-logo-dark.svg, for the dark one. In dark mode the app uses the dark file when it's there and falls back to operator-logo.svg when it isn't, so a deployment with only the regular file keeps working. Light mode needs the regular file: with only a dark file, light mode shows no logo. The logo follows the mode the reader is in, whether they chose Light or Dark on their profile or left it on System, and it swaps if their device switches while the app is open.
| File | Shown on | Keep contrast against |
|---|---|---|
web/public/operator-logo.svg | Light mode (and dark mode, when there is no dark file) | A light background |
web/public/operator-logo-dark.svg (optional) | Dark mode | A dark background |
A logo drawn for a light page, with gray or dark text, can nearly vanish on the dark background, so check each file against the background it appears on. If you ship only the regular file, make sure it stays legible on both. A missing dark file also shows as a harmless 404 in the browser network log.
api/wrangler.toml is gitignored because it holds your account and database IDs, so don't commit it. That also leaves it with no version history, so keep a copy somewhere safe: set WRANGLER_BACKUP_DIR and npm run deploy:tenant copies it there after each deploy.
Step 4: Run migrations
From api/:
npx wrangler d1 migrations apply floorvote-[slug] --remote --env [slug]WARNING
Always use migrations apply, never d1 execute with raw SQL files — apply tracks which migrations have run.
WARNING
Upgrading an existing deployment: some migrations must run either strictly before or strictly after the worker is redeployed — running them in the wrong order can silently overwrite an admin-customized tenant's AI config or break every tenant query until the migration lands. Check the comment block at the top of each new migration file for an explicit ordering requirement before you deploy. This does not apply to a fresh install: there is no old worker or old central deployment yet, so there's nothing for a new migration to be ordered against.
Step 4b: Seed the state list (multi-state teams only)
Single-state teams skip this — the STATE var is enough. For a multi-state team, from api/, store the list in the database:
npx wrangler d1 execute floorvote-[slug] --remote --env [slug] \
--command "INSERT OR REPLACE INTO association_config (key, value) VALUES ('state_coverage', '[\"NJ\",\"RI\",\"WY\",\"WI\"]')"Step 5: Deploy the worker
From api/:
npm run deploy:tenant -- [slug]This builds the frontend, applies pending migrations, and deploys the worker. Never run wrangler deploy --env [slug] directly — that skips the build and migrations.
Step 6: Set the tenant secrets
Set these after the first deploy — wrangler secret put errors if the worker doesn't exist yet. From api/, CF_AIG_TOKEN is always required — paste the same value you used for your last tenant:
npx wrangler secret put CF_AIG_TOKEN --env [slug]If you set TURNSTILE_SITE_KEY in Step 3, also set the matching secret now, from the same Turnstile widget as your other tenants:
npx wrangler secret put TURNSTILE_SECRET_KEY --env [slug]WARNING
Don't set one half without the other. With only the sitekey, the login page shows the Turnstile widget but the server never checks it, so the protection is silently off. With only the secret, every login is rejected, because no widget sends a token. scripts/new-instance.sh sets the secret for you whenever the sitekey is set.
TIP
Optional fallbacks. GEMINI_API_KEY is only read if you flip AI_GATEWAY_ENABLED to "false". You don't set LEGISCAN_API_KEY on a tenant — only central calls LegiScan.
RESEND_API_KEY is worth setting even on Cloudflare Email Service, and is required if you set EMAIL_PROVIDER="resend". The EMAIL binding goes first only with EMAIL_PROVIDER="cloudflare". Otherwise Resend goes first and needs RESEND_API_KEY, and a bound EMAIL binding is the fallback. With both configured, an email the first provider fails to send is retried once through the other one: Resend when the first is Cloudflare, Cloudflare when the first is Resend.
Which emails fall back. Sign-in, account, notification, and operator email falls back. That covers login and invite links, the email-change emails, mention and reply emails, and mail to operators and admins (feedback, invite-bounce notices, job alerts, and sample emails). Digests and week-ahead mail never do, so an outage on the first provider can't push a whole recipient list onto the second and use up its quota. An address that can't receive mail (it bounced or reported spam) is never retried through the other provider either.
The hourly email-health check alerts ALERT_EMAILS through the healthy provider first, then the failing one. With only one provider configured, there is no fallback, and the alert goes through the same provider that is failing. EMAIL_FROM must be verified on both providers for fallback to work.
Step 7: Bind the tenant on central
Central-to-tenant calls (engagement stats, force-register, digests) run over a per-tenant binding. Because the tenant worker must exist first, do this after the first deploy. Add the binding to central/wrangler.toml (the name is TENANT_ + the slug uppercased, hyphens becoming underscores):
[[env.legiscan.services]]
binding = "TENANT_[SLUG_UPPERCASED]"
service = "floorvote-[slug]"
entrypoint = "CentralApi"Then redeploy central so the binding resolves:
cd central && npm run deploy:centralStep 8: Register the tenant with central
This syncs the team's keywords (empty on a new instance) and state coverage to central, and provisions its queue:
TIP
You already have both of these — no dashboard needed. https://<your-central>.workers.dev is your CENTRAL_API_URL from the Step 3 config block (and the URL wrangler printed when you deployed central). <central ADMIN_SECRET> is the ADMIN_SECRET you set on central during its setup — the same value for every tenant. To make the commands here and in Step 12 copy-pasteable, export them once:
export CENTRAL="https://<your-central>.workers.dev" # your CENTRAL_API_URL
export ADMIN_SECRET="<central ADMIN_SECRET>"curl -X POST "$CENTRAL/api/tenants/[slug]/force-register" \
-H "x-admin-secret: $ADMIN_SECRET"(The tenant also self-registers on its daily cron and whenever you save config in the app — force-register just does it right now.) Verify:
# Expected response: { "ok": true }
curl https://[slug].[your-domain]/api/healthStep 9: Set up the custom domain
The custom domain comes from the routes field in your config block (Step 3) — when you deploy, Cloudflare provisions the domain and its TLS certificate automatically. If your domain's DNS is managed in Cloudflare, there's nothing more to do. For an external domain, add a CNAME pointing the subdomain at floorvote-[slug].<your-account>.workers.dev and update APP_URL to match.
Step 10: Create the founding owner
There's no self-signup. From api/, insert the first user directly, and make them an owner, not an admin — only an owner can grant the owner role, so a team whose first user is a plain admin can never have one:
npx wrangler d1 execute floorvote-[slug] --remote --env [slug] \
--command "INSERT INTO users (id, email, name, role) VALUES ('$(uuidgen)', '<owner-email>', '<owner-name>', 'owner')"Replace <owner-email> and <owner-name> with the founding owner's real email and display name; keep role as owner. Leave $(uuidgen) exactly as written — it's not a placeholder, it's a shell command your terminal runs to generate the user's unique id (standard on macOS and most Linux; if yours lacks it, paste any UUID instead).
They then log in with a magic link sent to that email address.
Step 11: Configure the instance
Now switch from the terminal to a browser. Go to your instance at the APP_URL from your Step 3 config block — https://[slug].[your-domain] (the custom domain from Step 9). There's no password: enter the founding owner's email from Step 10 and the app emails you a magic link; click it to sign in, then open Settings → Config. A new instance starts with no keywords and no AI instructions, so there are two ways to proceed from here:
- Path 1 (recommended — explore first). Leave the AI fields and keywords blank, seed the session in Step 12, and every bill arrives as a monitored bill with no AI cost. Browse and search the real corpus, then come back here and fill in both sections — and save both, in either order: keywords in the Bill keywords section, saved with Save keywords and sync (this is the button that queues the newly matching bills for analysis); and AI context, relevance question, and tag taxonomy in the AI instructions section, saved separately with Save AI instructions. Saving only one leaves the other on its blank default — in particular, writing AI instructions and clicking only Save keywords and sync leaves those instructions unsaved, silently falling back to the generic default. The free exploration window ends the moment you save the keywords — from then on, re-tuning keywords or instructions means paying for AI a second time on whatever newly matches.
- Path 2 (keywords already known). Write the config now, before seeding, so the Step 12 seed produces AI summaries on its first pass. Use the
wrangler d1 executeblock below to set it directly, or fill in the same fields in Settings → Config and save.
You can set these fields directly in the database if you prefer (from api/). They live in the association_config table:
| Key | Example |
|---|---|
keywords | ["election","voter","ballot","polling","absentee","referendum"] |
ai_context | "You are analyzing bills for an association of local election officials who administer elections at the county and municipal level." |
relevance_question | "Is this bill relevant to local election administration? Consider operational impact, funding, staffing, and legal authority." |
state_coverage | ["NJ"] — or ["*"] for all states, ["*","US"] for all states plus Congress |
tag_taxonomy | ["Voter Registration","Ballot Access","Election Funding","Poll Workers","Redistricting","Election Security"] |
wrangler d1 execute floorvote-[slug] --remote --env [slug] \
--command "INSERT OR REPLACE INTO association_config (key, value) VALUES
('keywords', '[\"election\",\"voter\",\"ballot\"]'),
('ai_context', 'You are analyzing bills for an association of local election officials.'),
('relevance_question', 'Is this bill relevant to local election administration?'),
('state_coverage', '[\"NJ\"]')"Each key is a row, so INSERT OR REPLACE also works for a key you set earlier, such as state_coverage in Step 4b.
What blank means. An unset ai_context, relevance_question, or tag_taxonomy falls back to a generic default that names the association and still works — nothing breaks. Unset keywords is different: it means no bill matches, so no bill is analyzed — central still links every bill to the team as a monitored bill, at no AI cost (see When do bills start flowing in? below), but none are analyzed until you set keywords and sync. To widen what gets analyzed, write broader keywords covering your real scope, or manually promote specific bills once they are in view as monitored bills. Bear in mind that keyword matching runs on a bill's title, its abstract, and its bill number (so hb 1019 or hb* track a bill or a prefix), and many states copy the title into the abstract verbatim — so in practice you are often matching against six or seven words, and broad keywords buy less coverage than they appear to. The keyword rules (whole words by default, * to widen, # for a digit) are the ones the Settings hint describes.
To analyze every bill instead, set the keywords to a single *. That is a keyword, not the ["*"] in state_coverage, which means every state. It works only as the sole keyword: in a longer list it is ignored, since it would make every other keyword redundant. Saving it asks for confirmation and shows how many bills it would queue, and every one of them costs AI usage, so treat it as a cost decision.
While you're here, set What you call your group under Labels (team, association, coalition, or a word of your own) — it drives the labels on the positions section. When you later change your AI instructions, Save AI instructions asks whether to apply them to bills that are already analyzed: prioritized bills only, all analyzed bills, or only future bill texts. Under the bill summary instructions, Summary citations is on by default and saves as soon as you toggle it. Turning it on offers to re-run AI on prioritized or all analyzed bills, since existing summaries get citations only when re-analyzed. Turned off, new analyses don't ask the model for citations and links already stored show as plain text, and turned back on, stored links show again. (A bill re-analyzed while it's off loses its stored links.) See how much it costs for what the citations add per analysis.
Step 12: Seed the active session(s)
Seeding loads a whole legislative session into the team at once — bills matching its keywords are analyzed, and the rest come in as monitored bills (no AI cost). This means the team mirrors the full session from day one.
TIP
No keywords yet is the expected case (Path 1 from Step 11). Seeding uses whatever keywords are already synced to central to decide which bills are analyzed — with none set, every bill comes in as a monitored bill, which is exactly the free-exploration state Path 1 wants. Save both sections when you're ready, as Step 11 describes.
There are two paths, depending on whether central already has the state's bills.
Path A: central already has the session
If another team already tracks the state, central has its bills. First, find the session's numeric id in central's sessions table — from central/ (the database name is the database_name in central/wrangler.toml):
npx wrangler d1 execute <central-db> --remote --env legiscan \
--command "SELECT session_id, session_name, year_start, sine_die FROM sessions WHERE state = '[STATE]' ORDER BY year_start DESC"Then seed in pages — the command handles up to 500 bills per call and tells you whether it finished:
curl -s -X POST \
"$CENTRAL/api/tenants/seed-session/[slug]?sessionId=[sessionId]&offset=0" \
-H "x-admin-secret: $ADMIN_SECRET"If the response shows "done": true, you're finished. If not, run it again with offset set to the nextOffset it returned. This loop does that for you until it's done:
OFFSET=0
while true; do
RESP=$(curl -s -X POST "$CENTRAL/api/tenants/seed-session/[slug]?sessionId=[sessionId]&offset=$OFFSET&limit=500" \
-H "x-admin-secret: $ADMIN_SECRET")
echo "$RESP"
DONE=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('done',''))")
NEXT=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('nextOffset') or '')")
if [ "$DONE" = "True" ] || [ -z "$NEXT" ]; then break; fi
OFFSET=$NEXT
doneThis makes no LegiScan API calls, and takes a few minutes for a large state (~10,000 bills) — it only links bills central already holds. Don't carry that figure over to Path B, which has to load those bills into central first and is far slower; see the timing warning there.
NOTE
If the queue rate-limits you. Linking a large session sends tens of thousands of queue messages, which can hit Cloudflare Queues limits. The endpoint then returns HTTP 429 and reports the offset it stopped at, so nothing is lost. Add &skipQueue=true to create the bill_tenants links without sending anything to a queue, page through the rest of the session that way, then queue the work in one pass once you have headroom:
curl -X POST "$CENTRAL/api/tenants/reprocess/[slug]" \
-H "x-admin-secret: $ADMIN_SECRET"reprocess sends straight to the team's queue and costs no LegiScan API calls.
NOTE
"Active" includes a session that has already adjourned. Seed the most recent regular (and any special) session — a legislature that has gone sine_die for the cycle is still exactly what a new team wants to monitor. (That's the sine_die = 1 row in the query above.)
Path B: brand-new state (central has no bills yet)
Load central from the LegiScan bulk zip first (as in Preload historical bills), but include the --tenant flag so it links the bills to this team in one shot:
CENTRAL_API_URL="$CENTRAL" CENTRAL_ADMIN_SECRET="$ADMIN_SECRET" \
npx tsx scripts/seed-legiscan.ts \
--from-dir path/to/RI/2026-2026_Regular_Session \
--state RI \
--session-id 2253 \
--tenant [slug] \
--remoteThe --session-id is LegiScan's numeric session id. You download the zip from the datasets page, but the site doesn't show the id — the reliable way to find it is to open any bill JSON in the extracted dataset (e.g. bill/HB0001.json; the names are zero-padded and the exact width varies by state) and read the session_id field near the top. (Or run the seeder with --from-api instead of --from-dir, which fetches the session list and prints each session_name (id=…) to pick from.)
An extracted session is hundreds of MB of JSON, so keep it out of version control — bulkseed/ and bulkseeds/ are both gitignored if you want it alongside the repo.
The --tenant flag links the bills and updates the team's state_coverage — no separate seed-session call needed.
WARNING
Budget hours, not minutes. The seeder writes to central's D1 in batches over HTTP, so throughput is bounded by round-trip latency, not by your machine — about 45 bills/min (measured 43–50/min across two states). Roll calls add time at a similar rate.
| Session size | Expect roughly |
|---|---|
| ~1,000 bills | 20 min |
| ~4,000 bills | 1.5 hr |
| ~12,000 bills | 4 hr |
Start a big state somewhere it can run unattended — a terminal you can leave open, tmux, or a background run with output going to a log. Seeding is idempotent, so if a run dies partway, re-run the same command and it picks up rather than duplicating.
NOTE
Central's sync waits for the seed. When the seeder creates a session, it creates it with sync off, so central's hourly sync doesn't spend LegiScan calls on bills the seeder is about to load. It turns sync on only after the bills have loaded and, with --tenant, the link step has finished. A seed that stops partway, or that skips bill files it can't read, leaves the session's sync off. Re-run the same command and sync comes on when that run completes. A session central already had keeps its sync setting.
If you give up on a seed and want central to sync the session anyway, run this command, which turns sync on and clears the seeder's marker for the session:
npx wrangler d1 execute <central-db> --remote --env legiscan \
--command "UPDATE sessions SET sync_enabled = 1 WHERE session_id = [sessionId]; DELETE FROM settings WHERE key = 'seed_pending_session:[sessionId]'"You can also tick sync enabled on the session's row under Sync schedules on central's admin dashboard Settings page and click Save. That leaves the seeder's seed_pending_session: marker in place, though. If you later turn sync off for the session and then a seed of it completes, the seed turns sync back on. The command above avoids that.
TIP
Watching a long seed. The seeder prints a running count. On a terminal that's an in-place counter; when stdout isn't a terminal (backgrounded, or piped to tee) it switches to one line per batch so a log file shows real progress. To check from another shell, count the rows in central directly:
npx wrangler d1 execute <central-db> --remote --env legiscan \
--command "SELECT COUNT(*) FROM bills WHERE session_id = [sessionId]"NOTE
Seeding several states at once? Run one invocation per session, sequentially. Parallel runs compete for the same D1 write path and the same queue budget, and the win would be small anyway since latency — not local CPU — is the limit. A team whose coverage is the ["*"] wildcard keeps it: the link step merges coverage additively and short-circuits on the wildcard, so seeding IL never narrows a wildcard team to ["IL"].
WARNING
--tenant --remote needs CENTRAL_API_URL. The seeder writes bills straight to central's database, but the --tenant link step calls central's API over HTTP. Without CENTRAL_API_URL (and the admin secret) it silently falls back to http://localhost:8787 and dies with fetch failed after seeding — the $CENTRAL and $ADMIN_SECRET you exported in Step 8 cover both.
WARNING
Per-legislator vote records are slow to seed on large states (20–30 min) and are skipped by default. Add --with-individual-votes to include them, or --skip-votes to skip roll calls entirely. Quota note: never bulk-queue bills without skipFetch — the seeder handles this for you; a bulk queue without it would spend a month of API budget in one run.
Step 13: Link bills and queue AI (only if you skipped --tenant)
Only needed if you ran Path B without --tenant. This links the seeded bills to the team and queues the keyword matches for text download and AI:
curl -X POST "$CENTRAL/api/tenants/seed-session/[slug]?sessionId=[sessionId]" \
-H "x-admin-secret: $ADMIN_SECRET"For more than 500 bills, page through it the same way as Path A (advance &offset= until "done": true).
Verify the instance end to end
Several of the steps above can fail without saying so: the site loads, but bills never arrive or email never sends. scripts/verify-instance.sh proves the instance works from end to end. new-instance.sh runs it as its last step; run it by hand against any instance too, for example after a deploy:
./scripts/verify-instance.sh [slug] # the four checks that cost nothing
./scripts/verify-instance.sh [slug] --ai # also analyze one bill (costs one AI call)It runs five checks, each cheap and none visible to the team's members, and prints pass, FAIL, or skipped for each:
| Check | What it proves |
|---|---|
| Health and build | The instance answers /api/health with the build you deployed. It tries the custom domain first and falls back to the workers.dev URL, so it works before the domain is verified. |
| Registration | Central knows the instance, with its states and keywords, is bound to it, and has a queue to deliver bills to. |
| Queue delivery | Central sends the instance one bill it already stores, through the real queue, and the instance receives it and can read it back from central. The instance records the arrival and stops, so it tracks no bill it didn't already. |
| AI analysis | The instance analyzes one stored bill end to end through your AI Gateway. Off by default when run by hand, since it costs one model call (new-instance.sh turns it on). Reported as skipped when the instance has no AI configured. Nothing is saved, so no bill is left tracked. |
A sample digest goes to the operator: the first address in the instance's ALERT_EMAILS, or --to <email>. It never sends a sign-in link, and never goes to the instance's owner or members. Works with either email provider, and is skipped if there is no address to send to. |
Above the checks, an info line says whether comment attachments are on, and with which bucket. It reads the instance's ATTACHMENTS_BUCKET binding in api/wrangler.toml, so it reflects your config rather than the running instance, and an instance without attachments still passes.
Run it after you have redeployed central and the instance on the same release: if either is older than the script, it says so and tells you to redeploy, and central refuses to send the delivery probe to an instance that would not understand it. None of it calls LegiScan. A failure names the check and its likely cause, for example Central has no binding for this instance: did central redeploy after the binding step? When new-instance.sh runs it, a failure also prints the --from-step command to resume from, and the script exits non-zero. Run by hand, it exits non-zero on any failure.
The script reads the same configuration new-instance.sh does (scripts/.env.ops, central/.dev.vars), so a deployment with its own RESOURCE_PREFIX, domain, and central needs no flags. Pass --central-url, --admin-secret, --app-url, --worker-url, or --prefix to override any of it, and run ./scripts/verify-instance.sh --help for the rest. By default it expects the build to be this checkout's commit; if the instance was deployed from another, pass --expect-build <sha> (or --expect-build any).
The optional setup report
FloorVote is self-hosted, so the project never sees an install. To learn whether setup works and which step trips people up, scripts/new-instance.sh can send one anonymous note when it ends. It asks once, before it preflights, and the answer defaults to No:
Send an anonymous setup report? [y/N]Before you answer it prints exactly what it would send, and if you say yes it prints the report again as it goes out. The whole report is these four fields:
{
"event": "tenant_setup",
"outcome": "verified",
"step": "verify",
"version": "2026-10-21"
}outcomeisverifiedwhen the run passes verification,unverifiedwhen the instance is provisioned but verification fails, orfailedwhen a step fails.stepis the script's name for the step the run ended on, such asverify,bind, orpreflight, never a number.versionis the date of your checkout's latest commit. It says how current your copy is, and it is a date rather than a commit hash so that a fork's own history can't be traced back to it.
It never includes your slug, states, team name, admin name or email, Cloudflare account ID, or any URL. The counter stores only these four fields: no IP address, user agent, or country. Cloudflare, which hosts floorvote.org, sees the connection like any web host, and its request logs may hold connection details for a short time.
The script sends nothing unless you agree:
- No terminal, no question. Run from CI, an agent, or any shell without a terminal on stdin, it doesn't ask and doesn't send, unless you have set
FLOORVOTE_INSTALL_PING=yes. - Answer once. Set
FLOORVOTE_INSTALL_PING=yesorFLOORVOTE_INSTALL_PING=noin your environment or inscripts/.env.ops, and it stops asking. A value in the environment wins over the file. If you run setup for your own team's deployment often, set it tono. - Preflight and Ctrl-C.
--preflight-onlynever sends, and neither does a run you interrupt with Ctrl-C. - It can't break setup. The report goes out with a five-second limit and its response is discarded. If you're offline, or the site is down, the run's result and exit code are the same as without it.
When do bills start flowing in?
Registration stores the team's keywords and state coverage and provisions its queue, but doesn't itself queue any bills. Delivery is driven by the hourly central cron, and timing depends on whether central already tracks the state:
- State already tracked by another team: the next full-sync pass links this team's bills and queues the keyword matches. Full passes run three times a day for LegiScan states and for Congress when it syncs from GovInfo, and hourly for Maryland, Virginia, and DC, so within ~10 hours for the first two and within the hour for the others — no seeding required.
- Brand-new state, not seeded: central discovers the session on its once-a-day pass, then bills flow — worst case ~24 hours.
- Want bills immediately: run the seed in Step 12.
Non-keyword bills arrive as monitored bills; keyword and manually added bills are analyzed.
Hiding subjects
Bills show the subject terms their legislature assigned them whenever their provider supplies them, and the Bills page has a Subject filter. If a state's terms are too coarse to be useful, you can hide them. There is no settings screen for this: set the subjects_suppressed_states row of association_config to a JSON list of state codes, or ["*"] for every state:
npx wrangler d1 execute floorvote-[slug] --remote --env [slug] \
--command "INSERT OR REPLACE INTO association_config (key, value) VALUES ('subjects_suppressed_states', '[\"NJ\"]')"A hidden state's subjects disappear from its bill pages, from the Subject filter and its counts, and from the admin data export's subjects column, and nothing needs redeploying. Delete the row to show them again. A tenant with no row shows subjects for every state.
Choosing the analysis model
The bill analysis model and its thinking budget both have built-in defaults, so you do not need to set either. Two optional vars override them per tenant, which is how you move one tenant to a different model before the rest:
GEMINI_MODEL = "gemini-3.5-flash" # unset = built-in default
GEMINI_THINKING_BUDGET = "-1" # "-1" lets Gemini size it, "0" disables thinkingGEMINI_MODEL takes Google's own model name, not the google/... form Cloudflare's model catalog shows — FloorVote calls Gemini through the gateway's pass-through endpoint, so the name goes to Google as written. Google publishes the model names, and Cloudflare's model catalog lists which ones you can reach through the AI Gateway and what each costs.
These are operator settings rather than tenant-editable config: the model is a cost-and-quality decision, and a cheaper model is not a preference a team should be able to pick for itself.
When a newer model is worth the money. A newer model with thinking enabled is markedly better at tags whose criterion is mechanical rather than topical — "the bill amends a section of chapter X" as opposed to "the bill is about housing". Measured on 103 bills with known answers, an older model without thinking assigned such tags at 25% precision and a current one with thinking at 100%. Thinking on the older model did not close the gap.
That accuracy is not free, but it is cheaper than the obvious choice. Newer models tokenised the same bill PDFs at roughly double the input tokens, and thinking tokens bill as output tokens. Every current-generation model tested reached 100%, so among them the difference is price rather than capability — the cheapest ran about 2.3x the default's cost per bill and the largest 13.7x, for identical scores. Do not reach for the biggest model.
If your tags are ordinary topical ones, the default is probably fine. If any tag is a mechanical test, measure on your own bills before deciding, and use at least ~100 of them — a 12-bill sample put the older model at 100% purely by chance.