---
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` for site
templates/statics, `save`/`publish` for flows, `apply` as the one-command
import, `runs` for the live funnel, `route` for the route map, `query` for
the query library, `media push`/`media refs` for the library, `subdomain`
check/claim, `secret` for the write-only store, and `entity` for content
work (`types`/`schema`/`items`/`get`/`create`/`update`/`delete` — the
dynamic field names are computed by the same canonical rules the schema
uses, so they cannot drift). 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`.

**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.
   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.
- **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.
- **`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 `{% assign v = x | strip %}{% if v != "" %}`
  (flows-guide §2.1); 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).
- **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:** 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. 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 flows through query variables (`$member`), never only
  inside the template.** A query-variable value `$member` (the member id) or
  `$member.<dotted.path>` (payload walk, e.g. `$member.profile.email`) is
  resolved SERVER-SIDE from the signed session cookie — no client input can
  touch it. A route using any `$member` var is member-gated automatically:
  anonymous → 302 to `login_path` (an empty «мои записи» page would be worse).
  Pair it with `members_only` on the route when the PAGE itself is private;
  **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`.**
  `$member` alone is for pages whose data is per-member. Rule: member identity
  in a TEMPLATE-only binding (`{{ member.id }}`) cannot scope a QUERY —
  the preflight cache would leak across members.
- **`$member` is a RESERVED variable in every query.** Any query declaring
  `$member` (`query($member: ID!) { bookings(where: {owner: {eq: $member}})
  … }`) gets the signed-in member's 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 visibility is the FLOW's
  job — `member.*` conditions in the flow, and `members_only` + `login_path`
  on the route binding for the page-level redirect (the SAME rule on
  preview — sign in as a member on the preview host to review a gated
  page; login POSTs run on preview).
  Preflight results shard per member (no cross-serving).
- **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 / 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
  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 built-in `page` /
  `navigation` / `page-seo` shapes the admin UI can seed on demand) 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. **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). 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; `publishSite` remains the bulk promoter (it also promotes every
  pending file draft and the query-library draft).
- **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.
- **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; mixing site fields with content fields in one 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 (120 mutations/min,
  100 publishes/min). Page 429s price only MISSES — cached repeats are free,
  so re-verifying the same URLs never trips them.
- **Reference mutable statics as `?v={{ site.version }}`** — the injected
  site generation bumps on every site mutation, 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.
- **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. 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.

## 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, Liquid context, 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.
- `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.2.20 — if fields mismatch,
trust introspection over this file.