Docs Asking questions
Segments
A filter tree over traits, context and behaviour, read back in English before you trust it.
A segment is a filter tree. POST /v1/segments/preview runs one ad hoc,
without saving it; POST /v1/segments and friends (below) save one so it can
be named, re-run, and listed. Every segment endpoint is server-key only — the
write key ships in browser JavaScript, and a segment’s count and membership
are aggregate information about everyone in the project.
curl -i http://localhost:3000/v1/segments/preview \
-H "x-lyraflow-server-key: $LYRAFLOW_SERVER_KEY" \
-H 'content-type: application/json' \
-d '{
"ast_version": 1,
"filter": {
"kind": "group", "op": "and", "children": [
{ "kind": "trait", "key": "plan", "operator": "=", "value": "trial" },
{ "kind": "behavior", "event": "import_started", "aggregate": "count",
"operator": ">=", "value": 3,
"window": { "kind": "last", "n": 7, "unit": "days" } },
{ "kind": "not", "child": {
"kind": "behavior", "event": "invite_teammate", "aggregate": "count",
"operator": ">=", "value": 1, "window": { "kind": "ever" } } }
]
}
}'
That reads as trial users who ran an import at least three times in the last seven days but never invited a teammate, and the response is:
{
"person_count": 128,
"warnings": [],
"as_of": "2026-08-07T09:30:00.000Z"
}
as_of is the instant the count describes. Events become queryable within
seconds rather than instantly, so a count is a recent answer, not a live one —
the timestamp says which answer you got instead of implying it is current. A
repeated preview of the same tree within a short window can be served from an
in-process cache; a cache hit still reports the as_of of the request that
actually computed it, never a fresher-looking timestamp than the count itself.
Retrieving members, not just the count
Add "include": ["members"] to get one bounded page of the people matching,
alongside the same count:
curl -s http://localhost:3000/v1/segments/preview \
-H "x-lyraflow-server-key: $LYRAFLOW_SERVER_KEY" \
-H 'content-type: application/json' \
-d '{ "ast_version": 1, "filter": { "kind": "trait", "key": "plan", "operator": "=", "value": "trial" }, "include": ["members"] }'
{
"person_count": 128,
"warnings": [],
"as_of": "2026-08-07T09:30:00.000Z",
"members": [
{ "person_id": "user-42", "first_seen": "2026-07-01T00:00:00.000Z",
"last_seen": "2026-08-06T09:30:00.000Z", "identified": true,
"country": "US", "region": "CA", "city": "San Francisco",
"device_type": "desktop", "os": "macOS",
"browser": "Chrome", "referrer": "https://google.com",
"utm_source": "google", "utm_medium": "cpc", "utm_campaign": "launch",
"traits": { "plan": "trial", "company": "Acme" },
"traits_num": { "seats": 12 },
"trait_total": 3 }
],
"next_cursor": "eyJ...base64url...",
"window_exhausted": false
}
Each member row carries person_id, first_seen, last_seen, identified,
and the ten context fields (see Node types below) at their current
value — not the first_touch one, even for the four fields that are only ever
recorded as first-touch (see the caveat below context for why latest reads
the same value there).
identified says whether that person has ever been identify()d, as opposed
to reached only through the device fallback: true when at least one of their
events carried a real user_id, false when their person_id is an
anonymous_id that nothing has ever tied to a person. The two are
indistinguishable as strings, and the difference is what decides whether there
is a profile behind the row: GET /v1/persons/:id answers 404 for the
unidentified one, for the reason Privacy: deletion and export gives below.
It also carries that person’s traits, split by type the way they are
stored: strings in traits, numbers in traits_num. At most 50 of each are
returned, in key order — a per-person map is of arbitrary size, and a hundred
rows of one would be unbounded by construction. trait_total is how many that
person actually has, so a truncated map is visible as truncated rather than
read as complete.
Pages are 100 rows, ordered last_seen descending. Pass the previous
response’s next_cursor back as cursor to continue:
curl -s http://localhost:3000/v1/segments/preview \
-H "x-lyraflow-server-key: $LYRAFLOW_SERVER_KEY" \
-H 'content-type: application/json' \
-d '{ "ast_version": 1, "filter": { "kind": "trait", "key": "plan", "operator": "=", "value": "trial" }, "include": ["members"], "cursor": "eyJ...base64url..." }'
next_cursor is null once there is no further page, or once the walk has
served 1,000 rows (10 pages) — window_exhausted: true marks that second
case specifically, so a caller can tell “you have seen everyone” apart from
“there is more, but not through this endpoint”. This is a preview of a
population, not an export of it: there is no way to page past 1,000 rows,
and no point-in-time snapshot of membership is kept — re-running the same
segment later can return a different set as people’s data changes. A cursor is
opaque, signed for the project that issued it, and rejected with 400 if
tampered with, built by hand, or replayed against a different project’s server
key.
Node types
kind | Matches on |
|---|---|
group | and / or over children |
not | negates one child |
trait | a trait set through identify() |
context | country, region, city, device_type, os, browser, referrer, or a utm_* value, with scope of latest or first_touch |
lifecycle | first_seen / last_seen |
behavior | an event name (or * for any), aggregated as count, sum, min, max, or distinct, over a last / absolute / ever window, optionally narrowed by where (see below) |
Operators
There are five families, and which ones a condition may use depends on what
it is comparing — a country is never a flag, a first_seen is always set.
| family | operators | value | admitted on |
|---|---|---|---|
| compare | = != > >= < <= between | one, or two for between | everything |
| text | contains not_contains starts_with not_starts_with ends_with not_ends_with | one string | traits, properties, context, event columns |
| presence | is_set is_not_set | none | traits, properties, context, event columns |
| boolean | is_true is_false | none | traits and properties |
| relative date | in_last not_in_last | { "n": 7, "unit": "days" } (hours or days) | traits, properties, lifecycle |
between takes exactly two values; the presence and boolean families take
no value key at all, and sending one is ignored rather than refused.
Four things about these that are easy to get wrong, and that Lyraflow has picked a side on:
- Text matching is case-insensitive;
=is not.path contains checkoutfinds/Checkout. Equality was case-sensitive before these operators existed and stays that way, because changing it would silently reinterpret every segment already saved. The folding is ClickHouse’slowerUTF8, which handles accented Latin and Greek but not Turkish dottedİ— socontains istanbuldoes not matchİSTANBUL. is_setis not!= "", and that is why it exists. A property or trait that was never sent reads back as the empty string, exactly like one that was sent empty. No comparison can separate them;is_setcan.- Negations include people who have nothing there.
plan not_contains promatches someone with noplanat all, as doesnot_in_last. If you mean “has a plan, and it is not pro”, combine it withis_set. is_true/is_falsematch the stored text"true"/"false", which is what ingest writes for a JSON boolean. They do not treat"1","yes"or a non-empty string as true.
A relative date is resolved when the segment runs, not when it is saved, and a trait or property is read through a best-effort date parse — a value that is not a date is simply not in the window.
where: narrowing a behaviour to particular events
A behavior — and a funnel step, which uses the same shape — may carry up to
ten where predicates. They are ANDed together and applied to each event
BEFORE it is aggregated, so they say which events count, not what the person
is like.
A funnel step shares more than that shape with a behavior now: it may also
carry a whole audience condition tree — the same FilterNode grammar this
section documents, gating which person may advance past the step rather
than which event counts. See Funnels above for the distinction and the
per-funnel cap on it.
A predicate names one of two things, and it says which:
{ "kind": "behavior", "event": "$page", "aggregate": "count",
"operator": ">=", "value": 1, "window": { "kind": "last", "n": 30, "unit": "days" },
"where": [
{ "property": "plan", "operator": "=", "value": "pro" },
{ "source": "attribute", "attribute": "utm_campaign", "operator": "=", "value": "spring" }
] }
- A property predicate reads a key from the event’s own
properties— whatever the caller put there. This is the default: a predicate with nosourceis a property predicate, which is why every segment written before attributes existed still means exactly what it meant. - An attribute predicate reads a column of the event itself. Set
"source": "attribute"and name one ofpath,url,referrer,utm_source,utm_medium,utm_campaign,utm_term,utm_content,device_type,os,browser,country,region,city. Any other name is a400. Values are strings, because every one of those columns is a string.
Nothing is inferred from the name. A property genuinely called path is
possible — properties comes from the caller’s own bag and path from
context — so { "property": "path" } reads the property and
{ "source": "attribute", "attribute": "path" } reads the column, whichever
one your events happen to carry.
A property predicate’s VALUE TYPE chooses which map it reads. Ingest puts
a finite number in properties_num and everything else in properties, and a
predicate reads one or the other: {"property": "results", "value": 21} reads
the numeric map, {"property": "results", "value": "21"} reads the string one.
They are different questions, and the wrong one matches nothing rather than
erroring — so send the type you sent at ingest. GET /v1/schema/properties
returns a value_kind per key if you need to look it up; the web UI reads it
and sends the matching type for you.
An attribute predicate is not a context condition, and the difference is
the question each answers. A context condition is about the PERSON: it
matches whoever was acquired through a campaign, whatever they later did. A
where predicate is about the EVENT: it matches people who did this thing
from that campaign. “Viewed pricing at least once in the last 30 days, from
the spring campaign” is the second, and cannot be written as the first.
A lifecycle bound with no timezone is UTC. 2026-08-01T10:00 means
10:00 UTC, and a bare 2026-08-01 means midnight UTC. Send an instant with a
Z or an offset if you want to be explicit — both are accepted and honoured —
but a value with neither is never read in the server’s local zone.
That is worth knowing if you have bounds stored from before this release: they used to be resolved with the server’s own timezone, so the same segment meant a different instant depending on where the process thought it was, and moving a deployment between zones silently changed which people it matched. Those bounds now mean UTC. If your server was not on UTC, such a bound has shifted by that offset — once, visibly, and the builder shows the instant it now names when you open the segment.
One caveat on context: referrer, utm_source, utm_medium and
utm_campaign are recorded only as first-touch, because for an
acquisition attribute the original value is the one that means something. A
scope of latest on those four returns the first-touch value rather than a
different one. The other six fields record both.
ast_version is required and must be 1. A tree saved today carries the
version it was written with, so a later release can migrate it rather than
silently reinterpret it.
Warnings
warnings is advisory — the query still ran. Each entry names the node
responsible by path, so a builder UI can point at it:
{
"person_count": 4210,
"warnings": [
{ "path": "filter.children[1]",
"reason": "the `import_started` condition uses an `ever` window, which scans all history rather than a bounded window" }
],
"as_of": "2026-08-07T09:30:00.000Z"
}
Limits
A filter tree is bounded, because the endpoint is reachable by anyone holding the server key:
| Limit | Value |
|---|---|
| Nesting depth | 10 |
| Total nodes | 100 |
| Behavioural conditions | 25 |
| Member page size | 100 rows |
| Member paging window | 1,000 rows (10 pages) per walk |
Exceeding a tree limit is a 400 naming which one:
{ "error": "filter tree is nested deeper than 10 levels", "code": "depth" }
A malformed tree is also a 400, with a per-field path:
{
"error": "invalid filter tree",
"detail": [{ "path": "filter.value", "message": "`between` requires exactly two values; other operators require one" }]
}
A tree that is valid but too expensive to finish returns 422 — it exceeded
the query’s time or memory ceiling. Narrow a window, or drop an ever, and
try again. 401 is a missing or invalid server key. A malformed or tampered
cursor is a 400 with an error mentioning cursor.
Saved segments
A saved segment is a named, stored filter tree you can re-run without
resending it, and see listed alongside your others. Create one with
POST /v1/segments:
curl -i http://localhost:3000/v1/segments \
-H "x-lyraflow-server-key: $LYRAFLOW_SERVER_KEY" \
-H 'content-type: application/json' \
-d '{ "name": "Trial power users", "ast_version": 1,
"filter": { "kind": "trait", "key": "plan", "operator": "=", "value": "trial" } }'
{
"id": 17,
"name": "Trial power users",
"ast_version": 1,
"filter": { "kind": "trait", "key": "plan", "operator": "=", "value": "trial" },
"stale": false,
"last_count": null,
"last_evaluated_at": null,
"created_at": "2026-08-07T09:00:00.000Z",
"updated_at": "2026-08-07T09:00:00.000Z"
}
The tree is validated against the exact same shape and cost limits as
/v1/segments/preview — a 201 here is a guarantee it will also run
cleanly later, not merely that it parsed. A duplicate name within the same
project is a 409.
| Method & path | Does |
|---|---|
GET /v1/segments | List every segment in the project, name-ordered |
POST /v1/segments | Create one |
GET /v1/segments/:id | Read one |
PATCH /v1/segments/:id | Rename it, replace its filter tree, or both |
DELETE /v1/segments/:id | Delete it — 204 |
POST /v1/segments/:id/preview | Run it and record the result |
PATCH accepts name, or ast_version + filter, or both. Sending a filter
clears the stored last_count / last_evaluated_at snapshot in the same
statement, because a stored count describes the tree it came from; a
rename-only PATCH leaves the snapshot untouched. A body that carries
ast_version or filter at all but fails to parse as a valid tree is
rejected with 400 and a field path, the same as a malformed body to
POST /v1/segments — it is never treated as a rename-only request.
GET, PATCH, and DELETE on a segment id that does not exist, or that
belongs to another project, both answer 404 — never 403, which would
confirm the id exists. A non-numeric :id is a 400 naming
invalid_segment_id, rather than a 503 from an unbound query parameter.
POST /v1/segments/:id/preview runs the stored tree — it accepts the same
include/cursor body as the ad hoc preview endpoint and returns the same
shape, minus warnings (nothing to warn about a tree that already saved
cleanly) — and then records last_count / last_evaluated_at on the segment,
whichever output mode you asked for:
curl -s -X POST http://localhost:3000/v1/segments/17/preview \
-H "x-lyraflow-server-key: $LYRAFLOW_SERVER_KEY"
# {"person_count":128,"as_of":"2026-08-07T09:30:00.000Z"}
A row that predates today’s AST or caps is marked, not hidden. If a stored
tree no longer parses — written by an older build, or against a schema
version this release no longer understands — GET/PATCH/POST .../preview
on that one segment return 400:
{ "error": "stored filter tree does not parse under ast_version 1", "ast_version": 1 }
but GET /v1/segments never fails the whole list for one bad row. That
segment appears with "filter": null, "stale": true so you can still see it,
rename it, or delete it, while every other segment in the list renders
normally — every listed segment carries stale (false for an ordinary one)
so a client can check the one field regardless of which route the row came
from.
Autocomplete: event and property names
GET /v1/schema/events and GET /v1/schema/properties list the event names
and property keys a project has recorded — the raw source a segment builder’s
autocomplete can be built on. Both are server-key only, for the same reason as
/v1/segments/preview: a project’s event taxonomy describes its product, and
the browser-shipped write key must not be able to read it.
Every event name is discoverable, including events that carry no properties
at all. That was not always true, and the limitation is worth recording
because upgrading is what fixes it: both endpoints read from event_schema,
which was fed only by an ARRAY JOIN over each event’s property maps — and an
ARRAY JOIN over an empty map produces no rows, so an event with no properties
registered nothing and was invisible to /v1/schema/events, not merely absent
from /v1/schema/properties.
A third view now writes one row per event whose only job is to register the name. Existing events are not backfilled: a property-less name recorded before this release appears once the next such event arrives, which for a name your product still sends is the next time it fires.
/v1/schema/properties for an event with no properties correctly returns an
empty list — the name is discoverable, and there is genuinely nothing to filter
on.
curl -s http://localhost:3000/v1/schema/events?q=import \
-H "x-lyraflow-server-key: $LYRAFLOW_SERVER_KEY"
# {"events":[{"event_name":"import_started","last_seen":"2026-08-01T00:00:00.000Z"}]}
curl -s http://localhost:3000/v1/schema/properties?event=import_started \
-H "x-lyraflow-server-key: $LYRAFLOW_SERVER_KEY"
# {"properties":[{"property_key":"rows","value_kind":"number"},{"property_key":"source","value_kind":"string"}]}
| Parameter | Applies to | Meaning |
|---|---|---|
q | both | prefix filter, matched against the event name or property key |
event | properties only | restrict to one event’s properties |
limit | both | max rows to return, default 50, capped at 100 |
limit above 100 is rejected with 400, not silently truncated.
Each event carries last_seen, the latest instant that event name was
recorded, as an ISO-8601 timestamp — enough to rank an autocomplete by recency
rather than alphabetically, which is what stops the list becoming useless once
a project is a year old. There is still no frequency signal, because
event_schema carries no counts.
Results are name-ordered, and limit is applied after that ordering. So a
project with more event names than the cap gets the alphabetically first N,
and can only re-rank within those — last_seen does not currently let you
ask for the most recent 50 event names out of 500. Ordering server-side by
recency would, and is the obvious next step; it is not the default today
because it changes which rows every existing caller receives.
Otherwise deliberately thin: prefix vs. fuzzy matching, and ranking by frequency or name, are questions for whichever builder UI ends up consuming this — this ships the raw read those can be built on top of, rather than a guess at one of them.
What this does not do yet
Every segment above is built and run through the HTTP API directly, in JSON. There is no export of a segment’s membership: the members endpoints are a bounded 1,000-row preview, not a way to pull an entire population out. There is no point-in-time membership — a saved segment stores its last count and when it was computed, not who was in it at that moment, so you cannot ask “who matched this segment last Tuesday”. Membership is also not recomputed automatically on any schedule; a saved segment’s snapshot only updates when you explicitly run it. Those are planned; none of them exist today.
This page is the Segments section of the product
README at v0.15.0. It is generated from that file rather than
written here, so a correction belongs upstream.