inmake DOCS
Docs / Generative / Workflows & ground rules

Workflows & ground rules

Typical agent workflows and the rules that never bend.

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") → updateStoreDocument with 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 declared editor:"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's editor is 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 field editor:"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 .html fragments — see drop-core://rules/handlebars-binding; writeWebTemplate and writeStoreElement return a warning on a brace/kind mismatch.
  • Change layout/design of a page: find the doc's schema (readStoreDocument the doc, or list schemas), read the current template version (readStoreDocument("schemas/{id}/templates/{v}")), then writeWebTemplate with schemaId + templateVersion and edits — a list of {field: "html"|"css"|"js", search, replace} where each search is 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 full html + css only for a rewrite, never both. Pass newVersion:true (with edits the 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, then writeStoreTemplate({templateId, data}) with the FULL document. Each record also carries a sample — the render variables the admin editor previews it against (rendering and mailing never read it); fill it with writeStoreTemplate({templateId, sample}) (data may 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 with writeSchema, write the documents, then registerCollection({key, name, template}) ONCE — template only 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 its seo.category key — no parent, 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), or columns — which columns the admin listing shows, in order (built-ins id/schema/status/timestamp or field keys from the template; omit to keep, [] to reset to the defaults), or keys — 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 answers needsConfirmation: true with the removed keys: relay the question to the user and END the turn; pass confirmRemove: true only 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 in seo.category is refused with the per-locale counts unless force: 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 changed seo.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 unless overridePage, and on a root key that is a routed collection's segment or alias) and retireFormerUrl({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) is setRedirect({from, target, temporary?, clear?}): stored canonicalized in redirects/{pathRedirectId(path)} and answered only where the site would otherwise 404 (a page or an address redirect there is refused unless overridePage). resolveAddress({path, locales?}) is read-only: what /public/slug answers per language (status 200/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: false unlocks, omitting it keeps the current state. Works on a shared schema and on a per-document one. It is not a write guard: writeSchema still 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 a kind:"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) — see drop-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 read window.DROP_ROUTE_CATEGORIES) — falling back to /ref/{collection}/{document}; a document's seo.scope (both | root | collection) says which of its URLs exist.
  • Provision locales: updateStoreDocument("store/{sid}", {locales: ["cs_CZ", …]}) — first entry = the default locale, tags are xx_YY. Do it BEFORE creating content (data moves neither automatically nor cheaply). Store-root writes accept only name and locales.
  • Paged listing ("load more"): read drop-core://rules/load-more first. 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's POST /content/{locale} route (body {path, field, after}) serves every later page from the same envelope. Store the envelope, never resolved documents.
  • Images: generateImage (AI) or importImageFromUrl (external file) both upload into the store files and return {url, imageValue}. For an image FIELD store imageValue EXACTLY as returned (via updateStoreDocument); use url only for decorative template markup. Always pass fieldKey (the destination field's dot-path) and schemaId (the destination document's schema — products, general/pages/home, …): the MCP server has no open document, so the field's format / max / sizes config is found only through schemaId. 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 a warning; treat a warning as a failed import and redo it. Write the schema (with sizes on the field) before importing the images that fill it. imageValue carries the generated renditions denormalized (variants: {name: {path, width, height}}, plus an optional variant pick) — templates address them as {{field.thumb}} / {{field.srcset}} / {{field.original}} with silent original fallback, so never strip the variants map when editing a document value.
  • Files that are not pictures (a PDF, a document, an archive): a kind:"file" field, filled from what uploadStoreFile returns — the value is {path: storagePath, ref, name, size, type} (+ per-usage metadata), and templates read {{field}} (URL), {{field.name}}, {{field.ext}}, {{field.size_text}}. Never a link holding a bucket URL, never the name / size typed into text fields beside it (see drop-core://rules/schema-fields).
  • Design system: assemble a DesignOnboarding (see drop-core://rules/design-onboarding), previewDesign to expand it into tokens + CSS without saving, confirm with the human, then commitDesign (pass notes — 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's expandDesign: 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 carries overrides (per-token pins) and custom (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, then readConsoleSettings (both documents + the page routes, item kinds and widget kinds to write them with), then writeConsoleMenu / writeConsoleOverview — each REPLACES the whole document, so write the full list back. Both documents are shared by Demo and PROD. A listing's columns are registerCollection {columns}, not a console setting. Writes need the owner role or a token with the project.console grant (role_forbidden otherwise).
  • 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): read drop-core://rules/services first (service vs module, the code shape, the drop API, 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; code without publish is only CHECKED (the report comes back, nothing goes live) — iterate until no error finding, then send the same code with publish: {note} (read the warnings as well: a dry_run_error that 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, readService here and writeService with store there (not on the pinned hosted endpoint); the secret values are set again by the user. A tier_required refusal 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 into writeStoreFile.
  • Reference material: listTemplateAssets / readTemplateAsset for uploaded template bundles; fetchWebPage for external pages (SSRF-guarded, text only).

Ground rules#

  • Stay inside the store: tool paths are validated to store/{sid}/…; customers is 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 a data-theme attribute or write [data-theme] selectors in templates.
  • Every tool carries annotations (MCP readOnlyHint / destructiveHint / idempotentHint / openWorldHint, plus drop-core's surface and requiresUser): reads are readOnlyHint: true; deleteStoreDocument, deleteStoreKeys and retireFormerUrl are requiresUser — the user confirms them every time, no policy or grant suppresses that. Three writes confirm on the executor side because the arguments cannot tell: writeSchema refuses a fieldsJson that drops stored fields until confirmDrop: true, writeStoreFile refuses an existing path until overwrite: true, registerCollection refuses a route.categories map that removes a stored category (or a category's stored segment) until confirmRemove: 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 (registerCollection ignores a flag the user has not been asked about and answers the question again).
  • In the console the per-turn [context] block carries a Your 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 an AbortError (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".