Start with the parameters, not the rules
A rule can only see fields you declared. If you never add locale, no rule can ask about locale — even if your app sends it. So the Parameters panel is the contract with your app: these are the names and types you promise to send.
Names are dotted paths of up to four segments. locale is one segment. user.plan is two. geo.country.code is three. Each segment starts with a letter and can continue with letters, digits, or underscores, up to 64 characters. You can declare at most 32 parameters on one ruleset.
Each parameter has a type. The type decides which operators you get.
| Type | What you send | Good for |
|---|---|---|
string | Text. Empty strings are treated as missing. | Locale, plan, campaign, path, country code. |
number | A finite number. Non-numbers are ignored. | Cart total, item count, version number. |
boolean | true or false. | Logged in, preview mode, feature already on. |
timestamp | Epoch milliseconds, or an ISO-8601 string with its zone (Z or -05:00). A string without a zone is read as UTC. | Sale start, last purchase, “is this still the weekend?” |
String parameters can opt into case-insensitive comparison. Turn that on for locale and plan unless you truly need DE and de to be different. It applies to eq, neq, in, not_in, prefix, contains, and ends with. The case insensitive box is on the parameter, and again beside each text condition that uses it. Both change the same setting.
now is reserved. You never send it. The engine injects the current clock so time comparisons work even when the visitor payload has no timestamp. In a condition's Param list it is now (current time), with gt, gte, lt, lte, and between. token and environmentId are reserved too — they belong to the request, not to your schema.
How your app's JSON becomes those names
You send a nested object. The engine flattens it onto the dotted names you declared. This body:
{
"locale": "de-DE",
"user": { "plan": "pro" },
"campaign": "spring-sale"
}becomes locale, user.plan, and campaign. Extra fields your schema does not name are ignored. A value with the wrong type, an empty string, an array, or null is treated as absent — which is why exists / not_exists are useful. Nesting deeper than four levels, or more than 256 leaves, is rejected.
Rules: a label, some conditions, one widget
Each rule has a label you will recognize in a dry-run trace (“Spring sale”), an on/off switch, a list of conditions, and an outcome. The outcome is always “return this widget key.” Rulesets do not do percentage experiments or multi-variant splits yet, and the editor will not let you author them. For a split today, have your app send a stable number from 0 to 99 (a hash of the user id, say) and write rules on it: under 50 sees one widget, the rest see the other.
Conditions on one rule are all required. There is no “or” inside a rule. If you need “German or Austrian,” that is two rules with the same outcome, or one in list: de-DE, de-AT.
A rule may have at most 16 conditions. A ruleset may have many rules — the editor warns after 200, and compile refuses after 1,000. You will not hit that on a homepage hero.
Disable a rule when you want it out of the running without deleting it — last week's campaign, a locale you are not ready to ship. Disabled rules are omitted from the published snapshot.Duplicate a rule when the next one is almost the same and you would rather edit a copy. Up and Down change evaluation order. There is no drag handle and no hidden priority number. The list you see is the list the engine walks.
Renaming a parameter updates every condition that used the old name. Deleting a parameter drops the conditions that referenced it. If that empties a rule's condition list, the rule is removed too. That cascade is why you add parameters first and delete them carefully.
Every operator
Not every operator is legal on every type. prefix, contains, and ends with are strings only. between is numbers and timestamps. Booleans are just “is this true” or “is this present.”
| Operator | Meaning | When you reach for it |
|---|---|---|
eq / neq | Equal / not equal. | campaign is spring-sale. Plan is not free. |
in / not_in | Value is / is not in a list of up to 64 literals. | Plan is pro or business. Locale is one of the DACH tags. |
exists / not_exists | The parameter is present / absent. | Logged-out visitors have no user.plan. A rule that needs a plan should not fire for them. |
gt / gte / lt / lte | Greater / less, with or without equality. | Cart total at least 100. A timestamp is still in the future. |
between | Inclusive range of two values. | Sale window from Friday 00:00 to Sunday 23:59. A score from 10 to 20. |
prefix | String starts with a prefix. | Locale starts with de so de-DE and de-AT share a rule. |
contains | String contains the text anywhere. | Path contains /pricing, so /pricing and /en/pricing/teams share a rule. |
ends with | String ends with the text. | Hostname ends with .de, so shop.example.de and example.de share a rule. |
contains and ends with need at least one character to look for. Every string contains nothing, so an empty value would match every visitor; the editor calls it an error instead.
| Type | Operators you can use |
|---|---|
string | eq neq in not_in exists not_exists prefix contains ends with |
number | eq neq in not_in exists not_exists gt gte lt lte between |
timestamp | eq neq gt gte lt lte between exists not_exists |
boolean | eq exists not_exists |
Times and time zones
A sale that runs through December is one condition: pick now (current time) as the param, between as the operator, and the first and last moment of the sale. The rule matches while the visit falls inside the window, both ends included, and stops matching on its own afterwards. Nobody has to remember to publish a revert. gte alone means “from this moment on”; lt alone means “until this moment.”
You type times in your own time zone. The editor names the zone under every time field — America/New_York, say — and saves the time with that zone's offset for that date, so a window that crosses a daylight-saving change still opens at the hour you typed. A teammate in another zone sees the same moment, shown in theirs.
A time saved without a zone is read as UTC. The editor flags one: Saved without a time zone, so it is read as UTC. Pick the time again and it saves in your zone.
A timestamp your app sends — a trial end, a last purchase — compares with a time you type the same way. There is no “in the last 30 days” operator yet. When you need one, have your app send a number such as days since signup, and compare that.
First match, worked through
Take the homepage hero from the overview, in this order:
campaignequalsspring-sale→ sale herolocaleprefixde→ German herouser.planinpro,business→ Pro hero
Default: the everyday hero. A German Pro visitor with campaign: "spring-sale" gets the sale hero, not the German one. Swap the first two rules if locale should beat the campaign. Dry run both orders before you publish. That is cheaper than discovering the order on the live site.
If no enabled rule matches — unknown locale, no plan, no campaign — the default fires. Always pick a default you are willing to show everyone.
What the editor allows you to save
Saving a draft is deliberately softer than publishing. A missing default, or a widget key that is not published in this environment, is a warning on save. You can keep typing. The same problems become errors when you publish or promote. A broken shape — a bad parameter name, too many rules, an operator that does not belong on that type — is an error even on save, and the request is rejected.
Dry run in the editor evaluates the draft you are looking at, including unsaved edits. It does not switch to the last published snapshot. If you need to see what Production is serving, open View published or a history version.
When the traces look right, publish, promote, or copy. When you want your app to call this in Development, resolve from your app. If you would rather describe the rules than build them, Ask AI drafts them in the editor for you to review.