Typical workflows#
Read before you write. Documents are deep-merged by
updateStoreDocument (objects merge key-by-key; arrays and scalars replace
whole), so read the current shape first and send only the keys you change.
Deleting exists — use the right tool. A merge cannot remove a key:
deleteStoreKeys(path, keys[]) deletes fields (dot-paths reach nested keys —
this is also how you repair an element placement polluted with a stray key),
and deleteStoreDocument(path) removes a whole document (unrecoverable —
read it first, prefer key deletion). Never fall back to the old workaround of
blanking a key with ""/false/0. The store root document and
customers/** are refused by both.
Audit what you built. At the end of a bulk build or fill — and whenever
the user asks for a review — run the read-only project audit
(drop-core://rules/audit-website): it reports design seeds that do not
describe the sheet, oversized and one-off elements, UI copy repeated on every
document, links split from their labels, collections acting as enums, image
variants that were declared but never generated or that listings and
galleries do not use, and a missing Data grouping or pinned menu. Start with auditProject (the
inventory in one read-only call) and checkSite; report first; fix only what
the user picks. A build ends with its collections in Data page groups
(writeDataGroups, registerCollection groups), their shared schemas
locked (writeSchema locked: true), every single-collection reference field
scoped with collection_ref, and a proposed console menu.
- Change page content:
readStoreDocument("data/pages/index")→updateStoreDocumentwith only the changed keys. The template re-renders automatically — do NOT rewrite the template for content changes. RICH TEXT IS A PLAIN STRING (the text storage contract, 2026-10-22): a text value is the text itself, or — on a field declarededitor:"html"— the HTML fragment itself; never a{"type":"html","value":…}wrapper (the pre-2026-10-22 shape; the write tools flatten one you send). The schema'seditoris the only place the mode lives, so a plain-text field must not carry tags: a string with a tag renders as markup and opens as rich text in click-to-edit — when a value needs bold / links / line breaks, declare the fieldeditor:"html". ONE LINE IS BARE: a fragment that is a single line of text is stored WITHOUT a<p>wrapper ("Inovativní firma v oblasti <em>recyklace</em>"), because templates drop such fields into headings (<h2>{{{ intro.title }}}</h2>);<p>(and<h2-h4>, lists, quotes) appear only when the value has two or more blocks. The console's editor stores the same way. Templates bind{{ }}for plain values and{{{ }}}ONLY for html / blocks / icon values and rendered.htmlfragments — seedrop-core://rules/handlebars-binding;writeWebTemplateandwriteStoreElementreturn awarningon a brace/kind mismatch. - Change layout/design of a page: find the doc's schema
(
readStoreDocumentthe doc, or listschemas), read the current template version (readStoreDocument("schemas/{id}/templates/{v}")), thenwriteWebTemplatewithschemaId+templateVersionandedits— a list of{field: "html"|"css"|"js", search, replace}where eachsearchis copied verbatim from the stored source and occurs exactly once (search: ""appends,replace: ""deletes; all-or-nothing, a miss comes back with the closest lines; max 20). Send the fullhtml+cssonly for a rewrite, never both. PassnewVersion:true(witheditsthe named version is patched into the next one) only when a new version is genuinely wanted. - Invoice / e-mail templates (
data/{locale}/templates/{id}— one whole HTML document per locale,drop-core://rules/store-template): read the record, thenwriteStoreTemplate({templateId, data})with the FULL document. Each record also carries asample— the render variables the admin editor previews it against (rendering and mailing never read it); fill it withwriteStoreTemplate({templateId, sample})(datamay be omitted; the sample is replaced whole), shaped like what the template's sender passes — every key the source binds, arrays of objects for{{#each}}, realistic values, no real customer data. - New collection: pick the schema scope first (one shared schema for a
repeating shape, a per-document
general/{collection}/{document}schema for one-off pages), create it withwriteSchema, write the documents, thenregisterCollection({key, name, template})ONCE —templateonly for a SHARED schema — otherwise the collection never appears on the admin Data page. Idempotent; re-call it to change the name/template/slugs,route({enabled, index?, categories?}— serve the documents at/{segment}/{slug}, the segment being the locale's collection slug;categories: {key: {name?, slugs?, index?}}adds one category level,/{segment}/{category}/{slug}, which a document joins by itsseo.categorykey — noparent, nested categories are not supported yet; category segments and aliases unique per language, aliases 301 to the primary; the category registries are server-written, never write registry entries yourself), orcolumns— which columns the admin listing shows, in order (built-insid/schema/status/timestampor field keys from the template; omit to keep,[]to reset to the defaults), orkeys— the extra query keys a filtered + sorted listing needs beyond the built-in ones (Query keys above). Removing a category (or a category's stored segment) is destructive — the tool answersneedsConfirmation: truewith the removed keys: relay the question to the user and END the turn; passconfirmRemove: trueonly in a LATER turn, after the user confirmed in chat — never on the first call, never in the same turn (an agent host ignores the flag until the user has answered, so a self-confirmed call writes nothing). A category that documents still carry inseo.categoryis refused with the per-locale counts unlessforce: true— the same rule (those documents fall back to/{segment}/{slug}only on their next save). - Addresses and redirects: a renamed
seo.slug(or a changedseo.category) keeps the old URL as a 301 automatically — never redirect it by hand. The registry is refused to the generic write/delete tools; its two human-editable parts have their own tools, used only when the user asks:setRedirect({key, collection?, category?, target | targets | clear, temporary?, overridePage?})sets a manual redirect on one address (target= every language,targets: {locale: target}= chosen languages, the modes replace each other; refused over a live page unlessoverridePage, and on a root key that is a routed collection's segment or alias) andretireFormerUrl({key, locale, collection?, category?})drops a renamed page's former URL (a live slot is refused). An OLD ADDRESS of another site (a migration — any URL shape:/cz/kampus/x/,/produkt.php) issetRedirect({from, target, temporary?, clear?}): stored canonicalized inredirects/{pathRedirectId(path)}and answered only where the site would otherwise 404 (a page or an address redirect there is refused unlessoverridePage).resolveAddress({path, locales?})is read-only: what/public/sluganswers per language (status200/301/302/404,document,redirect,why; a/en/…prefix is recognised, an old address stored with it found) — use it to verify any URL change. - Lock a schema's structure:
writeSchema({schemaId, locked: true})freezes the field structure for content editors — the admin document editor (and its AI) then only fills values;locked: falseunlocks, omitting it keeps the current state. Works on a shared schema and on a per-document one. It is not a write guard:writeSchemastill edits a locked schema, so restructure one only when asked (drop-core://rules/schema-fields, STRUCTURE LOCK). - Whole-site work (nav, cross-links):
readStoreDocument("setup/web")first, then the docs it references. A link TO another store document is akind:"link"field holding the envelope{type:"link", collection, document, hash?}(never a hand-typed path — the renderer emits the target's localized, routed URL: the homepage →/, else the slug under its scope + the collection route, else/ref/{collection}/{document}; a missing target renders nothing) — seedrop-core://rules/schema-fields. Documents reached THROUGH a collection/list reference (listing cards) still link with{{route "{collection}" slug}}(/{segment}/{slug}when the collection is routed, else/{slug}) — or{{route "{collection}" slug this.seo.category}}for a categorized document (/{segment}/{category}/{slug}; JS cards readwindow.DROP_ROUTE_CATEGORIES) — falling back to/ref/{collection}/{document}; a document'sseo.scope(both|root|collection) says which of its URLs exist. - Provision locales:
updateStoreDocument("store/{sid}", {locales: ["cs_CZ", …]})— first entry = the default locale, tags arexx_YY. Do it BEFORE creating content (data moves neither automatically nor cheaply). Store-root writes accept onlynameandlocales. - Paged listing ("load more"): read
drop-core://rules/load-morefirst. The page's collection-reference envelope ({type:"collection", collection, query?, limit, order_by}) is the whole contract: the renderer resolves page 1 from it and the render server'sPOST /content/{locale}route (body{path, field, after}) serves every later page from the same envelope. Store the envelope, never resolved documents. - Images:
generateImage(AI) orimportImageFromUrl(external file) both upload into the store files and return{url, imageValue}. For an image FIELD storeimageValueEXACTLY as returned (viaupdateStoreDocument); useurlonly for decorative template markup. Always passfieldKey(the destination field's dot-path) andschemaId(the destination document's schema —products,general/pages/home, …): the MCP server has no open document, so the field's format /max/sizesconfig is found only throughschemaId. Without both the image is stored with no size variants —{{field.srcset}}renders empty and{{field.thumb}}serves the full original — and the result carries awarning; treat awarningas a failed import and redo it. Write the schema (withsizeson the field) before importing the images that fill it.imageValuecarries the generated renditions denormalized (variants: {name: {path, width, height}}, plus an optionalvariantpick) — templates address them as{{field.thumb}}/{{field.srcset}}/{{field.original}}with silent original fallback, so never strip thevariantsmap when editing a document value. - Files that are not pictures (a PDF, a document, an archive): a
kind:"file"field, filled from whatuploadStoreFilereturns — the value is{path: storagePath, ref, name, size, type}(+ per-usagemetadata), and templates read{{field}}(URL),{{field.name}},{{field.ext}},{{field.size_text}}. Never alinkholding a bucket URL, never the name / size typed into text fields beside it (seedrop-core://rules/schema-fields). - Design system: assemble a DesignOnboarding (see
drop-core://rules/design-onboarding),previewDesignto expand it into tokens + CSS without saving, confirm with the human, thencommitDesign(passnotes— the plain-language style guide later page-building and code turns read; omitting it keeps the stored prose rather than clearing it). Both tools expand LOCALLY through core-lib'sexpandDesign: no server round-trip, no model, no credits, and the same bytes the admin's live preview and its Properties panel produce. The onboarding also carriesoverrides(per-token pins) andcustom(user-defined tokens emitted after every derived variable) — both survive every regeneration. - Console settings (the store's admin console, NOT the public site): the
pinned side menu and the Overview dashboard widgets. Read
drop-core://rules/console, thenreadConsoleSettings(both documents + the page routes, item kinds and widget kinds to write them with), thenwriteConsoleMenu/writeConsoleOverview— each REPLACES the whole document, so write the full list back. Both documents are shared by Demo and PROD. A listing's columns areregisterCollection {columns}, not a console setting. Writes need the owner role or a token with theproject.consolegrant (role_forbiddenotherwise). - Services (the store's own backend code, served on its site at
/fn/{id}/…— a third-party API call with a secret token, a form forwarded to another system): readdrop-core://rules/servicesfirst (service vs module, the code shape, thedropAPI, limits).listServices→readService {id, code: true}(start from the live code verbatim) →writeService. Settings (routes,egress,env,origins,secretNames,enabled) apply to the live service when written;codewithoutpublishis only CHECKED (the report comes back, nothing goes live) — iterate until noerrorfinding, then send the samecodewithpublish: {note}(read thewarningsas well: adry_run_errorthat is not about the dry run's fakes throws for real visitors too). Then VERIFY:callService {id, path}runs a route of the live version for real (real secrets and upstreams; a POST performs its action — only when the user agreed); a 5xx →readServiceLogs {id}for the thrown error;rollbackService {id, version}makes the last good version live again. Secrets are names only: you never read or set a secret value — an owner or admin sets values in the console (Studio › Services › {id} › Secrets); tell the user which ones (secretsToSet). Element JS calls/fn/{id}/…directly (no runtime, unlike module elements). To copy a service to another project,readServicehere andwriteServicewithstorethere (not on the pinned hosted endpoint); the secret values are set again by the user. Atier_requiredrefusal means the project's plan has no services — tell the user, do not retry. - Other binary files (video, PDF, fonts, archives — up to 1 GiB): on the
LOCAL (stdio) server
uploadStoreFile {localPath, dir?, name?, keepName?, id?}uploads one file from the project folder straight to storage — the result is PUBLIC, so only upload what the user asked you to publish; hidden files and paths outside the upload root are refused. On the hosted endpoints the tool does not exist — the user uploads in the console's Files view or through the SDK. Never base64 a binary intowriteStoreFile. - Reference material:
listTemplateAssets/readTemplateAssetfor uploaded template bundles;fetchWebPagefor external pages (SSRF-guarded, text only).
Ground rules#
- Stay inside the store: tool paths are validated to
store/{sid}/…;customersis always refused. - Never invent store data (menu items, image filenames, slugs) — read it. Bundle image paths must be copied EXACTLY from manifests or read HTML.
- Templates must follow the CSS scoping rules (unique page-root class, every selector prefixed) and be mobile-first responsive.
- Prefer the design-system CSS variables over hardcoded values; only the documented token names exist.
- The design stylesheet's
:root[data-theme="light"|"dark"]blocks are ADMIN-PREVIEW-ONLY (the preview's theme toggle) — published pages render the default theme. Never set adata-themeattribute or write[data-theme]selectors in templates. - Every tool carries annotations (MCP
readOnlyHint/destructiveHint/idempotentHint/openWorldHint, plus drop-core'ssurfaceandrequiresUser): reads arereadOnlyHint: true;deleteStoreDocument,deleteStoreKeysandretireFormerUrlarerequiresUser— the user confirms them every time, no policy or grant suppresses that. Three writes confirm on the executor side because the arguments cannot tell:writeSchemarefuses afieldsJsonthat drops stored fields untilconfirmDrop: true,writeStoreFilerefuses an existing path untiloverwrite: true,registerCollectionrefuses aroute.categoriesmap that removes a stored category (or a category's stored segment) untilconfirmRemove: true— all answer{ok:false, needsConfirmation:true, …}with what would be lost; ask the user, end the turn, and pass the flag only in a later turn after they agreed — never on your own, never in the same turn (registerCollectionignores a flag the user has not been asked about and answers the question again). - In the console the per-turn
[context]block carries aYour user:line (the member's role and the capabilities they may change / only read). Check it BEFORE a write: a path that needs a capability the user lacks is not attempted — say so and offer the read-only path or a draft a manager can apply (a refused write costs the whole turn's reads for one 403). - In the console the host runs an approval policy over the tools
(
ask-destructive/ask-writes/auto); a paused call reaches the user as a confirm card and a decline comes back as a refusal with the user's reason — continue without that change, never retry or work around it. - A result with
forbidden: true({ok:false, forbidden:true, path, capability?}) means the user's token may not touch that path — their role lacks the capability. Say which capability is needed and stop; do not retry under another path or tool. - A turn can be stopped by the user. Every runner takes a
signal: AbortSignal; when it fires the loop ends the turn before the next model call / tool call (an in-flight Gemini call is dropped client-side) and the stopped turn is not kept in the model's memory: the code agent returns{status: 'aborted', text}with the history it was given, the other five runners reject with anAbortError(isAbortError(e)) and the host keeps its prior history. Tool calls that had already run before the stop stand (a written document stays written) — so in the next turn the store may already contain work the history does not mention: read before assuming, never redo a write "because it is not in the conversation".