Flow config reference
Every field of a flow, its default, its valid range and what the engine does with it.
A flow is the versioned recipe that ranks a surface: which generators propose items and how much each one weighs, which filters apply, which ranker combines the scores, whether to explore, and the reason texts. You create a version with POST /surfaces/{key}/flows and activate it with POST /surfaces/{key}/flows/{version}/activate (Admin). This page lists every field the engine reads.
Minimal and full example#
The smallest valid config is one generator. Everything else has a default.
{
"generators": [ { "kind": "trending", "weight": 1 } ]
}A full config for a home feed:
{
"generators": [
{ "kind": "content_affinity", "weight": 0.5 },
{ "kind": "persona_bayes", "weight": 0.2 },
{ "kind": "trending", "weight": 0.2 },
{ "kind": "curated", "weight": 0.1, "params": { "item_ids": ["doc_1842", "doc_0977"] } }
],
"filters": {
"exclude_seen_days": 7,
"premium_policy": "cap",
"premium_cap": 2,
"per_collection_cap": 2,
"freshness_days": 365,
"diversity": 0.3
},
"ranker": { "kind": "linear" },
"exploration": { "kind": "plackett_luce", "share": 0.2, "temperature": 0.7 },
"reasons": {
"matches_goals": { "nl": "Past bij wat je leest", "en": "Fits what you read" }
}
}How scoring works#
For every candidate item, each generator returns a raw score or stays silent. The engine z-normalises each generator's scores across the candidates (mean 0, standard deviation 1), multiplies them by the generator's weight and adds them up. Because of the normalisation, weights compare directly: a weight of 0.6 against 0.3 means "twice the say", whatever the scale of the raw signal. A silent generator contributes nothing for that item; a generator that is silent for every item drops out of the request.
The generator with the largest contribution to an item "wins" it and decides the item's reason key. The contributions per generator are logged with the impression and shown in the trace.
generators#
A list of at least one generator. Order does not matter.
| Field | Type | Default | Range | Notes |
|---|---|---|---|---|
kind | string | required | one of the twelve kinds below | an unknown kind is a 400 |
weight | number | 0 | any; normally 0 to 1 | a weight of 0 switches the generator off; negative weights push items down |
params | object | none | curated reads item_ids; session_items reads sequence (both, next or neighbours, see below); user_to_user reads recent_items and half_life_days; vector_retrieval reads k; sequence_model reads top_items (1 to 2000, default 100: how many of its best items carry a score); any other kind rejects these keys with a 400 and ignores the rest |
| Kind | What it reads | Silent when | Reason key it leaves |
|---|---|---|---|
trending | the smoothed 7-day tap rate of each item on this surface ((taps + 1) / (slates + 20), a tap followed by an engaged view counting three), all locales together; the request's locale only decides which items are candidates. A surface without its own log uses 30 days of tenant-wide engagement instead. The same for everyone on the surface, not per segment or persona | an item has no data at all | trending from the surface's own log; popular from the tenant-wide prior |
curated | params.item_ids in order; without item_ids, the surface's curated lists from the panel, as scheduled. A boost by rank (the first item +2, less for each place down the list), not a z-score, so a short list never drowns a large catalog | the item is not on the list | editors_pick |
item_to_item | the anchor in context.anchor_item_id: the nightly behavioural and embedding neighbours, or tag cosine before they exist | the request has no anchor, or the anchor is not in the catalog | similar_to_read with an anchor |
content_affinity | the user's tag affinities: behaviour in personalized mode, declared goals in cohort mode, against the item's approved tags | non_profiled requests, users without a profile, items without tags | matches_goals |
persona_bayes | the nightly per-persona model; users without a persona get the cold persona | no model trained yet | people_like_you |
user_bayes | the user's nightly shortlist from matrix factorisation | anything but personalized mode, or no shortlist for the user yet | people_like_you |
surface_specific | freshness (newer is better, scaled over 180 days); in personalized mode communities the user already joined are pushed down hard | never | new for items under 30 days old, else recommended |
session_items | what this session just looked at (context.session_items and the session's own reports), recency-weighted, an engaged view three times and a tap twice a plain view; every mode. params.sequence picks the relation to the candidates: both (the default) reads the path where it is known and the topic where it is not: the directed next-item shares ("what people open after this", learned nightly from the order of each person's positives) for a session item that has them, its undirected neighbours ("what goes with this") otherwise; next reads the path only; neighbours the topic only (the behaviour before round 7). It helps within a visit: on the public benchmark the path alone put the next film in the top 10 for 20% of users against 8% for the topic alone, and on a return visit more than an hour later it added nothing | the session has no history | similar |
user_to_user | what the person's 200 nearest users did last: the nearest users are learned nightly from the same matrix factorisation as user_bayes (or from shared items for people the model has no row for yet), each neighbour's latest 10 completions, pins, taps and engaged views (params.recent_items) count by similarity and fade with a 30-day half-life (params.half_life_days), and the person's own items are left out. Personalized mode only. Next to session_items the engine blends the two by the gap since the person's previous visit: in the same visit the configured weights hold; after hours, days or a week away session_items is faded (×0.5, ×0.25, ×0.1) and user_to_user, user_bayes and content_affinity raised (×1.25, ×1.5, ×1.5), the weights used are logged with the slate and the gap bucket in the trace (gap). On the public benchmark it is the signal for the daily reopen, see the status page | not personalized, or no neighbours for the person yet | people_like_you, only when the neighbours did engage with the item |
vector_retrieval | the k items nearest by content to what the person looked at (since engine 0.3.3). Per request the engine builds one query vector, the recency-weighted mean of the text embeddings of the session's items (context.session_items first, then the session's own reports; every mode) and, in personalized mode, the person's recent positives, with session_items' weights (a view 1, a tap 2, an engaged view 3, each step back ×0.85), and asks the vector index for the params.k nearest published items of the surface's kinds (default 100, at most 500). Those items join the candidates even when no other generator proposed them, so a new item or one far down the catalog can be recommended; their score is the cosine similarity, everything else reads 0. Filters, caps and exclusions apply as to every other candidate. With Redis configured an interaction is reflected in the next slate of the session; without it the session is read from the log. On the public benchmark see the status page | nothing looked at yet (no session items, no recent positives) | similar, only on a positive cosine |
sequence_model | the nightly next-item model: a small neural network (a GRU) trained every night on the order in which each person or session opened items, exported to ONNX and evaluated inside the engine with ONNX Runtime. It reads what this session looked at (context.session_items and the session's own reports, like session_items) and, in personalized mode, the person's last 50 positives, and scores its best top_items (default 100) as the likely next ones, a shortlist that sits above every other item like user_bayes's; the session's own items get no score. Every mode. The jobs activate a new model only when its hit@10 on held-out data beats the directed path rule that session_items reads; without an active model, or without the ONNX Runtime library on the engine host, the generator scores nothing and the flow serves as if it were absent. On the public benchmark see the status page | no active model, no ONNX Runtime library, or nothing in the session and no history | similar, only for an item above the sequence's average score |
time_fit | the item's tap rate in the request's time bucket (weekday or weekend × morning, day, evening, night, from context.hour_bucket and context.weekday), learned nightly; before an item has a profile, a duration rule: short items fit the day, long ones the evening and the weekend. Every mode. It helps a feed whose items differ in length or in when people read them; on a surface with one kind of short item it adds little | no profile and no duration_min or reading_time_min | none: silent |
format_fit | the user's learned format preference (video, audio, text, from engaged views and completions) against the item's format attribute; personalized mode only. It helps where a catalog mixes formats and people clearly prefer one; with one format it does nothing | no preference learned yet, the item has no format, or not personalized | none: silent |
time_fit and format_fit are silent: they move scores, show up in the trace, and never win a reason. Start them at a small weight (0.1 to 0.2) next to the generators that carry the slate.
vector_retrieval.params:
| Field | Type | Default | Notes |
|---|---|---|---|
k | integer, 1 to 500 | 100 | how many nearest items the index returns per request; more widens the candidate pool and costs a little latency |
curated.params:
| Field | Type | Default | Notes |
|---|---|---|---|
item_ids | array of external ids | none (use the panel's curated lists) | ids not in the catalog or not published are skipped |
context.anchor_item_id is resolved against the whole catalog, any kind. An article can anchor a product surface. A slot item_to_item wins across kinds carries pairs_with only for a recipe or a workout with a product, or a community with a document, each with its own default text; any other cross-kind anchor carries more_about_tag. content_affinity leaves matches_goals for a declared goal and more_about_tag for behaviour.
filters#
Filters remove candidates before scoring, or cap the slate while it is built.
| Field | Type | Default | Range | What it does |
|---|---|---|---|---|
exclude_seen_days | integer | 0 (off) | 0 or more | drops items served to this user on this surface in the last N days (from the last 50 slates). Not applied in non_profiled mode: there is no user to remember. |
locales | array of strings | none | accepted and stored, but not read by the engine. The request's context.locale decides the locale. | |
premium_policy | string | none (no rule) | cap, exclude or absent | cap: at most premium_cap premium items for non-premium users, no cap for premium users. exclude: no premium items for non-premium users. |
premium_cap | integer | 2 when premium_policy is cap and this is 0 | 1 or more | |
per_collection_cap | integer | 0 (no cap) | 0 or more | at most N items from one collection in a slate |
freshness_days | integer or null | null (no limit) | 0 or more | drops items published more than N days ago |
diversity | number | 0 (off) | 0 to 1 | an MMR penalty: at each position an item's score is lowered by diversity times its highest tag similarity to the items already picked. It is applied inside the sampler, so logged propensities stay exact. |
region_boost | number | 0.3 | 0 to 1 | added to the score of items whose region is the request's context.region; other items are never hidden. Logged as the contribution region_match; never a reason |
exclude_dismissed_days | integer | 30 | 0 to 365 | items this person dismissed on any surface, or unpinned, stay out for N days (by user id, or by session id without one). Helps every surface: nobody wants to see a thing twice after saying no. 0 = off |
exclude_consumed_days | integer | 30 | 0 to 3650 | items the user consumed (item_completed, event_attended) stay out for N days; personalized mode. Read from the user's whole history (the nightly user_consumed set plus the last two days of events), so a heavy user's completed items never come back inside the window. 0 means ever, not off: nothing the user ever consumed is served again. Helps for workouts, protocols and articles read once; a surface where people repeat items on purpose sets a short window such as 1 |
paywall_cooldown_days | integer | 7 | 0 to 365 | an item that showed this free user a paywall (paywall_viewed) stays out for N days; applies when context.subscription_tier is free. Helps a free user not hit the same wall twice. 0 = off |
repurchase_days | integer | 30 | 0 to 365 | a product the user bought (from order_placed) is not offered again inside N days; the item's own repurchase_days attribute wins. Helps durable products; give consumables a short attribute instead of lowering this for the whole surface |
hide_unavailable | boolean | true | products whose available (or in_stock) attribute is false are never served. A hard rule, the same in both arms. Turn it off only if your app shows sold-out products on purpose | |
relevance_floor | number | 0 (off) | 0 to 1 | on a request with context.anchor_item_id, only items whose similarity to the anchor (precomputed neighbours, else tag cosine) reaches the floor count toward min_slots. With min_slots: 1 a block then hides when nothing is related to the page, in both arms alike. Helps anchored blocks such as products under an article; leave it at 0 on unanchored feeds, where it does nothing. Pick the value from the trace's similarities, then check the hidden share in Simulate |
diversity is the only diversity setting. Older contract versions had a top-level diversity: {mmr_lambda} object; it was never read, and the engine drops it on save.
Two kinds of rule. Hard rules (an item past expires_at, an event past starts_at, an unavailable product, the request's exclusions, joined communities, kinds, premium, freshness) are decided before the arm and apply in both arms; min_slots counts on them. Personal exclusions (dismissed, completed, paywalled, bought) are preferences: when they would leave the slate short, they are relaxed, and the fallback fill honours them too.
Caps and diversity apply in the deterministic top-k and inside the exploration sampler alike. The request's exclude_item_ids and the anchor item are always removed. So are the communities the user already joined: context.member_of, in every mode, plus community_joined / community_left events in personalized and cohort mode. When no candidate survives, the request answers 422 no_candidates.
ranker#
| Field | Type | Default | Range | Notes |
|---|---|---|---|---|
kind | string | linear | linear, learned_linear, gbdt | lambdamart is refused (400): no model is trained for it |
bandit | boolean | false | a Thompson-sampling mixture: per request, each generator's weight is multiplied by a draw from its nightly posterior for this surface. The drawn weights are logged with the impression, so propensities stay exact. Never in the control arm | |
gap_blend | boolean | true | the return-visit blend: when the flow has session_items next to a long-term generator (content_affinity, user_bayes, user_to_user), a personalized request after a pause fades the session weight (×0.5 after hours, ×0.25 after days, ×0.1 after weeks) and raises the long-term ones (×1.25, ×1.5, ×1.5). The blended weights are logged with the slate. false keeps the configured weights on every visit (a course or a playlist) | |
policy_version | string | empty | informational; the slate's policy_version says which artifact actually ran |
linear: the weighted sum above, with your weights.learned_linear: your weights replaced by the nightly per-generator weights fitted from logged taps (examination and inverse-propensity weighted). Generators without a learned weight keep yours. Until the first model exists it behaves likelinear. It trains on slates in armtreatmentand armnone(never control), from the interactions you report on them; filled positions are never ranking evidence.gbdt: the nightly gradient-boosted trees rescore the candidates from the generators' contributions, item age, premium and kind, the item features (engaged_rate,pullback_rate,activity_rate,purchase_rate, the time-bucket tap rate) and the request's hour, weekend and platform. Older models without the item and context features keep scoring. Until a model exists it behaves likelinear.
Simulate a ranker change first (POST /surfaces/{key}/simulate) and look at the effective sample size.
exploration#
| Field | Type | Default | Range | Notes |
|---|---|---|---|---|
kind | string | none | none, plackett_luce | anything else is a 400 |
share | number | the surface's exploration_share when 0 or absent | 0 to 0.5 | the share of eligible requests that sample instead of taking the top-k |
temperature | number | 0.7 when 0 or absent | above 0 | higher spreads picks further down the ranking |
With plackett_luce, a sampled slate draws positions one by one from a softmax over the scores, with caps and diversity applied inside the draw, and logs the exact probability of every pick. See Exploration and propensities.
Where exploration runs. On treatment slates during a holdout, and on slates outside any holdout (arm none). The control arm is never explored.
reasons#
A map from a reason key or a generator kind to a map from locale to text.
{
"reasons": {
"similar_to_read": { "nl": "Lees ook", "en": "Read next" },
"editors_pick": { "nl": "Uitgekozen voor jou", "en": "Picked for you" }
}
}An entry replaces the default for that key in every locale; give each locale you serve. A reason-key entry wins over a generator-kind entry, which wins over the default. The engine fills {anchor_title}, {tag} and {segment}, and rejects unknown keys, locales and placeholders with 400 invalid_reason_template. The keys and defaults: Reason keys.
Surface fields that interact with the flow#
These live on the surface, not the flow (Admin).
| Field | Default | Notes |
|---|---|---|
slots | 6 | slate length; a request's slots (1 to 50) overrides it |
item_kinds | required | candidates are only items of these kinds; context.candidate_kinds narrows it per request |
fallback | { "kind": "newest" } | the editorial list: newest, curated with item_ids, or popular (7-day trending); newest fills any gap. Served to the control arm and, flagged fallback, as the safety net. Optional by_anchor: an object from an anchor item id to a list of item ids, served to control on that anchor's page. A request's context.control_item_ids comes first, then by_anchor, then this list; the request's rules apply to all of them. A treatment slate short after its filters is filled from the same list, reported as fill_count. |
exploration_share | 0.2 | used when the flow's exploration.share is 0 or absent |
status | shadow | shadow, testers, live, off. shadow: the flow ranks every request, logged with arm none and marked as not shown (your app shows its own list). testers: testers get the flow; everyone else gets the editorial list with arm none. live: everyone gets the flow, in arms when a holdout runs. off answers 409 surface_off. |
min_slots | 0 | 0 to 50; when fewer items than this pass the request's rules (counted before the arm), the slate comes back with shown: false and no items, is logged as not shown and counts in its arm as served with zero success. 0 = always shown; 1 hides a block with nothing in it |
device_resort | false | the app may re-sort the served slate under a holdout; interactions then need displayed_position (422 displayed_position_required) |
A surface without an active flow version serves the editorial list, flagged fallback.
Validation#
POST /surfaces/{key}/flows checks the config and answers 400 bad_request with one of these messages:
| Message | Fix |
|---|---|
config must be an object: … | send a JSON object under config |
config.generators must hold at least one generator | add a generator |
unknown generator kind X | use one of the twelve kinds |
unknown config key X (generators, filters, ranker, exploration, reasons) | remove the key; a top-level diversity from older clients is tolerated and dropped |
ranker.kind lambdamart is not available yet: … | use linear, learned_linear or gbdt |
ranker.kind must be linear, learned_linear or gbdt | fix the kind |
filters.diversity must be between 0 and 1 | |
filters.relevance_floor must be between 0 and 1 | |
filters.region_boost must be between 0 and 1 | |
filters.exclude_dismissed_days must be between 0 and 365 days (also exclude_consumed_days, paywall_cooldown_days, repurchase_days) | |
exploration.kind must be none or plackett_luce | |
exploration.share must be between 0 and 0.5 | |
invalid_reason_template | use known reason keys and placeholders |
Unknown fields inside filters, ranker or exploration are stored but have no effect; an unknown top-level key is refused. Check a new version with the panel's preview or simulate before you activate it.