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

kindMatches on
groupand / or over children
notnegates one child
traita trait set through identify()
contextcountry, region, city, device_type, os, browser, referrer, or a utm_* value, with scope of latest or first_touch
lifecyclefirst_seen / last_seen
behavioran 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.

familyoperatorsvalueadmitted on
compare= != > >= < <= betweenone, or two for betweeneverything
textcontains not_contains starts_with not_starts_with ends_with not_ends_withone stringtraits, properties, context, event columns
presenceis_set is_not_setnonetraits, properties, context, event columns
booleanis_true is_falsenonetraits and properties
relative datein_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 checkout finds /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’s lowerUTF8, which handles accented Latin and Greek but not Turkish dotted İ — so contains istanbul does not match İSTANBUL.
  • is_set is 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_set can.
  • Negations include people who have nothing there. plan not_contains pro matches someone with no plan at all, as does not_in_last. If you mean “has a plan, and it is not pro”, combine it with is_set.
  • is_true / is_false match 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 no source is 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 of path, url, referrer, utm_source, utm_medium, utm_campaign, utm_term, utm_content, device_type, os, browser, country, region, city. Any other name is a 400. 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:

LimitValue
Nesting depth10
Total nodes100
Behavioural conditions25
Member page size100 rows
Member paging window1,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 & pathDoes
GET /v1/segmentsList every segment in the project, name-ordered
POST /v1/segmentsCreate one
GET /v1/segments/:idRead one
PATCH /v1/segments/:idRename it, replace its filter tree, or both
DELETE /v1/segments/:idDelete it — 204
POST /v1/segments/:id/previewRun 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"}]}
ParameterApplies toMeaning
qbothprefix filter, matched against the event name or property key
eventproperties onlyrestrict to one event’s properties
limitbothmax 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.