ChatBot Skriv
typing into a canvas

A Chrome extension that types into Google Docs through its hidden input frame, slips and all

From an idea to a product with paying users in the Chrome Web Store, with card and MobilePay subscriptions, in two months.

Sole engineerTypeScript · Chrome Extensions (Manifest V3) · FastifyAug 2026 to now

Try it

A walkthrough of real screens: an answer drafted and typed into a Google Doc, then the product’s own website.

Select the questions in a document. The side panel offers to answer exactly that text.

Select the questions in a document. The side panel offers to answer exactly that text.

Use the arrow keys or swipe

Architecture

The extension is Manifest V3: a React side panel, a background worker, and a content script that owns the insertion engine, because it is the only context with both the page’s DOM and a stable lifetime.

The engine is a small state machine (idle, running, paused, done, stopped, error) that consumes a finished string and is fully decoupled from generation, so stopping the typing never touches the model stream. Under it sit three adapters, one per kind of editor: plain text fields, rich contenteditable editors, and Google Docs, which is kept fully isolated as the fragile one. Any keystroke the user types into the document while a session runs pauses it at once, so their typing and ours never interleave.

The backend is Fastify on serverless functions. It holds the model key (models are reached through one OpenRouter client), checks the subscription, enforces rate limits and records usage in PostgreSQL with row-level security. Payments go through Stripe.

Writing into a canvas

Constraint. Docs paints its document on a <canvas>. There is no DOM to write into: every keystroke and paste is captured by a hidden same-origin iframe, and that mechanism is undocumented. Google broke exactly this kind of integration with its 2021 canvas migration.

Decision. Insert through synthetic paste events dispatched at that frame, which lands text at the live cursor. Run a self-test before every session, and if the mechanism has gone, show a visible message and fall back to the clipboard rather than doing nothing. Because the canvas can’t be read back, every result is reported as unverified, and the panel says so. Undo is counted in characters, one synthetic Backspace per character still in the document, because Docs may fold several pastes into one history step and one Cmd+Z too many would erase the user’s own earlier edit.

Result. No silent failures. When Docs changes, the user sees it on the next run, and an undo never takes more than the extension wrote.

Human typing that ends byte for byte

Constraint. Slips must look real, yet the finished document must equal the answer exactly, and undo must remove exactly what was inserted.

Decision. The answer is compiled into a script of steps: insert, delete one character (so a correction looks like a held key, not a cut), press a formatting shortcut, or pause. Every slip is deleted in full before the real text resumes. Cuts only ever fall inside words, because Docs drops a space at the edge of a paste. Each step records how much of the real text is correctly in the document once it has run, so the progress bar dips during a rewrite: that is the honest figure.

Result. A test replays the script at a 100% slip rate and compares the final text byte for byte.

Answering a whole form in one run

Constraint. A questionnaire in Docs is almost always a table: a question in one cell and an empty cell beside or below it. The page can’t see that structure, because it is a canvas. And writing over text the user already wrote is worse than not answering, because nobody who missed it can recover it.

Decision. Read the structure from Docs’ own copy HTML, which needs no Google permission at all. A cell holding only non-breaking or zero-width spaces counts as empty; a cell with anything else is reported as occupied, never as a place to write. Any uncertain read is treated as “no plan” and falls back to the answer on the line below the question. One run drives one typing session per answer, and each answer’s position is resolved immediately before it is written, so the answer to question 1 can’t shift the place for question 2.

Result. Every question in a form is answered in its own box, and a document without tables behaves exactly as it did before tables were understood.

Rate limits that survive a cold start

Constraint. The in-memory rate limiter keeps its counters per server instance, and on serverless every request may land on a fresh one. Measured on 26 August 2026: 70 concurrent requests to the chat endpoint were all answered.

Decision. Keep the counter where the data lives: one row per key in a fixed window in PostgreSQL, reachable only by the service role. Reuse the table that already throttles sign-ins rather than add a second one that would drift. Keys are per route and per user, never per IP on signed-in routes, because the first market is HR teams in one office behind one address. The in-memory limiter stays, as a burst guard for one warm instance.

Result. The limit is the limit, whatever the platform’s scheduling does.

A payment that always unlocks the product

Constraint. Stripe is the source of truth for subscriptions, but the app checks a cached copy, and webhooks can fail or arrive late. A customer who has paid and is still locked out is the worst support case there is.

Decision. The cache is refreshed three ways: by the checkout success page, by webhooks, and on demand, whenever the cache says “not allowed” but a Stripe customer exists. MobilePay can’t carry a recurring charge, so it sells a 30-day pass stored as an expiry date. The entitlement check also survives being deployed before its own migration: if the database has no pass column yet, it asks again without it and logs loudly, instead of failing every request for every user.

Result. A completed payment unlocks the product even if every push channel failed, and a deploy-order mistake costs one feature, not the whole product.

Code

How a slip is made. Unedited.

apps/extension/insertion/human.ts

function makeSlip(word: string, rng: () => number): Slip | null {
  const len = word.length;
  // Never the first letter (a person sees that one), never the last (nothing to notice it by).
  const k = 1 + Math.floor(rng() * (len - 2));
  // How many correct letters get typed before the slip is noticed.
  const extra = (from: number) => Math.min(Math.floor(rng() * 3), len - from);
  const roll = rng();
  if (roll < 0.5) {
    const wrong = neighbour(word[k], rng);
    if (wrong) {
      const more = extra(k + 1);
      return { at: k, typed: wrong + word.slice(k + 1, k + 1 + more), resumeAt: k };
    }
  }
  if (roll < 0.7 && k + 1 < len) {
    // Two letters swapped: "teh".
    const more = extra(k + 2);
    return { at: k, typed: word[k + 1] + word[k] + word.slice(k + 2, k + 2 + more), resumeAt: k };
  }
  if (roll < 0.85) {
    // A letter doubled: "lettter". The first copy is right, so it stays.
    const more = extra(k + 1);
    return { at: k + 1, typed: word[k] + word.slice(k + 1, k + 1 + more), resumeAt: k + 1 };
  }
  if (k + 1 < len) {
    // A letter skipped: "lettr".
    const more = extra(k + 2);
    return { at: k, typed: word[k + 1] + word.slice(k + 2, k + 2 + more), resumeAt: k };
  }
  return null;
}

Background

Google Docs draws its document on a canvas. There is no text field for an extension to write into, and the input mechanism Docs listens on is undocumented.

ChatBot Skriv is a Chrome extension that writes AI-generated Danish text into Google Docs the way a person would: word by word, with the occasional slip noticed and corrected, and pauses between thoughts. It can answer every question in a form document in one run, or only the text the user has marked. It is listed in the Chrome Web Store, and users pay by monthly card subscription or with a 30-day MobilePay pass, because MobilePay cannot carry a recurring charge.

What I’d do differently

  • Test against the live editor from the first day. The most expensive bugs came from assuming Docs behaved like a normal page, such as treating a paste event’s return value as proof the text landed. It isn’t.

Deep dives

In the order the system was hard to build.