Polymarket Studio · Request Component

Component Author

Agent AI, który w zamkniętej maszynie pisze brakujący komponent maila, robi mu zdjęcia i oddaje człowiekowi do oceny.

agent (model) sandbox, czyli zamknięta maszyna host, czyli nasz serwer człowiek
obrazek mail z sekcją-obrazkiem agent repo (kopia kodu) sandbox: bez internetu, bez sekretów 4 podglądy: HTML + PNG + patch.diff człowiek ocenia
1

Problem: sekcja, której nikt nie umiał zbudować

Projekt z Figmy zamienia się w mail automatycznie. Gdy jakaś sekcja nie pasuje do żadnego istniejącego komponentu, ląduje w mailu jako płaski obrazek.

Operator klika na ten obrazek Request Component. Powstaje zgłoszenie i zaczyna się praca agenta.

Figma nieznane ingestion mail hero odds_card Image Fallback PNG footer Request
2

Zgłoszenie idzie po torach

Każde zgłoszenie ma status. Może przejść tylko na sąsiedni tor. Agent pracuje w dwóch miejscach: authoring (pisze) i verifying (sprawdza). Reszta to ludzie i publikacja.

requested authoring visual_review approved verifying publishing … applied „popraw” → jeszcze raz agent pisze człowiek patrzy bramki + naprawy
studio/lib/component-requests/contracts.ts
export const ALLOWED_TRANSITIONS = {
  requested:     ["authoring", "cancelled"],
  authoring:     ["visual_review", "requested", "budget_paused", "cancelled"],
  visual_review: ["authoring", "approved", "cancelled"],
  approved:      ["verifying", "ready_to_apply", "checks_failed", "cancelled"],
  verifying:     ["publishing", "repairing", "checks_failed", "budget_paused", "cancelled"],
  // …
};
3

Co minutę ktoś sprawdza, czy jest robota

Na serwerze (Vercel) co minutę budzi się worker. Bierze zgłoszenie, zakłada na nie „kłódkę” (lease), żeby drugi worker go nie ruszył, i odpala komendę pasującą do statusu.

studio/lib/component-requests/worker.ts
async function drive(request, command) {
  switch (command) {
    case "runAuthor":       return app.runAuthor(request.id);
    case "runVerification": return app.runVerification(request.id);
    case "recoverApply":    return app.apply(request.id, { recover: true });
  }
}
// claim → drive → complete; kłódka chroni przed dwoma workerami
claim = await deps.work.claim(candidate.id, deps.workerId, leaseSeconds);
4

Budujemy agentowi zamknięty pokój

Agent nie dostaje naszego serwera. Dostaje świeżą maszynę wirtualną z kopią repo i dowodami. Jedyne drzwi na zewnątrz prowadzą do modelu. Klucza API w środku nie ma: dokleja go firewall po drodze.

Vercel Sandbox · snapshot z node_modules i Chromium /workspace/repo checkout.tar.gz @ jeden commit studio/lib/email/… studio/app/(studio)/composer/… .claude/skills/add-email-component kod może zmieniać tylko na białej liście fixtures (dowody, tylko do czytania) frame.png tree.json candidate.json section.json annotations.json ← uwagi operatora zrzut Figmy, drzewo, cały mail, ta sekcja agent api.anthropic.com jedyne drzwi klucz dokleja firewall ✕ GitHub✕ Figma API✕ npm install✕ sekrety
studio/lib/component-requests/author/sandbox.ts
const AUTHOR_EGRESS_ALLOWED_HOSTS = ["api.anthropic.com"];

// VM zna tylko placeholder; prawdziwy klucz wstrzykuje polityka sieciowa
env: { ...request.environment, ANTHROPIC_API_KEY: "vercel-sandbox-brokered" }

// żadna zmienna o takiej nazwie nie wejdzie do sandboxa
const prohibitedEnvironmentName = /(?:ANTHROPIC|FIGMA|SUPABASE|GITHUB|VERCEL|TOKEN|SECRET|PASSWORD)/i;
5

Agent pracuje: czyta, klasyfikuje, pisze, patrzy

W środku uruchamia się Claude Agent SDK. Model ma zwykłe narzędzia do plików i terminal, plus trzy nasze: zapisz rozwiązanie, wyrenderuj podglądy, zakończ. Najpierw decyduje: użyć istniejącego komponentu, zrobić wariant, czy nowy.

czytaframe.png, section klasyfikujeReuse / Variant / New edytuje kodRead Edit Write Bash save_resolutionJSON bloku + walidacja render_previewsChromium → 4 PNG finishwerdykt + raport porównuje PNG z frame.png, poprawia, renderuje znowu desktop / light desktop / dark mobilelight mobiledark 680 px i 375 px · prawdziwy renderer maila, nie mock
studio/lib/component-requests/author/worker.ts
query({
  prompt,
  options: {
    cwd: repoRoot,
    model: "opus",
    maxTurns: 120,
    maxBudgetUsd,
    systemPrompt,
    allowedTools: [
      "Read", "Edit", "Write", "Glob", "Grep", "Bash",
      "mcp__component_author__save_resolution",
      "mcp__component_author__render_previews",
      "mcp__component_author__finish",
    ],
    permissionMode: "bypassPermissions", // VM nie ma sekretów
    resume: resumeSessionId,             // kontynuuje poprzednią rozmowę
  },
});
studio/lib/component-requests/author/instructions.ts (prompt systemowy)
# Component Author

You author one email component from a Figma
section that ingestion could only ship as an
image fallback. You work in an isolated sandbox.

## Reuse is a valid verdict
If an existing block renders this section
faithfully, do not write code. Finish with
`reuse_existing`. The tree must be unchanged.

## Allowed paths (enforced by the host)
- studio/lib/email/blocks/types.ts
- studio/lib/email/blocks/manifest.ts
- studio/app/(studio)/composer/config.tsx
  …
Never touch: .github/, .claude/, supabase/ …
Never run git commit, git push or gh.

Prompt zawiera całą naszą instrukcję add-email-component, tę samą, z której korzysta człowiek dodający komponent ręcznie.

6

Host odbiera pracę i sprawdza, czy agent nie oszukał

Agent nic nie publikuje. Host robi git diff w sandboxie, pobiera łatkę i zrzuty, i odrzuca wszystko, co łamie umowę. Wynik to rewizja: niezmienny pakiet dowodów w prywatnym bucketcie.

sandbox patch.diff verdict.json resolution.json previews/*.png ×4 transcript.jsonl ✓ zmienione pliki tylko z białej listy ✓ „reuse_existing” ⇒ zero zmian w kodzie ✓ „new_component” ⇒ są zmiany ✓ są wszystkie 4 PNG, resolution wskazuje ten komponent Revision niezmienna · w bucketcie <requestId>/<revisionId>/… koszt w USD zapisany każde ✕ = failure z kodem, np. patch_outside_allowlist · łatka trafia do logs/rejected-patch.diff
studio/lib/component-requests/allowlist.ts
export const DEFAULT_COMPONENT_PATCH_POLICY = {
  // istniejące pliki, w których wolno tylko DOPISAĆ (modify)
  sharedWiringSurfaces: [
    "studio/lib/email/blocks/types.ts",
    "studio/lib/email/blocks/manifest.ts",
    "studio/app/(studio)/composer/config.tsx",
    // … 14 plików
  ],
  // tu wolno tylko DODAĆ nowe pliki
  newComponentRoots: ["studio/lib/email/previews/**", "studio/lib/email/blocks/__fixtures__/**"],
  forbiddenRoots: [".github/", ".claude/", "supabase/", "studio/supabase/", /* … */],
};
7

Człowiek patrzy na żywy HTML i mówi „popraw” albo „ok”

W Studio operator nie ogląda zrzutów. Widzi prawdziwy HTML maila w odizolowanym iframe (bez skryptów, linki otwierają się w nowej karcie), w 4 wariantach: desktop i mobile, jasny i ciemny. PNG zostają jako dowód i zapas dla starych rewizji.

Uwagę można przypiąć do konkretnego elementu (mapa elementów z DOM) i wysłać agenta na kolejną rundę. Sandbox zostaje ciepły 15 minut, a model wraca do swojej poprzedniej rozmowy.

<iframe srcDoc sandbox> · Live markup 1 [desktop/light] #cta „Przycisk za mały, brak ramki jak w Figmie” Regenerate Approve visual_review → authoring (jeszcze raz) lub → approved
studio/app/(studio)/composer/component-requests/preview-frame.tsx
// HTML z rewizji, bez skryptów i formularzy; wolno mu tylko otworzyć link w nowej karcie
if (markup) return <iframe {...common} srcDoc={markup} sandbox="allow-popups allow-popups-to-escape-sandbox" />;
// PNG tylko dla rewizji sprzed zapisywania HTML
return <iframe {...common} src={preview.imageUrl} sandbox="" />;
8

Po „ok”: 12 bramek w czystej maszynie

Zatwierdzoną łatkę host nakłada na świeży sandbox (nie ten, w którym model miał terminal, bo mógłby coś tam podstawić) i puszcza pełny zestaw testów repo. Czerwona bramka to runda naprawcza: model dostaje log błędu do promptu.

typecheck email_guard parity fixtures docs manifest roundtrip starters folders canvas catalog baseline wszystkie bramki lecą do końca, bez przerywania: prompt naprawczy chce całego obrazu repairing ostatnia bramka: nowy komponent nie może zmienić wyglądu żadnego istniejącego maila
studio/lib/component-requests/author/gates.ts
export const AUTHOR_GATE_SUITE = [
  { gate: "typecheck",        command: "./node_modules/.bin/tsc --noEmit" },
  { gate: "component_parity", command: "./node_modules/.bin/tsx scripts/component-parity.ts",
    hint: "Puck config key, itemToBlock arm, module bridge or catalog variant round-trip is missing." },
  { gate: "email_baseline",   command: "… email-baseline.ts render && test -z \"$(git status --short lib/email/baseline/)\"",
    hint: "A new component must not alter existing template output." },
  // … 12 bramek
];
9

Bezpieczniki

$25limit na jedno zgłoszenie. Po przekroczeniu status budget_paused, nic nie ginie.
120tur modelu na sesję. Po limicie praca i transkrypt są zapisane, Retry kontynuuje.
45 minna jedną sesję. Dłużej: host przerywa proces i zapisuje koszt.
15 minciepłego sandboxa po każdej rundzie. Potem maszyna znika, a łatka odtwarza się z bucketu.
0sekretów w maszynie. Klucz do modelu widzi tylko firewall.
1proces modelu na rundę. Blokada katalogu chroni, gdyby host padł zaraz po starcie.

Serwer jest bezstanowy, więc runda dzieje się w dwóch tickach workera: pierwszy odpala proces w sandboxie i zapisuje checkpoint, kolejny odbiera wynik z execution.json. Gdy coś padnie, dowody zapisują się zanim sandbox zostanie zamknięty.

studio/lib/component-requests/author/runner.ts
// tick 1: odpal i wyjdź
worker = await deps.sandbox.runDetached(sandbox.ref, { command: `mkdir ${jobRoot}/dispatch-${revisionId} && (${command})` });
await deps.checkpoints.set(request.id, key, { ...checkpoint, worker });
throw new AuthorWorkPending();

// tick 2+: czy jest wynik?
const collected = await readJson(sandbox, "execution.json");
if (!collected) throw new AuthorWorkPending();   // jeszcze pracuje, wróć za minutę
10

Co dalej: PR na GitHubie

Ta część jeszcze nie działa na produkcji. Wdraża ją osobny PR #777 (draft, ustawiony na gałąź z tego opisu). Poniżej plan, nie stan.

Po zielonych bramkach zgłoszenie stoi w publishing. Host, nie agent, bierze zapisaną rewizję i zanosi ją na GitHub jako bot. Agent i bramki nie uruchamiają się drugi raz. Człowiek robi code review jak dla każdego PR. Po merge i deployu obrazek w mailu zamienia się w prawdziwy komponent.

Revisionpatch.diff GitHub Appallowlist jeszcze razbranch + PR, idempotentnie PR → mainmerge = deploy (ADR 0003)Action: niezależny pre-review developercode review, merge deployhost sprawdza, czy komponent żyje Applyobrazek → komponent uwagi z review → ograniczona liczba commitów naprawczych od agenta człowiek zostaje w pętli: brak auto-merge publishing technical_review merged_awaiting_deploy ready_to_apply → applied gałąź: component/<request-id>-<slug> · autor: bot, więc łatwo odróżnić i objąć osobną polityką

Dlaczego bot może otwierać PR do main? Merge to od razu deploy, a komponent maila działa dopiero po deployu. Obrona tej decyzji to allowlist, zielone bramki przed PR i człowiek, który klika merge.

Co z czerwonym reviewem? Uwagi developera trafiają do agenta jako runda pr_repair, z tym samym werdyktem i komponentem. Liczba prób jest ograniczona, potem zgłoszenie wraca do człowieka.

studio/lib/component-requests/contracts.ts (te przejścia już istnieją, czekają na #777)
publishing:             ["technical_review", "policy_rejected", "cancelled"],
technical_review:       ["merged_awaiting_deploy", "visual_review", "budget_paused", "cancelled"],
merged_awaiting_deploy: ["ready_to_apply", "cancelled"],
ready_to_apply:         ["applying", "cancelled"],
applying:               ["applied", "ready_to_apply"],
// dziś worker zatrzymuje się na publishing; UI pokazuje „Awaiting publication”
Gdzie to jest w repo
studio/lib/component-requests/
  contracts.ts        statusy, przejścia, typy
  worker.ts           cron co minutę: claim → drive → complete
  allowlist.ts        biała lista plików
  author/
    runner.ts         host: sandbox, prompt, odbiór, kontrole
    worker.ts         w sandboxie: Agent SDK + 3 narzędzia
    instructions.ts   prompt systemowy i użytkownika
    sandbox.ts        Vercel Sandbox, polityka sieci
    session.ts        ciepłe sandboxy, wznawianie rozmowy
    gates.ts          12 bramek weryfikacji
    preview-render.ts renderer podglądów (Chromium)