---
name: dynapi-site-dev
description: >-
  Develop a tenant site on DynapiCMS through its GraphQL API — create content
  types and entries, manage site routes/templates/static files/queries as
  drafts, publish, and wire forms. Use when the user asks to build or change
  their DynapiCMS site, landing pages, blog, or forms programmatically.
---

# DynapiCMS site development

## Prerequisites (ask the user if missing)

1. **API key** — a `dca_…` key with admin rights (or scoped to
   `site:manage` + `data:write` + `media:write` — site fields and the
   contact-form workflow need `site:manage`, media uploads need
   `media:write`). Created in the CMS admin → Access keys.
2. **The endpoint** — `https://dynapi.ru/cms/graphql`. It is the same for
   every project; flowctl has it built in; there is no other admin URL and
   never a path to append.
3. **flowctl — download it BEFORE anything else.** It is the default tool
   for the whole job; do NOT start with curl. One-time setup (the endpoint
   is built in, so the key completes it):

   ```
   curl -fsSL https://dynapi.ru/cms/agent-skill/cli/flowctl-linux-amd64 -o flowctl && chmod +x flowctl
   export DYNAPI_APIKEY="dca_…"
   ./flowctl subdomain   # smoke check
   ```

   (Windows: `.../flowctl-windows-amd64.exe`; macOS: `.../flowctl-darwin-arm64`
   Apple Silicon, `.../flowctl-darwin-amd64` Intel.)

   **Many things to upload at once?** That is `apply`, not a loop: one
   `<slug>.dynflow.json` bundle carries the flow + its routes + its queries
   (they land in the library) + its templates, and ONE
   `./flowctl apply <slug>.dynflow.json` ships the whole page. Do NOT
   emulate batching with a PowerShell loop around hand-built curl —
   concatenating GraphQL JSON in PS breaks quoting. If a loop is truly
   needed, loop over flowctl invocations: flags and files in, nothing to
   escape.

**Default to flowctl, not curl.** The CLI owns the whole build loop:
`validate` offline, `dryrun` against drafts, `files push`/`files delete` for
site templates/statics, `save`/`publish` for flows, `apply` as the
one-command import, `runs` for the live funnel, `route` for the route map
(`list`/`save`/`publish`/`delete`), `query` for
the query library (`list`/`save`/`publish`/`delete`),
`template list` + `static list` + `site publish` for the file inventory and
the bulk promotion, `media push`/`media list`/`media refs`/`media delete`
for the library, `subdomain`
check/claim, `secret` for the write-only store, `entity` for content
work (`types`/`schema`/`items`/`get`/`create`/`update`/`delete`/
`delete-type`/`create-type`/`add-field`/`count` — the
dynamic field names are computed by the same canonical rules the schema
uses, so they cannot drift; `create-type` + `add-field` cover the schema
deltas too — both validate the type slug against the canonical set BEFORE
any request, and `add-field` shows a dry picture + confirmation because an
existing field name is REPLACED by the upsert), and `wizard`
(`list`/`install`) to install site wizards — e.g. a SECOND landing in one
command: `flowctl wizard install landing-page --flow landing-2
--set page_path=/promo` (the declarations and the apply logic live
SERVER-side; the binary never goes stale — `wizard list` shows what the
platform currently installs and every step it takes). When a subcommand
exists, USE IT — hand-rolled
curl for the same job is error-prone boilerplate (escaping, envelope
shapes, save-vs-publish footguns). When in doubt, check `flowctl --help`.

**A subcommand "does not exist"? Check the binary's age.** New builds add
subcommands; a binary downloaded months ago simply lacks them.
`./flowctl version` shows the build — if it is older than the current
release, re-download from the same URL above and replace the file (the
endpoint never changes; only the CLI evolves).

**Raw curl is the fallback, not the starting point** — full `__schema`
introspection, playground probing, the rare operation no subcommand covers
yet. Send those as:

```
POST https://dynapi.ru/cms/graphql
Content-Type: application/json
X-API-Key: dca_...
```

Never use `?api_key=` (leaks into logs). Never send `Authorization` together
with `X-API-Key` (bearer wins and breaks the key path). The explicit curl
recipes in the references serve these fallback cases and one-off probes.

Env vars live in the shell process that exported them: a one-shot script
invocation (PowerShell `powershell -File api.ps1`, `sh -c`, a fresh
terminal) does NOT see them — set them again inside that context
(PowerShell: `$env:DYNAPI_APIKEY = "…"`), or pass the key on the command
line. A wrapper script with its own private variable name still needs that
variable fed every run — prefer the shared variable above so tools and
scripts agree.

## First moves (always)

1. `./flowctl subdomain` → learn the subdomain + base domain; derive the
   public URL (`https://<sub>.<base>`) and preview URL
   (`https://preview-<sub>.<base>`).
2. `siteRoutes` + `siteQueryLibrary` + `siteTemplates` → inventory what exists.
   flowctl shortcuts: `./flowctl route list`, `./flowctl query list`,
   `./flowctl template list`, `./flowctl static list` (per-path live / draft /
   live+draft state), `./flowctl flow list`, `./flowctl entity count <slug>`.
   A fresh tenant is EMPTY: author routes/queries/templates from scratch;
   references/workflows.md §1 walks it.
3. `flows` + `siteRoutes { path flows { handle methods } }` → the flow
   inventory. The site is made of FLOWS: a route is a path + flow bindings,
   and a route with a GET binding is a page. The flow list IS the site map.
4. Introspect `__schema` when you need fields not covered in references/.

## Golden rules

- **Queries live in the library.** A page's data comes from queries the
  flow references BY NAME, so every read must be saved to the library:
  `./flowctl query save` for standalone edits, or ship `queries[]` inside
  the `.dynflow.json` bundle (`apply` imports them into the library). A
  dynamic route's `sitemapQuery` must name a library query too. Saving a
  flow without saving its queries ships a page with no data. A linked node
  resolves the entry's text at EXECUTION time — publishing an edited entry
  retargets every scenario that references it on the next render, no flow
  republishes (flows-guide.md §3; a wizard store leans on this —
  shop-wizard.md; the wizard catalog — wizards.md).
- **Flows are the unit of a site.** Every page/form/endpoint is a flow bound
  to routes; flows with `set_cookie`/`redirect`/`respond_json`/`render` run
  synchronously and shape the HTTP answer. The whole contract is
  `references/flows-guide.md` — read it before building anything beyond a
  plain template route.
- **A header-conditioned binding beats the unconditional one** on the same
  route, regardless of list order; if it runs but doesn't answer → 500
  `FLOW_NO_RESPONSE`.
- **Response cache TTL lives on the ROUTE (`cache.defaultTtl`); unset or 0 =
  never cached (every request executes the flow). A value > 0 caches
  anonymous 200 answers for that many seconds. Signed-in members are NEVER
  served from cache. Cache hits are free (before rate limits); `X-Cache:
  hit|miss` shows which.
- **Request bodies are namespaced by transport:** browser forms →
  `form.*`, JSON clients → `json.*`, XML clients → `xml.*`. Unsupported
  formats get 415 — never a silently-empty submission.
- **Two languages, chosen by FIELD NAME:** Liquid renders the DOCUMENTS
  (`body_template`s, email bodies, cookie/redirect values, the graphql
  query text); expr computes the VALUES (`compute.expr`,
  `http_post.body_expr`, `http_request.body_expr`, `respond_json.body_expr`,
  `graphql.variables_expr`,
  condition `predicate_expr`). For NEW flows build data with the expr
  fields: a body is the JSON encoding of the computed value (numbers stay
  numbers — no invalid-JSON holes, no `| json` escaping), a variable map is
  one object expression (wins entirely over `variables` when both are set),
  a branch is one boolean expression. `secret("name")` is the expr-side
  secret convention. Every expr source compile-checks at save
  (`FLOW_EXPR_INVALID` names node+key; `flowctl validate` offline) and
  fails VISIBLY at run — a nil chain without `?.` is an error, never a
  silent empty (`?.` guards key access only, never `[0]` indexing — guard
  first-element reads with `len(... ?? []) > 0`; flows-guide §2.5).
  Details: flows-guide.md §2.5, compute nodes §3.2,
  predicate helpers §4.
- **`blank` is not a condition keyword** — `x != blank` means "x is
  present", never "x is non-empty" (this is osteele/liquid, not Shopify
  Liquid; a missing JSON key is nil, a form's unfilled input is ""). The
  safe skip-empty guard is one filter: `{% if x | present %}` (v1.7.4 —
  false for missing/false/""/whitespace-only/empty array-or-map, 0 counts;
  both engines). The older `{% assign v = x | strip %}{% if v != "" %}`
  recipe is verdict-identical
  (flows-guide §2.1 — the Liquid path; a new flow's `body_expr` skips this
  whole trap family, §2.5); an unquoted empty interpolation inside a JSON
  `body_template` yields invalid JSON (`"id":,`) → the peer answers 400.
  Cross-request state: no flow-local state exists — entities, sealed
  cookies, member session, redirect+params (flows-guide §2.2).
- **Long-form text renders with `| markdown`** (both engines):
  `{{ doc.body_markdown | markdown }}` turns a stored Markdown string into
  ready HTML (GFM tables/lists, newline = `<br>`). Safe core — raw HTML in
  the text never passes through, link schemes allowlisted, altless images
  dropped (liquid-guide Notes).
- **Pick the request shape by CALLER, not habit:** a program —
  integration, webhook, another service, your own `fetch()` — gets a JSON
  handler (`json.*` body + `respond_json` with `status`); only a
  human-filled HTML form gets the form flow (`form.*` + redirect PRG).
  flows-guide §2.3.
- **JSON in Liquid (legacy for data assembly):** arrays index
  (`items[1].x`) and loop (`{% for %}` + `forloop.last` for commas);
  "has items" is `size > 0` — NEVER `size == 0` (a missing key fails that
  check, nil again); embed whole maps/arrays with `| json` instead of
  hand-building. Keep for existing `body_template`s — new flows build
  bodies/variables with `body_expr`/`variables_expr` (flows-guide §2.5).
  flows-guide §2.4.
- **Auth flows come from presets only** (gallery: page/login/register/
  forgot/change/logout). The member contract — bcrypt+pepper, sealed session
  cookie, `members_only` (GET redirect + POST denial), `$member` injection,
  email tokens — is `references/member-auth.md`. Dynamic-entity lists in
  graphql results have NO `nodes{}` wrapper: read
  `results.<key>.<plural>[0].<prop>`. Ready-made flows ship as
  `presets/*.dynflow.json` (login, register, verify, forgot, reset,
  change-password, page) — API agents apply them with the import mutations
  (member-auth.md §2); the presets themselves are admin-UI only.
- **Member identity is server-side: `$member` in queries, `member.*` in the
  flow context — never a client-supplied id.** `$member` is a RESERVED
  variable in every flow graphql query: one that DECLARES it
  (`query($member: ID!) { bookings(where: {owner: {eq: $member}}) … }`)
  gets the signed session's member id injected SERVER-SIDE — any
  client-passed value is overridden, so a client can never name another
  member's id; anonymous requests inject the empty string (an
  `eq: $member` filter matches nothing). `{{ member.* }}` in templates,
  conditions and Liquid-rendered variables reads the same signed payload.
  Member VISIBILITY is the flow/route's job — `member.*` conditions in the
  flow, and an EXPLICIT page gate: `members_only` + `login_path` on the
  route (route queryVars are gone — routes are flow-only, nothing
  auto-gates on a query text). The gate's JSON-client rule is one line: a
  request whose `Accept` starts with `application/json` gets 403
  AUTHENTICATION_REQUIRED (works for GET and POST alike — the request
  body's Content-Type is irrelevant); anything else gets the browser
  redirect (302 GET / 303 POST) to `login_path` (the SAME rule on
  preview — sign in as a member on the preview host to review a gated
  page; login POSTs run on preview). Without a gate an anonymous hit
  renders `{{ member.id }}` empty and a create/update flow silently writes
  OWNERLESS rows. Cache safety is structural: flow-response caches only
  ever store ANONYMOUS answers — signed-in visitors always run live (no
  cross-member replay).
- **Export format literal is `dynapi-flow`/`dynapi-flows`.**
- **`notify_telegram` is a webhook, not a bot:** other URLs receive
  Slack-style `{"text":"…"}`; an `api.telegram.org` Bot API URL additionally
  needs `chat_id` (required there) and receives `{"chat_id":…,"text":…}`.
  There is no bot-token field — tokens live inside the webhook URL.
- **Slow outbound tasks can go fire-and-forget:** `"async": true` on an
  action node (send_email / http_post / http_request / notify_telegram only)
  detaches the
  task onto a background goroutine — the flow moves on immediately and the
  node's result goes nowhere. See flows-guide.md §3.1 before reaching for it;
  never use async when a later node consumes the response.
- **Dry-run before publish.** `dryRunFlow(flow, context)` executes an
  UNSAVED flow document against a mocked request — emails/webhooks
  suppressed, graphql answers mocked, conditions traced (`field_not_resolved`
  names the empty field paths). Loop: assemble → dry-run 3–4 scenarios
  (happy path, empty body, member vs anonymous, bad signature) → save →
  publish. flows-guide.md "Dry run".
- **Secrets via `secret://<name>`, never inline.** A string leaf whose WHOLE
  value is `secret://name` — action configs (`smtp.password`, http_post /
  http_request `headers`, http_post `secret_params` — the captcha guards'
  `{"secret": "secret://turnstile_secret"}`; SmartCaptcha/reCAPTCHA use
  `yandex_captcha_secret` / `recaptcha_secret`) AND condition clause values
  (`headers.X-Pay-Secret eq secret://pay_sig`, the HMAC secret side of a
  `digest_matches` spec) —
  resolves server-side from the tenant's write-only encrypted store
  (`upsertTenantSecret` — a saved value can never be viewed again). Never
  paste SMTP/HMAC secrets inline when a secret ref can be used. Unknown
  name → fail-closed: the action errors `SECRET_NOT_FOUND`, the condition
  clause denies (run log: `secret_not_resolved`). Logs and dry-run traces
  show the reference, never the value. flows-guide.md "Secrets".
- **Content lives in the CMS, not in templates.** Every user-visible SENTENCE
  — page headings, paragraphs, hero/about copy, hours, price lines, footer
  blocks, nav items, meta tags — is stored in a content entity (typed
  entities you create, the seeded `media` entity, or the standard types
  (`page`, `navigation`, `page-seo`, `journal-post`, …) the admin imports
  on demand via the «Добавить стандартные типы» picker) and
  reaches the page through a flow graphql node. Templates hold
  markup, Liquid logic, CSS classes, and interface micro-labels ONLY (button
  captions, form field labels, placeholders — three words or fewer, never a
  sentence). Self-check before publish: no sentence of the site's language in
  any template outside the micro-label whitelist; editing any text on the
  site must require a CMS mutation, never a template edit + republish. A site
  whose texts live in templates is a static site — it defeats the platform.
  Details: `references/conventions.md` ("Content lives in the CMS").
- **Pick the property SHAPE by how the data is used.** Items with
  position/quantity (order lines, gallery, FAQ) = child entity + the
  parent's `entity[]` property (ids in order) — NEVER numbered scalar
  fields (`pid_1…pid_3`: slot guards, broken-JSON combinatorics, schema
  migration to add #4). Rich prose = one `text` property. Fixed-set value
  = `select`. `json` = machines only, last resort. If a field name has a
  NUMBER in it, you are modeling a relation wrong.
  Details: `references/conventions.md` ("Property shapes").
- **Flow bodies stage; routes go live with their save.** A flow document
  written by `saveFlowDoc` lands in the flow's STAGING copy — live
  visitors keep the previous version until `publishFlowDoc(slug)`
  promotes it (per-flow publish, immediate cache invalidation; returns
  `published: false` when nothing is pending).
  An EMPTY flow (nothing saved yet — the normal state right after
  creation) renders a default bilingual placeholder page (HTTP 200) on
  the PREVIEW host only — live visitors get a 404 until the flow is
  published. The preview host (`https://preview-<sub>.<base>`) always
  executes the staged copy (POST flow mutations included — forms, login and
  registration run on preview), so drafts are testable before promotion —
  no query param needed, the `preview-` host alone switches to drafts. Flow state fields:
  `flows { flow_live flow_has_draft }` («Новый» / «Черновик» / «Опубликован»
  in the UI); `flowDoc(slug, draft: true)` returns the staged
  copy plus `has_draft`.
- **Route writes publish themselves in the admin UI** — a draft write
  followed immediately by `publishSiteRoute(path)`. When driving the raw
  API, do the same: every `saveSiteRoute`/`deleteSiteRoute` is followed by
  its per-item publish. **Deleting a live route** is a two-step too:
  `deleteSiteRoute(path, draft: true)` removes it from the draft copy AND
  records a tombstone — even when the path is missing from a drifted draft
  file (the tombstone is what matters); `publishSiteRoute(path)` then takes
  the deletion live. A path that exists NOWHERE 404s with
  `SITE_ROUTE_NOT_FOUND`; publishing a path with no draft state 404s with
  `SITE_ROUTE_NOT_IN_DRAFT`. `flowctl route delete <path> --publish` runs
  the whole recipe (and retries the publish once). **Query-library writes
  are draft-first**:
  `saveSiteQuery`/`deleteSiteQuery` stage the entry into
  `queries.draft.json` — the PREVIEW host executes the draft library, so
  data changes are testable before promotion. The draft file is seeded from
  live on the first write and rewritten in sync on every publish (a raw-API
  live hotfix while a draft exists is NOT picked up by preview — publish or
  re-save to reconcile); `publishSiteQuery(name)` (the library tab's row
  button) goes live explicitly; a tombstoned name publishes as a deletion
  (`SITE_QUERY_IN_USE` while live routes still reference it).
  **Deleting a library query is ALWAYS the tombstone recipe** —
  `flowctl query delete <name> [--publish]`; deleting only the live entry
  (the raw mutation without a draft tombstone) resurrects it at the next
  full site publish, and until the tombstone is published the live entry
  keeps serving. Files (templates AND static assets) are draft-first too:
  a save writes the draft copy, the preview host serves it immediately, and
  `publishSiteTemplate(path)` / `publishSiteStatic(path)` promotes ONE
  file. **Deleting a file is a two-copy operation:** the DRAFT copy must go
  first (it is what a later full publish would resurrect the file from),
  then the live copy — `flowctl files delete <path> [--publish]` removes
  both (idempotent) and, with `--publish`, follows with a full site publish
  so every replica drops cached pages referencing the file.
  `flowctl template list` / `flowctl static list` show each path's state
  (live / draft / live+draft) so you can see what is pending.
- **Deleting a flow is immediate** — no publish step; the handle row, its
  caches and its route bindings are swept together.
- **`publishSite` merges per item, not whole-file.** Every route/query carries
  a server `updatedAt`; at publish the NEWER version wins. A live hotfix
  written after the draft copy survives (reported in `routesConflicts` /
  `queriesConflicts`), and a live-only route is never deleted. Drafts are
  cleared after a successful publish. It also rejects routes lacking a flow
  binding — fix what `SITE_PUBLISH_VALIDATION_FAILED` lists
  in `details`, then re-publish. `flowctl site publish` runs it and prints
  what moved (plus the kept conflicts).
- **Single-item publish.** `publishSiteRoute(path)` / `publishSiteQuery(name)`
  push one item (or its draft deletion) straight to live, FORCING the draft
  version even over a newer live item; `publishFlowDoc(slug)` is
  the per-flow equivalent, `publishSiteTemplate(path)` /
  `publishSiteStatic(path)` the per-file equivalents (Files tab rows).
- **Custom 404: a live route with path exactly `/404`** is AUTOMATICALLY
  served for every unmatched path with HTTP status 404 (the preview host uses
  the draft `/404` route). Its flow draws the page like any page's, but must
  not depend on `:params` — there are none; direct navigation to `/404` still
  returns 200. No in-template 404 workarounds or catch-all interceptor
  routes are needed.
- **Route matching: static segments beat `:param` segments** at the same
  position, regardless of pattern length — a catch-all `/:section` can never
  shadow `/about`.
- **Static files are draft-first too** (same as templates) —
  a save writes `static/_draft/<path>`, the preview host serves it
  immediately, and publish (per file or site-wide) promotes it. Domain and
  subdomain operations remain live immediately.
- **Keep site operations in dedicated requests.** Site-only requests are
  quota-exempt, and so are pure management documents since v1.5.2 — schema
  CRUD (`createEntity`/`updateEntity`/`deleteEntity`), the flow lifecycle
  (`createFlow`/`saveFlowDoc`/`publishFlowDoc`/…) and tenant secrets
  (`upsertTenantSecret`) meter nothing. Content data is what counts
  (`createProduct`, `uploadMedia`, …); mixing it into a site/management
  document makes the whole request meter against the tenant's hourly
  quotas.
- **429 = backoff, not error.** Respect `retry_after` (`LIMIT_API_*`,
  `SITE_RATE_LIMITED`). Site ops have hard abuse caps (management mutations
  120/min by default, publish 100/min — platform-tunable). Page 429s price
  only MISSES — cached repeats are free, so re-verifying the same URLs
  never trips them.
- **Bulk work is paced, not hammered.** A wizard-scale import (~111 plan ops)
  or a site-wide `files push` can cross the per-minute caps mid-run even
  though every single call is legal — the failure lands on the run's tail
  (the final publish pass), leaving a half-installed site. Split big jobs
  into batches with pauses between them, retry a 429 after `retry_after`,
  and never loop publishes.
- **Reference mutable statics as `?v={{ site.version }}`** — the injected
  site generation bumps on every site mutation AND every flow-doc publish
  (v1.5.2+; the bump is also what keeps render replicas coherent), so
  browser-cached CSS/JS (max-age=3600) refreshes itself after hotfixes.
  Never bump URLs by hand.
- **Numeric fields: `int` or `number` freely** — the two kinds are
  block-union-compatible even when a seed entity uses the other kind
  (`navigation.position` is `number`; your `position: int` is fine).
- **Schema rebuilds are async.** After `createEntity`/`updateEntity`, poll
  introspection until the new type resolves (usually <2s) before querying it.
  The rebuild is not visible even one request later: using the new
  fields/type in the SAME document as the mutation that created them returns
  "Unknown field" — split mutation and follow-up query into separate requests.
- **Browser-JS data needs a flow JSON endpoint, not a bare library query.**
  A library query by itself is only executed by flows (graphql nodes,
  sitemap expansion) — there is no public keyless query endpoint. For live
  search/filter chips, bind a flow to a GET path and answer with a
  `respond_json` render node (see above).
- **Numeric filter variables are `Float`, not `Int`** (`$priceMin: Float`).
  An Int variable in a Float filter position fails that flow's graphql run
  with a variable-type error (and silently skips the route's sitemap
  contribution when it is the enumerating query). Dry-run every saved query
  with empty `variables` against `/cms/graphql` before relying on it
  (workflows.md §Step 2 checkpoint).
- **Query-library `orderBy.field` is an ENUM value** — `CREATED_AT`, `DESC`,
  never the quoted property name `"created_at"`: the string form saves without
  complaint and then fails the flow's graphql run at execution. The
  `<Type>OrderByField` enum holds only THAT
  entity's own fields — ordering by a field that exists on a different
  entity fails the same way; validate fields before
  publishing. camelCase properties split at the camel boundary:
  `startsAt` → `STARTS_AT`.
- **Library queries must start with `{` or `query(...)`** — `pages(where: …)
  { … }` WITHOUT braces is a syntax error and is rejected at save time with
  `SITE_QUERY_INVALID_SYNTAX` (write it as `{ pages(where: …) { … } }`).
- **Templates are compile-validated at save** — Liquid source with a syntax
  error (most often an unclosed `{% if %}` / `{% for %}` / `{% case %}`) is
  rejected with `TEMPLATE_COMPILE_FAILED`; the parser diagnosis (with the
  line, when the engine provides one) rides in the error detail. A broken
  template is never stored. If a published page serves the fallback page,
  re-save its template to surface the exact error — and never blame the
  file's byte size: templates of any size render fine (100 KB verified).
- **`date`/`datetime` values are strings**: `"2026-07-28"` (bare date, OK) or
  full RFC3339. Other formats are rejected at write time with
  `FIELD_INVALID_DATE` naming the field.
- **Optional filters: an absent or empty variable is skipped.** In a graphql
  node's `variables` an absent or empty-rendered string leaf is OMITTED →
  the variable resolves to `null` → the filter operator is skipped (the
  unfiltered page works). A literal `eq: ""` still
  matches nothing — never hardcode an empty string into a filter. This holds
  in FLAT multi-field `where` too (`{category: {eq: $cat}, in_stock: {eq:
  true}}` with `$cat` absent just skips `category`) — no `AND:`-group
  workaround is needed.
- **Required Boolean variables are legal: `$v: Boolean!` fed from a query
  param.** In a flow graphql node, `"v": "{{ query.v }}"` with
  `query($v: Boolean!)`: when the param is absent or empty the platform
  executes the query with `$v = false` (graphql-go rejects the two
  spec-shaped alternatives — nullable `Boolean` can't feed `@include(if:)`,
  and `Boolean! = false` default is refused).
  Use it for PRG thanks-states: `query($v: Boolean!) { … @include(if: $v) }`
  + variables leaf `"v": "{{ query.v }}"`; absent → base render, `?v=1` →
  conditional block.
- **`isNull: true/false` on any scalar filter selects unset/present rows.**
  An unset boolean property is SQL NULL — `{eq: false}` does NOT match it
  (the "pending moderation" trap). Queue pattern: `where: {OR: [{approved:
  {isNull: true}}, {approved: {eq: false}}]}`.
- **Numbers bound into Liquid are real ints.** Integral JSON numbers
  (`rating: 5`, `seats_left: 4`) arrive as int, so `{% for i in (1..rating) %}`
  works. Non-integer floats (4.5) still cannot drive a range — compare with
  `if/elsif` instead. If a page serves the fallback, the actual render error
  is in the render-service log — there is no error response header.
- **Entity data edits propagate to the site within ~2s.** Every create/
  update/delete on entity data — and on entity DEFINITIONS — invalidates
  the render caches (flow-response caches, preflight queries) automatically
  — an approved review shows up immediately; no `?b=` cache-bust, no waiting
  out the TTL. (A route's `cache.defaultTtl` remains the freshness fallback
  only for routes that opted into caching.) The flow-response cache is
  generation-keyed and also drops on ANY site mutation (route/template/query
  edit, publish).
- **Dynamic routes need `sitemapQuery` for a complete sitemap.** A route like
  `/catalog/:slug` must name the library query that enumerates its URLs
  (e.g. the catalog's `works_all`): `saveSiteRoute(... sitemapQuery:
  "works_all")`. Every `:param` in the path is substituted from the
  enumerated items by EXACT field name — `/terms/letter/:letter` needs items
  carrying a `letter` field; items missing any param field are skipped, and
  there is NO `slug`/`id` fallback. Without a `sitemapQuery` the route
  contributes NOTHING.
  Enumerating queries must declare variables NULLABLE (`$slug: String`, not
  `String!`) — the publisher preflight runs them without path vars, and a
  `String!` kills the route's whole sitemap contribution.
- **Media uploads: GraphQL `uploadMedia` is the one-call path** (stores the
  file AND creates the media entity). The REST `POST /cms/media/upload`
  stores the file only — if you use it, pair it with
  `createMedia(input: {name, url, …})` or `entity→media` has nothing to
  reference. From flowctl: `media push` (prints `id  url  name` — the id is
  what entity media-reference fields store), `media list` (pages the
  library — the BACKEND CAPS A PAGE AT 100 ROWS, so a bare list can be a
  fraction of the library; a full page warns on stderr, and `--all` drains
  every page and prints the exact total: `media list --all`), `media refs
  <id>` (where a file is used — run it BEFORE any
  delete), `media delete <id>…`. **`media delete` is BYTES-FINAL**: it
  removes the S3 object too, so it is irreversible unless you hold a local
  copy — always `media refs` first and list the full library before bulk
  cleanup (files referenced by entity rows are nulled on delete, but a
  blanket "delete all listed" has eaten referenced covers before, and
  judging a URL dead because it missed a one-page list has cost a day of
  phantom repairs — absence from page 1 is NOT deletion). Storage
  quota returns on delete. Uploaded by a FORM (files in multipart flow
  submissions, see `references/forms-flow.md`), files land in the same
  media library with `form.<field>` = `{id, url, …}` in the submission.
- **Client-side dynamic loading uses a FLOW JSON endpoint.** Bind a flow to
  a GET path, add a
  render node with `respond_json` + `Content-Type: application/json`, and
  have the page's JS fetch that path: conditions, member context, and
  transforms are all available in the flow, and params arrive as
  `query.*`/`params.*`. The browser NEVER holds an API key — no keyed
  browser API exists. The no-JS server-rendered baseline must still work:
  server renders page 1 inline, JS only appends ("показать ещё",
  live search). Contract: `references/flows-guide.md` §6.
- **Errors are machine codes.** `errors[0].message` is a code like
  `SITE_QUERY_IN_USE` (look in `extensions` for context) — never parse prose.
- **Cyrillic/Windows hygiene.** Slugs are ASCII-only — Cyrillic runes are
  silently deleted, not transliterated (`"Блог"` → `SLUG_REQUIRED`,
  `"Статьи о CMS"` → `cms`). Send non-ASCII JSON bodies from UTF-8 files
  (`curl --data-binary @body.json`), never inline shell strings. Localized
  fields: one `{ru:…, en:…}` map per mutation — a bare scalar on update
  folds into the default locale, siblings survive. Details:
  `references/conventions.md`.

## Workflows

Read `references/workflows.md` for full recipes:
site-from-scratch (flows-first) · add-a-page · blog-with-dynamic-routes ·
contact-form · custom-domain · seo-and-structured-data ·
member-login-site · export-import-flows · delete-a-route ·
delete-a-flow · media-housekeeping · delete-a-site-file ·
delete-a-query · remove-a-content-type · full re-publish.

## Reference

- `references/api-reference.md` — every site* field, forms fields, entity CRUD
  patterns, pagination/filtering. Read when composing queries.
- `references/flows-guide.md` — the flow engine: document shape, every action's
  config, the two context languages (Liquid documents, expr values),
  route bindings, flow pages + response cache,
  export/import. Read before building flows.
- `references/member-auth.md` — member accounts: presets, passwords, sessions,
  members_only, `$member`, email tokens, ready-made preset flows. Read before
  adding site login.
- `references/wizards.md` — the wizard catalog: every installable card
  (store, landing, the two payment packs, the single-flow builders), what
  one run puts on the tenant, re-run semantics (which pieces skip, which
  overwrite), secrets, and where to edit after an install. Read to
  recognize a wizard-installed project — and before re-running any card
  on it.
- `references/shop-wizard.md` — the «Магазин» wizard card: what a
  wizard-installed store contains (17 scenarios, 18 routes, live-linked
  library queries, the YooKassa secret pair), its traps (reinstall
  overwrites the library entries, cancelled-install shadow scenario,
  `files push` basename/sentinel/palette-placeholder (`tokens.css` is
  substituted per tenant — never push the `${variant.*}` source),
  stale-bytes verification) and the
  storefront design canon (tokens, rem scale, hairlines, dark theme,
  scroll-snap gallery, WCAG AA). Read when the project was installed by
  a wizard — and before editing its templates.
- `presets/*.dynflow.json` — the ready-made flows themselves (export format):
  apply with the mutations in member-auth.md §2.
- `references/liquid-guide.md` — the renderer's Liquid dialect. Read before
  writing or editing templates.
- `references/conventions.md` — error codes, quotas, rebuild timing, draft
  model, the flow response cache. Read when something fails.
- `references/forms-flow.md` — the flow document JSON. Read before building flows.

Version: v1.7.52 — if fields mismatch,
trust introspection over this file.