> ## Documentation Index
> Fetch the complete documentation index at: https://docs.formal.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Workflows

> Automate actions based on triggers using Formal workflows

## Overview

Workflows enable automation of actions based on triggers. A workflow consists of:

* **Trigger**: What starts the workflow (e.g., an API request, a new log event, or a new autodiscovered resource)
* **Actions**: What the workflow does when triggered (e.g., send a Slack message, call the Formal API)

Workflows are defined in YAML and can be managed via Terraform or the Formal API.

## Triggers vs Actions

| Component   | Purpose                          | When it runs                                               |
| ----------- | -------------------------------- | ---------------------------------------------------------- |
| **Trigger** | Defines what starts the workflow | Once, when the triggering event occurs                     |
| **Actions** | Defines what the workflow does   | After the trigger fires, in sequence based on `depends_on` |

A workflow has exactly **one trigger** and **one or more actions**. Actions can be chained together using `depends_on` and conditionally executed using `if`.

## Workflow Structure

```yaml theme={null}
trigger:
  name: "my-trigger"           # Unique name for this trigger
  type: "api-request"          # Trigger type
  args:                        # Type-specific arguments
    allow: "user.email == 'admin@example.com'"

actions:
  - name: "first-action"       # Unique name for this action
    type: "send-slack-message" # Action type
    args:                      # Type-specific arguments
      text: "Workflow triggered!"
      recipient_email: "ops@example.com"

  - name: "second-action"
    type: "formal-app-command"
    depends_on:                # Wait for these actions to complete
      - "first-action"
    if: "actions.first_action.message_sent == true"  # Conditional execution
    args:
      # ...
```

## `depends_on` - Action Chaining

The `depends_on` field controls the execution order of actions. Actions with empty `depends_on` run immediately after the trigger. Subsequent actions run after their dependencies complete.

```yaml theme={null}
actions:
  # Runs immediately after trigger
  - name: "step-1"
    type: "send-slack-message"
    args:
      text: "Starting workflow..."
      recipient_email: "ops@example.com"

  # Runs after step-1 completes
  - name: "step-2"
    type: "formal-app-command"
    depends_on:
      - "step-1"
    args:
      # ...

  # Runs after step-2 completes
  - name: "step-3"
    type: "send-slack-message"
    depends_on:
      - "step-2"
    args:
      text: "Workflow complete!"
      recipient_email: "ops@example.com"
```

## Referencing Previous Trigger and Action Outputs

Actions can reference previous triggers and actions outputs via the following syntax in [CEL expressions](https://cel.dev/):

* `trigger.<output_field>`: reference any output field of the trigger
* `actions.<action_name>.<output_field>`: reference any output field of an action by its name

Some arguments, such as `if` or the `api-request` `allow` argument, take CEL expressions by default. For arguments that take strings
instead, embed CEL expressions via the `${{}}` syntax.

## `if` - Conditional Execution

The `if` field is a CEL expression that determines whether a subsequent action should execute based on the previous actions or trigger.

```yaml theme={null}
actions:
  - name: "notify-on-success"
    type: "send-slack-message"
    depends_on:
      - "create-resource"
    if: "actions.create_resource.status_code == 200"
    args:
      text: "Resource created successfully!"
      recipient_email: "admin@example.com"

  - name: "notify-on-failure"
    type: "send-slack-message"
    depends_on:
      - "create-resource"
    if: "actions.create_resource.status_code != 200"
    args:
      text: "Failed to create resource: ${{ actions.create_resource.body.message }}"
      recipient_email: "admin@example.com"
```

## Trigger Types

### `api-request`

Triggered when a user calls the `CreateWorkflowTrigger` API endpoint. Useful for manually triggered workflows or integrations.

**Args:**

| Arg     | Type   | Required | Description                                                                               |
| ------- | ------ | -------- | ----------------------------------------------------------------------------------------- |
| `allow` | string | No       | CEL expression to authorize the request. If omitted, all authenticated users can trigger. |

**Outputs (available in actions):**

| Output    | Type   | Description                              |
| --------- | ------ | ---------------------------------------- |
| `user`    | object | The user who triggered the workflow      |
| `payload` | object | Custom payload passed in the API request |

**Example:**

```yaml theme={null}
trigger:
  name: "manual-trigger"
  type: "api-request"
  args:
    allow: "user.groups.exists(g, g == 'groupname')"
```

**Triggering via API:**

```bash theme={null}
curl -X POST -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"workflow_id": "workflow_abc123", "payload": {"key": "value"}}' \
  https://api.joinformal.com/core.v1.WorkflowService/CreateWorkflowTrigger
```

***

### `new-log`

Triggered when a new log event matches a specified condition. Useful for reacting to specific database queries, policy violations, or access patterns.

**Args:**

| Arg  | Type   | Required | Description                                                                            |
| ---- | ------ | -------- | -------------------------------------------------------------------------------------- |
| `if` | string | Yes      | CEL expression to filter logs. Only logs matching this condition trigger the workflow. |

**Outputs (available in actions):**

| Output | Type   | Description                                   |
| ------ | ------ | --------------------------------------------- |
| `log`  | object | The full log event that matched the condition |

**Example:**

```yaml theme={null}
trigger:
  name: "blocked-query-alert"
  type: "new-log"
  args:
    if: "log.event_type == 'session-login-failed' && log.triggered_policies.exists(p, p.status == 'active' && p.type == 'block')"
```

***

### `new-autodiscovered-resource`

Triggered when a new resource is autodiscovered via cloud integration (AWS, GCP, etc.). Useful for automatically onboarding new databases.

**Args:** None

**Outputs (available in actions):**

| Output     | Type   | Description                        |
| ---------- | ------ | ---------------------------------- |
| `resource` | object | The autodiscovered resource object |

**Example:**

```yaml theme={null}
trigger:
  name: "new-resource-discovered"
  type: "new-autodiscovered-resource"
```

***

### `updated-autodiscovered-resource`

Triggered when an existing autodiscovered resource is updated.

**Args:** None

**Outputs (available in actions):**

| Output     | Type   | Description                                |
| ---------- | ------ | ------------------------------------------ |
| `resource` | object | The updated autodiscovered resource object |

**Example:**

```yaml theme={null}
trigger:
  name: "resource-updated"
  type: "updated-autodiscovered-resource"
```

***

### `deleted-autodiscovered-resource`

Triggered when an autodiscovered resource is deleted because it no longer exists in the cloud provider.

**Args:** None

**Outputs (available in actions):**

| Output     | Type   | Description                                |
| ---------- | ------ | ------------------------------------------ |
| `resource` | object | The deleted autodiscovered resource object |

**Example:**

```yaml theme={null}
trigger:
  name: "resource-removed"
  type: "deleted-autodiscovered-resource"
```

***

### `cron-schedule`

Triggered on a recurring schedule defined by a standard 5-field cron expression (`minute hour day-of-month month day-of-week`). All times are evaluated in **UTC**. Useful for periodic checks, scheduled reports, and recurring maintenance tasks.

**Args:**

| Arg        | Type   | Required | Description                                                                                                                          |
| ---------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `schedule` | string | Yes      | 5-field cron expression (e.g., `"0 9 * * 1-5"` for weekdays at 9am UTC)                                                              |
| `recovery` | string | No       | Controls behavior when scheduled triggers are missed during a Formal outage. One of `none` (default), `latest`, or `all`. See below. |

**Outputs (available in actions):**

| Output                   | Type   | Description                                                                                         |
| ------------------------ | ------ | --------------------------------------------------------------------------------------------------- |
| `trigger.scheduled_time` | string | The time (UTC, RFC 3339) the cron was scheduled to run, always truncated to the minute.             |
| `trigger.execution_time` | string | The actual time (UTC, RFC 3339) the trigger fired. Matches `scheduled_time` under normal operation. |

**Example:**

```yaml theme={null}
trigger:
  name: "workday-check"
  type: "cron-schedule"
  args:
    schedule: "0 8,17 * * 1-5"   # 8am and 5pm UTC on weekdays

actions:
  - name: "notify"
    type: "send-slack-message"
    args:
      text: "Workday check ran at ${{ trigger.execution_time }} (scheduled for ${{ trigger.scheduled_time }})"
      recipient_channel: "ops"
```

**Recovery policy (optional):** Under normal operation, cron triggers fire reliably every minute and `recovery` has no effect. It only matters in the unlikely event of a Formal platform outage where the workflow engine is temporarily unable to process scheduled triggers. When the platform recovers, any missed ticks are handled according to the `recovery` policy. If omitted, defaults to `none`. Missed triggers are capped at a 24-hour lookback window.

| Policy   | Behavior                                                                                                                                                                                       |
| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `none`   | Skips all missed triggers and resumes from the next future tick on the schedule.                                                                                                               |
| `latest` | Fires a single trigger for the most recent missed tick, then resumes normally. The `scheduled_time` output reflects the missed tick, while `execution_time` reflects the actual recovery time. |
| `all`    | Fires every missed tick (up to 24 hours) in chronological order, each with its own `scheduled_time`, then resumes normally.                                                                    |

<Note>
  Cron schedules are always evaluated in UTC. To schedule at a local time, convert manually (e.g., 9am US Eastern = `"0 14 * * *"` during EDT or `"0 13 * * *"` during EST).
</Note>

***

### `form-submission`

Triggered when a user submits a [Formal form](/docs/guides/configuration/forms) via Slack. Useful for approval workflows, access requests, and other structured data collection scenarios.

**Args:**

| Arg  | Type   | Required | Description                                     |
| ---- | ------ | -------- | ----------------------------------------------- |
| `id` | string | Yes      | The ID of the form to listen for submissions on |

**Outputs (available in actions via `trigger.form_submission`):**

| Output            | Type   | Description                              |
| ----------------- | ------ | ---------------------------------------- |
| `form_id`         | string | The ID of the submitted form             |
| `form_name`       | string | The name of the submitted form           |
| `submitter_email` | string | Email of the user who submitted the form |
| `submission`      | object | Map of field IDs to submitted values     |

**Example:**

```yaml theme={null}
trigger:
  name: "access-request"
  type: "form-submission"
  args:
    id: "form_abc123"
```

**Referencing form values in actions:**

```yaml theme={null}
actions:
  - name: "notify-approver"
    type: "send-slack-message"
    args:
      text: "${{ trigger.form_submission.submitter_email }} submitted a request: ${{ trigger.form_submission.submission.field_reason }}"
      recipient_channel: "approvals"
```

<Note>
  See the [Forms documentation](/docs/guides/configuration/forms) for details on creating forms and the full Slack integration.
</Note>

## Action Types

### `send-slack-message`

Sends a direct message to a Slack user or channel. Requires a Slack integration to be configured.

**Args:**

| Arg                 | Type   | Required | Description                                                                   |
| ------------------- | ------ | -------- | ----------------------------------------------------------------------------- |
| `text`              | string | Yes      | Message text to send (supports Slack markdown)                                |
| `recipient_email`   | string | One of   | Email address of the Slack user (mutually exclusive with `recipient_channel`) |
| `recipient_channel` | string | One of   | Slack channel name without `#` (mutually exclusive with `recipient_email`)    |

**Outputs:**

| Output              | Type    | Description                                |
| ------------------- | ------- | ------------------------------------------ |
| `recipient_email`   | string  | Email of the recipient (if email was used) |
| `recipient_channel` | string  | Name of the channel (if channel was used)  |
| `message_sent`      | boolean | Whether the message was successfully sent  |

**Example:**

```yaml theme={null}
actions:
  # Send to a user by email
  - name: "notify-admin"
    type: "send-slack-message"
    args:
      text: "New resource discovered: ${{ trigger.resource.name }}"
      recipient_email: "admin@example.com"

  # Send to a channel
  - name: "notify-channel"
    type: "send-slack-message"
    args:
      text: "New resource discovered: ${{ trigger.resource.name }}"
      recipient_channel: "security-alerts"
```

***

### `ask-in-chat`

Sends an interactive message with Yes/No buttons. The workflow pauses until the user responds.

**Args:**

| Arg                 | Type   | Required | Description                                                                |
| ------------------- | ------ | -------- | -------------------------------------------------------------------------- |
| `message`           | string | Yes      | Message text to display                                                    |
| `integration`       | string | Yes      | Must be `"slack"`                                                          |
| `recipient_email`   | string | One of   | Email of the Slack user (mutually exclusive with `recipient_channel`)      |
| `recipient_channel` | string | One of   | Slack channel name without `#` (mutually exclusive with `recipient_email`) |

**Outputs:**

The user's response triggers a webhook that continues the workflow with `action-response`.

**Example:**

```yaml theme={null}
actions:
  - name: "approve-access"
    type: "ask-in-chat"
    args:
      message: "Approve access request from ${{ trigger.user.email }}?"
      integration: "slack"
      recipient_channel: "security-approvals"
```

***

### `formal-app-command`

Calls a [Formal API endpoint](/docs/api/introduction) using a machine user's credentials.

**Args:**

| Arg               | Type   | Required | Description                                                        |
| ----------------- | ------ | -------- | ------------------------------------------------------------------ |
| `app`             | string | Yes      | Service name (e.g., `Resource`, `User`, `Policy`)                  |
| `command.name`    | string | Yes      | Method name without prefix (e.g., `Resource` for `CreateResource`) |
| `command.type`    | string | Yes      | Method prefix (e.g., `Create`, `Update`, `Delete`, `Get`)          |
| `input`           | object | Yes      | Request payload for the API call                                   |
| `machine_user_id` | string | Yes      | ID of the machine user to authenticate as                          |

**Outputs:**

| Output        | Type   | Description                      |
| ------------- | ------ | -------------------------------- |
| `status_code` | int    | HTTP status code of the response |
| `body`        | object | Parsed JSON response body        |

**Example:**

```yaml theme={null}
actions:
  - name: "create-resource"
    type: "formal-app-command"
    args:
      app: "Resource"
      command:
        name: "Resource"
        type: "Create"
      input:
        name: "${{ trigger.resource.name }}"
        technology: "${{ trigger.resource.technology }}"
        hostname: "${{ trigger.resource.hostname }}"
        port: "${{ trigger.resource.port }}"
      machine_user_id: "user_abc123"
```

## Terraform Examples

```hcl theme={null}
resource "formal_workflow" "autopromote_prod_resources" {
  name = "Autopromote Production Resources"

  code = <<-YAML
    trigger:
      name: "resource_discovered"
      type: "new-autodiscovered-resource"

    actions:
      - name: "promote_resource"
        type: "formal-app-command"
        depends_on: ["resource_discovered"]
        if: trigger.resource.tags.Environment == "Production"
        args:
          app: "Resource"
          command:
            name: "Resource"
            type: "Create"
          input:
            name: "${{ trigger.resource.name }}"
            technology: "${{ trigger.resource.technology }}"
            hostname: "${{ trigger.resource.hostname }}"
            port: "${{ trigger.resource.port }}"
          machine_user_id: "user_abc123"
  YAML
}
```

```hcl theme={null}
resource "formal_workflow" "request_policy_suspension_from_api_request" {
  name = "Request oneoff SQL queries in Slack from API requests"

  code = <<-YAML
    trigger:
      type: api-request
      name: request_query
      args:
        allow: true # write a condition on who can perform approval requests if applicable
    actions:
    - type: ask-in-chat
      name: ask_approver
      args:
        recipient_email: <recipient email here>
        message: 'Do you want to allow `${{ trigger.payload.query }}` from ${{ trigger.user.email }}?'
        integration: slack
    - type: formal-app-command
      name: suspend_policy
      depends_on: [ask_approver]
      if: actions.ask_approver.response == "yes"
      args:
        app: Policies
        command:
          name: PolicySuspension
          type: Create
        machine_user_id: "user_abc123"
        input:
          policy_id: "policy_1234"
          identity_type: user
          identity_id: ${{ trigger.user.id }}
          input_condition: ${{ trigger.payload.query }}
          oneoff: false
          expiration_minutes: 60
  YAML
}
```

```hcl theme={null}
resource "formal_workflow" "request_policy_suspension_from_form" {
  name = "Request oneoff SQL queries in Slack from API requests"
  # assume we have a form `form_abc123` with two fields:
  # one field with id `field_resource` that should be the resource name
  # one field with id `field_access_un` that should store the timestamp that the requester is requesting access until
  code = <<-YAML
    trigger:
      type: form-submission
      name: form_submission
      args:
        id: form_abc123
    actions:
      - type: ask-in-chat
        name: ask_approval
        args:
          message:  'Approve access request to "${{trigger.form_submission.submission.field_resource }}" from ${{ trigger.form_submission.submitter_email }} until <!date^${{ int(timestamp(trigger.form_submission.submission.field_access_un)) }}^{date_short_pretty} at {time}|invalid_time>?'
          recipient_channel: some-slack-channel
          integration: slack
      - type: formal-app-command
        name: get_user_id
        depends_on: [ask_approval]
        args:
          app: User
          command:
            name: Users
            type: List
          machine_user_id: user_abc123
          input:
            limit: 1
            filter:
              field:
                key: "db_username"
                operator: "contains"
                value:
                  value: ${{ trigger.form_submission.submitter_email }}
                  "@type": "type.googleapis.com/google.protobuf.StringValue"
      - type: formal-app-command
        name: suspend_policy
        depends_on: [get_user_id]
        if: actions.ask_approval.response == "yes" && (int(timestamp(trigger.form_submission.submission.field_access_un)) > int(now) + 60) && actions.get_user_id.status_code == 200 && size(actions.get_user_id.body.users) > 0
        args:
          app: Policies
          command:
            name: PolicySuspension
            type: Create
          machine_user_id: user_abc123
          input:
            policy_id: policy_abc123
            identity_type: user
            identity_id: ${{ actions.get_user_id.body.users[0].id }}
            input_condition: 'input.resource.name == "${{ trigger.form_submission.submission.field_resource }}"'
            oneoff: false
            expiration_minutes: ${{(int(timestamp(trigger.form_submission.submission.field_access_un)) - int(now))/60}}
  YAML
}
```

```hcl theme={null}
resource "formal_workflow" "daily_compliance_report" {
  name = "Daily Compliance Report"

  code = <<-YAML
    trigger:
      name: "daily-report"
      type: "cron-schedule"
      args:
        schedule: "0 9 * * 1-5"   # weekdays at 9am UTC
        # recovery: latest   # optional, defaults to none

    actions:
      - name: "send-report"
        type: "send-slack-message"
        args:
          text: "Daily compliance report (scheduled: ${{ trigger.scheduled_time }}, ran: ${{ trigger.execution_time }})"
          recipient_channel: "compliance"
  YAML
}
```

```hcl theme={null}
resource "formal_workflow" "blocked_query_alert" {
  name = "blocked-query-alert"

  code = <<-YAML
    trigger:
      name: "blocked_query"
      type: "new-log"
      args:
        if: "log.event_type == 'request' && log.triggered_policies.exists(p, p.status == 'active' && p.type == 'block')"

    actions:
      - name: "alert_security"
        type: "send-slack-message"
        args:
          text: |
            Query ${{ log.request.query.normalized }} blocked by policy!
            User: ${{ trigger.log.user.email }}
            Resource: ${{ trigger.log.resource.name }}
          recipient_email: "security@example.com"
  YAML
}
```

## Next Steps

<CardGroup cols={2}>
  <Card title="Policies" icon="shield" href="/docs/guides/policies/introduction">
    Learn about Formal policies for access control
  </Card>

  <Card title="Terraform Provider" icon="code" href="/docs/guides/configuration/terraform">
    Full Terraform provider documentation
  </Card>
</CardGroup>
