A browser spends 2–5s rendering a form to submit it. The form only needs one HTTP request. fastform sends that request — about 300ms, start to recorded — and reads the reply to prove the row actually landed.
Submits a Google Form by POSTing directly to formResponse — no browser, no
page render, no automation driver.
Stdlib only, Python 3.11+ (developed on 3.14). Live cookie reads on macOS/Linux; stored (paste-once) auth works on any OS, including Windows.
- Design:
docs/superpowers/specs/2026-08-05-fastform-design.md - Plan:
docs/superpowers/plans/2026-08-05-fastform-phases-0-4.md
A browser spends seconds rendering the form, running its scripts, and posting
the result. fastform skips all of it: it fetches the form once, scrapes the few
hidden fields the POST needs, and sends a single formResponse request over one
warm connection — then reads the reply to decide what actually happened.
The one thing it will not do is fake your session. Google rotates the Chrome session token every few minutes, so a copied cookie header is dead within minutes. On macOS/Linux (the default), fastform instead reads Chrome's own cookie store live, at fire time — that is why the setup below is about Chrome, not about pasting a token. A pasted-header ("stored") mode also exists for Windows or portable/CI use; see Authentication for how the two modes differ and their tradeoffs.
-
Sign into Google in Chrome. fastform reads your cookies straight from Chrome when it runs — there is no token to copy and nothing to keep fresh.
-
Choose which Chrome profile submits. A machine can have several profiles on different accounts, and the tool refuses to guess — the wrong profile submits as the wrong person. List what you have:
python3 -c "from fastform import cookies; [print(f'{p.name}: {e}') for p,e in cookies.resolve_accounts()]"Then create your local config from the template and pin one:
cp config/profile.toml.example config/profile.toml
# config/profile.toml [chrome] profile = "Profile 1"
config/profile.tomlis gitignored, so your choice stays on your machine — profile names differ per machine and must not be shared. (Or pass--profile "Profile 1"on the command line to override it, and skip the file.) -
Generate your answers file from the form. Instead of hand-writing
matchstrings, let scaffold read the form's real questions:# blank stubs — fill in the value = "" lines: python3 tools/scaffold.py -u <viewform-url> # or answer each question interactively: python3 tools/scaffold.py -u <viewform-url> -i
It writes
config/answers.tomlwith each question's exact text as thematch, choice options listed, and unfillable questions (file upload, grid) commented out. It only reads the form — it submits nothing. It refuses to overwrite an existing answers file without--force. You can still hand-edit the result; each entry matches a question by text and supplies a value.
On first run macOS may prompt to let the tool read "Chrome Safe Storage" from the Keychain. Approve it — and do so well before you need to move fast, so the prompt is not what slows you down at go-time.
fastform gets your session cookie one of two ways, chosen by [auth] method
in config/profile.toml (unset falls back to the platform default):
| OS | live (default: macOS/Linux) | stored (default: Windows) |
|---|---|---|
| macOS | reads Chrome's cookie store live at fire time — nothing to do beyond picking a profile | opt-in; set method = "stored" |
| Linux | same as macOS | opt-in; set method = "stored" |
| Windows | not available (Chrome's app-bound encryption has no stdlib/openssl decrypt path) | the only option — no profile.toml edit needed |
live (macOS/Linux, the default): nothing to set up beyond the Chrome profile above — every run reads a fresh cookie straight from Chrome.
stored (Windows, or portable/CI use on any OS): paste a cookie header once, then verify it:
cp config/cookies.txt.example config/cookies.txt
# paste your Cookie: header into config/cookies.txt, then:
python3 tools/auth-check.pyauth-check confirms the pasted header authenticates and prints which account
it resolves to. There is no auto-refresh, and none is planned — a spike against
Google's RotateCookies endpoint returned only the *SIDCC cookies, never the
__Secure-1PSIDTS token that actually expires. The stored header lasts ~20
minutes before Google rotates it stale, so re-paste it before each session.
config/cookies.txt is gitignored and is full account access, not just
Forms — never commit it or share it.
Pass either the full https://docs.google.com/forms/d/e/.../viewform URL or a
shortened https://forms.gle/… link — short links are resolved automatically,
and the resolved target is printed before anything counts.
The whole flow, start to finish:
python3 tools/scaffold.py -u <url> # 1. write answers.toml from the form
python3 tools/dryrun.py -u <url> # 2. confirm the request — sends nothing
python3 tools/probe.py -u <url> # 3. (before a race) check throttling
python3 -m fastform.race -u <url> -t 17:59:30 # 4a. fire at a scheduled time
python3 -m fastform.fire -u <url> # 4b. or fire once, right nowEach step is detailed below.
Dry run first — build the request and print it, sending nothing:
python3 tools/dryrun.py --url <viewform-url>Use this to confirm your answers match the form's questions before it counts. It exercises the whole pipeline except the send.
Fire — fill and submit, exactly once:
python3 -m fastform.fire --url <viewform-url>fire submits once and never retries (see Safety). It prints the
outcome, the HTTP status, the elapsed time, and which account it submitted as,
then exits with one of these codes:
| Code | Meaning |
|---|---|
| 0 | Recorded (or already responded) |
| 1 | Rejected — payload wrong |
| 2 | Not parseable — closed, deleted, or a redirect |
| 3 | Form is closed |
| 4 | Auth failed — that Chrome profile is not signed in |
| 5 | Undetermined — check the response sheet |
| 6 | Gaps or blockers — nothing sent |
| 7 | Config — no profile set, or cookies unreadable |
(All commands also accept the short flags -u/--url and -p/--profile.)
Race — fire at a scheduled time, polling until it lands:
For a contest where the form opens at an unknown instant, race pre-warms the
connection ahead of time, then from the target moment polls until one submission
is accepted, stopping the instant it records:
python3 -m fastform.race -u <viewform-url> -t 17:59:30Because Google Forms has no server-side dedupe, an ambiguous timeout (UNKNOWN)
cannot be resolved, so you choose the policy: --on-unknown retry (default)
keeps trying, --on-unknown stop bails; --max-attempts (default 2) caps the
total sends, so a duplicate is at most one extra row.
| Flag | Default | Meaning |
|---|---|---|
-t, --at |
(required) | target time today, HH:MM:SS |
-l, --lead |
30 | seconds before the target to pre-warm |
-i, --interval |
0.25 | seconds between attempts |
-d, --deadline |
60 | seconds after the target to keep trying |
-s, --send-lead |
0.0 | seconds before the target to begin firing |
-k, --on-unknown |
retry |
retry or stop |
-n, --max-attempts |
2 | cap on total sends (= max rows) |
Same exit codes as fire. Timing is only as accurate as your Mac's clock — sync
via NTP before a real race.
Calibrate — measure your real fire latency (warm vs cold):
Before a race, find out whether you are fast enough. calibrate submits N POSTs
on a warm connection and N on cold sockets, then reports p50/p95/max and what
warming buys:
python3 tools/calibrate.py -u <throwaway-url> -n 50 --yeswarm (n=50): p50 312ms · p95 480ms · max 890ms · recorded 50/50
cold (n=50): p50 640ms · p95 920ms · max 1400ms · recorded 50/50
warm-up saves ~328ms at p50 · suggested timeout ~1.4s (cold p95 ×1.5)
Every POST appends a row, so a run submits 2×N real rows. It therefore
requires --yes and refuses otherwise, printing the row count first — point
it only at a throwaway form you will clear.
Probe — check whether Google throttles the race poll (GET-only, no rows):
race polls with a GET every 0.25s. Before a real race, confirm that rate is
not throttled. probe fires GETs at the race interval on one warm connection
and reports status codes, latency, and any throttle signals. It never POSTs,
so it appends no rows and is safe on any form — no --yes needed:
python3 tools/probe.py -u <viewform-url> -n 0.25 -d 60status 200 x240
latency p50 631ms · p95 812ms · max 1290ms
throttle 0 hard · 0 latency-spike
verdict SAFE at 0.25s over 60s — race interval holds
A THROTTLED verdict (HTTP 429/403 or a google.com/sorry interstitial) means
you should raise race --interval.
python3 -m unittest discover -s tests -vNo network: the transport tests run against a local server, and everything else runs against committed fixtures.
fastform is built around one hard fact: Google Forms has no server-side duplicate protection. A "limit to 1 response" setting does not bind a direct POST, and there is no already-responded state — every accepted POST appends a row. Three invariants follow from that:
firesends once;racesends at most--max-attemptstimes. The client'sRECORDEDresult is the only thing preventing a duplicate row, sofirenever resends, andracestops the instant a send records — and on an ambiguousUNKNOWNtoo, unless you opt into--on-unknown retry.- The Chrome profile is required. Unset,
firelists your accounts and exits without sending, rather than guess which account should submit. - Uncertain means uncertain. A network error mid-submit reports code 5 (undetermined) and tells you to check the response sheet — it never resends, because a false retry is the worst outcome.
Built and tested end to end against a real form:
- Pipeline +
fire(Phases 0–4): cookies, parse, match, compile, classify, transport, and the single-shot submit CLI. dryrun— preview the request without sending.race— scheduled, pre-warmed, bounded-retry firing (Phase 6 discipline).calibrate— warm-vs-cold latency profiling (Phase 5, experiment E7).probe— GET-only rate-limit check for the race interval (Phase 5, E8).forms.glelinks — resolved automatically across every entrypoint that takes a URL.scaffold— generateanswers.tomlfrom the form (stub or interactive).
Not yet built: connection survival over hours (E9), the last of Phase 5's
long-running experiments. It stays open because a long race --lead makes it
answerable with shipped code.
The Phase 7 daemon — clipboard/LAN intake, a persistent ARMED_HOLDING
state, and a TUI — will not be built, and E6 (token/fbzx staleness when
arming early) closes with it, since it existed only to de-risk holding a
compiled payload for hours.
race already covers the scheduled fire, connection pre-warm, open-polling and
bounded retry the daemon was going to provide, and dryrun surfaces unmatched
required answers hours ahead in one command. What remains is a few seconds of
URL-intake latency — not worth a long-running process, a 5 ms clipboard poll,
and an HTTP listener on your LAN that can arm a tool which submits as you.
If intake latency ever proves to be the real bottleneck at a live event, add a
--watch-clipboard flag to race (~20 lines, replacing -u) rather than
reopening the daemon. Full reasoning:
docs/superpowers/specs/2026-08-11-daemon-scope-closure-design.md.
MIT — see LICENSE.