# Reflex

> Flows that run on their own: triggers, a catalogue of steps, a human approval before anything leaves the building, and an API to build, start and follow them.

Synapse routes, Cortex knows, **Reflex acts**. A flow is a small graph of steps that runs without anyone asking: a night ingestion, a 7 am sweep of deadlines, a review every Friday, three follow-up emails prepared for you to approve. Each run is recorded step by step, with what it read, what it concluded and what it did.

Three rules are enforced by the engine rather than suggested to the model:

- **Nothing leaves without a human.** A step that sends something out of the building needs an approval in front of it.
- **A missed slot fires once.** A machine that was off for three days runs the flow once when it wakes up, not once per period skipped.
- **Silence is a result.** A run whose steps found nothing worth saying notifies nobody.

> **INFO: Where the scheduler runs**
> The scheduler runs in any long-lived Subsidia process: an on-premise installation and the hosted cloud (our own server) alike. Only a serverless deployment, frozen between requests, relies on an external call to `POST /internal/cron/reflex`. `GET /engine/reflex/status` returns `enabled: false` when the scheduler is not running in the instance you are talking to (for example `REFLEX=off`).

## Triggers

| Trigger node | Starts the flow when | Notes |
| --- | --- | --- |
| `schedule` | The time expression is due. | Grammar below. At least 5 minutes between runs. |
| `manual` | Someone presses Run, or `POST /engine/flows/:id/run` is called. | Never fires by itself. |
| `webhook` | An outside system posts to the flow URL. | Needs a shared secret. See below. |
| `folder.changed` | A document arrives or changes in a watched folder. | The folder is configured in the Cortex synchronisation settings. |
| `mail.received` | A message arrives in a connected mailbox. | Optional filter on the sender address or domain. |

**Schedule grammar.** No cron strings: a flow nobody can read at a glance is one nobody dares change.

| Expression | Meaning |
|---|---|
| `daily@08:30` | Every day at 08:30 in the flow's time zone (default `Europe/Paris`). |
| `weekly@fri@17:00` | Every Friday at 17:00. Days: `mon tue wed thu fri sat sun` (French abbreviations also accepted). |
| `every@30m`, `every@2h` | Every N minutes or hours. Minimum 5 minutes. |
| `once@2026-11-03T09:00` | One time, then the trigger switches itself off. |

A schedule has **active hours** (`windowHours`, default `7,21`): outside them the run waits instead of firing. And a **misfire policy**: `fire-once` (default) catches up once, `skip` forgets what was missed.

### Catch-up and idempotency

Every scheduled run carries an idempotency key made of the flow and the slot it was scheduled for, and the key is unique. A Subsidia installation that wakes up after three days offline computes the slots it missed and the uniqueness collapses them into **one** run instead of 144. A one-shot (`once@...`) that was missed is still fired when the machine is back, because losing it is worse than running it late.

The flow's `concurrency` decides what happens when a trigger fires while a run is in progress: `skip` (default) drops the new one, `queue` waits for the previous, `parallel` runs both. Runs beyond the global ceiling wait in line; they are never dropped.

## Steps (node catalogue)

Each step has a **key** (stable) and a **type**. A step's effect class decides how the engine treats it: `read` has no side effect, `local-write` stays on the machine, `outbound` leaves the building. The catalogue is data: `GET /engine/node-types` returns the full list with each step's configuration fields, which is what the editor itself uses.

| Type | Category | Effect | What it does |
| --- | --- | --- | --- |
| `schedule` | trigger | read | Starts on a time expression. |
| `manual` | trigger | read | Starts when someone presses Run. |
| `webhook` | trigger | read | Starts when an outside system posts to the flow URL. |
| `folder.changed` | trigger | read | Starts when a document lands in a watched folder. |
| `mail.received` | trigger | read | Starts when mail arrives in a connected mailbox. |
| `agent.run` | agent | read | One full agent turn: its documents, memory and (optionally) tools. A silence word (default `RAS`) means nothing to report. |
| `agent.debate` | agent | read | The workspace panel argues a question and converges on an answer. |
| `cortex.search` | cortex | read | Passages that answer a question, with their sources. |
| `cortex.read` | cortex | read | The text of one document, by id. |
| `cortex.inventory` | cortex | read | The pieces a dossier contains, as a plain list with no model. It is what lets a flow say what is missing. |
| `cortex.ingest` | cortex | local-write | Files a text from a previous step (or the trigger document) in a Cortex dossier. Same content means same piece: nothing is duplicated. |
| `cortex.deadlines` | cortex | read | Reads the date columns of your registers (CSV, XLSX) and returns the approaching deadlines, exact rows, no model. |
| `light.ask` | cortex | read | Asks [Light](https://dev.subsidia.protypa.fr/docs/light.md): answers strictly from the documents with citations, or an honest refusal. |
| `discovery.night` | cortex | read | Re-reads dossiers under watch overnight and prepares the morning questions: missing pieces, overdue deadlines, contradictions. |
| `gate` | logic | read | Stops the branch when the previous step had nothing worth saying (modes `actionable`, `covered`, `uncovered`, `always`). |
| `branch` | logic | read | Sends the flow one way or the other; the conditions are on the links. |
| `delay` | logic | read | Waits 1 to 240 minutes. |
| `notify` | effect | local-write | Tells you, through a configured channel or the in-app inbox. |
| `file.write` | effect | local-write | Saves a result to a folder on the machine. |
| `mail.draft` | effect | local-write | Writes an email and stops. Nothing is sent. |
| `http.call` | effect | **outbound** | Calls an external URL. A GET reads, anything else writes. |
| `connection.call` | effect | **outbound** | Calls a service registered under Connections, with its stored credentials. |
| `mail.send` | effect | **outbound** | Sends an email. |
| `mail.reply` | effect | **outbound** | Replies to the sender of the mail that triggered the flow. Self-approving: the run always pauses on the exact message before anything is sent. |
| `ask.human` | human | read | Asks you a question with context. Your answer is filed in the dossier, then the run resumes. |
| `approval` | human | read | Parks the run in your inbox until you approve or reject. |

**Passing a result to the next step.** Any text field of a step can reference an earlier step by its key: `{{http_call.output.status}}`, `{{agent_run.summary}}`, `{{trigger.output}}`, plus `{{date}}`, `{{time}}`, `{{flow}}`. The reference uses the step key, which never changes when the label does. An unknown reference is left in the text as written, so a mistake is visible instead of silently blank.

Every step returns `output`, a one-line `summary` and `actionable`. The last one is the silence rule made structural: a `gate` stops the branch when it is false, and a run whose final steps are all non-actionable notifies nobody (for flows set to `only-if-actionable`).

## The approval rule for outbound steps

Anything that stays on the machine runs unattended. Anything that leaves (`mail.send`, `http.call`, `connection.call`, `mail.reply`) is prepared, proposed and waits for a person.

The graph validator enforces it: an outbound step with no `approval` step directly feeding it is an `unguarded-outbound` error, and **a flow with a blocking error cannot be switched on or run**. Two things relax it, and only these two:

- the step is flagged `requiresApproval` in its own configuration, which pauses the run on it;
- the flow has `allowUnattendedOutbound: true`, an explicit waiver that is off by default and written to the access journal every time it changes.

`mail.reply` is self-approving: it always pauses with the exact message, even with no approval step in the graph and even with the waiver on.

> **DANGER: An API key cannot approve its own work**
> Deciding an approval (`POST /engine/approvals/:id/decide`), turning on `allowUnattendedOutbound`, and managing notification channels and connection credentials all require a **signed-in person**. An API key can build, start and follow flows, but calls to those routes are refused (for the waiver, `403 session_required`). Otherwise the rule that a human decides before anything leaves would be a formality.

## Authentication and scope

Reflex lives under `/engine/*`. Developer API keys reach it with the **`reflex`** scope (keys created before scopes existed also work). Send `Authorization: Bearer sk_live_...` or `x-api-key: sk_live_...`. Every flow, run and approval is scoped to the workspace of the key. A read-only key can list and read, not create, change or run.

## Endpoints

### GET /engine/reflex/status

Is the scheduler running, and how many flows are there.

- **Authentication:** API key or session
- **Scopes:** `reflex`

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/engine/reflex/status" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/reflex/status', {
  method: 'GET',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr" + "/engine/reflex/status",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: `enabled` is false on a cloud instance or when Reflex has been halted. The scheduler snapshot fields are added alongside.

```json
{
  "enabled": true,
  "flows": 6,
  "active": 4,
  "pendingApprovals": 2
}
```

### GET /engine/node-types

The step catalogue, with each step configuration fields.

The same definitions the validator, the executor and the editor read. Use it to build a valid `config` for a step, or to render your own form. `minIntervalMinutes` is the floor of recurring schedules.

- **Authentication:** API key or session
- **Scopes:** `reflex`

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/engine/node-types" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/node-types', {
  method: 'GET',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr" + "/engine/node-types",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: The catalogue.

```json
{
  "nodeTypes": [
    {
      "type": "schedule",
      "category": "trigger",
      "effect": "read",
      "label": "On a schedule",
      "isTrigger": true,
      "fields": [{ "key": "expr", "type": "schedule", "required": true, "default": "daily@08:30" }]
    }
  ],
  "minIntervalMinutes": 5
}
```

### GET /engine/flows

List the flows of the workspace.

- **Authentication:** API key or session
- **Scopes:** `reflex`

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/engine/flows" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/flows', {
  method: 'GET',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr" + "/engine/flows",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: Most recently updated first. `schedule` is a readable label of the schedule trigger.

```json
{
  "flows": [
    {
      "id": "flw_7a11",
      "name": "Echeances de la semaine",
      "description": "",
      "enabled": true,
      "nodeCount": 4,
      "schedule": "Tous les jours a 08:30",
      "nextRunAt": "2026-10-10T06:30:00.000Z",
      "lastRun": { "id": "run_5d20", "status": "succeeded", "createdAt": "2026-10-09T06:30:01.000Z", "actionable": true },
      "updatedAt": "2026-10-08T17:02:11.000Z"
    }
  ]
}
```

### POST /engine/flows

Create a flow.

Creates a flow with two starter steps (a daily 08:30 schedule feeding a `notify`), disabled. Replace the graph with `PUT /engine/flows/:id/graph`, then enable it.

- **Authentication:** API key or session
- **Scopes:** `reflex`

#### Request body

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `name` | `string` | yes |  | Display name. |
| `description` | `string` | no |  | Free text. |
| `timezone` | `string` | no | `Europe/Paris` | IANA time zone used to read schedule expressions. |

#### Request examples

_curl_

```bash
curl -X POST "https://api.subsidia.protypa.fr/engine/flows" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY" \n  -H "Content-Type: application/json" \n  -d '{
  "name": "Echeances de la semaine",
  "timezone": "Europe/Paris"
}'
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/flows', {
  method: 'POST',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    "name": "Echeances de la semaine",
    "timezone": "Europe/Paris"
  }),
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.post(
    "https://api.subsidia.protypa.fr" + "/engine/flows",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
    json={
      "name": "Echeances de la semaine",
      "timezone": "Europe/Paris"
    },
)
print(res.status_code, res.json())
```

#### Responses

**201**: The flow in its full shape: `id`, `name`, `enabled`, `version`, `timezone`, `concurrency`, `notifyPolicy`, `allowUnattendedOutbound`, `dailyTokenBudget`, `nodes`, `edges`, `triggers`.

**400**: No name.

```json
{ "error": "A flow needs a name" }
```

### GET /engine/flow-templates

Ready-made flows you can start from.

Each template has an `id`, a name, what it is for (`why`), what it `needs` configured and its `stepCount`. Current ids include `deadlines-watch`, `night-watch`, `piece-check`, `new-client-document`, `client-mail-triage`, `subcontractor-replies`, `weekly-review`, `follow-ups` and `service-watch`.

- **Authentication:** API key or session
- **Scopes:** `reflex`

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/engine/flow-templates" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/flow-templates', {
  method: 'GET',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr" + "/engine/flow-templates",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: The templates (`templates` array).

### POST /engine/flows/from-template

Create a flow from a template.

The new flow is returned with its `issues`: a template deliberately leaves workspace-specific fields (which agent, which mailbox, which folder) empty, and those steps come back flagged. Fill them with `PUT /engine/flows/:id/graph`.

- **Authentication:** API key or session
- **Scopes:** `reflex`

#### Request body

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `templateId` | `string` | yes |  | An `id` from `GET /engine/flow-templates`. |

#### Request examples

_curl_

```bash
curl -X POST "https://api.subsidia.protypa.fr/engine/flows/from-template" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY" \n  -H "Content-Type: application/json" \n  -d '{
  "templateId": "deadlines-watch"
}'
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/flows/from-template', {
  method: 'POST',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    "templateId": "deadlines-watch"
  }),
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.post(
    "https://api.subsidia.protypa.fr" + "/engine/flows/from-template",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
    json={
      "templateId": "deadlines-watch"
    },
)
print(res.status_code, res.json())
```

#### Responses

**201**: The flow and its validation issues (`flow`, `issues`).

**404**: Unknown template.

```json
{ "error": "Unknown template" }
```

### GET /engine/flows/:id

Get a flow with its graph, triggers and issues.

`issues` lists what is wrong with the graph. Each has a `level` (`error` or `warning`), a `nodeKey`, a `code` (`missing-config`, `orphan`, `unguarded-outbound`, `cycle`, `no-trigger`...), `blocks` (`save` or `run`) and a `message`. Errors that block `run` stop you enabling or starting the flow.

- **Authentication:** API key or session
- **Scopes:** `reflex`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | The flow id. |

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/engine/flows/$ID" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/flows/' + id, {
  method: 'GET',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr" + "/engine/flows/" + id,
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: `{ flow, issues }`.

**404**: No such flow in this workspace.

```json
{ "error": "Flow not found" }
```

### PATCH /engine/flows/:id

Update flow settings: name, enabled, concurrency, notification policy.

Send only the fields to change. Enabling a flow with a blocking issue is refused with the list of issues, because a failure knowable at 4 pm should not wait until 4 am.

- **Authentication:** API key or session
- **Scopes:** `reflex`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | The flow id. |

#### Request body

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `name` | `string` | no |  | New name. |
| `description` | `string` | no |  | New description. |
| `timezone` | `string` | no |  | IANA time zone. |
| `enabled` | `boolean` | no |  | Switch the flow on or off. |
| `concurrency` | `string` | no |  | What to do when a trigger fires during a run. One of: `skip`, `queue`, `parallel`. |
| `notifyPolicy` | `string` | no |  | Whether a run that found nothing notifies anyone. One of: `always`, `only-if-actionable`. |
| `dailyTokenBudget` | `number` | no |  | Daily ceiling, `null` for none. |
| `allowUnattendedOutbound` | `boolean` | no |  | Waive the approval rule. **Session only**: an API key gets `403 session_required`. |

#### Request examples

_curl_

```bash
curl -X PATCH "https://api.subsidia.protypa.fr/engine/flows/$ID" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY" \n  -H "Content-Type: application/json" \n  -d '{
  "enabled": true,
  "notifyPolicy": "only-if-actionable"
}'
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/flows/' + id, {
  method: 'PATCH',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    "enabled": true,
    "notifyPolicy": "only-if-actionable"
  }),
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.patch(
    "https://api.subsidia.protypa.fr" + "/engine/flows/" + id,
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
    json={
      "enabled": True,
      "notifyPolicy": "only-if-actionable"
    },
)
print(res.status_code, res.json())
```

#### Responses

**200**: `{ flow, issues }`.

**400**: The flow cannot be enabled yet.

```json
{ "error": "Fix the flow before switching it on", "issues": [{ "level": "error", "nodeKey": "send", "code": "unguarded-outbound", "blocks": "run", "message": "This step leaves the building. Put an approval in front of it, or allow unattended sending in the flow settings." }] }
```

**403**: An API key tried to change `allowUnattendedOutbound`.

```json
{ "error": "session_required", "message": "Allowing a flow to send without approval is a decision for a signed-in person, not an API key." }
```

**404**: No such flow.

### PUT /engine/flows/:id/graph

Replace the graph of a flow.

Saves all nodes and edges in one go (the previous graph is replaced, not merged). Work in progress is accepted: unwired steps and empty fields come back as `issues` that block switching the flow on, not saving. Only a graph the executor could not read back is refused: a cycle, an edge to a node that does not exist, a node feeding itself, two nodes sharing a key, an unknown type, a trigger with an input.

- **Authentication:** API key or session
- **Scopes:** `reflex`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | The flow id. |

#### Request body

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `nodes` | `object[]` | yes |  | The steps. |
| `nodes.key` | `string` | yes |  | Stable identifier, unique in the flow. Used in `{{key.output}}` references and edges. |
| `nodes.type` | `string` | yes |  | A type from `GET /engine/node-types`. |
| `nodes.label` | `string` | no |  | Display label. |
| `nodes.config` | `object` | no |  | The step fields, as listed by the catalogue. |
| `nodes.x` | `number` | no |  | Canvas position. |
| `nodes.y` | `number` | no |  | Canvas position. |
| `nodes.timeoutSec` | `integer` | no |  | Hard timeout of the step. Default 900. |
| `nodes.retryMax` | `integer` | no |  | Retries with exponential backoff. |
| `nodes.requiresApproval` | `boolean` | no |  | Pause the run on this step until a person approves it. |
| `edges` | `object[]` | yes |  | The links. |
| `edges.fromKey` | `string` | yes |  | Key of the upstream step. |
| `edges.toKey` | `string` | yes |  | Key of the downstream step. |
| `edges.label` | `string` | no |  | Display label. |
| `edges.condition` | `object` | no |  | Condition for a link leaving a `branch` step. |

#### Request examples

_curl_

```bash
curl -X PUT "https://api.subsidia.protypa.fr/engine/flows/$ID/graph" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY" \n  -H "Content-Type: application/json" \n  -d '{
  "nodes": [
    {
      "key": "trigger",
      "type": "schedule",
      "config": {
        "expr": "weekly@fri@17:00",
        "windowHours": "7,21",
        "misfire": "fire-once"
      }
    },
    {
      "key": "deadlines",
      "type": "cortex.deadlines",
      "config": {
        "horizonDays": 14
      }
    },
    {
      "key": "gate",
      "type": "gate",
      "config": {
        "mode": "actionable"
      }
    },
    {
      "key": "tell",
      "type": "notify",
      "config": {
        "channel": "all",
        "title": "Echeances",
        "body": "{{deadlines.summary}}"
      }
    }
  ],
  "edges": [
    {
      "fromKey": "trigger",
      "toKey": "deadlines"
    },
    {
      "fromKey": "deadlines",
      "toKey": "gate"
    },
    {
      "fromKey": "gate",
      "toKey": "tell"
    }
  ]
}'
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/flows/' + id + '/graph', {
  method: 'PUT',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    "nodes": [
      {
        "key": "trigger",
        "type": "schedule",
        "config": {
          "expr": "weekly@fri@17:00",
          "windowHours": "7,21",
          "misfire": "fire-once"
        }
      },
      {
        "key": "deadlines",
        "type": "cortex.deadlines",
        "config": {
          "horizonDays": 14
        }
      },
      {
        "key": "gate",
        "type": "gate",
        "config": {
          "mode": "actionable"
        }
      },
      {
        "key": "tell",
        "type": "notify",
        "config": {
          "channel": "all",
          "title": "Echeances",
          "body": "{{deadlines.summary}}"
        }
      }
    ],
    "edges": [
      {
        "fromKey": "trigger",
        "toKey": "deadlines"
      },
      {
        "fromKey": "deadlines",
        "toKey": "gate"
      },
      {
        "fromKey": "gate",
        "toKey": "tell"
      }
    ]
  }),
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.put(
    "https://api.subsidia.protypa.fr" + "/engine/flows/" + id + "/graph",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
    json={
      "nodes": [
        {
          "key": "trigger",
          "type": "schedule",
          "config": {
            "expr": "weekly@fri@17:00",
            "windowHours": "7,21",
            "misfire": "fire-once"
          }
        },
        {
          "key": "deadlines",
          "type": "cortex.deadlines",
          "config": {
            "horizonDays": 14
          }
        },
        {
          "key": "gate",
          "type": "gate",
          "config": {
            "mode": "actionable"
          }
        },
        {
          "key": "tell",
          "type": "notify",
          "config": {
            "channel": "all",
            "title": "Echeances",
            "body": "{{deadlines.summary}}"
          }
        }
      ],
      "edges": [
        {
          "fromKey": "trigger",
          "toKey": "deadlines"
        },
        {
          "fromKey": "deadlines",
          "toKey": "gate"
        },
        {
          "fromKey": "gate",
          "toKey": "tell"
        }
      ]
    },
)
print(res.status_code, res.json())
```

#### Responses

**200**: `{ flow, issues }` after the save.

**400**: The graph cannot be stored.

```json
{ "error": "The graph loops back on itself.", "issues": [{ "level": "error", "nodeKey": null, "code": "cycle", "blocks": "save", "message": "The graph loops back on itself." }] }
```

**404**: No such flow.

### DELETE /engine/flows/:id

Delete a flow with its history.

- **Authentication:** API key or session
- **Scopes:** `reflex`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | The flow id. |

#### Request examples

_curl_

```bash
curl -X DELETE "https://api.subsidia.protypa.fr/engine/flows/$ID" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/flows/' + id, {
  method: 'DELETE',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
console.log(res.status)
```

_Python_

```python
import os, requests

res = requests.delete(
    "https://api.subsidia.protypa.fr" + "/engine/flows/" + id,
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code)
```

#### Responses

**204**: Deleted.

**404**: No such flow.

### POST /engine/flows/:id/run

Run a flow now.

Queues a manual run and returns immediately; follow it with the run endpoints or the live stream. A manual run is subject to the same rules as any other: blocking issues refuse it, and an outbound step still waits for its approval. It works whether or not the flow is enabled.

- **Authentication:** API key or session
- **Scopes:** `reflex`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | The flow id. |

#### Request examples

_curl_

```bash
curl -X POST "https://api.subsidia.protypa.fr/engine/flows/$ID/run" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/flows/' + id + '/run', {
  method: 'POST',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.post(
    "https://api.subsidia.protypa.fr" + "/engine/flows/" + id + "/run",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**202**: Queued.

```json
{ "runId": "run_5d20" }
```

**400**: The flow has a blocking issue.

```json
{ "error": "Fix the flow first", "issues": [] }
```

**404**: No such flow.

**409**: A run for this slot already exists.

### POST /engine/flows/:id/webhook

Start a flow from an outside system (webhook trigger).

The only Reflex route without an API key: the caller is a business application or a controller with no session, so the **shared secret of the trigger** is the credential. The flow needs a `webhook` trigger step with a secret; without one the route refuses (`403 webhook_secret_required`) rather than treating the URL as public. The secret is compared in constant time.

The JSON body you post becomes the trigger output, available to later steps as `{{trigger.output}}`. Send `x-reflex-event-id` to make a retry of the same fact start one run instead of two. Active hours, concurrency and the approval rule apply as for any trigger: this route opens no back door.

- **Authentication:** Header x-reflex-secret (the trigger secret), no API key

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | The flow id. |

#### Headers

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `x-reflex-secret` | `string` | yes |  | The shared secret configured on the webhook trigger step. |
| `x-reflex-event-id` | `string` | no |  | Your own id for the event (up to 200 characters). A second call with the same id starts nothing. |

#### Request body

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `(any JSON)` | `object` | no |  | Free-form payload handed to the flow. |

#### Request examples

_curl_

```bash
curl -X POST https://api.subsidia.protypa.fr/engine/flows/$ID/webhook \
  -H "x-reflex-secret: $REFLEX_SECRET" \
  -H "x-reflex-event-id: invoice-2026-0412" \
  -H "Content-Type: application/json" \
  -d '{"invoice": "2026-0412", "client": "Durand"}'
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr/engine/flows/' + flowId + '/webhook', {
  method: 'POST',
  headers: {
    'x-reflex-secret': process.env.REFLEX_SECRET!,
    'x-reflex-event-id': 'invoice-2026-0412',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ invoice: '2026-0412', client: 'Durand' }),
})
console.log(res.status, await res.json())
```

_Python_

```python
import os, requests

res = requests.post(
    "https://api.subsidia.protypa.fr/engine/flows/" + flow_id + "/webhook",
    headers={
        "x-reflex-secret": os.environ["REFLEX_SECRET"],
        "x-reflex-event-id": "invoice-2026-0412",
    },
    json={"invoice": "2026-0412", "client": "Durand"},
)
print(res.status_code, res.json())
```

#### Responses

**202**: A run was started.

```json
{ "started": 1 }
```

**401**: Wrong secret.

```json
{ "error": "Bad secret" }
```

**403**: The trigger has no secret (`webhook_secret_required`).

**404**: No such flow, or it has no webhook trigger.

**409**: The flow is not active, or nothing started (`no_run_started`): it is busy with concurrency `skip`, the call is outside active hours, or the `x-reflex-event-id` was already delivered.

### GET /engine/flows/:id/runs

Run history of a flow, newest first.

- **Authentication:** API key or session
- **Scopes:** `reflex`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | The flow id. |

#### Query parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `limit` | `integer` | no | `30` | Up to 100. |

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/engine/flows/$ID/runs?limit=10" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/flows/' + id + '/runs' + '?limit=10', {
  method: 'GET',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr" + "/engine/flows/" + id + "/runs" + "?limit=10",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: Run summaries. `status` is one of `queued`, `running`, `awaiting-approval`, `succeeded`, `failed`, `cancelled`, `stalled`.

```json
{
  "runs": [
    {
      "id": "run_5d20",
      "status": "succeeded",
      "triggeredBy": "schedule",
      "actionable": true,
      "tokensSpent": 3120,
      "scheduledFor": "2026-10-09T06:30:00.000Z",
      "startedAt": "2026-10-09T06:30:01.000Z",
      "finishedAt": "2026-10-09T06:30:19.000Z",
      "error": null,
      "createdAt": "2026-10-09T06:30:00.000Z"
    }
  ]
}
```

**404**: No such flow.

### GET /engine/runs/:runId

One run with the result of every step.

The step-by-step record: for each attempt of each step, its status, input, output, logs, duration and tokens, plus the approvals the run raised.

- **Authentication:** API key or session
- **Scopes:** `reflex`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `runId` | `string` | yes |  | The run id returned when the run was started. |

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/engine/runs/$RUNID" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/runs/' + runId, {
  method: 'GET',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr" + "/engine/runs/" + runId,
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: `{ run }`, with `nodeRuns`, `approvals` and `flow: { id, name }`.

**404**: No such run in this workspace.

```json
{ "error": "Run not found" }
```

### POST /engine/runs/:runId/resume

Resume a run that ended badly.

Puts a `failed`, `cancelled` or `stalled` run back in the queue. Steps that already succeeded are not run again (their recorded output is replayed), so a three-hour ingestion that died on its last step does not start over.

- **Authentication:** API key or session
- **Scopes:** `reflex`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `runId` | `string` | yes |  | The run id returned when the run was started. |

#### Request examples

_curl_

```bash
curl -X POST "https://api.subsidia.protypa.fr/engine/runs/$RUNID/resume" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/runs/' + runId + '/resume', {
  method: 'POST',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.post(
    "https://api.subsidia.protypa.fr" + "/engine/runs/" + runId + "/resume",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: Queued again.

```json
{ "ok": true }
```

**400**: The run is not in a state that can be resumed.

**404**: No such run.

### POST /engine/runs/:runId/cancel

Cancel a queued, running or waiting run.

- **Authentication:** API key or session
- **Scopes:** `reflex`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `runId` | `string` | yes |  | The run id returned when the run was started. |

#### Request examples

_curl_

```bash
curl -X POST "https://api.subsidia.protypa.fr/engine/runs/$RUNID/cancel" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/runs/' + runId + '/cancel', {
  method: 'POST',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.post(
    "https://api.subsidia.protypa.fr" + "/engine/runs/" + runId + "/cancel",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: Cancelled.

```json
{ "ok": true }
```

**404**: No such run, or it is already finished (`Nothing to cancel`).

### GET /engine/runs/:runId/stream

Follow a run live (Server-Sent Events).

Events: `run.started`, `node.started`, `node.log`, `node.finished` (with `status`, `summary`, `actionable`, `durationMs`, `tokens`), `run.finished` and `approval.requested`. Each is sent as a `data:` line holding JSON. A `: ping` comment is sent every 15 seconds. Read it with `fetch` and a stream reader: `EventSource` cannot send the Authorization header.

- **Authentication:** API key or session
- **Scopes:** `reflex`
- **Streaming:** yes, Server-Sent Events when `stream: true`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `runId` | `string` | yes |  | The run id returned when the run was started. |

#### Request examples

_curl_

```bash
curl -N https://api.subsidia.protypa.fr/engine/runs/$RUNID/stream \
  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr/engine/runs/' + runId + '/stream', {
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
for (;;) {
  const { done, value } = await reader.read()
  if (done) break
  buffer += decoder.decode(value, { stream: true })
  const lines = buffer.split('\n')
  buffer = lines.pop()!
  for (const line of lines) {
    if (line.startsWith('data: ')) console.log(JSON.parse(line.slice(6)))
  }
}
```

_Python_

```python
import json, os, requests

with requests.get(
    "https://api.subsidia.protypa.fr/engine/runs/" + run_id + "/stream",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
    stream=True,
) as res:
    for line in res.iter_lines(decode_unicode=True):
        if line and line.startswith("data: "):
            print(json.loads(line[6:]))
```

#### Responses

**200**: `text/event-stream`. The stream only carries events emitted while you are connected; read the run with `GET /engine/runs/:runId` for what happened before.

**404**: No such run.

### GET /engine/approvals

Approvals waiting for a decision.

Everything parked in the inbox: what the step wants to do (`summary`), its exact `payload` (for an email, the message that would be sent), and when it expires. An API key can read the list but cannot decide: a person approves or rejects in the app, from the link in a notification, or through `POST /engine/approvals/:id/decide` with a signed-in session.

- **Authentication:** API key or session
- **Scopes:** `reflex`

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/engine/approvals" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/approvals', {
  method: 'GET',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr" + "/engine/approvals",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: Pending approvals, newest first.

```json
{
  "approvals": [
    {
      "id": "apr_9c02",
      "runId": "run_5d20",
      "flowId": "flw_7a11",
      "flowName": "Relance des factures",
      "nodeKey": "send_mail",
      "kind": "approval",
      "summary": "Send 3 reminder emails",
      "payload": {},
      "expiresAt": "2026-10-12T06:30:00.000Z",
      "createdAt": "2026-10-09T06:30:15.000Z"
    }
  ]
}
```

### POST /engine/approvals/:id/decide

Approve or reject a waiting step (signed-in person only).

Refused for API keys by design. The decision is recorded with the person who made it. The same decision logic serves the in-app panel and the links sent in emails and push notifications.

- **Authentication:** User session (JWT). API keys are refused.

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | The approval id. |

#### Request body

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `decision` | `string` | yes |  | What to do. One of: `approve`, `reject`. |
| `note` | `string` | no |  | Optional comment. |

#### Request examples

_curl_

```bash
curl -X POST https://api.subsidia.protypa.fr/engine/approvals/$ID/decide \
  -H "Authorization: Bearer $USER_JWT" \
  -H "x-workspace-id: $WORKSPACE_ID" \
  -H "Content-Type: application/json" \
  -d '{"decision": "approve"}'
```

#### Responses

**200**: Recorded; the run continues (approve) or stops that branch (reject).

```json
{ "ok": true }
```

**400**: `decision` is not `approve` or `reject`.

**409**: Nothing to decide: already decided or expired. The body says why.

#### Errors

| Status | Code | When |
| --- | --- | --- |
| 401 |  | No user session. An API key cannot reach this route. |

### GET /engine/connections

List the services registered for flows.

Connections are external services a person registered once in the app (Reflex, Connections) with a base URL and credential headers, used by the `connection.call` step. Header values are never returned. Creating, changing, testing and deleting connections requires a signed-in person, because they hold credentials; a step can only supply a path, so a flow cannot send the credentials anywhere else.

- **Authentication:** API key or session
- **Scopes:** `reflex`

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/engine/connections" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/connections', {
  method: 'GET',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr" + "/engine/connections",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: The connections, without their secret header values.

## Getting notified instead of polling

Two [webhook events](https://dev.subsidia.protypa.fr/docs/webhooks.md) are pushed to your own system so you do not have to poll: `flow.run.finished` (run id, flow, status, whether it was actionable) and `flow.approval.requested` (run id, step key, approval id). Both carry identifiers only; fetch the run for details.

## Frequently asked

**Can a flow send emails on its own?**

Only if you waive the approval, which requires a signed-in person, is off by default and is logged. With the default setting every `mail.send`, `http.call` and `connection.call` needs an `approval` in front of it, and `mail.reply` always stops on the exact message.

**My machine was off over the weekend. What runs on Monday?**

One catch-up run per flow (policy `fire-once`), not one per missed slot. Use `skip` on a trigger if a late run is useless.

**Does a run cost questions?**

Background work does not consume your question allowance; it is metered internally. A flow can still have a `dailyTokenBudget` as a guard rail.

**Can I run Reflex in the cloud?**

Not today. Flows can be edited through the API, but the scheduler only runs on local-mode installations.
