Skip to content

AI WF2Des — System Workflows

The other pages specify the pieces — the Overview for the worker and its generation and internal event paths, the Assembly Architecture for the engine, the I/O Definition and the Internal API for the field-level contracts. This page shows the pieces moving: every runtime flow, end to end, across the backend, the apps/wf2des worker, wf2des-api, and the Figma plugin.


The Landscape

flowchart LR
  subgraph SURFACES["Surfaces"]
    PL["Plugin (in Figma)"]
    AC["API callers"]
    MA["MCP agents"]
  end
  subgraph BE["Backend — product control plane"]
    API["wf2des API<br/>trigger · registration · status · list ·<br/>confirm · cancel ·<br/>placement / feedback flags"]
    PG[("PostgreSQL wf2des row<br/>status · phase · attempt ·<br/>open-generation partial unique")]
    DREG[("platform design table<br/>type=component — the SHARED registry")]
    SQSG[("generation SQS queue<br/>(backend-owned)")]
    WH["POST /v1/webhooks/ai-status<br/>(X-API-Key) + registry apply"]
  end
  subgraph AI["AI side — guinness-ai-v2"]
    WK["apps/wf2des worker<br/>generation + internal events · never writes PG"]
    SQSE[("wf2des-events intake queue<br/>(AI-owned; backend send-only)")]
    EB["EventBridge schedule<br/>(component resync)"]
    DAPI["wf2des-api<br/>(internal-api route group)"]
    LLM["LLM<br/>model + prompt version pinned"]
  end
  subgraph ST["Stores"]
    DD[("DocumentDB guinness_v2<br/>wireframe · design_rule · design_component ·<br/>project_figma_file · design_generation_result ·<br/>design_resolution")]
    S3[("S3 — snapshots · artifacts ·<br/>result manifests")]
  end
  FIG["Figma file"]

  PL -->|"trigger · registration · poll ·<br/>confirm · cancel · flags · discovery"| API
  AC --> API
  MA --> API
  API -->|"row FIRST — status '0', phase parse"| PG
  API --> SQSG
  API -->|"rule / plugin events"| SQSE
  SQSG --> WK
  SQSE --> WK
  EB --> WK
  WK <--> LLM
  WK --> DD
  WK --> S3
  WK -->|"completion webhook {job_id, attempt, nonce,<br/>status, error?, result_manifest_url?,<br/>manifest_schema_version}"| WH
  WK -->|"component_sweep manifest<br/>(discovered type=component)"| WH
  WH -->|"attempt-guarded flip: status/phase<br/>+ result refs + flag_count"| PG
  WH -->|"design type=component upserts"| DREG
  PL <-->|"data plane: file reg · preview ·<br/>spec · details"| DAPI
  MA -.->|"spec reads"| DAPI
  DAPI <--> DD
  DAPI <--> S3
  WK -.->|"REST read (PAT): component walk ·<br/>rule-board render · memo fallback"| FIG
  API -.->|"trigger-time snapshot (service-account PAT):<br/>frame + board section"| FIG
  PL ==>|"the ONLY writes into Figma"| FIG

Three laws shape every flow on this page. One — only a Figma session writes into Figma: Figma's REST surface has no design-writing endpoints, so every pixel that appears in a file is created by the plugin's main thread inside a designer's editor session. Two — workers never write PostgreSQL: every PG write is backend-side — the trigger and frame-registration endpoints, the confirm / cancel / placement / feedback endpoints, the ai-status webhook handler at generation completion, plus the component-registry upserts, where the backend writes the platform design table (type=component) from component_sweep's manifest. Three — register → process → commit: the backend registers the run (the wf2des row plus immutable S3 inputs), the worker processes it (DocumentDB context + S3 artifacts and manifests), and the commit is backend-side (the generation webhook flips the row; the backend applies component_sweep's manifest to the registry).

Status vocabulary used throughout: status '0' processing · '1' completed · '2' failed · '3' rejected · '4' cancelled; phase lives inside status '0' — '0' parse · '1' awaiting_confirm · '2' assemble — and is cleared once the row reaches a terminal status.


Project Onboarding — Once per Project

Onboarding is throughput work, not latency work: it happens once per project, and everything generation later pins — files, components, rules, wireframes — enters here.

flowchart TD
  A1["1 · Project exists on the platform;<br/>the service account has view access"]
  A2["2 · Register the Figma files —<br/>POST /internal/projects/{project_id}/figma-files via wf2des-api<br/>(role: working | library) → project_figma_file docs;<br/>also the plugin's org/project + file resolution"]
  A3["3 · COMPONENTS — component_sweep populates the SHARED registry:<br/>EventBridge schedule / plugin resync event →<br/>Figma REST full-file walk (/components lists published only) →<br/>design_component context docs (variants · text slots · size)<br/>+ the sweep manifest → backend UPSERTS platform design rows type=component"]
  A4["4 · RULES — designer selects guideline boards in the plugin →<br/>rule event on wf2des-events → rule_process renders each board (PAT)<br/>+ vision-extracts per board + deterministic merge →<br/>versioned IMMUTABLE design_rule doc, worker-written directly<br/>(webhook-free; keyed (design_rule_id, content_hash of the merged RuleSet))"]
  A5["5 · WF REGISTRATION — designers register selected frames<br/>in-Figma via the plugin → backend snapshot +<br/>registration events on wf2des-events → wf_parse →<br/>wireframe cache docs"]
  A6["6 · INGEST ISSUES LOG (staging/issues.json) —<br/>memo-marker unknowns · screen-ID grammar violations ·<br/>non-auto-layout roots — reviewed BEFORE generation<br/>runs against the file"]
  A1 --> A2 --> A3 --> A4 --> A5 --> A6

Two properties of this flow matter downstream. First, all three ingest runs are idempotent: each output document is its own record, so a replayed event converges rather than duplicates. Second, the WF snapshot captured at registration is a cache, not a commitment — generation re-snapshots the frame at trigger time, so a stale registration never feeds a run.


Generation via the Plugin — The Primary Flow

The interactive flow: a designer selects a wireframe frame, the backend registers and snapshots it, the worker parses, the designer confirms the parse, the worker assembles, and the plugin materializes the result as native Figma.

sequenceDiagram
  actor D as Designer
  participant P as Plugin
  participant A as Backend (API + PG)
  participant Q as Generation SQS
  participant W as wf2des worker
  participant X as wf2des-api

  D->>P: select WF frame + screen ID (prefilled)
  P->>A: POST trigger (figma_file_key, wf_node_id, screen_id, auto_confirm, prompt?, placement_target?)
  A->>A: INSERT wf2des row FIRST — status '0', phase parse<br/>(partial-unique dedupe hit → 409 + the existing wf2des_id)
  A-->>P: 201 + wf2des_id (immediate)
  Note over A: SNAPSHOT AT TRIGGER — between row creation and enqueue,<br/>Figma REST (the wf2design service-account PAT) captures the frame<br/>+ its enclosing board section → S3 + wf_content_hash;<br/>unregistered frames auto-register
  A->>Q: parse message {wf2des_id, attempt, nonce, snapshot scope + hash, input URLs}
  Q->>W: PARSE (one-shot)
  Note over W: deterministic WFNode extraction + LLM roles/intent →<br/>result-doc parse block + immutable parse.json<br/>(DocDB commits CAS-fenced on (wf2des_id, attempt))
  W->>A: webhook parse_done {job_id, attempt, nonce}
  A->>A: wf2des row → phase awaiting_confirm
  P->>A: poll GET (backoff) — status '0', phase awaiting_confirm
  P->>X: GET /internal/wf2des/{wf2des_id}/parse
  X-->>P: parse block + presigned parse.json
  P-->>D: PARSE PREVIEW — structure + memo influences (resolved memos excluded) + snapshot age/hash
  D->>P: confirm or reject
  alt reject
    P->>X: POST /internal/wf2des/{wf2des_id}/reject {reason_code, note} → parse.rejected (detail FIRST)
    P->>A: POST confirm {attempt, reject: true} → status '3' rejected (flag LAST)
  else confirm
    P->>A: POST confirm {attempt, parse_artifact_hash}
    A->>A: CAS on phase awaiting_confirm + attempt → phase assemble
    A->>Q: assemble message {wf2des_id, attempt, fresh nonce, confirm decision}
    Q->>W: ASSEMBLE (one-shot — pins rehydrated from the result doc)
    Note over W: registry hygiene + deterministic role/kind gate →<br/>design_resolution ledger consult → section-parallel K-sample<br/>self-consistency voting selection → ledger write-back → deterministic stitch →<br/>rules validator → computed confidence
    W->>W: S3 result artifact + result manifest
    W->>A: webhook succeeded {job_id, attempt, nonce, result_manifest_url}
    A->>A: handler — deduped on (job_id, attempt, nonce), ONE PG txn:<br/>status '1' + result refs + flag_count, phase cleared
    P->>A: poll GET → completed (+ top-level flag_count)
    P->>X: GET /internal/wf2des/{wf2des_id}/result
    X-->>P: self-contained spec (no follow-up lookups)
    Note over P: MATERIALIZE — chunked build ·<br/>pluginData {jobId, specVersion} stamped
    P-->>D: new tagged frame, flags visualized
    P->>X: POST /internal/wf2des/{wf2des_id}/placement (detail FIRST)
    P->>A: POST placement → materialized_at (flag LAST)
    D->>P: adopt / fix
    P->>X: POST /internal/wf2des/{wf2des_id}/feedback (detail FIRST)
    P->>A: POST feedback → feedback_status (flag LAST)
  end

The load-bearing details:

  • Row first, then enqueue. The wf2des row exists before the job does, so a missing-row read inside the worker is a real error, never an eventual-consistency window. The open-generation partial unique — (figma_file_key, wf_node_id) WHERE status='0' — dedupes concurrent triggers: the second caller gets 409 + the existing wf2des_id and attaches to the running generation.
  • The trigger sends identifiers, not trees. The wireframe content never travels from the plugin; the backend captures the snapshot server-side at trigger time, scoped to the frame plus its enclosing board section, and the SQS message carries the scope + wf_content_hash.
  • The confirm checkpoint is a CAS. Confirm carries {attempt, parse_artifact_hash} and is compare-and-swapped against the row's phase awaiting_confirm + attempt — a stale confirm gets 409, a repeat gets a 200 echo.
  • Detail first, flag last — every plugin write pair lands its content detail in wf2des-api (DocumentDB) before flipping the corresponding flag on the backend row: parse.rejected before status '3', the placement detail before materialized_at, the feedback detail before feedback_status.
  • One completion writer. The ai-status handler is the sole completion-time PG writer: deduped on (job_id, attempt, nonce) — job_id is the wf2des row id — attempt-guarded, one PostgreSQL transaction, applying the manifest referenced by result_manifest_url.

Soft latency targets (p50, measured — not gates): trigger→parse preview < 60s · confirm→spec < 2min · spec→materialized < 30s. Every phase is timestamped on the result doc's timings block.


Generation via API / MCP — Differences Only

An API caller or an MCP agent runs the same wire as the plugin, with three differences:

  1. auto_confirm = one invocation. The same backend POST with auto_confirm=true runs parse + assemble back-to-back inside a single worker invocation — no awaiting_confirm park, no confirm round-trip, and only the terminal webhook fires. The parse record (memo influences, roles) is still written, for review at materialize time.
  2. Status from the backend, content from wf2des-api. The caller polls the backend GET to completion (+ top-level flag_count); the spec is read from GET /internal/wf2des/{wf2des_id}/result — never from a Figma frame, because no frame exists yet (the write boundary).
  3. MCP agents ride the same two planes. The backend MCP server fronts the trigger/status tools — following the platform's trigger-* / get-* tool naming; the final tool names land with the MCP wrapper — with auto_confirm forced: no human sits at the parse checkpoint. Content reads go through internal-api with the read-scoped service token. An MCP agent triggers and reads; it never writes into Figma (a later, prototyping-only path can drive an open Figma desktop session — which is "inside Figma", on channel 4's side of the wall).

The generated design becomes native Figma later, through deferred materialization — next.


Deferred Materialization

After the native frame is built, the plugin captures its actual PNG, node/bounds manifest, and materializer report and submits a hash-fenced render_review event. A passing review retains the frame. A first needs_changes result may create one adjacent candidate while preserving the original; a second render verifies that candidate. Only a pass promotes it in place. Failure, stale identity, or unverifiable evidence discards the candidate and retains the original. The second review is verification-only, so there is no recursive correction loop.

The plugin session and the job are decoupled: closing Figma mid-job loses nothing, and an API/MCP-triggered generation simply waits, completed, with materialized_at unset. On every open, the plugin runs a discovery handshake against the backend — readiness is client-facing status, and that lives in PostgreSQL:

  1. The general list endpoint (GET …/wf2des, filtered by status / phase / screenId / feedbackStatus / materialized / createdFrom / createdTo) returns two kinds of hits for the open file: parked previews (phase=awaiting_confirm — the designer resumes the confirm/reject decision, or ends the job via POST …/wf2des/{id}/cancel) and pending builds (completed rows with materialized_at unset, the stored placement_target included). The same endpoint serves project history.
  2. The duplicate-build guard runs before any pending build is offered: the plugin checks for an existing placement detail via wf2des-api and scans the file for pluginData {jobId}. A session that crashed between the placement detail and the materialized_at flag therefore never produces a second frame.
  3. Auto-confirm jobs get their skipped review here. For an API/MCP-triggered job, the plugin first fetches the parse record and surfaces the memo influences and flags — the checkpoint no human sat at — before offering the build.
  4. The spec is pulled from wf2des-api, built at the stored placement_target, and closed out with the normal placement detail + flag pair. The reviewing designer's adopt/fix feedback attaches at this materialize-and-review moment — exactly like the interactive flow's last step.

This handshake is how an API caller's design reaches native Figma: any designer who later opens the file is offered the pending build.


The Four Figma Channels

Everything between Figma and the system travels over exactly four channels. Figma never calls in — there are no Figma webhooks today; all movement is initiated by the plugin, by the backend (the trigger-time snapshot), by the worker, or by an operator.

# Channel Direction Initiator Auth Carries
1 Plugin → backend API (control plane) out of Figma plugin UI the platform's auth (login in the plugin UI) trigger (identifiers only), frame registration + resync, status polls (+ phase), confirm/reject, cancel, placement + feedback flags, discovery via the general list endpoint
2 Plugin ↔ wf2des-api (data plane — the wf2des routes in internal-api) both plugin UI session-issued token {org_id, project_id, exp} file registration (project_figma_file role/config), parse preview, the spec (self-contained), placement + feedback details
3 Server side → Figma REST (read) into the system worker · the backend at trigger the wf2design service-account PAT (wf2design's own secret store — never the platform figma_token table) component full-file walk, memo-fallback node reads, WF-registration snapshots, the trigger-time WF snapshot (frame + enclosing board section)
4 Plugin main thread ↔ the open file (Figma Plugin API) inside Figma plugin main thread none — the designer's own session read the selected WF frame; write the generated frame (instances, overrides, pluginData)

Channel 4 is the only write path into Figma anywhere in the system; channel 3 is the only server-side read, and it is read-only by Figma's own API design. The 1-vs-2 split mirrors the storage split: status truth lives in PostgreSQL and is served by the backend; content lives in DocumentDB + S3 and is served by wf2des-api (see the Internal API contract). The plugin stitches the two with the wf2des_id it receives at trigger time.

Inside the plugin, the two halves have disjoint powers, so every flow is a relay:

flowchart LR
  subgraph FIGMA["Figma editor session"]
    FILE[("open file<br/>WF frames · components ·<br/>generated frames")]
    MAIN["plugin MAIN THREAD<br/>(sandbox — figma.* only, no network)"]
    UI["plugin UI IFRAME<br/>(network only, no figma.*)"]
    FILE <-->|"read selection / build nodes"| MAIN
    MAIN <-->|postMessage| UI
  end
  UI -->|"1 · control: trigger · frame reg + resync ·<br/>poll · confirm · cancel · flags · discovery"| BE["Backend API<br/>(PostgreSQL)"]
  UI <-->|"2 · data: file reg · preview ·<br/>spec · details"| XAPI["wf2des-api<br/>(DocumentDB + S3)"]
  W["wf2des worker"] -->|"3 · REST read (PAT):<br/>component walk · memo fallback ·<br/>WF-registration snapshots"| FC["Figma cloud<br/>(REST API)"]
  BE -->|"3 · REST read at trigger<br/>(service-account PAT)"| FC

The materialization mechanics on channel 4:

  • The same-file constraint. Components are instanced directly by node_id (Figma's /components lists published components only, so local components have no publish key) — the placement file must contain the source components, and the materializer refuses with a clear error otherwise.
  • Overrides by layer name/path, never ordinally — matching the spec's layer_path addressing, so a reordered layer list never mis-targets an override.
  • Always a new tagged frame. The build never overwrites; the output frame is stamped pluginData {jobId, specVersion}.
  • JP-font preflight. The plugin lists the spec's fonts, probes availability in the editor, and warns before the build starts.
  • Chunked builds. Large specs build in chunks so the editor stays responsive.
  • Identifiers, not trees. The WF content never travels from the plugin — the backend snapshots the frame server-side at trigger time, so channel 1 stays thin and the job's input is content-addressed in S3.

Feedback — Bookkeeping Only

The designer marks the materialized result adopted or fixed. The plugin writes the detail to wf2des-api first — POST /internal/wf2des/{wf2des_id}/feedback with {status, changed_nodes[], diff_url, at, by}, where status is adopted | fixed, changed_nodes are spec layer_path values, and oversized inline diffs spill to S3 — then flips feedback_status on the wf2des row via the backend feedback endpoint (flag last). And that is the end of the flow: no event is emitted, no worker leg runs, and there is no automated learning from the diffs.

The stored adopt/fix diffs are review material for the designer's own rule and component revisions, and those revisions re-enter the system through their normal channels — a rule edit travels the rules-update flow below; a component edit is picked up by the next registry resync.


Rules Update

A designer updates the project's guideline boards in Figma and re-registers them: the designer selects the guideline board frames in the plugin → the backend emits a rule-upload event ({design_rule_id, figma_file_key, board_node_ids[]}) onto wf2des-events → rule_process renders each selected board over Figma REST (service-account PAT; a missing board or a failed render is a hard error), runs one vision extraction per board (LLM fence call 4), merges the fragments deterministically, and writes a new immutable, versioned design_rule document directly to DocumentDB — one doc per (design_rule_id, content_hash of the MERGED RuleSet), with a version ordinal, the board image refs, extraction provenance, and draft_source="llm_extracted". The extraction lands pending designer review: the designer inspects the extracted rules and re-registers after guideline fixes — identical merged rules converge on the existing revision, changed rules mint the next version. The run is webhook-free and PG-free; the design_rule collection is the rule's revision history.

Versioning is what keeps this safe mid-flight: running generations keep their pinned (design_rule_id, content_hash) — a rule edit never changes a run already in progress — while new generations pin the latest revision at trigger time.


Registry Resync

A resync fires from either the AI-owned EventBridge schedule or a plugin resync event on wf2des-events → component_sweep walks the project's registered files over Figma REST, hash-diffing against stored content hashes so only changed components are re-snapshot. Two outputs follow:

  • The DocDB context, written directly: design_component docs (variant properties, text slots, default size), keyed to the platform design row id, plus field-level stamps on project_figma_file — components_synced_at last, sweep_error on failure.
  • The discovered-component manifest, applied backend-side: the backend upserts platform design rows (type=component) — the shared component registry, the same catalog des2code consumes — which owns each component's existence, name, status, and removal.

A single-flight guard — the sweep_marker on the project_figma_file doc — keeps two concurrent sweeps from racing the registry; stale references surface through output-doc freshness (components_synced_at) and the next sweep's re-convergence. Note the asymmetry with the other internal runs: wf_parse and rule_process are entirely self-contained (the output doc is the record), while component_sweep additionally hands the backend its sweep manifest for the registry upsert — a separate path from the generation-only ai-status webhook.


When Things Fail

Failure What happens Who sees what
Worker crash mid-job One-shot Lambda dies → SQS redelivery (partial-batch); retries exhausted or row stuck '0' → no recovery path today: the row stays non-terminal and keeps blocking new triggers for that wireframe ops / plugin Retry button
Generation fails …-failed.json artifact + failed webhook {job_id, attempt, nonce, status: failed, error, result_manifest_url → …-failed-manifest.json} → handler records error on the row, flips status '2', clears phase designer sees the reason + Retry
Provider credits exhausted, credentials/access invalid, or configured model unavailable terminal failed artifact + manifest + safe client-visible failed webhook; consume because redelivery cannot repair configuration/account state designer sees a concrete provider error
Ordinary provider 429 rate limit or 5xx no terminal surface; throw for SQS redelivery temporary retry state
Parse looks wrong designer rejects the preview — detail {reason_code: wrong_roles \| wrong_memos \| wrong_sections \| other, note} to wf2des-api first (→ parse.rejected), then the backend flips status '3' rejected (detail first, flag last); a retry after reject skips the parse cache clean stop, no spec; the reason feeds parse-config curation
Awaiting-confirm forgotten a backend-side sweep over wf2des rows enforces the timeout (e.g. 72h) → fails closed, reason recorded designer notified on the next poll
Preview parked (designer left mid-decision) the job holds at phase=awaiting_confirm — nothing runs, nothing is lost; nothing expires it any later session finds it via the general list endpoint and resumes confirm/reject — or ends it via POST …/wf2des/{id}/cancel
Webhook undelivered the worker's send raises on failure → SQS redelivers and the send retries; the terminal S3 manifest stays the durable completion record. If the retries are exhausted the manifest is never applied — the stuck-generation sweep that would scan and replay it is NOT implemented, so the row stays non-terminal. The webhook is generation-only — component_sweep needs no delivery: its context and manifest re-converge / re-apply on the next sweep self-heals only while redelivery lasts; after the DLQ, manual
Duplicate trigger (same WF already in flight) the open-generation partial unique (figma_file_key, wf_node_id) WHERE status='0' dedupes → 409 + the existing wf2des_id both callers poll the same row
SQS send fails after row creation the trigger returns 500 and best-effort fails the row to '2' so the node frees up. If that fail-out also fails there is no backstop — the stuck-'0' sweep is NOT implemented, and the row blocks every future trigger for that wireframe until cleared by hand immediate error; manual if the fail-out did not land
Internal run fails (wf_parse · rule_process · component_sweep) SQS redelivery on wf2des-events / EventBridge retry + idempotent content-addressed / LWW / upsert writes that converge; stuck-run visibility = output-doc freshness + the SQS dead-letter queue — the backend never sees internal runs ops (output freshness + the DLQ)
Materializer can't resolve or safely apply content name_fallback, ordinal_fallback, prop_rejected, unmatched, or preserved is recorded in materializer_report with its actual detail visible actionable diagnostic
A node can't build at all build_error is visible and never silently dropped; font_fallback is separate when a substitute font rendered placeholder or degraded-font warning
Build fails midway (e.g. a component deleted between trigger and build) pinned inputs keep the spec valid; unresolvable nodes flag (build_error badges) instead of aborting the build a built frame with flagged nodes — never a half-done silent failure
Session crash between placement detail and flag the detail exists in wf2des-api but materialized_at never flipped the duplicate-build guard: the next session checks the placement detail via wf2des-api and scans in-file for pluginData {jobId} before rebuilding — never a second frame
Pinned input missing / hash mismatch on rehydrate the job fails closed — never a silent fallback to older rules or components error + Retry after the fix
Fonts unavailable in the editor the JP-font preflight lists the spec's fonts, probes availability, and warns before the build font warning before anything is built
Components not in the placement file components are instanced by node_id, so the placement file must contain the sources (the same-file constraint) the materializer refuses with a clear error; build in the file that holds the components
wf2des-api unreachable, backend up data-plane outage; content reads retry with backoff — no state is lost, each store stays authoritative for its own half status remains visible via the backend poll

The recovery spine underneath the table: the terminal S3 manifest is the durable record and the webhook is only the fast path; every row effect is idempotent under the (job_id, attempt, nonce) dedupe; and completed generations are never reprocessed — the a terminal row is final and re-running a wireframe creates a NEW row, because a re-run would overwrite committed human-event data with a different nondeterministic spec.


Official field-level contract: I/O Definition.