# Workflows

A **Workflow** is an automation graph that runs at the account level — not tied to any single agent. Workflows fire on triggers (conversation events, schedules, incoming webhooks), execute a graph of nodes, and can call external APIs, transform data, write to Tables, send messages, etc.

If a Flow is "what the agent says next", a Workflow is "what happens around the agent".

## Identity

| Field | Type | Notes |
|  --- | --- | --- |
| `id` | number | Use as `workflowId`. |
| `name` | string |  |
| `description` | string? |  |
| `status` | enum | `ACTIVE` · `DRAFT` · `PAUSED` · `ARCHIVED`. |
| `triggerType` | enum? | Workflow-level metadata: `CONVERSATION_ENDED` · `CONVERSATION_IDLE` · `FEEDBACK_CAPTURED` · `CONTACT_CREATED` · `CONTACT_UPDATED` · `TABLE_ROW_CREATED` · `TABLE_ROW_UPDATED` · `OBJECT_RECORD_CREATED` · `OBJECT_RECORD_UPDATED` · `INCOMING_WEBHOOK` · `SCHEDULED_TRIGGER` · `COMPOSIO_TRIGGER`. `SCHEDULED_TRIGGER` here means the automation uses a **Scheduled Trigger node** — it is **not** a valid `data.triggerType` on a `TRIGGER` node (see below). |
| `runsCount` | number | Total executions. |
| `runsFailedCount` | number | Total failed executions. |
| `lastRunDate` | string? | ISO timestamp of the most recent run. |
| `liveSnapshotId` | string? | Published snapshot. |
| `draftSnapshotId` | string? | Working draft. |


## The graph

### Node types

| Type | Purpose |
|  --- | --- |
| `TRIGGER` | Event-based entry point (conversation ended, contact created, …). |
| `SCHEDULED_TRIGGER` | Cron-style entry point. |
| `WEBHOOK` | Entry point fed by an [Incoming Webhook](/docs/concepts/incoming-webhooks). |
| `API` | Call an external HTTP endpoint. |
| `TOOLS_AI` | Let the AI choose a Tool to call. |
| `CONDITIONAL_ROUTING` | Branch based on AI-evaluated conditions. |
| `AI_CAPTURE` | Extract structured data from text using an LLM. |
| `DATA_TRANSFORMER` | Reshape data via prompt. |
| `DYNAMIC_TABLES` | Create, update, delete, search, or change the record type of records in a Table or Object. |
| `CREATE_RECORD_ACTIVITY` | Log a manual activity (note, call, meeting, email, WhatsApp) on a specific Object record. |
| `ITERATION` | Loop over an array variable (`body` / `completed` / `empty` handles). |
| `BREAK` | Exit the current loop early and continue on the loop's `completed` path. |
| `AUTOMATION_STATUS` | Set another automation live, draft, or toggle its status. |
| `SEND_MESSAGE` | Push a message to a conversation. |
| `SEND_WHATSAPP_MESSAGE` | Send a WhatsApp Business template to a phone number or People record. |
| `TRANSCRIPTION` | Transcribe an audio URL. |
| `FILE_ANALYSIS` | OCR + AI analysis of a file from a URL. |


Each node carries `nodeId`, `alias`, `position`, `type`, and a typed `data` payload.

#### Conversation triggers are bound to agents

`CONVERSATION_ENDED`, `CONVERSATION_IDLE`, and `FEEDBACK_CAPTURED` fire per agent: the workflow runs only for the agents whose ids are listed in `data.triggeredByAgentIds` (agent UUIDs from [Agents](/docs/concepts/agents)). A trigger created without that array is accepted and then never fires. Related pitfalls: setting `data.triggeredBy` to `USER` blocks these triggers, since conversation events always originate from the agent, and `CONVERSATION_IDLE` additionally depends on `enableIdle` plus `idleSettings` being configured on the agent's channel settings.

Record triggers need their target as well: `TABLE_ROW_CREATED` / `TABLE_ROW_UPDATED` require `data.triggeredByTableId`, and `OBJECT_RECORD_CREATED` / `OBJECT_RECORD_UPDATED` require `data.triggeredByTableId` plus `data.triggeredByRecordTypeId`.

#### Integration triggers (`COMPOSIO_TRIGGER`)

A `TRIGGER` node with `triggerType: COMPOSIO_TRIGGER` starts the workflow from a third-party event — a file created in Google Drive, a new Gmail or Outlook message, a HubSpot contact, a Stripe checkout. It requires three fields in `data`:

| Field | Value |
|  --- | --- |
| `connectedAccountId` | Numeric id of the connected account, from `GET /public/v1/integrations`. Must belong to your account. |
| `triggerToolkit` | The toolkit that owns the event, uppercase (`GOOGLEDRIVE`, `GMAIL`, `OUTLOOK`, `HUBSPOT`, …). |
| `triggerSlug` | The provider event, e.g. `GOOGLEDRIVE_FILE_CREATED_TRIGGER`. An unsupported slug returns `400` with `Unsupported trigger: <slug>`. |


**Discovering slugs and their config.** `GET /public/v1/integrations/trigger-types` (optionally `?toolkit=GOOGLEDRIVE`) returns every supported event with its `slug`, `toolkit`, and `configFields`. Each config field reports whether it is `required`, whether it is `userConfigurable`, and a `resourceType` when its value has to be looked up. `GET /public/v1/integrations/trigger-resources?resourceType=…&connectedAccountId=…` resolves those values — Drive folders and shared drives, Sheets spreadsheets and tabs, Asana workspaces and projects, Salesforce sobjects and fields, Gmail labels, Outlook folders and calendars, OneDrive folders. Pass `parent` when browsing inside another resource (`spreadsheetId`, `workspaceGid`, `sobjectName`).

`triggerConfig` narrows the subscription and is validated per slug: an undeclared key returns `Unknown trigger config field for <slug>: …`, an invalid value returns `Invalid trigger config: <field> - <message>`, and platform-managed fields (`userConfigurable: false`, typically `interval`) are ignored if sent. Google Sheets, Asana, YouTube, and HubSpot events cannot be created without their required fields. Values are **not** checked against the provider, so a well-formed but wrong id subscribes successfully and then never fires — resolve ids through `trigger-resources` rather than by hand.

Saving the node subscribes to the event with the provider and writes the resulting `connectedAccountTriggerId` back into the node — it is an **output** field, so do not send it yourself. Updating the node re-subscribes with the new configuration and deleting the node removes the subscription, so a change of event or account means editing the existing trigger rather than adding a second one. Delivered events are queued only for automations that are `ACTIVE`; the payload is exposed to downstream nodes as the built-in `{trigger_payload}` variable.

Slack has Tools but no trigger, so a workflow cannot be started from a Slack message.

#### Scheduled triggers

Cron entry points are the separate `SCHEDULED_TRIGGER` **node type**, not a `triggerType` on a `TRIGGER` node. Do not send `"triggerType":"SCHEDULED_TRIGGER"` inside a `TRIGGER` node's `data` — the API rejects it. The workflow's top-level `triggerType` field may still be `SCHEDULED_TRIGGER` after you add a scheduled trigger node. `data` requires `cronExpression` and `timezone`, plus `startTime` and `endTime`, which are nullable but not optional — send `null` when the schedule has no daily window. `startDate`, `endDate`, and `frequency` (`INTERVALS` · `DAILY` · `WEEKLY` · `MONTHLY`) are optional, and `frequency` is only a label: the cron expression is what schedules the run.

The underlying job is registered while the automation is `ACTIVE`, whether the node is created before or after activation. Moving the workflow back to `DRAFT` disables it.

> **Reference validation**: on create/update, the API validates every cross-resource reference inside `data` — `aiModelId`, `customToolIds`, `tableId`, `recordTypeId`, `triggeredByAgentIds[]`, `triggerByWebhookIds[]`, `connectedAccountId`, `knowledgeBaseIds`, etc. If any referenced id does not exist or does not belong to your account, the request returns `400 bad_request` with `details.issues[].code === "not_found"` and the node is **not** persisted. See [Errors](/docs/errors#example-node-references).
Schema-level checks also apply to `SCHEDULED_TRIGGER` (`cronExpression` validated by `cron-validate`, `timezone` against IANA), `WEBHOOK` and `API` (`url` must be a valid URL).


### Runtime variables

Automation workflows expose **one runtime variable per node**, with `name = nodeId`. Since node ids follow `node_<uuid>`, a node saved as `node_2222…` is referenced downstream as `{node_2222…}` — read the exact id from the graph. This is in addition to explicitly declared [Workflow Variables](/docs/concepts/workflow-variables).

Interpolable fields include the same set as Flows, plus:

- AI Capture: `prompt`, `instructions`.
- Data Transformer: `prompt`.
- Tools AI: `instructions`, `prompt`.
- Transcription: `audioUrl`.
- Create Record Activity: `rowId`, `content`.
- WhatsApp template variables: `templateVariables.header`, `body`, `buttons`.


**Capturing specific values.** The `API` node can extract values from its JSON response into named variables via its `variables` field (`{ key, value, fullResponse }`), and `AI_CAPTURE` / `TOOLS_AI` / `TRANSCRIPTION` populate variables via `captureVariables`. **All capture targets must reference a variable that already exists** (create it first via `POST /workflows/{workflowId}/variables`). In `captureVariables` you may reference it by `{ "name": "<var>" }` or `{ "id": <id> }` — the API resolves and links it to the canonical `{ id, name, description }`; an unknown name/`key` returns `400`. For the API node's `value` **path syntax** (dot/`[n]` property access into the JSON response), see [Flows → API node response paths](/docs/concepts/flows#api-node-response-paths-value).

### `CREATE_RECORD_ACTIVITY` node

Logs a manual activity on a specific Object record at workflow runtime.

```json
{
    "nodeId": "node_44444444-4444-4444-4444-444444444444",
    "type": "CREATE_RECORD_ACTIVITY",
    "position": { "positionX": 640, "positionY": 0 },
    "data": {
        "type": "CREATE_RECORD_ACTIVITY",
        "recordTypeId": 5,
        "rowId": "{contact_row_id}",
        "activityType": "PHONE_CALL",
        "content": "Llamada de seguimiento: {call_summary}"
    }
}
```

| Field | Required | Notes |
|  --- | --- | --- |
| `recordTypeId` | ✅ | Numeric id of the Object's record type. Validated: must exist and belong to your account. |
| `rowId` | ✅ | MongoDB ObjectId of the record to log on. Supports `{varName}` interpolation. |
| `activityType` | ✅ | One of: `NOTE`, `EMAIL`, `PHONE_CALL`, `MEETING`, `WHATSAPP`. Validated at save time. |
| `content` | ✅ | Activity body text. Supports `{varName}` interpolation. |


**Validation:** `recordTypeId` must point to a real record type on an Object (not a Table) in your account. `activityType` must be a valid enum value. Variables in `rowId` and `content` must exist — the API returns `400` with the offending variable name if any reference is unknown.

### `ITERATION` (Loop) node

Iterates over an array stored in a workflow variable. The wire type is **`ITERATION`**.

```json
{
    "nodeId": "node_66666666-6666-6666-6666-666666666666",
    "type": "ITERATION",
    "alias": "Loop",
    "position": { "positionX": 320, "positionY": 0 },
    "data": {
        "type": "ITERATION",
        "variableName": "items",
        "variablePath": "data.items",
        "continueOnError": false
    }
}
```

| Field | Notes |
|  --- | --- |
| `variableName` | Name or alias of an existing workflow variable. Must resolve to an array at runtime. |
| `variablePath` | Optional dot path inside the variable (e.g. `data.items` when the variable holds a full API response). |
| `continueOnError` | When `true`, a failed item inside the loop is skipped. When `false`, any failure stops the automation. |


**Limits:** At most **50 items** per loop invocation. If the resolved array contains more than 50 items, the automation run **fails before the loop body starts**; no items are processed. The whole automation is also capped at **500 node execution steps** (each executed node counts one step), which can stop a loop after some items.

**Edges:** connect three outgoing handles — `body` (for-each), `completed` (after all items), `empty` (null/empty list; does **not** route to `completed`). Inside the loop, `{<loopNodeId>.item}`, `{<loopNodeId>.index}`, and `{<loopNodeId>.length}` are available (use the loop node's `nodeId` from the graph). `{<loopNodeId>.item}` is the **entire** current item (JSON if object) — there is no `{<loopNodeId>.item.field}` dot-access. DYNAMIC_TABLES **SEARCH** stores a top-level array of rows; leave `variablePath` empty when looping that output.

### `BREAK` node

Exits the current loop and continues on the loop's `completed` path. Place only inside a Loop `body` branch; outside an active loop the automation fails.

```json
{
    "nodeId": "node_77777777-7777-7777-7777-777777777777",
    "type": "BREAK",
    "position": { "positionX": 640, "positionY": 0 },
    "data": { "type": "BREAK" }
}
```

### `AUTOMATION_STATUS` node

Changes another automation's status (`SET_ACTIVE`, `SET_DRAFT`, or `TOGGLE`). Does not publish draft graph changes.

```json
{
    "nodeId": "node_88888888-8888-8888-8888-888888888888",
    "type": "AUTOMATION_STATUS",
    "position": { "positionX": 320, "positionY": 0 },
    "data": {
        "type": "AUTOMATION_STATUS",
        "automationId": 1,
        "action": "SET_ACTIVE"
    }
}
```

`automationId` is validated at save time — it must exist in your account.

### `SEND_WHATSAPP_MESSAGE` node

Sends a WhatsApp Business template message. Distinct from `SEND_MESSAGE` (conversation push).

```json
{
    "nodeId": "node_99999999-9999-9999-9999-999999999999",
    "type": "SEND_WHATSAPP_MESSAGE",
    "position": { "positionX": 320, "positionY": 0 },
    "data": {
        "type": "SEND_WHATSAPP_MESSAGE",
        "recipientMode": "PHONE_NUMBER",
        "personName": "{first_name} {last_name}",
        "phoneNumber": "{phone_number}",
        "phoneNumberId": "1132681309928224",
        "template": "hello_world",
        "templateVariables": { "body": { "1": "{first_name}" } }
    }
}
```

| Field | Notes |
|  --- | --- |
| `recipientMode` | `PHONE_NUMBER` (default) or `PEOPLE_RECORD`. |
| `personName` | Required in `PHONE_NUMBER` mode. Supports `{var}` interpolation. |
| `phoneNumber` | Required in `PHONE_NUMBER` mode. Full international format. Supports `{var}`. |
| `peopleRowId` | Required in `PEOPLE_RECORD` mode. People record ObjectId. Use `{var}` or `{<loopNodeId>.item}` when looping scalars — not `{<loopNodeId>.item.id}`. |
| `phoneNumberId` | Meta WhatsApp Business phone number id. Must exist and have an `assistantId` (validated). |
| `template` | Approved template name. |
| `templateVariables` | Maps template parameter ids to values (`body`, `header`, `buttons`). |


Resolve `phoneNumberId` and `template` via the read-only **channels** endpoints below.

## Channels (read-only)

| Verb | Path | Purpose |
|  --- | --- | --- |
| `GET` | `/public/v1/channels` | List connected WhatsApp numbers, Instagram accounts, Messenger pages |
| `GET` | `/public/v1/channels/whatsapp/templates` | List APPROVED WhatsApp templates from Meta for this account |


For `SEND_WHATSAPP_MESSAGE`, use a WhatsApp number where `canSendMessages` is `true` (an assistant is assigned).

## Integrations (read-only connected accounts)

| Verb | Path | Purpose |
|  --- | --- | --- |
| `GET` | `/public/v1/integrations` | List OAuth/API connected accounts (Google Sheets, Gmail, Slack, etc.) |


Use the returned `id` as `connectedAccountId` in agent settings, flows, and workflows. Requires a USER API key.

## Operations

| Verb | Path | Purpose |
|  --- | --- | --- |
| `GET` | `/public/v1/workflows` | List, with `status` filter |
| `POST` | `/public/v1/workflows` | Create |
| `GET` | `/public/v1/workflows/{workflowId}` | Detail (`?includeNodes=true` for nodes) |
| `PUT` | `/public/v1/workflows/{workflowId}` | Update metadata / status |
| `DELETE` | `/public/v1/workflows/{workflowId}` | Soft delete + cleanup of triggers |
| `GET` | `/public/v1/workflows/{workflowId}/graph` | Full graph |
| `POST` | `/public/v1/workflows/{workflowId}/nodes` | Create node |
| `PUT` | `/public/v1/workflows/{workflowId}/nodes/{nodeId}` | Update node |
| `DELETE` | `/public/v1/workflows/{workflowId}/nodes/{nodeId}` | Delete node + incident edges |
| `POST` | `/public/v1/workflows/{workflowId}/edges` | Add edge |
| `DELETE` | `/public/v1/workflows/{workflowId}/edges` | Remove edge |
| `GET` | `/public/v1/workflows/{workflowId}/analytics` | Run analytics |
| `GET` | `/public/v1/workflows/{workflowId}/logs` | List run logs (history) |
| `GET` | `/public/v1/workflows/{workflowId}/logs/{logId}` | One run + per-node results |


## Run logs

Every time a workflow runs it records an execution. Read them to audit results,
debug failures, or track credit usage.

**List** `/public/v1/workflows/{workflowId}/logs` — paginated, with optional
`status`, `start_date`, and `end_date` filters. Each run (basic fields):

| Field | Notes |
|  --- | --- |
| `id` | Use as `logId`. |
| `successful` | Whether the run completed without error. |
| `started_at` | ISO timestamp. |
| `completed_at` | ISO timestamp (null while running). |
| `duration` | Milliseconds. |
| `operations` | Number of node operations executed. |
| `ai_credits` | AI credits consumed. |
| `error` | Error message if the run failed. |
| `insufficient_credits` | Run stopped because the account ran out of credits. |
| `prevented_loop` | Run stopped because a loop was detected. |


**Detail** `/public/v1/workflows/{workflowId}/logs/{logId}` — the run above plus
`node_results: [{ node_id, alias, type, success, error, ai_credits, created_at }]`,
one entry per node that executed.

## CLI

```bash
frontline workflows list --table
frontline workflows create --name "Daily CRM Sync"
frontline integrations trigger-types --toolkit GOOGLEDRIVE --table
frontline integrations trigger-resources --type googledrive_folders --connected-account-id 42 --table
frontline workflows nodes create --data '{"type":"TRIGGER","position":{"positionX":0,"positionY":0},"data":{"type":"TRIGGER","triggerType":"CONTACT_CREATED"}}'
frontline workflows analytics --start-date 2026-01-01 --end-date 2026-12-31
frontline workflows logs --workflow-id 2 --table
frontline workflows logs --workflow-id 2 --status FAILED --start-date 2026-01-01
frontline workflows logs get 9001 --workflow-id 2 --pretty
frontline channels list --table
frontline channels whatsapp-templates --table
```