# How-to: automate with workflows

**Goal:** make the platform react on its own — *when this happens, run these
steps* — without polling from your app.

Assumes you've done the [Quickstart](/docs/quickstart.html) and have `$LYDIAN`,
`$TENANT`, and `$KEY` exported. Workflows are admin-plane: they need the API
key (an end user's bearer token cannot read or write them).

---

## 1. What a workflow is

A **workflow definition** is tenant data: a **trigger** plus an ordered list
of **steps** from a fixed vocabulary. Each time the trigger fires, the
platform creates a **run**, executes the steps in order on its job runner,
and records what happened. There is no scripting language — the steps are
platform operations you would otherwise call yourself, with `{{path}}`
templates that read from the run's context.

```
trigger fires ──► run (QUEUED) ──► step 1 ──► step 2 ──► … ──► SUCCEEDED
                                       │ throws
                                       └── retried (3 attempts, backoff) ──► FAILED
```

## 2. Create one

The canonical example: when someone is added to an event, tell the office.

```bash
OFFICE=…    # a person id in your tenant

curl -s -X POST "$LYDIAN/tenants/$TENANT/workflow-definitions" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{
        "name": "Office heads-up",
        "description": "Notifies the office whenever someone joins an event",
        "trigger": {"type": "DOMAIN_EVENT", "eventType": "event.participant_added"},
        "steps": [
          {"kind": "send_notification",
           "config": {"recipientPersonIds": ["'"$OFFICE"'"],
                      "subject": "{{eventName}}: new participant",
                      "body": "Person {{participantPersonId}} was added."}}
        ]
      }' | jq
```

Definitions are **validated at write time**: an unknown step kind, a missing
required config key, a bad cron expression, or a reference to an attribute
that does not exist is `400 VALIDATION_FAILED` on create, not a failed run
later. Add a person to any event now and the office person's inbox gets the
notification within a couple of seconds.

## 3. Triggers

| `trigger.type` | Fires when | Extra fields |
|---|---|---|
| `DOMAIN_EVENT` | a platform event happens | `eventType` (below); optional `filter` — an equality match on payload fields, e.g. `{"tag": "billing"}` |
| `SCHEDULE` | a cron expression is due (UTC, five fields) | `cron`, e.g. `"0 8 * * MON"` |
| `MANUAL` | you call `POST …/workflow-definitions/{id}/run` | the optional JSON body becomes the context |
| `WEBHOOK` | anyone posts to the definition's hook URL | none — the URL carries a minted token |

Any definition can be run manually regardless of its trigger type. A
`WEBHOOK` definition's response includes `webhookToken`; the anonymous
route is `POST /hooks/workflows/{definitionId}?token=lydw_…` (no
`/tenants` prefix, no API key) and its JSON body becomes the run context —
this is how another system starts a workflow.

The domain events and the fields each puts in the context:

| `eventType` | Context fields |
|---|---|
| `invoice.issued` | `invoiceId`, `invoiceNumber`, `recipientPersonId` |
| `document.checked_in` | `documentId`, `title`, `versionNumber`, `checkedInBy`, `notifyPersonIds` |
| `event.participant_added` | `eventId`, `eventName`, `participantPersonId`, `seriesId`, `occurrenceAt` (null outside a [series](/docs/recurring-events.html)) |
| `event_series.participant_added` | `seriesId`, `seriesName`, `participantPersonId`, `firstOccurrenceAt`, `ruleText` — once per person per series, never per occurrence |
| `event.occurrence_created` | `eventId`, `eventName`, `seriesId`, `occurrenceAt`, `startAt`, `participantPersonIds` — once per occurrence a series creates; no notification |
| `email.ingested` | `documentId`, `subject`, `tag`, `senderPersonId`, `matchedPersonIds` |
| `meeting.captured` | `eventId`, `eventName`, `transcriptDocumentId`, `participantPersonIds`, `durationMinutes`, `calendarLinkId`, `seriesId`, `occurrenceAt` |
| `usage.threshold` | `meter`, `allowance`, `used`, `fraction` (`0.8` or `1.0`) — once per meter per month when a tenant crosses 80 % / 100 % of an allowance |

## 4. Steps

Every step has a `kind`, a `config`, and an optional `skipUnless`. Each step's
result joins the context under a key (`resultKey`, with a default per kind)
so later steps can reference it as `{{key}}`.

| `kind` | Does | Key config | Default `resultKey` |
|---|---|---|---|
| `create_event` | creates an event | `name`, `startAt`, `endAt`, `eventTypeId`, `participantPersonIds`, `attributeValues` | `eventId` |
| `create_document` | creates a markdown document as the acting person | `title`, `content`, `documentTypeId`, `attributeValues` | `documentId` |
| `update_record` | merges fields onto a person, event, or document | `recordKind`, `recordId`, `baseFields`, `attributeValues` | — |
| `link` | sets a reference attribute on a record | `recordKind`, `recordId`, `attributeId`, `value` | — |
| `send_notification` | system notification to people | `recipientPersonIds`, `subject`, `body` | `notificationId` |
| `send_message` | a message *from* the acting person | `recipientPersonIds`, `subject`, `body` | `messageId` |
| `create_invoice` | a DRAFT invoice (issuing stays a separate decision) | `recipientPersonId`, `invoiceTypeId?`, `issueDate?` (default today), `dueDate?` or `dueInDays?`, `currency?`, `periodStart?`, `periodEnd?`, `memo?` | `invoiceId` |
| `add_invoice_lines_from_events` | the from-events populate on a draft invoice | `invoiceId`, `attributeId`, `quantityAttributeId?`, `billedByAttributeId?`, `from?`, `to?`, `personId?`, `eventIds?`, `where?` (`{attributeId, value}`) | — |
| `ai_task` | runs an [AI task](/docs/use-ai-tasks.html) inline and merges its result | `kind` plus that kind's input | the task's result keys |

Templates resolve dot paths against the context — `{{eventName}}`,
`{{aiResult.summary}}` — and a value that is entirely one template keeps its
type (so `"participantPersonIds": "{{participantPersonIds}}"` passes the
array through). `skipUnless` is an equality condition on the context; when it
does not hold the step is skipped, not failed:

```json
{"kind": "send_message", "skipUnless": {"tag": "urgent"}, "config": {…}}
```

**The acting person.** Steps that write documents or send messages act *as*
someone. Set `actingPersonId` on the definition; validation tells you when a
step needs it. That person is delete-blocked while named.

## 5. Watch the runs

```bash
DEF=…   # definition id
curl -s -H "X-API-Key: $KEY" \
  "$LYDIAN/tenants/$TENANT/workflow-definitions/$DEF/runs?status=FAILED" | jq
```

A run carries `status` (`QUEUED`, `RUNNING`, `SUCCEEDED`, `FAILED`), its
`triggerPayload` and final `context`, a per-step list with each step's status
and error, and `createdIds` — the records the run created, keyed by step.
A failing step is retried up to three times with backoff, and a retried run
**reuses the records it already created** rather than duplicating them; only
after the last attempt is the run `FAILED`, with the error on the run.

## 6. Change and remove

`PUT …/workflow-definitions/{id}` replaces the definition; runs already
queued keep the version they were created with. Set `"enabled": false` to
pause a trigger without deleting anything.

Runs are history and **block deleting their definition**:
`DELETE …/workflow-definitions/{id}/runs` purges the finished ones, then the
definition can go. Definitions block tenant deletion in the same way.

## 7. The pattern that pays off

Anything downstream of a platform event that you would otherwise implement
as "poll, notice, react" in your app is a candidate. The built-in example is
the meeting pipeline in [Connect email, calendar, and meetings](/docs/connect-email-calendar-meetings.html):
the platform delivers a transcript and fires `meeting.captured`; a four-step
workflow — `ai_task` summarize, `link` the notes to the meeting,
`update_record` the billable hours, `send_notification` the participants —
is tenant configuration, not platform code.
