surface-write-targets
Recipe for making an existing surface agent-writable via manifest writeTargets. Use when asked to 'let agents write/update/fill X on this page', 'add write targets', or 'wire apply_surface_write', or
Files1
View on GitHubSKILL.md.claude/skills/surface-write-targets/SKILL.md
GitHubsurface-write-targets — make a page agent-writable
<!-- rules:stamp -->Rules from
common-docs/systems/platform/ui-shell/FEATURE.md— edit there, never here (python3 common-docs/meta/scripts/one_home.py --write).
- Delete means archive, everywhere. A delete that does not archive is a defect.
- A menu regroup ships as a side-by-side demo page with a what-goes-where list, never as a direct change.
- Every surface mounts the canonical menu. Use
NonEditableContextMenuorEditableContextMenufromfeatures/context-menu-v3/. Never use a bespokeDropdownMenuor a rawcontextmenuhandler, and never keep both. - A menu without a source is a defect. A menu with no
contentSource,entityorsurfaceNameopens with every verb dark. Fix these before unwired surfaces. - Lists built on
EntityListPageinherit the row menu throughItemContextMenu. They need their own wiring only for surface-specific sections. - A window or overlay mounts its own menu. It never delegates to the page under it.
- Reuse the domain's action registry. Never create a second one.
- Secret-bearing surfaces follow
VaultContextMenu. PassgetApplicationScope, forceselection: "", put names and field keys in the content (never values or notes), and pass noentity. - Done means the menu opens clean. The console shows no
INERT MENUorVALUE MAPPING GAPscream. - Chat is a universal agent host. Never bind a page-agent roster to it.
- Anything with an id shows it through the one canonical id component (display plus one-click copy).
- Flagship and first-run screens get a bespoke elevated stage, not the 28px app-chrome controls.
- Before a link goes to Arman, every screen on its path passes the Steve Jobs bar (walked and fixed by an Opus reviewer).
- Admin tables filter, search and sort on the server, and people are picked by name or email, never by id.
- Wasted vertical space is the worst sin; every status shown is backed by real state.
- A new output style or menu layout is a NEW function used only by the new path; never thin a shared builder.
- A page fetches only what it shows, each thing once; every removed fetch ships its on-engagement load.
- Floating things never move the page. Pills, assists, badges, alarms, notices, toasts and the
chat dock float over it; when one would cover something, it moves itself (lifts, rests above
the bar, yields). No page pads, shrinks or re-centres for one. Only the empty scroll room after a
page's last line may grow. Guard:
pnpm check:overlays-never-move-page(matrx-frontend). - Every feature has the same pages (front door,
/all,/new, record, sub-feature) and derived breadcrumbs: feature pages.<!-- rules:stamp-end -->
Surfaces already tell agents what a page SHOWS (values). This skill adds the
other half: declared write paths agents can drive, gated by policy. The
plumbing is ALL built and automatic — when a mounted surface has agent-writable
targets, every agent run on it is offered ONE inline tool
(apply_surface_write), and its calls route through applySurfaceWrite
(../aidream/apps/shared/chat/src/surfaces/runtime/surface-writeback.ts) with origin: "agent":
ask shows an in-place confirm, auto applies, manual refuses loudly, a
decline returns a non-error result. You add ZERO plumbing — only manifest
declarations + page handlers. Changing the writeback seam itself is not this
skill: that is surface-writeback.ts + features/surfaces/FEATURE.md.
Worked references (read one before writing anything):
features/surfaces/manifests/tasks.manifest.ts+ handler block infeatures/tasks/components/editor/TaskEditorBody.tsx— draft-into-Redux editor + entity actions. THE exemplar.features/surfaces/manifests/marketing-page.manifest.ts+features/marketing/components/pages/MarketingPageWriteTargets.tsx— entity targets through canonical services, handlers registered from a child viauseSurfaceWriteHandlers.
Step 0 — what a page gets (decided 2026-09-27, from the My Classes test)
Every record type a page lists gets full CRUD over LISTS, through the
page's own save functions, each target ask:
| Target | Value | Notes |
|---|---|---|
create_<plural> |
array of 1-25 objects | only what the page's own create would take |
update_<plural> |
array of { id, …fields to change } |
only the fields sent change; merge onto the current record |
delete_<plural> |
array of ids (or { id }) |
description states exactly what is lost and steers to archive when the record can be archived |
<record>_draft |
object | only when the page has a "New ___" dialog: opens it and fills EVERY field, nothing saved |
- One set per record type, built with
collectionWriteHandlers(../aidream/apps/shared/chat/src/surfaces/runtime/collection-write-targets.ts). A page with five record types calls it five times. Never one catch-all target. - Archive/restore is a field on
update_<plural>(archived: true|false) when the record can be archived. - Deletion is allowed: the person approves every one on the card, and the description states the consequence (law: a destructive action states what is lost). Prefer archive in the description.
- Still NO targets for: ids/ownership, credentials, billing, permissions, and derived evidence (scores, mastery, counts). Those stay read-only values.
- Authored fields on an editor page (a description, a brief, meta tags) keep
their
drafttargets as before (worked reference:tasks.manifest.ts+TaskEditorBody.tsx).
Worked example of all of it: features/surfaces/manifests/education-classes.manifest.ts,
features/education/classes/classAgentWrites.ts (pure parsers + tests),
features/education/classes/components/ClassesHome.tsx (the helper) and
ClassFormDialog.tsx (the draft target).
Step 1 — declare targets on the manifest
Per target: name, label, valueType (array for the CRUD targets),
updatesValue (the list value it changes), mode (entity for CRUD,
draft for the dialog), applyPolicy: "ask", group, sortOrder.
The description is the ONLY contract the model sees (until the target names
a valueKind). Write it so a small model gets it right the first time:
- start with what it does and whether it saves ("saved immediately" / "NOTHING is saved");
- "Value is a JSON ARRAY of objects, each { … }" — spell every field, its
type, allowed values (
"open" | "closed" | "paid"), formats (YYYY-MM-DD), and which are required; - what is refused (duplicates, unknown ids, rules between fields);
- for delete: what is lost, and "prefer update with archived: true".
Every record type also gets its list as a value (owned_<plural> or the
page's name for it) with the ids the targets need, and an archived_<plural>
value when archived records exist, so update and restore can name them.
Step 2 — register handlers on the page
A handler entry is { validate, apply }
(SurfaceWriteHandlerEntry, ../aidream/apps/shared/chat/src/surfaces/runtime/SurfaceRuntimeContext.tsx):
validate(value)runs BEFORE the person's approval card. Throw a sentence the agent can act on and no card is shown. Put the whole-list check here (a pure parser, unit-tested).apply(value)runs after approval. Re-parse against the live page, save, and RETURN{ summary, data }: what landed, with ids. The agent's page snapshot was taken when its run started and will not show new records, so this return value is how it knows.collectionWriteHandlersdoes both for list CRUD, plus the part-way failure message ("Created 1 of 3 … Not attempted: …") so a retry never duplicates.
The seam (surface-writeback.ts) already, for every target:
- turns a JSON string into the array/object the target declares, and refuses a wrong type before the card, naming expected vs received;
- returns every outcome to the agent: refused before approval, declined by the
person, failed after approval (with the handler's message), or succeeded
(with your
summary/data).
Rules that still bite:
- Save through the page's canonical function (the one its button calls), never a parallel write. If it replaces a whole JSON column (settings), merge onto the current value first.
- No workspace selected: a create resolves it with
ensureOrgId(orgId), which asks the person; treatisOrganizationSelectionCancelledas a refusal ("ask which workspace"), never a silent no-op. - A dialog with its own fields: mark its root
data-surface-layer="<surface>"(SURFACE_LAYER_ATTRIBUTE) so the generic window-form net stands down, and register its draft handler withuseSurfaceWriteHandlersfrom the component that owns the fields (it stays mounted while closed, so it can open itself). - Intro: name the targets for the jobs agents will be asked to do, and say not to use generic tools (scope/context tools) for this data.
Step 3 — verify with a REAL agent on the live site (non-negotiable)
No dev server. After the release carrying your commit is live:
pnpm surface:probe --surface <client/name> --route <route> --commit <sha> \
--agent '<a real request, e.g. "Add these classes: A (closed), B (open, final 2026-12-10)">'
It runs a real agent from the Agents menu and presses Apply for you (use obviously named test data). Run, for each record type:
- create two items in one request;
agent.approvalsshows ONE approval andagent.replynames both with ids; - update one field on one item and archive another;
- a request that must be refused (a duplicate name, a bad date): the reply
carries your validator's reason and
agent.approvalsis EMPTY (refused before the card); - then a read-only SQL query: every created row is COMPLETE (every field the page's own create sets, links and memberships included), compared with one made by hand.
Then pnpm check:surface-drift, the focused type check, and the DB mirror
sync (see surface-authoring/references/campaign-worker.md §3). Report the
test rows you created so the person can remove them.
Step 4 — what the agent sees up front, the guide, and feedback
- What the agent sees up front — THE CONTEXT BUDGET (Arman, 2026-09-27).
Protect the model's context, but never starve it: an agent missing what it
needs either fails or spends MORE tokens looking the same data up. So:
- Send what the page's likely jobs need, up to a page budget. The sum of
a surface's
inlineUpTo(inherited values included) may not exceedPAGE_CONTEXT_BUDGET(10,000 chars,../aidream/apps/shared/chat/src/surfaces/types.ts); the declaration check refuses more without Arman'scontextBudgetApprovalon the manifest. Within it, every number is your judgment. Everything not inlined stays at the 200-char default and is one lookup away. - Spend it by the page's shape:
- Focused page — one record and little else (a note, a study guide, an agent definition): the record gets up to ~10,000.
- Record with companions — the record plus its folders, comments,
notes: the record ~7,000, companions share the rest (worked example:
education-study-guide— guide 7,000, annotations 1,500, comments 1,500). - List page — the CONDENSED visible list (id, name, 3-6 key fields, the person's sort and filter, first ~25 rows) ~4,000; count and active sort/filter as their own small values; full rows a separate lookup value.
- Broad page — covers many unrelated things (SEO, search console, dashboards): you can't know the task, so send a compact OVERVIEW bundle (~2,000-3,000: what is here, the headline numbers, what needs attention) and nothing else inline; the guide tells the agent how to discover the rest.
- Small drafts, recents and tabs: ~1,000-2,000 each.
- Bundle it. Inlined content goes out as ONE well-prepared XML bundle
per job, built with
../aidream/apps/shared/chat/src/surfaces/runtime/context-bundle.ts(xmlElement,xmlTextwithmax,xmlListwithmaxRows): attributes for scalars, empty parts omitted, clipped text markedclipped="true" total_chars="N", dropped rows counted. Never raw JSON rows with every column. Worked examples:features/research/browse/surface.tsandfeatures/flashcards/components/home/deckSurface.ts(list bundles),features/marketing/lib/surface-context.ts(overview bundles). Manifests that still inline raw arrays (e.g. education-classes) predate this rule. - The record's OWN sub-lists (a guide's notes and comments, a conversation's
messages) are part of the record's bundle. Long text fields in it are also
patchablewrite targets. - Measure it: the live agent test must answer "what is on this page" with
no lookups, and
pnpm surface:openersshould fall after your change.
- Send what the page's likely jobs need, up to a page budget. The sum of
a surface's
- A page's own conversation never sees the page. Where the page itself
runs an agent (the main chat, the builder's test run, the runner, each
battle lane), declare it on the provider —
ownConversationId={id}, orisOwnConversation={(id) => …}for several — so that conversation gets no page context and no surface tools on any turn, while every other agent on the screen (a window, a sidebar) gets both. Its launcher also passesruntime: { surfaceName: null }. (Arman, 2026-09-27;isPageOwnConversation.) - Universal hosts (
agentRosterMode: "universal", e.g. Chat, where any agent may be the page's main one) have no bound roster; the Agents menu offers "Run an agent on this page" (any agent, launched with the page's live scope in a window) instead. Verify it the same way —surface:probe --agentfalls back to that picker. - A rejected value reports EVERY problem at once, per item then list-level
(duplicates, unknown ids), ending "Nothing was changed." — never just the
first (Arman, 2026-09-27;
collectProblemsincollection-write-targets.ts). - Judge it by what agents do first. A surface is failing when most agent
runs on it open with
contextlookups for the same value. Measure it withpnpm surface:openers --surface <client/name> [--days 14](prints read-only SQL for the Supabase MCP; an estimate until conversations record their surface). Record the share before and after your change in your report. The live agent test (Step 3) must show the agent answering "what is on this page" with no lookups; report any lookup it made. - A guide for any page with more than one record type or any rule the
descriptions can't hold. Write
features/surfaces/guides/<surface-slug>.md(80-150 lines: what the page is, each value, each target with a worked example value, the rules, what to do when stuck) and setguide: "features/surfaces/guides/<slug>.md"on the manifest. The sync publishes it as the platform skillsurface-guide-<slug>and adds a pointer to the intro. Worked example:features/surfaces/guides/education-classes.md. The intro stays short: the basics and which target does which job. - Very complex pages (dozens to hundreds of values, e.g. the search-console and SEO pages): the guide is mandatory; every value sits in a named group; the intro names the few values that matter for the common jobs; only the overview bundle (and the record being worked on, if one is open) is inlined — everything else stays a lookup the guide explains; and write targets follow the same per-record-type sets, never one target per field.
- Read the page's agent feedback before changing a surface. Every page
offers agents the platform target
surface_feedback(saved to the central feedback system, tagged with the surface). Before editing, runpnpm surface:feedback --surface <client/name>and run the SQL it prints through the Supabase MCP; fix what it reports or say why not. Mark handled rows resolved. - Document: one Change Log line in the touched feature's
FEATURE.md; file anagent.review_queuerow (skillagent-review-queue).
The approval system — defaults, named exceptions, Arman's sign-off
Approved by Arman 2026-09-27. Every judgment call on a surface has a DEFAULT, a short list of NAMED EXCEPTIONS a worker may use when their condition holds, and everything else needs Arman's approval recorded on the manifest (who, date, why). A worker never picks a number or a policy of their own.
| Call | Default | Named exceptions (no approval needed) | Anything else |
|---|---|---|---|
| How much the agent sees up front | platform default (200 chars) per value | any inlineUpTo you judge right while the surface's total stays within PAGE_CONTEXT_BUDGET (the context budget above) |
contextBudgetApproval on the manifest — enforced by the declaration check |
| Whether a write asks the person first | applyPolicy: "ask" |
"auto" on a ui target (view state only) |
approval on the target — enforced by the declaration check |
| Delete targets | allowed, ask |
— (always allowed when the description states exactly what is lost and steers to archive where it exists) | — |
| Read-only (no targets) for a record type | not allowed when the page itself can create/edit/delete it | the page has no such action (say so in the manifest header) | — |
readiness: "verified" |
partial with an exact note |
— | only after the live agent test, the DB completeness check and an independent reviewer pass |
| A guide | not required | required for more than one record type, or rules the descriptions can't hold, or a very complex page | — |
To ask for an approval: leave the default, file the question with the
evidence (skill ask-arman), and report it. When he approves, record it on
the manifest exactly as "Arman <YYYY-MM-DD>: <why>".
Traps
- The approval card can still read "The agent proposed this change…" instead
of the agent's real name when neither the shared agent-name cache
(
useAgentNames/resolveAgentName) nor the agent-definition slice has resolved a name yet — known nit, not yours to fix per-surface (seedispatch-surface-write.thunk.ts). content-plan-nodeand its siblings are nowapplyPolicy: "ask"/"auto"(2026-08-09); onlynode_primary_keyword_idstaysmanual(no keyword options exposed). Never leave a new targetmanualby omission.- Don't declare a target whose handler you can't wire to a CANONICAL write path — a declared-but-unwired target is a loud runtime defect by design.
- Multiple values in one field object (like
page_meta_tags{meta_title?, meta_description?}) beat five micro-targets when they're edited together; separate targets when they're independent decisions. - The seam parses a JSON string into the declared object/array (since
2026-09-27). A
valueType: "string"target still receives a string, so if a target legitimately takes structured data, declareobject/arrayand accept the value itself. - CHECK FOR A COLLISION BEFORE YOU WRITE ANYTHING, and again before you
commit. Subagents and other sessions fan out in parallel, and the same
surface gets assigned more than once.
git fetch origin mainand confirm the manifest still lackswriteTargetsON LATEST MAIN — not on your clone's base, which goes stale within the hour — andgit ls-remote origin <your-branch>to see whether someone is already pushing there. If a DIFFERENT design already landed on main, it wins: do NOT merge a competing target set on top of it (two targets covering the same fields is a defect, not a merge), keep the landed work, and contribute only what is genuinely additive. Never force-push over another agent's branch. Scraper hit this three ways at once — a branch implementation, a second agent on the same branch name, and a third, better design already merged to main. - A surface can have several provider mounts, and the ROUTE is often not one
of them. Confirm which component actually mounts
SurfaceRuntimeProviderbefore verifying — an agent run on a page with no mounted runtime is offered no write tool at all, which looks exactly like a broken target. (Scraper's live mount is thescraperWindowfloating panel, not/scraper.) - Collisions land MID-FLIGHT, not just before you start. The trap above
says check before you write and again before you commit — the second check
is the one that fires.
connections-skillspassed a clean STEP ZERO (no branch, noagent.review_queuerow, nowriteTargetson main) and a complete competing design was merged to main while the live-agent verification was still running. When that happens the landed design wins even if yours is finished and verified: revert your competing files rather than reconciling them, and keep only what is genuinely additive. Budget for it — a surface you scouted is not a surface you hold.
Verification-harness traps (these silently fake a PASS or a FAIL)
- Pass a real FUNCTION to
page.evaluate, never a string. A string like"() => {...}"evaluates to a function object rather than being called, so the poll returns nothing and you conclude "no confirm dialog appeared" while five appeared and blocked the run. Cost four full agent runs onconnections-skills. - Match dialog buttons on
textContent, notinnerText.innerTextneeds layout and comes back""for the portal'd radixAlertDialog's Apply / Keep as is buttons in headless Chromium — the text is plainly indocument.body, so the dialog looks present and unclickable at once. Same for finding a list row by its label. - The confirm's smallest common ancestor is the FOOTER, not the dialog.
Walking up from the Apply button until you find "Keep as is" lands on the
button row, so your evidence log captures two button labels and none of the
description you are trying to prove. Take
closest('[role="alertdialog"]')for the text. input[placeholder*="search" i]is ambiguous on shell pages — on/agent-connectionsit matches the SIDEBAR's section filter, not the list search. A dirty-editor guard test that types into the wrong box makes the editor stay clean and the write get ACCEPTED, which reads exactly like a broken guard. Identify a field by an EXACT value match and then assert the state you meant to create (Save button enabled) before trusting the probe.getScope()is sampled when the user presses ▶. A second message in the same agent conversation carries the FIRST run's scope snapshot, so an agent that writes and then re-reads within one thread reports its own writes as missing. Start a fresh run to prove a read twin — that is the design, not a stale twin, and it is worth saying so before someone "fixes" it.