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.
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.
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.
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"], // … };
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.
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);
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.
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;
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.
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ę
},
});
# 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.
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.
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/", /* … */], };
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.
// 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="" />;
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.
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 ];
Bezpieczniki
budget_paused, nic nie ginie.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.
// 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ę
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.
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.
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”
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)