A collection can declare typed fields: an event has a venue, a start time and a ticket link; a job has a deadline and a salary range. Agents fill them through a JSON Schema instead of guessing, people get a form in Studio, and themes can rely on every value being there in the right shape.
The design and its reasons are in RFC-0041 and ADR-0043; references and repeaters in RFC-0045 and ADR-0046.
Each field has a key (lower camelCase, like startsAt), labels per language (with optional help text), a type, and a choice of whether it is localized. Definitions live in the site's collection settings, in form order, at most 30 per collection and 200 per site. Values live in each entry's fields object under the key.
| Type | Value | Shown on the site as |
|---|---|---|
text |
a string up to maxLength (5,000); multiline allows line breaks |
escaped text |
richText |
Cloudhal Markdown, up to 50,000 characters | HTML from the same Markdown pipeline as article bodies |
number |
a number; optional integer, minimum, maximum |
formatted for the reader's language (1,234.5, 1.234,5) |
boolean |
true / false |
the theme's words for yes and no |
date |
a calendar day, YYYY-MM-DD (never shifts across time zones) |
a long date in the reader's language |
datetime |
{ "at": "2026-11-05T16:30:00Z", "timeZone": "Europe/Istanbul" } |
the date and time in the value's own zone ("7:30 PM GMT+3") |
url |
https:, http:, mailto:, tel:, a site-relative path or a #fragment |
a link |
image |
an image asset of the same site (asset_…) |
an <img> with the asset's alt text in the reader's language |
select |
one of the field's options, or a list of them with multiple |
the options' labels in the reader's language |
reference |
another entry of the same site, by its translation group id (entry_…), or a list of them with multiple |
a link to the target in the reader's language, or nothing while no published version shows |
repeater |
an ordered list of items, each a flat object of sub-fields | a nested list, one item each |
References and repeaters have their own section below.
A field can be required to publish (requiredToPublish, every type but boolean): drafts save without it, but an entry cannot go to review or be published while the value is missing. Text and rich-text fields marked searchable add their words to site search from the next publish on (off by default, so internal notes never reach search snippets; see the search guide).
Collections without definitions keep fields as free-form JSON, exactly as before. Once a collection has definitions, new keys without a definition are refused (keys stored earlier are kept).
- Studio: open the collection's settings, then Fields. Add fields (the key follows the label until you save), reorder them, and edit labels, help, limits and options. Only people who may change site settings can edit fields. Entries of that collection then get a Fields card in the editor, one control per type.
- Agents (MCP):
list_collectionsreturns each collection'sfieldsandfieldsSchema(the JSON Schema of the values, with labels and help in the caller's language).update_collectionchanges definitions, andcreate_entry/update_entrytakefields(nullremoves a value). Definition changes always wait for a person who may change site settings, in every mode. - API:
PUT /api/sites/:siteId/collections/:keywithfieldsin the settings; entry routes takefieldslike any other property. Invalid values answer422 invalid_fieldswith one issue per problem (fields/startsAt/at). - Files: definitions travel in the site node, values in entries, so
cloudhal push/pulland site archives carry them unchanged.
Adding fields, reordering, editing labels and help, adding options and toggling multiline, searchable or requiredToPublish are plain edits. A plain edit can never lose a value, so these changes are operations instead:
| Change | Studio | CLI | MCP |
|---|---|---|---|
| rename a key | the field's menu, Rename… | cloudhal fields rename <collection> <from> <to> |
rename_field |
| change the type | Change type… | cloudhal fields retype <collection> <key> <type> [--options a,b] [--multiple] [--clear] |
change_field_type |
| localized on/off | Languages… | cloudhal fields localize <collection> <key> on|off |
set_field_localized |
| remove | Remove… | cloudhal fields remove <collection> <key> |
remove_field |
Each runs a dry run first (--dry-run): how many entries change, and which values move, convert or would be cleared. A type change converts values with a fixed table (text to rich text, number, select or link; any of those back to text; date to datetime at midnight in the site's time zone and back); values that do not convert block it unless you opt in to clearing them (--clear, onInvalid: "clear"). Every operation takes a snapshot first and can be reverted (cloudhal fields revert <collection> <id>). A long operation resumes where it stopped (cloudhal fields continue). Operations change the draft graph only; publish as usual.
A reference points to entries of the same site: an event's venue, a talk's speakers, a product's brand.
- Targets.
collectionsnames one to five collections a target may belong to (declared collections of the site, or ones its entries already use). A target is never the entry itself or another language version of it. - One or several. Without
multiplethe value is one target. Withmultiple: trueit is an ordered list without repeats, of at mostmaxItemstargets (20 when absent, 50 at most). - Conditions. With exactly one target collection,
where(up to four conditions on that collection's list fields, RFC-0042, nonow) narrows what may be newly chosen, as Studio's picker does: "only places withcapacity≥ 500". A target that stops meeting it later stays. - What is stored. The target's translation group id: the id of its group's original, the same in every language version and in every language edition of the project. A page in Turkish shows the Turkish version of the venue, else the default language's under
localization.fallback: default-with-notice, else nothing. When the target has no published version the page can show (a draft, archived, embargoed or hidden entry, or one deleted elsewhere), the field shows nothing: no id, no title, no empty link. - Publishing warns, never fails. Studio's site publish dialog lists published entries whose references will not resolve, and
cloudhal import --dry-runreports them for an archive.
A repeater is an ordered list of items with the same sub-fields: an agenda's sessions, a recipe's ingredients, a job's benefits.
- Sub-fields.
fieldsholds one to ten sub-fields, each with a key, labels and a type:text,richText,number,boolean,date,url,image, a singleselector a singlereference. Nodatetime, no multiple select or reference, and no repeater inside a repeater, so every schema agents see has a fixed depth. - Items. At most
maxItemsitems (20 when absent, 50 at most). An item holds only sub-field keys, and an empty item is refused.requiredToPublishon a sub-field means every item of an entry in review or published needs it; on the repeater, at least one item. - Item labels.
itemLabelnames the text, select or reference sub-field that names an item in Studio's legends ("Item 2: Ada Lovelace") and in approval prompts. Absent: the first such sub-field. - Languages.
localizedis the repeater's and applies to the whole list: each language version has its own list, or only the original holds one. Sub-fields have nolocalizedof their own. - Images and rich text inside items are checked like top-level ones (an image sub-field takes image assets only) and count as uses of the asset.
- 100 leaves per collection: fields plus repeater sub-fields. Top-level fields stay at 30 per collection.
- 200 per site, sub-fields included.
- 100 references per entry, across fields and repeater items (
too_many_references).
A reference holds its target in place while a published entry shows it, its own value or one a published translation inherits:
- Deleting the target's last version in the site, or relinking it into another translation group, is refused with
409 entry_referenced; moving it to a collection the field does not allow, with409 reference_target_moved. Both name up to 20 published referrers and the total. Studio's delete dialog lists them before you confirm; drafts that would lose the value are listed too, without blocking. Deleting one language version while another remains is allowed: that language's pages then fall back or show nothing, and the dialog says which. - Archiving or unpublishing the target is always allowed. It renders nothing from then on, but it does not release the lock: the referrers still hold it, so the delete stays refused until they change or are unpublished. Unpublishing a referrer releases its hold at once.
- Drafts, entries in review and archived entries lock nothing on their own. A contributor, who cannot publish, therefore never pins anyone's entry.
- "Used by" on an entry's page in Studio lists the entries that reference it, published or not, with the field. Agents use
list_references(direction: "to"or"from").
The four operations above work on both types, and take sub to work on one sub-field of a repeater (cloudhal fields rename events speakers who --sub person renames the sub-field person of speakers to who; in Studio, pick the sub-field in the operation's dialog):
-
Rename and remove a sub-field. Removing a repeater's only sub-field is refused: remove the repeater instead.
set_field_localizedtakes nosub, since sub-fields share their repeater's setting. -
Change type has these conversions besides RFC-0041's table:
- text ↔ reference: a text value becomes the target whose title it matches in the field's collections (exactly one, not the entry itself); a reference becomes its target's title;
- reference single ↔ multiple, and reference (multiple) ↔ repeater with one reference sub-field;
- a repeater with exactly one text, rich-text or reference sub-field → text (one line per item) or reference (multiple);
- a sub-field's type (
--sub), with--collectionsfor a new reference sub-field.
A conversion that carries ids over may only widen the target collections. The CLI takes
--collections a,b,--max-items nand--sub-fields file.json(a JSON list of sub-field definitions). -
While a conversion into references runs, deletes, moves and relinks of entries in its target collections wait (
409 field_operation_running), so no value it converts can lose its target. -
"Clear and finish." A value can stop converting while an operation runs (its target was archived, or a second entry took the same title). The operation then stops with those entries listed as unconvertible and keeps its marker. Studio's dialog offers Clear and finish behind a confirmation; the CLI's
continueandreverttake--clear, the API and MCPonInvalid: "clear". It drops only what no longer converts.
- Schemas.
list_collectionsreturnsfieldsSchema(references asentry_…strings, repeaters as arrays of flat objects; no$ref, depth at most 4) andfieldsSchemaStrictfor providers' strict modes, withstrictCost(properties,depth,enumValues,unions) so an agent can tell when to send a subset. Each property of the strict schema stands alone, so splitting it is safe. - Writing.
create_entryandupdate_entrytakefields(a merge patch:nullremoves a key) orfieldsStrict(strict output:nullmeans "leave as it is", and inside an item "unset"), withclearFieldsfor removals. A given reference or repeater value replaces the whole value. Items left empty bynulls are dropped and reported (droppedItems). The prechecks run the store's own checks, and approval prompts name targets readably (venue → "Main hall" (entry_…, Places)). - Translations. Item paths are
key/index/sub(speakers/0/talk).get_translation_sourcereturnssourceRev, whichsave_translationneeds with item paths. Once a translation holds its own list for a repeater, item paths into it are refused (translation_shape_mismatch):get_translation_sourcethen returns the source's whole list under the repeater's key infieldsand names it inwholeLists. Translate its text and rich-text sub-values and send the whole list back, in the source's order. A whole list sent alone is the translation's own and is stored as sent. References and other sub-values are not translated. update_collectionandchange_field_typelist both types where the server can render them; the store still decides per site (below).
Both types are accepted on every write path (Studio, MCP, the API, cloudhal fields, imports, push, pull, cloudhal import --replace and --revert) wherever the renderer serving the site reads them:
- Cloudflare: once the site Worker serving that site answers
GET /__cloudhal/featureswithfields: 2(0.9.0 and later). With several site Workers (an attached theme, multi-site), each is asked for the sites it serves, so a site on an older attached Worker keeps refusing while the others accept. Deploy the site Worker first; until it reports it, the admin Worker refuses both types for its sites withfields_unsupported, and a push or archive that defines them is refused whole. - Node (
cloudhal dev,studio, self-host, Docker): always, since the renderer runs in the same process.
Upgrading runs a one-time check of every site's reference index (refs.rebuild, every five minutes on the admin Worker until each site is done; at start-up on Node). It is a safety net and should find nothing to change. It leaves a site alone while a text ↔ reference type change runs there, and checks it once the change is done.
Each field decides once whether it is localized:
- Localized (
localized: true): every language version of an entry has its own value. When a translation has none, readers see the original's value (Studio shows it as a placeholder; themes getfromOriginal: true). Changing a localized value of the original marks its translations as needing an update, like body edits. - Not localized (
localized: false): only the translation group's original holds the value, and every translation shows it. A translation cannot store its own different copy (field_not_localized). Changing it never makes translations stale. Machine translation never sends these values. - Language editions (an original in another site of the project): values are copied when the edition is made, and each site owns its own from then on.
Labels, help and option labels are per language too. A site language without its own label uses the site's default language, then any.
New sites created with cloudhal init show field values on entry pages automatically: the blog starter's article draws a Details list under the cover for collections with fields. Entries of collections without definitions render exactly as before. Themes copied before this release keep working unchanged; they simply do not show the values until you add the lines below.
---
import { EntryFields } from "@cloudhal/astro-renderer/components";
// in your entry route, with `source`, `entry`, the page's `media` context and your theme's words
const fields = await source.entryFields?.(entry, { media, words: { yes: "Yes", no: "No" } });
---
{fields && <EntryFields fields={fields} label="Details" />}It draws a plain definition list of the fields that have a value, in definition order, and nothing at all when none does:
<dl class="ch-fields" aria-label="Details">
<div class="ch-field" data-field="venue" data-type="text"><dt>Venue</dt><dd>Kadıköy Hall</dd></div>
<div class="ch-field" data-field="startsAt" data-type="datetime"><dt>Starts</dt><dd><time datetime="2026-11-05T16:30:00Z">November 5, 2026 at 7:30 PM GMT+3</time></dd></div>
</dl>It has no styles of its own. Style .ch-fields, .ch-field[data-type="…"] or one field with [data-field="<key>"]. Multi-line text fields carry data-multiline (give them white-space: pre-line), and values read from the original carry data-from-original.
A reference is a link to each target the page shows, by its graph path (localized like every entry link, with lang on a card from the fallback language), several in a ul.ch-references. A repeater is an ol.ch-items with one nested dl per item that has something to show:
<div class="ch-field" data-field="venue" data-type="reference"><dt>Venue</dt><dd><a href="/places/main-hall/">Main Hall</a></dd></div>
<div class="ch-field" data-field="speakers" data-type="repeater"><dt>Speakers</dt><dd><ol class="ch-items">
<li><dl class="ch-fields"><div class="ch-field" data-field="person" data-type="reference"><dt>Person</dt><dd><a href="/people/ada/">Ada Lovelace</a></dd></div></dl></li>
</ol></dd></div>Themes from v0.5.0 on draw both with no change of their own.
source.entryFields(entry, options) (or entryFields(source, entry, options) from @cloudhal/astro-renderer/fields, for any source) returns every defined field by key, in definition order, ready to draw:
| Property | What |
|---|---|
label, help |
in the page's language |
empty |
nothing to show: no value, an unreadable one, an unsafe link or a missing image |
text |
the value as plain text in the page's language; print it escaped |
html |
richText only: sanitized HTML (render with set:html) |
href |
url only: the checked link target |
image |
image only: <img> attributes (src, srcset, sizes, width, height, alt, …) and assetId |
datetime |
date and datetime: the machine-readable value for <time datetime> |
value, def, fromOriginal |
the resolved value, its definition, and whether it came from the original |
references |
reference only: the targets the page shows, in stored order, as cards (title, path, url, summary, image, collection, locale, fromFallbackLocale) |
items |
repeater only: each item's sub-fields, rendered like fields |
Options: locale (default: the entry's), media (the page's media context: assets, image transforms, placeholders), timeZone (show every datetime in one zone instead of its own), words ({ yes, no }), sizes (for image fields), depth (1, the default: reference cards; 2: each card's own fields too, with their references as cards without fields).
A page resolves at most 200 targets (placeholderBudget().references); pass one placeholderBudget() per request in media.budget, as the starter does. For "events at this venue", source.referencedBy?.(groupId, { collection, field, limit }) lists the entries the page's view shows whose values name the group, newest first; its page is purged when such an entry appears, goes or changes. Drawing those entries' own fields is outside that guarantee.
---
const f = (await source.entryFields?.(entry, { media })) ?? {};
---
{f.startsAt && !f.startsAt.empty && <p class="when"><time datetime={f.startsAt.datetime}>{f.startsAt.text}</time></p>}
{f.tickets?.href && <a class="cta" href={f.tickets.href}>{f.tickets.label}</a>}From @cloudhal/astro-renderer (re-exported from @cloudhal/core-ts/fields):
resolveFields(site, entry, original?): the values a reader sees, applying the language rules above (pure; you load the original).formatFieldValue(value, def, { locale, defaultLocale?, timeZone?, words? }): one value as plain text.fieldLabel(def, locale, defaultLocale),fieldHelp(…),optionLabel(def, value, locale, defaultLocale).
entry.fields still holds exactly what is stored: no fallback, no formatting. Prefer entryFields for anything a reader sees.
- Values are content, never markup. Text is escaped. Rich text goes through the article body's Markdown pipeline: raw HTML is shown as text, links keep only safe schemes, and stored HTML is never written to a page.
- Links are checked twice: on write (
https:,http:,mailto:,tel:, relative, fragment) and again on render, sojavascript:anddata:links never reach a page; an unsafe stored value is dropped. - Images must be image assets of the same site, so a value can never point at another site's media or an outside URL, and a used asset cannot be deleted.
- Agents see field values as data. Definitions cannot carry regular expressions, and every definition change and field operation waits for a person.
- References stay inside the site. A value names a translation group, and resolves and locks only through members of the same site, so no value can reach or pin another tenant's entry.
- Limits: 30 fields per collection (100 with repeater sub-fields), 200 per site, 256 KB of values per entry, 50 options per select, 100 references per entry.