Pre-launch API. The concepts described here are settled, but the API shape is not: request and response fields, parameters and defaults can still change. Build against it, and talk to your Luigi's Box contact before you put an integration into production.
Filters are a JSON tree in the request body. One object restricts a result set:
{ "$and": [ { "availability": { "eq": 1 } }, { "price": { "lt": 50 } }, { "brand": { "in": ["northwear", "atlas"] } } ]}This page is the reference for that syntax. The node grammar and the evaluation rules are the same wherever Luigi’s Box AI accepts a filter; what changes between surfaces is which operators the surface accepts. Discovery takes one on three endpoints:
| Surface | Field |
|---|---|
| Search | filters |
| Collections | filters (the shopper’s narrowing); the page itself is named by collection_id |
| Recommendations | filters |
Nothing needs quoting or escaping: a value with spaces, commas or quotes is a JSON string, and a field name with a colon is a JSON key.
Search surfaces can also infer constraints from the query. See Query interpretation for supported price locales, inferred facet boosts and currency requirements.
A filter is a tree of nodes. Every node is one of two things.
A condition node maps field names to operator objects:
{ "brand": { "eq": "northwear" } }A group node holds exactly one of $and, $or or $not:
{ "$or": [{ "color": { "eq": "blue" } }, { "color": { "eq": "navy" } }] }$and and $or take an array of nodes. $not takes a single node. A node may not mix the
two forms — a $-key and a field name in the same object is a 422.
Everything adjacent is AND
Section titled “Everything adjacent is AND”Two shorthands reduce $and wrappers. Several operators on one field combine as AND:
{ "price": { "gte": 25, "lte": 75 } }And several fields in one node combine as AND:
{ "availability": { "eq": 1 }, "color": { "eq": "blue" } }$and is needed to place a group beside a condition, or to repeat a field:
{ "$and": [ { "availability": { "eq": 1 } }, { "$or": [{ "color": { "eq": "blue" } }, { "color": { "eq": "navy" } }] } ]}There is no operator precedence; nesting is explicit.
Operators
Section titled “Operators”| Operator | Means | Value | Example |
|---|---|---|---|
eq | Equals | Scalar | { "color": { "eq": "blue" } } |
neq | Does not equal | Scalar | { "color": { "neq": "blue" } } |
gt gte lt lte | Numeric comparison | Scalar | { "price": { "gte": 25 } } |
in | Is one of | Non-empty array | { "brand": { "in": ["northwear", "atlas"] } } |
not_in | Is none of | Non-empty array | { "color": { "not_in": ["black", "white"] } } |
exists | Field is present | true / false | { "image_url": { "exists": true } } |
in and not_in require at least one entry. There is no empty-set form — when a shopper
has selected no brands, omit the condition rather than sending an empty array.
const clauses = [{ availability: { eq: 1 } }];if (brands.length) clauses.push({ brand: { in: brands } });const filters = clauses.length === 1 ? clauses[0] : { $and: clauses };{ "exists": false } is the same as wrapping { "exists": true } in $not. Both mean
“objects that do not carry this field at all”.
Fields
Section titled “Fields”A field is a catalog attribute name, used verbatim — the same name you indexed it under, as a JSON key.
{ "color": { "eq": "blue" } }{ "price": { "lte": 50 } }{ "@category": { "in": ["category/shirts"] } }{ "lbx:group_primary": { "eq": true } }{ "params.material": { "eq": "cotton" } }{ "my odd field": { "eq": 1 } }Namespaced names contain a colon, nested attributes use a dot-path, and a name with spaces
or punctuation needs nothing special — a JSON key holds any of them as-is. The only
restrictions are that a field name must not begin with $, which is reserved for group
keys, and must not have leading or trailing whitespace.
To see which attributes a catalog holds, list its attributes and their values.
Values
Section titled “Values”A value is a JSON scalar: a string, a number, a boolean, or null.
{ "in_stock": { "eq": true } }{ "price": { "gt": -0.5 } }{ "category": { "eq": "musical instrument" } }{ "brand": { "eq": "Atlas & Co" } }{ "name": { "eq": "salt and pepper" } }Types must match the field’s. A number compared against a text field is a
filter_type_mismatch.
Size limits
Section titled “Size limits”A filter holds at most 100 conditions, in groups nested at most 10 deep — the same condition
and depth limits a channel’s visibility_filter and a merchandising rule’s conditions have,
counted the same way:
- Conditions are counted, groups are not. Each operator is one condition, so
{ "lbx:price": { "gte": 50, "lte": 200 } }is two. How they are spread across$and/$orarrays does not matter; only the total does. Fifty selected brands are oneincondition, not fifty. - Depth counts groups. The outermost group is level 1, and a condition inside a group is
not a level of its own. A condition node with several operators or several fields, or an
exists: false, is a group of its own, one level deeper.
A filter past either limit is a 422 with code filter_too_complex: as the type of its
validation_errors entry in a request body, and as exception_details.error_code for a
filters query parameter. A filter far past them — a few dozen levels deep, or thousands of
conditions — is refused the same way before it is read.
Unlike a stored filter, an in / not_in list here has no 100-value limit.
Missing attributes
Section titled “Missing attributes”Not every object has every attribute. When an attribute is missing:
- A positive test on a missing attribute is false.
eq,in,gt,gte,lt,ltedo not match an object that lacks the attribute. - A negative test on a missing attribute is true.
neqandnot_indo match it.
For example:
{ "color": { "neq": "blue" } }matches every object with no color at all, alongside the red and green ones. For “has a
colour, and it is not blue”, add exists:
{ "color": { "neq": "blue", "exists": true } }Filters you save as configuration are the exception: they add that exists for you. A neq
or not_in in a campaign trigger or rule
selector, in a channel’s
visibility filter, or in a curated
collection’s filters, is stored with a
presence check on the same field, so a rule that bans products outside a category leaves
products with no category alone, and a not around a single condition is pinned the same
way, from the outside — though never to exists itself, since asking for objects that lack
a field is a deliberate thing to write and pinning it would make it match nothing. The write’s
own response carries the stored form, so you see what was saved.
Four cases are left alone, so reading a saved campaign or channel back shows no added clause.
The slots every object carries — @id, @type and @updated_at — need no check, since a
negation on one always has a value to compare against. @title is not among them: objects
stored before titles were required may carry none, so a negation on it is paired like any other. Nor does any trigger.request:*
field: those are absent when the request is of another kind, not when a product happens to
lack an attribute, and a campaign says which kinds it fires on through its targeting. A field
you already paired with your own exists keeps the pairing you wrote rather than gaining a
second one. And a not wrapped around a whole group is left
as written: which of its fields would have to be present is not one question, so there is no
single clause that answers it.
Keeping the original reading
Section titled “Keeping the original reading”To save a condition that does match objects missing the field — “not blue, and colourless is fine” — write both halves out:
{ "operator": "or", "conditions": [ { "operator": "not", "conditions": [{ "field": "color", "operator": "exists" }] }, { "operator": "and", "conditions": [ { "field": "color", "operator": "exists" }, { "field": "color", "operator": "neq", "value": "blue" } ] } ]}Either it has no color at all, or it has one and that one is not blue — which is what the
bare neq used to mean. It is stored exactly as sent: exists is never rewritten, and the
neq sits in an and that already pins its field.
Nowhere else is it added for you: in a discovery filter the rule above holds, and the
exists is yours to write.
Worked examples
Section titled “Worked examples”Everything in stock under €50, from two brands:
{ "availability": { "eq": 1 }, "price": { "lt": 50 }, "brand": { "in": ["northwear", "atlas"] }}Two categories, excluding clearance:
{ "@category": { "in": ["category/shirts", "category/knitwear"] }, "clearance": { "neq": true }}A price band, either of two colours:
{ "$and": [ { "price": { "gte": 25, "lte": 75 } }, { "$or": [{ "color": { "eq": "blue" } }, { "color": { "eq": "navy" } }] } ]}Only the primary product of each variant group:
{ "lbx:group_primary": { "eq": true } }Everything except what is already in the cart:
{ "availability": { "eq": 1 }, "@id": { "not_in": ["product/sku-1001", "product/sku-2087"] }}Only products that have an image:
{ "image_url": { "exists": true } }Building a filter from facet selections
Section titled “Building a filter from facet selections”Rebuild the tree from the selected values on each request rather than mutating one across requests; this keeps the “clear all filters” case correct:
function buildFilters({ brands, maxPrice, inStockOnly }) { const clauses = []; if (brands.length) clauses.push({ brand: { in: brands } }); if (maxPrice != null) clauses.push({ price: { lte: maxPrice } }); if (inStockOnly) clauses.push({ availability: { eq: 1 } });
if (clauses.length === 0) return undefined; // omit the field entirely if (clauses.length === 1) return clauses[0]; return { $and: clauses };}Omit filters rather than sending null or {} when there is nothing to filter on: an
empty object is not a valid node.
When a filter is rejected
Section titled “When a filter is rejected”A filter is checked in two stages, and the two rejections have different shapes.
A tree that does not match the node grammar — an unknown operator, an empty in array, a
node mixing $and with a field name, anything over a size limit — is rejected before the
request is served, as a
validation error. Each entry’s
loc names the offending path:
{ "reason": "Request validation failed", "exception_details": { "validation_errors": [ { "loc": ["body", "filters"], "msg": "Value error, filters.$and[2]: unknown group operator '$nor' (allowed: $and, $or, $not)", "type": "value_error" } ] }}A tree that is well-formed but does not fit the catalog is rejected when it is applied,
and carries an error_code instead:
exception_details.error_code | Cause |
|---|---|
filter_unknown_field | The field is not present in this catalog |
filter_type_mismatch | The value’s type does not match the field’s — a number compared to text |
A filters query parameter carries an error_code for its own refusals too:
filter_syntax when the expression does not parse, and filter_too_complex when it is
past the size limits.
A filter_type_mismatch usually means the attribute was indexed as text when you expected
a number: price arriving as "29.90" rather than 29.90 in one feed run types the field
as text for good. Check the field’s type with
catalog metadata and fix it at the
mapping, not in the filter.
Other surfaces that filter
Section titled “Other surfaces that filter”Everything above about values and evaluation holds wherever a filter appears. Four things vary by surface: which operators it accepts, how the tree is written, how large it may be, and whether a negation gets its presence check added for you.
| Surface | Written as | Operators | Size | Negations |
|---|---|---|---|---|
| Discovery — search, collection, recommendations | The map syntax on this page | The nine above | The limits above | As written |
Business rules — match and record_match | field / operator / value leaves in a conditions array, or the map syntax | Those nine, plus matches for substring and range for a bounded interval | 100 conditions in the whole tree, in groups nested up to 10 deep, and 100 values per in / not_in, with no bound per array | Presence added on save |
Channel visibility — visibility_filter | Either syntax, as business rules | The same as business rules | The same as business rules | Presence added on save |
Curated collections — filters | The map syntax on this page | The nine above | The same as business rules, and 64 KiB once stored | Presence added on save |
// A business rules leaf, for comparison with a condition node above{ "field": "brand", "operator": "eq", "value": "northwear" }A surface rejects an operator it does not accept; each surface’s own reference lists the operators it takes. For campaigns, the trigger field metadata reports the operators each field accepts.
See also
Section titled “See also”- Search · Collection · Recommendations
- Facets — turning filters into a sidebar
- Catalog metadata — which fields and values exist
- Business rules — the same semantics, in a different tree shape
Was this page helpful?
Thanks.