A privacy property is a test, not a sentence.
Part 1 built the loop. Part 2 gave it a server. Part 3 makes it something a designer can install: a hosted engine that runs on the user’s own key, a Figma and FigJam plugin that draws the result on the board, and a Community listing. Along the way the plan’s privacy line turned out to be false — the redacted logs were keeping the model’s answers, and the answers quote the transcripts — and it stayed false until a test said so.
fly ssh console, not captured — so the hero is the terminal record itself, verbatim.The engine ran in a terminal, on one key, in one process. A designer who will never open a terminal can’t reach it, and a plugin can’t run Python.
One hosted service wrapping synth/engine.py unchanged, with the user’s Anthropic key forwarded per job and never logged; a plugin that uploads transcripts, streams the loop’s own progress, and draws the board itself from the same layout the MCP tool returns. Free on your own key. ETOT’s key runs prepaid credits or nothing.
Three constraints wrote the architecture
Part 2 left an importable engine and five MCP tools. Neither could serve a plugin: core/llm.py built one module-level Anthropic() client from the environment, so every run used the machine’s key.
A contextvars client (using_key) so a hosted job runs on its caller’s key, with the environment still the default so the CLI and MCP local mode don’t change; surfaces/hosted/ (job API, job registry with caps and TTL, credits ledger, Dockerfile, fly.toml); surfaces/mcp/remote.py rewritten as a client of the job API, with the server’s five remote branches wired to it. Eleven new tests in tests/test_hosted.py; suite 30 passed in 4.19 s.
- 01A job API, not a request.From docs/part2-notes.md: the refactor regression took 440.9 s, the recorded 15-transcript run 2,064.6 s and $4.88. A single HTTP request that long dies at a proxy. Submit, stream, fetch — and a plugin window that closes mid-run picks the job up by id.
- 02The key travels in a header.The plugin UI runs in a sandboxed iframe whose origin is null, so every call is a CORS preflight. CORSMiddleware allows any origin for GET and POST with Content-Type, X-Motif-Key, Authorization, Last-Event-ID; the key is never a cookie, so an open origin policy exposes nothing the caller didn’t already hold.
- 03A second bug the tests caught.The remote client merged the engine’s result over the job id, so run_id came back as the guessable run-directory name instead of the random job id later calls need. Fixed in _as_tool_result.
- 04Deploying is its own list.fly auth login refuses to run under Claude Code’s ! prefix; fly apps create refused until a card was on the account; [build] dockerfile in fly.toml resolves relative to the toml, not the repo root; the build context was 250 MB across 9,920 files until a root .dockerignore allow-listed six paths. Final image 62 MB.
The MCP server in remote mode synthesised michelle + david through the deployed service: job 5GfWRXE3yUt43vfGyPhNSmzb, hosted run 20260905-054758-C-hosted, 11,482 words, 15 insights, contested I-02, I-05, I-08, 3 iterations, $1.1409, engine wall 719.5 s, client wall 721.4 s, 21 progress notifications, run record redacted=true with 8 calls and 224,535 → 73,279 tokens.
The engine didn’t need rewriting. It needed a place to put the key.
The claim that was false until it was asserted
The spec’s privacy line is one sentence: transcripts processed for the run and discarded, nothing stored. In the plan that mapped onto something the logger already had — redact=True writes prompt bodies as digests and skips the corpus snapshot — and the processed corpus lives in the job’s memory for the run plus a short TTL, so remote motif_receipts only works inside that window and the plugin gets its receipts inline instead.
test_synthesize_job_end_to_end failed with transcript text leaked into 003_synthesis.json. redact=True had digested the prompts and kept the model’s response verbatim — and a synthesis response quotes the transcripts, because the receipts are the point. Fixed in core/logger.py: response text is a digest too, data is dropped from the record, usage, timing and stop reason stay.
Not in the code — on the live volume, over fly ssh console: the run directory is 556 KB; there is no corpus/ directory anywhere under /data/runs; calls/003_synthesis.json holds system, user and text as {chars, sha256}. A five-word phrase from a cited turn appears only in output.md, the run’s own report, and in the job’s scratch directory — both deleted by the expiry sweep an hour after the job finishes, covered by test_expiry_drops_corpus_and_run_content and not re-observed live in that session.
So the privacy page could say “deleted 30 days after the run” before it went up, JobStore.sweep_records() deletes any run record whose meta.json started is older than retention_days (default 30, MOTIF_RETENTION_DAYS), with directory mtime as the fallback; it runs at startup and at most hourly, and /healthz reports retention_days and records_swept_at.
- 01Redaction is a property of the record, not of the prompt.Half a redaction is a leak with a clean name.
- 02A privacy sentence you can’t fail is marketing.The FAQ now points at a check that ran on the volume, and cites the notes entries by date.
- 03Retention needs a sweeper, not a policy.The page could not say thirty days until something deleted at thirty days.
A privacy property is a test, not a sentence.
What ETOT’s key is for
The plugin ships free, on the user’s own Anthropic key: pasted once, stored in figma.clientStorage, sent only in a request header with a run, used for that run’s model calls, then dropped. The paid tier is prepaid credits through ETOT’s hosted engine on ETOT’s key. The rule underneath both is one sentence: ETOT’s key runs prepaid credits or it doesn’t run.
Figma’s payments API sells one-time purchases or subscriptions, not a depleting balance, so credits need a ledger ETOT owns, topped up by Stripe Checkout through a webhook. Designed in stage 1, built when the prerequisites are dated. In this release the Credits · not yet control is hidden entirely rather than shown disabled — a disabled control in a public listing draws questions and reviewers read it as unfinished — and the code path stays behind the server’s MOTIF_PAID_ENABLED flag, which refuses any job without a BYOK key.
LLC filing has started, single-member. Stripe and the credits ledger stay unbuilt until there are first users. [not in notes: LLC — entity name, state, filing and formation dates]
- 01No free run on ETOT’s key, ever.Not a trial, not an allowance. If a trial is needed later it is a coupon for credits.
- 02A hidden control is a decision, not a gap.The listing’s version notes say a paid tier follows in a later release; the flag is the truth of it.
- 03The paid tier is not a feature list.It sells not managing a key, an invoice, a licence, and a support address — which is why the prerequisites are entity, rails, support, terms, and not code.
ETOT’s key runs prepaid credits or it doesn’t run.
Run in the user’s own Figma files
Every stage of the plugin ends in a gate that cannot be run by the machine that built it: development plugins load only in the Figma desktop app, so install timing, the clipboard, and the drawn board are the user’s run, logged in the notes before anything is called done.
Covered in section 01. Gate: the MCP server in remote mode synthesises the sample corpus against the deployed service, and the run directory on the volume holds a redacted record and no transcript text. Passed 2026-09-05.
Built in surfaces/figma/: TypeScript, esbuild, no framework. code.ts holds client storage, notifications and resize; ui.ts the screens; api.ts submit, SSE with resume, fetch; docx.ts reads Word files in the plugin with a zip walk, DecompressionStream("deflate-raw") and word/document.xml paragraphs, no library. dist/ui.html 33 KB, dist/code.js 2 KB. The manifest allow-lists one domain.
Parity before trust. The plugin’s own docx extraction, run under Node against all 16 sample files (29.8 MB → 587 KB of text), ingests to the same manifest as python-docx: 15 transcripts, 101,042 words, one empty file skipped on both paths; 1,868 turns compared, 1,867 byte-identical, one differing only by a line break inside a paragraph, 0 mismatches.
Because the window won’t shrink: dist/ui.html runs in a browser tab with localStorage standing in for Figma’s client storage; test/harness.html frames it at 440, 375 and 320 px. It immediately caught a bug that would have shown on every first open in Figma: the “A run is still going from last time” card displayed with no run, because .card { display: flex } outranks the browser’s [hidden] rule. Fix: [hidden] { display: none !important; }.

display:flex outranking [hidden]. 440 / 375 / 320 px.Job ngqJoYXl17yhFKHVFdosoV84, hosted run 20260905-171330-C-hosted, 11,482 words, 12 insights, contested I-05 and I-11, 3 rounds, $1.0328, engine wall 660.1 s, 20 progress events shown live; zero layout issues at 440 px.

Install to run-start 8 m 20 s including looking up the key and reading the instructions — call it 5–6 minutes for a repeat user; the run 12 m 33 s against the plugin’s own reported 12.4 minutes; install to result about 21 minutes. The report copied to the clipboard inside Figma as full Markdown with receipts, no fallback box. Nothing clipped or broken on any screen. Ruling: the ten-minute target applies to install-to-run-start, not install-to-result, because a run alone takes twelve minutes.
Gate in section 05. Two observations came out of the stage-2 run and went into stage 3: with two transcripts every insight reads “low”, because high needs four participants, so the chip now says why — low · 1 of 2; and the UI still uses Figma’s default blue and Inter, so the restyle onto the ETOT chassis waits for the design-system session’s tokens.
- 01The clipboard is not guaranteed.In the harness both navigator.clipboard.writeText and execCommand("copy") failed on a synthetic click. The copy button falls back to revealing the report in a selected read-only box — 58,847 characters, no overflow at 440 and 375 px. In Figma, the real click worked and the fallback never appeared.
- 02The error screen relays the engine verbatim.Anthropic’s 401 API key is invalid. arrives as written, under a plain hint for the three cases a user can act on, and last_job is cleared on failure so a failed run never offers itself for resumption.
- 03The gate that the builder cannot run is the gate that matters.Everything up to the desktop app is a claim.
Development plugins load only in the desktop app. Which means the last gate is always someone else’s hands.
One layout, two editors
synth/board.py::layout is the single source of truth for what a Motif board looks like; part 2’s MCP tool handed that layout to a host, which ran the scripts. The plugin draws it itself, and had to grow a second renderer: stickies and connectors are FigJam nodes, and Figma Design has sections but no stickies.
verdict_layout in synth/board.py (a section per checked claim, coloured by outcome, one sticky per objection, a final section for corpus-level objections) and a run card on both layouts; engine.board serving critique runs; the hosted /board endpoint accepting any finished job. In the plugin, src/board.ts renders on the current page — FigJam with sections, stickies in two rows, “contested by” connectors and a card above the grid; Figma Design with the same sections as auto-layout frames and text, no connectors.
A fake Figma API first: 15 sections, 70 stickies, 7 connectors on FigJam and 0 on Design; the verdict layout 9 sections and 33 stickies. Then the plugin’s own renderer bundled and run on a throwaway FigJam board through Figma’s MCP: 4 sections, 20 stickies, 1 connector, run card at y = −568, every colour read back as a palette value and none as CUSTOM. The same script on a throwaway Design file: 4 sections, 20 frames, frames hugging their text.


From the user’s FigJam run of run 20260905-221749-C-hosted (16 insights, 2 contested, 3 rounds, $1.14, 12.1 min): the board drew 16 sections, 71 stickies and 4 connectors — 16 claims, 32 receipts, 4 counters, 16 opportunities, 2 contested, 1 card — to the right of an existing verdict board, so the free-origin rule held on a page that wasn’t empty. Then the same result in Figma Design: 16 sections, 71 stickies, no connectors, on the dark page.


- 01The sandbox has no toLocaleString.The run card read "11482 words" on the first real board; Number.toLocaleString returns bare digits in Figma's plugin sandbox, so thousands() formats by hand.
- 02documentAccess: dynamic-page forbids the synchronous lookup.The board drew in full and the step after drawing threw Cannot call with documentAccess: dynamic-page. The MCP runtime allowed both forms, so the earlier verification never saw it. code.ts now resolves ids with getNodeByIdAsync.
- 03Opening the plugin should show what you have.A fresh file opened on the empty form instead of the stored last result. Now, with a key, a stored result and no run in flight, it opens on the result screen with Build board ready.
- 04Titles truncate at the section edge,because the title is the whole claim. FigJam hard-cuts, Design ellipsises. Rendering, not a defect — but an ellipsis at a fixed length is on the ledger.
Six failures Motif dealt itself
The critic was right and the report was wrong. A deterministic rule counted a missing receipt as a mismatched one, and the only reason it surfaced is that Motif was pointed at its own output — the one document whose correctness nobody had checked mechanically. The fix touched one deterministic rule’s handling of one input shape and left the model-judged rules alone, so the instrument measured in parts 1 and 2 is unchanged.
The post-fix pass on the plugin path: job PDt5IyygwZLrUc1Vni5o2L7V, “structured 16 insight(s) from Motif-format markdown (deterministic)”, “deterministic checks: 0 failure(s)”, verdict FAIL with 16 claims, 2 fails, 0 warnings, and the honest line “Not checked: missing_theme (no intake notes for a pasted document)”. The fails: I-01 missing_counterexample, which matches the original run’s own contested flag, and I-13 unsupported, which is new. I-10, contested in the original run, passed here — critic variance, recorded and not tuned. The verdict drew as a board: 16 sections, 19 stickies.

- 01A predicted failure left unfixed is a failure you scheduled.The root cause was written down on 2026-09-04 and left; it arrived on 2026-09-05 as six red stickies in the user's own file.
- 02Verdicts need their own numbers.The verdict screen carried no cost or time until _critique returned cost_usd and wall_seconds; until the deploy ran, the live tiles showed "–". After the deploy, one live pass: job 6R0hYQM-Rccnlk6Yj0STOHCq, 15 claims, 3 fails, $0.1546, 88.4 s.
- 03A dollar sign nearly didn’t survive the build.The cost tile rendered "0.49": build.mjs inlined the script with a string String.replace, and $$ in a replacement string collapses to one $, so $${cost} reached dist/ui.html as ${cost}. Every build to date had shipped the tile without the sign. Fixed with function replacers; verified on the rendered tiles as "$0.49".
Motif failed its own report six times. The report was wrong.
Everything that isn’t code
Submitted to Figma Community review on 2026-09-06 at 00:31 CDT under the ETOT team profile @etot, free tier only. Plugin id 1678295978273812914 — the first generated id was superseded, and the second is the one in the manifest at commit 7cd99fd. [not in notes: Community review outcome and date]
Not a picture. Per ETOT’s per-tool identity rule, each tool’s icon is its own generated field: one colour, no letterform, with the monogram reserved for the studio. surfaces/figma/listing/icon.png, 128 × 128, 2,165 bytes, generated by etot-site/scripts/icons.py at commit 23c596d from etot-site/assets/icons/motif-128.png.
And says so. surfaces/figma/listing/cover.png, 1920 × 960, composited from docs/exhibits/stage3-plugin/08-board-I-01-contested-zoom.png — the I-01 section of run 20260905-221749-C-hosted as the plugin drew it — cropped to the section and letterboxed on the FigJam canvas grey #F5F5F5. Screenshot-derived rather than a canvas export, because the boards were deleted before an export was taken. It replaces the spec’s I-09 choice, whose only exhibit is the part-2 MCP-drawn board.
The spec asked for Research / Whiteboarding. Figma’s plugin taxonomy has no such category — the Whiteboarding entries are templates — so the listing sits under Design tools › Content generation, with the five tags Figma allows: ux research, research synthesis, user interviews, transcripts, affinity mapping.
A design surface. Answered as: a hosted backend that receives no plugin-API data; network requests only to motif-hosted.fly.dev; no user authentication; no plugin-API data stored; solo developer; no formal vulnerability process yet, reports via GitHub issues or hello@etot.design; no accreditation. Comments on; support hello@etot.design with a stated two-working-day response; privacy policy at https://etot.design/privacy/, live before submission. Two-factor authentication is required by Figma to publish, and the session had to be restarted after enabling it.
The carousel was nearly submitted as the raw exhibits — menu bar, browser tabs, clock, avatar all visible. Caught before submission. Worse, the same raw screenshots were already in the repo’s history: 22 of them under docs/exhibits/stage3-plugin/, showing employer file names in the Figma tab strip on every exhibit and the last four characters of an API key on 02. git filter-repo --path docs/exhibits/stage3-plugin/ --invert-paths in a fresh clone removed the folder from every commit — 3 commits had touched it, 0 after; pack 43.47 MiB → 24.22 MiB — with a mirror backup kept, and 22 cropped replacements committed on top under the same filenames, so every notes line that cites an exhibit by number stays valid.
Now in CONTRIBUTING.md § Conventions and the plan’s stage-4 QA gate: raw screenshots never leave the repo and never enter it either. Every exhibit and every publication asset is cropped to the product surface and checked for personal information — menu bar, tabs, clock, avatar, file names — before it is saved. Publication assets live under surfaces/figma/listing/ or docs/figures/.
- 01An icon system is cheaper than an icon.The rule decided the artwork; the artwork took a script.
- 02The taxonomy is the market’s, not the spec’s.Where the category doesn't exist, say which one you chose and why.
- 03Screenshots carry the day job.A cropping rule is not tidiness; it is the difference between an exhibit and a leak.
The tab strip is part of the screenshot. So is the day job.
What it costs
About two seconds. On the stage-1 QA run, client wall 721.4 s against engine wall 719.5 s — the event stream kept pace and no proxy cut it. On the shorter PyPI-installed run, 339.2 s against 338.3 s. The stage-1 run cost twice the part-2 local regression ($0.5529, 1 iteration, 440.9 s) because it ran all three iterations, not because it was hosted.
| run | corpus | result | cost | wall |
|---|---|---|---|---|
| stage-1 QA, MCP remote 20260905-054758-C-hosted | michelle + david, 11,482 words | 15 insights, 3 contested, 3 iterations | $1.1409 | 719.5 s |
| PyPI 0.3.0 live 20260905-164234-C-hosted | michelle + david, max_iterations=1 | 13 insights, 2 contested | $0.458 | 338.3 s |
| browser path 20260905-171330-C-hosted | michelle + david | 12 insights, 2 contested, 3 rounds | $1.0328 | 660.1 s |
| the user’s Figma run 20260905-221749-C-hosted | David.txt + Michelle.txt | 16 insights, 2 contested, 3 rounds | $1.14 | 12.1 min |
| Check a synthesis 20260906-000123-critique-doc | the stage-2 report + its transcripts | 16 claims, 2 fails | $0.1793 | 116.6 s |
| Check a synthesis, post-deploy 20260906-010935-critique-doc | the stage-1 report + its transcripts | 15 claims, 3 fails | $0.1546 | 88.4 s |
| part 2, full corpus 20260904-154633-C | 15 transcripts | 23 insights, 3 contested | $4.88 | 2,064.6 s |
In the plugin’s own words, and in the listing: about $1 for two transcripts, about $5 for fifteen. The result screen shows the exact API cost of every run.
One shared-CPU machine with 1 GB of memory and a 1 GB volume, kept running, in ord. [not in notes: first Fly.io invoice — amount, period, date]. Total part-3 API spend, summed live from the volume’s redacted meta.json (/data/runs/*/meta.json over fly ssh console, not reconstructed from the table above): $5.7838 across 11 records — 9 billed, 2 abandoned at $0 (started, no model call, empty calls/), all from 2026-09-05/06, nothing yet aged out by the 30-day sweep. Motif repo commit 2ae47a8, docs/part3-notes.md.
- 01The hosted path costs what the CLI path costs.Same corpus, same settings, same shape as part 2's local regression.
- 02The engine is the bill.Hosting is a machine; the model calls are the money, and they are the user's.
- 03A critique pass is a fifth of a synthesis.One call, ninety to a hundred and twenty seconds, under twenty cents — which is what makes "check a synthesis someone else wrote" a reasonable thing to do twice.
Still open
The plan said the logs were clean. The test said they weren’t. Everything published here rests on the second sentence.

