Content studio

Content studio (the Content item in the sidebar) is where you write a message once and reuse it: in email campaigns, in website popups, and in other places that accept a content reference. A content document is channel-neutral — the same document is checked and rendered separately for each channel.

Editing needs the same bot right as reactions. Without it the screen opens read-only: you can inspect documents, but not create, edit, publish, archive or test-send them.

Documents, drafts and versions

Every document has a type that fixes what it can be used for: Message, Email, Popup, In-app message, Push, Quick reply, Lead bot.

A document always has exactly one editable draft and any number of published versions:

  • editing changes the current draft only;
  • Publish freezes that draft as an immutable version and immediately opens the next draft with the same content;
  • a published version is never rewritten. Consumers such as a scheduled campaign or an enabled popup keep pointing at the exact version they captured.

Because of that, changing a document can never silently change a campaign that is already running. To change a live send you publish a new version and point the consumer at it.

Saves are protected against concurrent edits: the dashboard sends the version and revision it loaded, and the server answers 409 if someone else changed the document in the meantime. Reload before retrying.

Archive hides a document from the list. It is refused while the document is still referenced by an enabled popup, by a scheduled, running or paused email campaign, or by an accepted or running web push message. Remove those references first.

Blocks and limits

The editor builds a document out of blocks:

  • Text, Heading, List, Divider — text with an optional format;
  • Image, File, Video — a media-library asset or a URL, plus a caption;
  • Button — a label with a link or callback data;
  • Columns — a group of up to four child blocks. It is a grouping device in the editor: on delivery the children are emitted one after another, and a channel that declares no column support also raises the columns_flattened warning;
  • Conditional content — a group shown only when its condition matches.

The server enforces these limits when you save and again when you publish:

  • document name: 1–120 characters;
  • at least one block, at most 200 blocks in total;
  • nesting depth at most 8;
  • at most 10 buttons on one level;
  • Columns takes at most 4 children;
  • every block needs an id, and ids must be unique inside the document.

Saving a draft and publishing both re-validate the whole document server-side, and both check that every referenced media asset belongs to this bot. A rule that the browser lets through is still refused by the server.

Substitutions

Substitutions use double curly braces and are resolved at send time:

Hello, {{profile.first_name}}! Your order {{event.order_id}} is on its way.

Only four shapes exist:

  • {{profile.<field>}} — a field of the recipient profile;
  • {{event.<field>}} — a field of the current event;
  • {{system.unsubscribe_url}} — the recipient's unsubscribe link;
  • {{system.current_date}} — the current date as YYYY-MM-DD.

Anything else is rejected. Content cannot run JavaScript, SQL, a template language or an HTTP request.

profile and event fields are allowlisted. A field is available only when the calling context explicitly publishes it; a value that merely exists in the database is not enough. An unknown or non-published path fails rendering with a "forbidden substitution" error instead of quietly producing an empty string.

A missing allowed field also fails rendering, unless the document has a fallback for it. Fallbacks live in the document settings under fallbacks, keyed by the full path:

{ "fallbacks": { "profile.first_name": "there" } }

A fallback is accepted only for a path that is itself allowlisted, and its value is capped at 16 000 characters.

Escaping is automatic and depends on where the value lands: inside HTML-formatted text values are HTML-escaped, and inside a URL they are percent-encoded. system.* values inserted into a URL are not re-encoded, because they are already complete URLs.

Conditional blocks

Any block can carry a condition; a Conditional content block groups several blocks under one.

Conditions available inside content are deliberately narrower than segment conditions, because rendering sees only the profile and the current event:

  • subject: profile or event only;
  • operators: eq, ne, gt, lt, ge, le, gte, lte, exists, not_exists, contains, not_contains, in, not_in, between, before, after, has_any, not_has_any;
  • no time-window conditions ("in the last N days" and similar).

A condition that needs data unavailable during rendering is reported as condition_unsupported, and a malformed one as condition_invalid. Older published versions are re-checked against the same rules before a send, so an unsupported condition surfaces as a preflight error rather than a failed delivery.

Channel check and preview

Check renders the selected version against a channel's real capabilities and returns two lists.

Errors block publication and sending:

  • text_too_long — the text exceeds the channel's limit;
  • media_missing — an image, file or video block has neither an asset nor a URL;
  • media_unsupported — the channel cannot carry this media type;
  • button_unsupported — the channel has no inline buttons and the button has no URL to degrade to;
  • invalid_document — a structural rule above is violated;
  • condition_invalid, condition_unsupported.

Warnings are safe to accept but tell you how the content will degrade:

  • columns_flattened — the channel has no columns, so children are emitted one after another;
  • format_flattened — the channel does not support this text format, so it is sent as plain text;
  • button_as_link — the button becomes a plain link;
  • missing_image_alt — the image has no alternative text;
  • vague_link_text — the button label is empty or one of "click here", "here", "link", "more".

The preview panel shows the rendered result for the chosen channel (Website, Email, Telegram, VK, WhatsApp) on desktop and mobile. Selecting a real recipient renders with that recipient's data, so substitution errors appear before you send.

Capabilities differ per channel, so the same document can be clean for one and blocked for another. Email, for example, keeps columns and HTML formatting, carries images and files but not video, and turns buttons into links (button_as_link).

Test send

Test send runs the exact same validation, rendering and outbox path as a real delivery, for one recipient. It deliberately does not accept a raw address or chat id — you pick from the list of already deliverable identities of this bot.

The request is refused when:

  • the identity is not verified (422);
  • the identity is suppressed, for example unsubscribed or hard-bounced (409);
  • the document changed since you loaded it (409);
  • the channel is not available in this installation (422);
  • the content cannot be rendered for the chosen channel (422);
  • the bot's channel quota is exhausted (409);
  • too many test sends were made in a short period (429). The limit is per account: three in a burst, then one every 15 seconds.

A successful call answers 202 and queues the message; it is delivered by the normal outbox.

Templates

Templates offers ready layouts. Choosing one copies its blocks into a new draft — a template is a starting point, not a live link, so later template changes never touch documents already created from it.

The gallery lists the templates provided with the installation plus the templates belonging to this bot; it is empty until at least one exists. There is no "save as template" button yet: bot templates are created over the API (POST /api/bots/{botID}/content-templates) and removed the same way.

Troubleshooting

  1. "Forbidden substitution" — the path is not in the allowlist for this context, or it is misspelled. Insert substitutions with the picker instead of typing them.
  2. "Missing substitution" — the recipient has no value for an allowed field. Add a fallback in the document settings.
  3. Publication is refused with a 409 — someone else edited the document. Reload and publish again.
  4. Archiving is refused — an enabled popup, a live campaign or a running push still references the document.
  5. Test send unavailable — the recipient list is empty until the bot has at least one verified, non-suppressed identity on a connected channel.

What's next