Skip to content

feat(onboarding): add a demo mode that runs without an API key - #57

Merged
mangit955 merged 1 commit into
mainfrom
feat/demo-mode
Aug 10, 2026
Merged

feat(onboarding): add a demo mode that runs without an API key#57
mangit955 merged 1 commit into
mainfrom
feat/demo-mode

Conversation

@mangit955

Copy link
Copy Markdown
Owner

Why

Woopcode's first screen demanded an API key. onboarding/setupWizard.tsx walked welcome → select provider → key URL → paste, and nothing ran until a key was stored. Users who wanted to try the tool before handing over a credential stopped there.

The obvious fix — ship a free-tier Gemini key for people to copy — does not work, for three reasons that have nothing to do with cost:

Shared quota Free-tier limits attach to the project, not the caller (rate limits). One Woopcode turn is up to 40 provider requests, and free-tier Flash-Lite sits around 15 RPM. Two demo users at once means 429s for everyone — the first impression becomes "this is broken," which is worse than the key prompt.
Revocation A key printed in a TUI reaches screenshots and blog posts, gets scraped, and is revoked. Every demo user breaks at once and there is no way to push a replacement.
Terms Unpaid-tier content is used to improve Google's models and may be read by human reviewers; the terms say outright not to send confidential information. A coding agent sends the user's repository.

So the key stays server-side and the client holds a token instead.

What

A demo session stores { type: "demo", apiKey: <token>, baseUrl, demoExpiresAt }. The token is worthless to Google — it authenticates to Woopcode's proxy, which applies the quota and forwards under the real key. That makes the demo revocable, rationable and switchable off without a client release.

Mechanically it is one line. @google/genai takes a full-URL override, so a demo session is an ordinary Gemini client pointed elsewhere:

new GoogleGenAI({ apiKey, ...(baseUrl ? { httpOptions: { baseUrl } } : {}) })

The wire format, the streaming, runtime/loop.ts and the Message type are untouched. baseUrl is honoured only by Google — forwarding it would point the Anthropic or OpenAI SDK at a proxy that answers neither.

The proxy's two refusals were chosen to land on rules runtime/retry.ts already has, so that file needed no change:

Status Meaning Existing behaviour
429 + Retry-After proxy busy already retryable — the loop backs off, which is how load shedding works
403 daily allowance spent already fatal — fails immediately instead of spending three attempts on a fixed answer

config/demoAccount.ts owns the lifecycle so nothing else knows the endpoint: a random install id to ration by, session requests with a 10s deadline, expiry helpers, and the exhaustion-message mapping. WOOPCODE_DEMO_URL points it at a local proxy for development.

The disclosure is mandatory and deliberate. The proxy runs a free-tier key, so demo traffic falls under the training and human-review terms above. The wizard states that plainly and requires an explicit y rather than any keypress — a screen dismissed by whatever someone pressed next is not a notice anyone read. Moving to a paid-tier key later is a server-side change needing no client release.

Two pre-existing defects this turned up

Both were found by reading the diff, not by the suite:

  • Every path that stored a user's key spread over the previous entry (/login, providers login, the wizard). Upgrading out of demo mode would keep type: "demo", the proxy URL and the old expiry underneath the new key — sending a real Google credential to Woopcode's proxy and expiring it on the demo's schedule. All three now build the entry fresh via apiProviderEntry().
  • normalizeConfig rebuilds provider entries field by field, so a field it does not name is dropped on the next read. The docstring claimed extra keys were preserved; that is true at the top level only. A demo entry would have kept its token and lost the URL it is only valid against.

setProvider has the same hazard at runtime and now assigns baseUrl unconditionally, unlike model.

Verified

bun run verify --staged   →  docs lint ok, docs surface ok, type check ok, tests ok — 4 gate(s) passed
                             (re-run by .githooks/pre-commit at commit time, passed again)
bun test $(git ls-files '*.test.ts' '*.test.tsx' | sort -r)
                          →  1698 pass, 0 fail across 105 files

Both regression tests were proven by reverting their fix and confirming the revert applied:

  • removing the two lines from normalizeConfig turned 2 tests red, and revealed a cascade worth knowing about — a live demo session resolves as null, because the expiry is dropped alongside the URL;
  • removing the httpOptions line turned 3 of the 5 diversion tests red, correctly leaving the two negative ones green.

The baseUrl tests run against a real Bun.serve on a real port, because the failure being guarded is silent: a request that still goes to Google carries a demo token as if it were a Google key. Two of them initially made live calls to Google, Anthropic and OpenAI; those now stub the global fetch, which took the file from 1.72s to 114ms and removed a network dependency from CI.

Not verified, and outside the suite's reach: every wizard screen, including the disclosure keypress, since none of it is rendered in a test; a real 403 or 429 from an actual proxy; and the exhaustion message end to end — the DEMO_EXHAUSTED_MARKER contract is tested as a pure function only.

This is inert until the proxy exists. The default endpoint does not resolve, so no user can reach demo mode; the wizard reports the service as unavailable and falls through to provider selection. The server is a separate private repo and is not in this PR.

The first screen demanded an API key before anything would run, and users
who wanted to try the tool before handing over a credential stopped there.

Shipping a shared Gemini key was the obvious fix and does not work. The
free-tier quota belongs to the project rather than the caller, so everyone
holding a copy competes for one bucket while a single turn is up to 40
requests; a key printed in a terminal reaches screenshots and gets revoked;
and there is no way to replace it once it is on thousands of disks.

So the key stays on a proxy and the client holds a token instead. The token
is worthless to Google — it authenticates to Woopcode's proxy, which applies
the quota and forwards under the real key — which makes the demo revocable,
rationable and switchable off without shipping a release.

Mechanically this is one line: @google/genai takes a full-URL override, so a
demo session is an ordinary Gemini client pointed somewhere else. The wire
format, the streaming, the loop and the message type are untouched, and the
proxy's two refusals were chosen to land on rules runtime/retry.ts already
has — 403 for a spent allowance is fatal there, 429 for a busy proxy is
retryable, so load shedding works and exhaustion does not cost three
attempts to reach the same answer.

The proxy runs a free-tier key, so demo traffic falls under terms that let
Google train on it and have humans read it. The wizard says so and requires
an explicit keypress, because the alternative is a user sending a private
repository somewhere they were never told about. Moving to a paid-tier key
later is a server-side change and needs no client release.

Two failures this turned up in code that already existed: every path that
stored a user's key spread over the previous entry, and normalizeConfig
rebuilds provider entries field by field. Together they meant an upgrade out
of demo mode would keep the proxy URL underneath a real Google key, and a
demo entry would lose that URL on the next read while keeping the token.
Both are covered by tests that were confirmed to fail with the fix reverted.
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
woop-code Ready Ready Preview Aug 10, 2026 2:55am

@mangit955
mangit955 marked this pull request as ready for review August 10, 2026 02:57
@mangit955
mangit955 merged commit 95d3834 into main Aug 10, 2026
6 checks passed
@mangit955
mangit955 deleted the feat/demo-mode branch August 10, 2026 02:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant