Building a Strand — Developer Guide
For people first, language models second. Everything a strand can do, with an example of each.
Current as of 2026-08-22. The contract is the TypeScript in src/lib/types.ts; this page explains it.
1. What a strand is
LifeOS is a personal secretary. A strand is one piece of life admin it looks after for you: your car's MOT, your pet's worming, birthdays, a book club's subs, a darts league.
A strand is a definition (what to ask the user, what tasks and events to create, how to show them) that a user installs with their own config (their car's registration, their pets). The engine turns definition + config into dated tasks and events in the user's dashboard, and keeps them fresh.
There are two kinds:
| Declarative strand | Native strand | |
|---|---|---|
| What it is | A JSON document | Shipped code behind a manifest |
| Who writes it | Anyone — the wizard, a JSON file, or an LLM | The platform team |
| Examples | Car Care, Birthdays, Match Organiser | League (Toads, Darts, Pool, Chess), Club (Book Club, Street) |
| Runs in | The strand engine, sandboxed by this contract | Its own routes, tables and server code |
| Installing creates | Tasks and events for the user (or a group) | A context — a league or a club — with a public page, people, billing and email |
This guide is about declarative strands. Native strands are covered at the end (section 9) so you know where the line is.
2. The smallest possible strand
A strand that asks for one date and creates one task.
{
"id": "passport-renewal",
"name": "Passport",
"description": "Never get caught by the six-month validity rule.",
"icon": "🛂",
"thread": "stability",
"version": "1.0.0",
"pattern": "task_generator",
"configSchema": [
{ "id": "expiryDate", "label": "Passport expiry date", "type": "date", "required": true }
],
"taskTemplates": [
{
"id": "renew",
"title": "Renew passport",
"description": "Many countries need six months left on your passport. Renew before this date.",
"priority": "high",
"healthImpact": -20,
"dueDate": { "source": "config.expiryDate", "offset": { "months": -7 } }
}
]
}
That is a complete, installable strand. Everything else in this guide is optional.
3. The top-level fields
| Field | Required | Meaning |
|---|---|---|
id |
yes | Slug, lower-case with hyphens. Official strands live at /strands/<id>; yours at /strands/@yourhandle/<id>. |
name |
yes | Shown in the catalogue. |
description |
yes | One or two sentences. |
icon |
yes | An emoji ("🚗") or a Font Awesome class ("fa-solid fa-car"). |
thread |
yes | Which life thread this feeds: health, social, love, stability, career, growth. |
version |
yes | Semver, "1.0.0". Bump it when you change the definition (see §7). |
pattern |
yes | How the engine treats it — §4. |
targetType |
no | individual (default), group, or both. Group strands install into a group and can assign tasks to members (§6). |
configSchema |
yes | The questions asked at install (§5.1). May be []. |
taskTemplates |
yes | Tasks to generate (§5.2). May be []. |
eventTemplates |
no | Calendar events to generate (§5.3). |
forms |
no | Forms the user can open later to add items or events (§5.4). |
uiLayout |
no | How the strand's dashboard widget and detail page look (§5.5). |
migrations |
no | How to move a user's config between versions (§7). |
fees |
no | Member fees a group strand declares; the group's owner collects them (§6.3). |
native, presets |
— | Native strands only (§9). Leave these out. |
4. Patterns
The pattern tells the engine and the UI what kind of thing this is.
| Pattern | Use it for | Typical shape |
|---|---|---|
task_generator |
Dated chores and deadlines — MOT, boiler service, prescription reorders | configSchema with dates + taskTemplates with dueDate rules and recurrence |
list_with_tasks |
A list of things, each generating tasks — birthdays, appliances, pets | An array config field + taskTemplates using forEach |
habit_tracker |
Something done on a cadence with streaks — workouts, practice | Daily/weekly recurrence; healthImpact rewards completion |
goal_tracker |
A target with progress — savings pot, race training | Config with a target + uiLayout with a progress_ring |
event_organiser |
Dated events people RSVP to — match nights, game nights (group) | forms with create_event; uiLayout with event_list |
Patterns don't unlock different JSON — every strand may use every field below. They set defaults and tell the wizard and widgets how to present things.
5. Every capability, with an example
5.1 Config schema — what you ask the user
Each entry is a field on the install form. Grouped into wizard steps with step.
{
"id": "serviceIntervalMonths",
"label": "Service interval",
"type": "select",
"required": true,
"default": "12",
"helpText": "Check your handbook",
"options": [
{ "value": "6", "label": "Every 6 months" },
{ "value": "12", "label": "Every 12 months" }
]
}
Field types:
type |
Value stored | Notes |
|---|---|---|
text |
string | placeholder supported |
textarea |
string | multi-line |
number |
number | |
date |
ISO date "2027-03-14" |
the workhorse for due-date rules |
datetime |
ISO timestamp | for events |
boolean |
true/false |
checkbox |
select |
one of options[].value |
strings, even for numbers ("12") — the engine parses them when used as offsets |
frequency |
string | a select whose values are cadences; pairs with dueDate.type: "frequency" |
array |
array of objects | repeating items (pets, people, appliances); define the item's fields in itemSchema; minItems optional |
file |
storage URL | accept ("image/*") and bucket ("photos") |
An array field is how list strands work:
{
"id": "pets", "label": "Your pets", "type": "array", "required": true, "minItems": 1,
"itemSchema": [
{ "id": "name", "label": "Name", "type": "text", "required": true },
{ "id": "species", "label": "Species", "type": "select", "required": true,
"options": [{ "value": "dog", "label": "Dog" }, { "value": "cat", "label": "Cat" }] },
{ "id": "lastWormed", "label": "Last wormed", "type": "date", "required": false }
]
}
5.2 Task templates — the tasks you generate
{
"id": "worming",
"title": "Worm {{pet.name}}",
"description": "Every 3 months for {{pet.species}}s.",
"priority": "medium",
"healthImpact": 5,
"recurrence": { "type": "monthly", "interval": 3 },
"forEach": "config.pets",
"as": "pet",
"condition": { "field": "pet.lastWormed", "exists": true },
"dueDate": { "source": "pet.lastWormed", "offset": { "months": 3 }, "fallback": { "type": "relative", "days": 7 } }
}
| Key | Meaning |
|---|---|
id |
Stable per template; the engine uses it to regenerate without duplicating. |
title, description |
May contain {{config.field}} placeholders anywhere, and {{item.field}} inside forEach. An empty optional field leaves its placeholder out and tidies the separator next to it. |
priority |
low · medium · high |
healthImpact |
Integer. Positive = completing it helps the thread's health; negative = missing it hurts. Range ±20 is sensible. |
recurrence |
{ "type": "weekly", "interval": 2 } — when completed, the next occurrence is created. type and interval may be references ("interval": "bin.every"), or give a cadence map: { "source": "person.cadence", "map": { "weekly": { "type": "weekly", "interval": 1 }, "monthly": { "type": "monthly", "interval": 1 } } }. Unresolvable → the task is one-off. |
forEach + as |
Make one task per item of an array config field. forEach: "config.pets", as: "pet" → {{pet.name}} and pet.lastWormed are available. |
condition |
Only generate when true (§5.2.2). |
dueDate |
When it's due (§5.2.1). |
groupAssignment |
Group strands only — who gets it (§6.2). |
5.2.1 Due-date rules
The engine evaluates dueDate at install (and regeneration) time. Four forms:
{ "type": "relative", "days": 30 }
30 days from now. Also "months": 6.
{ "source": "config.motDueDate" }
On the date in that config field. source can point at a config field (config.x) or a forEach item field (pet.x).
{ "source": "config.motDueDate", "offset": { "days": -21 }, "fallback": { "type": "relative", "days": 7 } }
Three weeks before the MOT; if the user didn't give a date, a week from now. Offsets may be negative. An offset can reference config: "offset": { "months": "config.serviceIntervalMonths" }.
{ "type": "frequency", "source": "config.washFrequency",
"map": { "weekly": { "days": 7 }, "fortnightly": { "days": 14 }, "monthly": { "days": 30 } },
"fallback": { "type": "relative", "days": 14 } }
Look up the user's chosen cadence and add that offset.
If nothing matches, the engine falls back to 30 days from now — so always give a fallback for optional dates.
{ "date": "2026-04-05" }
A fixed calendar date. If the template recurs yearly or monthly, a date that has passed rolls forward to its next occurrence.
{ "fn": "everyNWeeksFrom", "args": { "from": "bin.next", "weeks": "bin.every" }, "offset": { "days": -1 } }
A function from the standard library, for the dates the DSL can't say. Args are literals or references. Offsets apply after. The library:
fn |
args |
Returns (always the next matching day on or after today) |
|---|---|---|
annualOn |
month, day |
the next e.g. 31 January |
ukTaxYearEnd |
— | the next 5 April |
nextWeekday |
weekday ("saturday" or 0–6), from? |
first such weekday on or after from (default today) |
nextNthWeekdayOfMonth |
n (1–4, or -1 for last), weekday |
e.g. the first Tuesday — this month if still to come, else next month |
everyNWeeksFrom |
from (date), weeks |
the next date in the series from, from+N weeks, … |
All dates are the user's local calendar days: "2026-09-12" is the 12th wherever they are, and a task is due at the end of its day.
Event dates (§5.3) accept fn/args too.
5.2.2 Conditions
{ "field": "config.washFrequency", "notEquals": "never" }
{ "field": "pet.lastWormed", "exists": true }
{ "field": "config.hasGarden", "equals": true }
One of exists, equals, notEquals. exists: true means non-empty. Values compare exactly ("12" is not 12).
5.3 Event templates — calendar entries
Where a task is a to-do, an event is a moment: a birthday, an anniversary, an appointment. Events can carry reminder tasks that appear N days before.
{
"id": "pet-birthday",
"title": "{{pet.name}}'s birthday",
"eventType": "birthday",
"allDay": true,
"forEach": "config.pets",
"as": "pet",
"date": { "source": "pet.birthday", "recurrence": "yearly" },
"reminderTasks": [
{ "title": "Get {{pet.name}} a treat", "priority": "low", "daysBefore": 3 }
]
}
| Key | Meaning |
|---|---|
eventType |
birthday · anniversary · appointment · reminder · other |
date |
{ "source": "config.x" }, { "date": "2026-12-25" } fixed, or { "fn": "ukTaxYearEnd" } (§5.2.1), plus recurrence: "yearly" | "monthly" | "once" and optional offset. A recurring date is rolled forward to its next occurrence at install — a birthday entered as 1980-03-14 becomes the coming 14 March, and reminders count back from that. |
allDay, duration, busy, location |
calendar details (duration: { "hours": 1, "minutes": 30 }) |
reminderTasks[] |
each becomes a task daysBefore the event |
forEach/as, condition, healthImpact |
as for tasks |
5.4 Forms — letting the user add things later
Forms open from the strand's page (via uiLayout buttons) and write back into the config or create events/tasks.
{
"id": "add-appliance",
"title": "Add an appliance",
"mode": "create",
"entityType": "appliance",
"fields": [
{ "id": "name", "label": "What is it?", "type": "text", "required": true },
{ "id": "warrantyEnds", "label": "Warranty ends", "type": "date", "required": true }
],
"submitLabel": "Add",
"onSubmit": { "type": "append_to_config", "target": "appliances", "regenerateTasks": true }
}
onSubmit.type:
| Type | Effect |
|---|---|
append_to_config |
Push the submitted object onto the array config field named by target; regenerateTasks: true re-runs the task templates so the new item gets its tasks. |
update_config_item |
Edit one item in that array (mode: "edit"). |
create_event |
Create a calendar event from the fields (title, date, location…); inviteGroupMembers: true invites the group. |
create_task |
Create a one-off task. |
5.5 UI layout — how it looks
Optional. Without it the strand gets a sensible default (health ring + next tasks). With it you compose widgets for the dashboard card and the detail page from these components:
type |
What it shows |
|---|---|
card |
container with optional title and children |
tabs |
{ "tabs": [{ "label", "content" }] } |
stat |
label + value (may use {{config.field}}), format: "date" | "number" | "text" |
stats_grid |
several stats in columns |
progress_ring |
value/max (both may reference config) — goal trackers |
task_list |
filter: "strand" | "all" | "overdue" |
next_tasks |
limit, showDueDate, emptyMessage |
strand_health |
showRing, showStats, stats: ["pending","completed","overdue"] |
entity_list |
rows from an array config field: source: "config.pets", display: { title: "{{item.name}}", subtitle }, row actions (open_form, delete), emptyState |
text |
content, variant: "heading" | "body" | "caption" |
action_button |
label, icon, variant, action |
event_list, next_events |
event organisers: filter: "upcoming" | "past", RSVP controls |
Actions: { "type": "open_form", "formId": "add-appliance" } or { "type": "rsvp", "eventId": "…", "status": "going" }.
"uiLayout": {
"dashboard": { "type": "card", "children": [
{ "type": "strand_health", "showRing": true },
{ "type": "next_tasks", "limit": 3, "showDueDate": true }
]},
"detail": { "type": "tabs", "tabs": [
{ "label": "Appliances", "content": { "type": "entity_list", "source": "config.appliances", "entityType": "appliance",
"display": { "title": "{{item.name}}", "subtitle": "Warranty ends {{item.warrantyEnds}}" },
"actions": [{ "type": "open_form", "formId": "add-appliance", "label": "Edit" }, { "type": "delete", "label": "Remove" }],
"emptyState": { "message": "No appliances yet", "actionLabel": "Add one", "action": { "type": "open_form", "formId": "add-appliance" } } } },
{ "label": "Tasks", "content": { "type": "task_list", "filter": "strand" } }
]}
}
6. Group strands
Set "targetType": "group" (or "both"). A group strand installs into a group instead of a person; its tasks can be shared out; it may declare fees. (The group UI is currently behind the social feature flag while the platform focuses on leagues and clubs — the engine and contract are live.)
6.1 Config is shared
One config for the group (the rota, the kitty). Installing asks the installer; admins can edit.
6.2 Assigning tasks to members
"groupAssignment": { "mode": "rotate", "rotationKey": "bins" }
mode |
Who gets the task |
|---|---|
everyone |
one task per member |
rotate |
next member in turn; rotationKey groups templates that share a rota (state lives in config under __rotation) |
fixed |
a role: fixedRole: "owner" | "admin" | "any" |
select |
chosen when the task is created |
6.3 Fees — charging members
A group strand may say "this group has a sub". On install the platform creates the matching fee rows for that group; the group's owner is the tenant — they connect their own Stripe and the money goes to them, with the platform taking its fixed percentage. The strand author never handles money.
"fees": [
{ "id": "kitty", "label": "Monthly kitty", "amount_pence": 300, "interval": "month" },
{ "id": "trip", "label": "Trip deposit", "amount_pence": 5000, "interval": "one_off" }
]
interval: one_off · month · year. Members see "Pay" in the group; managers see who's paid in People and can mark cash payments. See TOADS-LEAGUE-SPEC.md → platform billing.
7. Versions and migrations
Users install a version. When you publish a new one, they can upgrade; if the config shape changed, give a migration so their data moves across.
"version": "2.0.0",
"migrations": [
{
"fromVersion": "1.0.0",
"description": "Pets became a list",
"operations": [
{ "type": "wrapInArray", "fields": ["petName", "petSpecies"], "targetField": "pets", "mapping": { "petName": "name", "petSpecies": "species" } },
{ "type": "setDefault", "field": "reminderDays", "value": 7 }
]
}
]
Operations: renameField (from,to) · copyField · wrapInArray · setDefault · removeFields (fields) · transformValue (transform: "lowercase" | "uppercase" | "trim").
Rule of thumb: patch for copy and fixes, minor for new optional fields, major when a migration is needed.
8. Publishing and review
- Wizard: Strands → Create Custom Strand walks you through config and templates.
- JSON: official strands are plain JSON (see
static/strands/*.json); yours can be too. - By an LLM (planned — MVP Phase 2): the MCP server exposes
get_strand_schema(this contract as JSON Schema) anddraft_strand(submit a definition → validated → saved as a draft for you to review in the wizard).
What validation rejects: unknown top-level keys; missing id/name/thread/version; a thread not in the six; duplicate template or field ids; forEach without as; a source that isn't config.<field> or <as>.<field>; select options without value; a condition with none of exists/equals/notEquals. Fix the message it gives you and resubmit.
Sharing and selling (gated): strands are private to their author until the review/provenance flow ships. The schema already supports a price on a strand paid to its author through their own Stripe account; the catalogue won't show author prices until review, provenance labels and takedown exist.
9. Native strands — where the line is
Some things can't be JSON: a league needs fixtures, two-captain score corroboration, live scores, public tables; a club needs a roster of people who never sign up, subs, invitations. Those are native strands: code modules behind a manifest (src/lib/strands/native/).
The manifest is the contract that keeps them strands rather than separate apps:
interface NativeStrandManifest {
module: 'league' | 'club';
displayName; icon; thread; description;
presets: NativePreset[]; // "Toads League", "Book Club" — the catalogue entries
installPath(preset): string; // the install form; creates the context
routes: { public(ctx): string; app(ctx): string };
capabilities: { tenantFees; people; invitations; rsvp; publicPage };
dashboardCards(userId): Promise<DashboardCard[]>;
}
Everything a native strand does with people, money and email goes through the same platform primitives a declarative group strand uses: context_people (roster), fees/fee_payments (billing), invitations, messaging, the People surface, RSVPs. That's deliberate — it's what lets "a club" and "a league" look the same in the catalogue and on the dashboard.
To add a native strand: write the manifest, register it in src/lib/strands/native/index.ts, seed it (it appears automatically), build its routes. New native strands are platform releases, not user uploads.
10. Contract summary (for LLMs and quick reference)
One skeleton with every key. Optional keys are marked ?. Delete what you don't use.
{
"id": "kebab-slug", // required
"name": "Display name", // required
"description": "One or two sentences.", // required
"icon": "🧩", // required: emoji or "fa-solid fa-…"
"thread": "stability", // required: health|social|love|stability|career|growth
"version": "1.0.0", // required: semver
"pattern": "task_generator", // required: task_generator|list_with_tasks|habit_tracker|goal_tracker|event_organiser
"targetType?": "individual", // individual|group|both
"configSchema": [ // required (may be [])
{ "id": "f", "label": "…", "type": "date", "required": true,
"placeholder?": "…", "helpText?": "…", "default?": "…", "step?": "Basics",
"options?": [{ "value": "a", "label": "A" }], // select / frequency
"minItems?": 1, "itemSchema?": [ /* fields */ ], // array
"accept?": "image/*", "bucket?": "photos" } // file
],
"taskTemplates": [ // required (may be [])
{ "id": "t", "title": "… {{item.field}}", "description?": "…",
"priority": "medium", "healthImpact": 5,
"recurrence?": { "type": "monthly", "interval": 1 }, // type/interval may be refs ("item.every"),
// or a cadence map: { "source": "item.cadence", "map": { "weekly": { "type": "weekly", "interval": 1 } } }
"forEach?": "config.items", "as?": "item",
"condition?": { "field": "config.x", "exists": true }, // or equals / notEquals
"dueDate": { "source?": "config.date", "date?": "2027-04-05",
"fn?": "annualOn|ukTaxYearEnd|nextWeekday|nextNthWeekdayOfMonth|everyNWeeksFrom", "args?": { "month": 4, "day": 5 },
"offset?": { "days": -7, "months": "config.n" },
"type?": "relative|frequency", "days?": 30, "months?": 1,
"map?": { "weekly": { "days": 7 } }, "fallback?": { "type": "relative", "days": 7 } },
"groupAssignment?": { "mode": "rotate", "rotationKey?": "k", "fixedRole?": "owner" } }
],
"eventTemplates?": [
{ "id": "e", "title": "…", "eventType?": "birthday", "allDay?": true,
"date": { "source?": "config.date", "date?": "2026-12-25", "fn?": "ukTaxYearEnd", "args?": {}, "recurrence?": "yearly", "offset?": { "days": 0 } },
"duration?": { "hours": 1 }, "busy?": true, "location?": "…", "healthImpact?": 0,
"forEach?": "config.items", "as?": "item", "condition?": { "field": "…", "exists": true },
"reminderTasks?": [{ "title": "…", "priority": "low", "daysBefore": 3 }] }
],
"forms?": [
{ "id": "form", "title": "…", "mode": "create", "entityType?": "thing", "fields": [ /* config fields */ ],
"submitLabel?": "Add",
"onSubmit": { "type": "append_to_config", "target?": "items", "regenerateTasks?": true, "inviteGroupMembers?": false } }
// type: append_to_config | update_config_item | create_event | create_task
],
"uiLayout?": { "dashboard?": { "type": "card", "children": [] }, "detail?": { "type": "tabs", "tabs": [] } },
// components: card, tabs, stat, stats_grid, progress_ring, task_list, next_tasks, strand_health,
// entity_list, text, action_button, event_list, next_events
"migrations?": [{ "fromVersion": "1.0.0", "description": "…",
"operations": [{ "type": "renameField", "from": "a", "to": "b" }] }],
// types: renameField, copyField, wrapInArray, setDefault, removeFields, transformValue
"fees?": [{ "id?": "kitty", "label": "…", "amount_pence": 300, "interval": "month" }] // group strands only
}
Paths: config.<fieldId> for config; <as>.<fieldId> inside forEach. Placeholders in text: {{config.<fieldId>}} anywhere, {{<as>.<fieldId>}} inside forEach.
Dates are ISO (YYYY-MM-DD); money is integer pence; intervals are one_off|month|year; recurrence types are daily|weekly|monthly|yearly.
11. Worked example — a complete list strand with events, a form and a layout
{
"id": "appliance-warranties",
"name": "Appliance Warranties",
"description": "Register what you own; get warned before each warranty ends and nudged to decide repair-or-replace.",
"icon": "🔌",
"thread": "stability",
"version": "1.0.0",
"pattern": "list_with_tasks",
"configSchema": [
{ "id": "appliances", "label": "Appliances", "type": "array", "required": false, "step": "Your appliances",
"itemSchema": [
{ "id": "name", "label": "Appliance", "type": "text", "required": true, "placeholder": "Washing machine" },
{ "id": "bought", "label": "Bought on", "type": "date", "required": true },
{ "id": "warrantyEnds", "label": "Warranty ends", "type": "date", "required": true },
{ "id": "serviceEvery", "label": "Service every", "type": "select", "required": false, "default": "never",
"options": [{ "value": "never", "label": "No servicing" }, { "value": "12", "label": "12 months" }, { "value": "24", "label": "24 months" }] }
] }
],
"taskTemplates": [
{ "id": "warranty-warning", "title": "{{appliance.name}}: warranty ends soon", "priority": "medium", "healthImpact": -8,
"forEach": "config.appliances", "as": "appliance",
"dueDate": { "source": "appliance.warrantyEnds", "offset": { "days": -30 } } },
{ "id": "service", "title": "Service the {{appliance.name}}", "priority": "low", "healthImpact": 4,
"forEach": "config.appliances", "as": "appliance",
"condition": { "field": "appliance.serviceEvery", "notEquals": "never" },
"recurrence": { "type": "monthly", "interval": 12 },
"dueDate": { "source": "appliance.bought", "offset": { "months": "appliance.serviceEvery" } } }
],
"eventTemplates": [
{ "id": "warranty-end", "title": "{{appliance.name}} warranty ends", "eventType": "reminder", "allDay": true,
"forEach": "config.appliances", "as": "appliance", "date": { "source": "appliance.warrantyEnds", "recurrence": "once" } }
],
"forms": [
{ "id": "add-appliance", "title": "Add an appliance", "mode": "create", "entityType": "appliance",
"fields": [
{ "id": "name", "label": "Appliance", "type": "text", "required": true },
{ "id": "bought", "label": "Bought on", "type": "date", "required": true },
{ "id": "warrantyEnds", "label": "Warranty ends", "type": "date", "required": true }
],
"submitLabel": "Add", "onSubmit": { "type": "append_to_config", "target": "appliances", "regenerateTasks": true } }
],
"uiLayout": {
"dashboard": { "type": "card", "children": [
{ "type": "strand_health", "showRing": true, "stats": ["pending", "overdue"] },
{ "type": "next_tasks", "limit": 3, "showDueDate": true, "emptyMessage": "Nothing due" },
{ "type": "action_button", "label": "Add appliance", "variant": "secondary", "action": { "type": "open_form", "formId": "add-appliance" } }
]},
"detail": { "type": "tabs", "tabs": [
{ "label": "Appliances", "content": { "type": "entity_list", "source": "config.appliances", "entityType": "appliance",
"display": { "title": "{{item.name}}", "subtitle": "Warranty ends {{item.warrantyEnds}}" },
"actions": [{ "type": "delete", "label": "Remove" }],
"emptyState": { "message": "Nothing registered yet", "actionLabel": "Add one", "action": { "type": "open_form", "formId": "add-appliance" } } } },
{ "label": "Tasks", "content": { "type": "task_list", "filter": "strand" } }
]}
}
}
12. Checklist before you publish
The platform validates every definition on the way into the catalogue (validateStrandDefinition in src/lib/strands/validate.ts) — seed, publish and the catalogue lint test all refuse a definition with errors, and they tell you exactly which field is wrong. Run npx vitest run src/lib/strands locally to see the same messages.
- Every
idis unique within its list and never changes between versions. - Every optional date has a
fallback. -
selectvalues are strings; numeric offsets that reference them still work (the engine parses). -
forEachtemplates use{{as.field}}andas.fieldpaths, notconfig.…. -
healthImpactis modest and signed sensibly. - You've installed it yourself and looked at the tasks it made on a dashboard.
- If the config shape changed since the last version, there's a
migrationand a major bump.