# 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`](../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.

```json
{
  "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`.

```json
{
  "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:

```json
{
  "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

```json
{
  "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:

```json
{ "type": "relative", "days": 30 }
```
*30 days from now.* Also `"months": 6`.

```json
{ "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`).

```json
{ "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" }`.

```json
{ "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.

```json
{ "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.

```json
{ "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

```json
{ "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.

```json
{
  "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.

```json
{
  "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 `stat`s 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" }`.

```json
"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

```json
"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.

```json
"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](TOADS-LEAGUE-SPEC.md).

---

## 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.

```json
"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) and `draft_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 `id`s; `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/`](../src/lib/strands/native/)).

The manifest is the contract that keeps them strands rather than separate apps:

```ts
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.

```jsonc
{
  "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

```json
{
  "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 `id` is unique within its list and never changes between versions.
- [ ] Every optional date has a `fallback`.
- [ ] `select` values are strings; numeric offsets that reference them still work (the engine parses).
- [ ] `forEach` templates use `{{as.field}}` and `as.field` paths, not `config.…`.
- [ ] `healthImpact` is 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 `migration` and a major bump.
