Skip to main content

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

ComponentPurposeWhen it runs
TriggerDefines what starts the workflowOnce, when the triggering event occurs
ActionsDefines what the workflow doesAfter 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

trigger:
  name: "my-trigger"           # Unique name for this trigger
  type: "api-request"          # Trigger type
  args:                        # Type-specific arguments
    allow: "user.email == '[email protected]'"

actions:
  - name: "first-action"       # Unique name for this action
    type: "send-slack-message" # Action type
    args:                      # Type-specific arguments
      text: "Workflow triggered!"
      recipient_email: "[email protected]"

  - 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.
actions:
  # Runs immediately after trigger
  - name: "step-1"
    type: "send-slack-message"
    args:
      text: "Starting workflow..."
      recipient_email: "[email protected]"

  # 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: "[email protected]"

Referencing Previous Trigger and Action Outputs

Actions can reference previous triggers and actions outputs via the following syntax in CEL expressions:
  • 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.
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: "[email protected]"

  - 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: "[email protected]"

Trigger Types

api-request

Triggered when a user calls the CreateWorkflowTrigger API endpoint. Useful for manually triggered workflows or integrations. Args:
ArgTypeRequiredDescription
allowstringNoCEL expression to authorize the request. If omitted, all authenticated users can trigger.
Outputs (available in actions):
OutputTypeDescription
userobjectThe user who triggered the workflow
payloadobjectCustom payload passed in the API request
Example:
trigger:
  name: "manual-trigger"
  type: "api-request"
  args:
    allow: "user.groups.exists(g, g == 'groupname')"
Triggering via API:
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:
ArgTypeRequiredDescription
ifstringYesCEL expression to filter logs. Only logs matching this condition trigger the workflow.
Outputs (available in actions):
OutputTypeDescription
logobjectThe full log event that matched the condition
Example:
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):
OutputTypeDescription
resourceobjectThe autodiscovered resource object
Example:
trigger:
  name: "new-resource-discovered"
  type: "new-autodiscovered-resource"

form-submission

Triggered when a user submits a Formal form via Slack. Useful for approval workflows, access requests, and other structured data collection scenarios. Args:
ArgTypeRequiredDescription
idstringYesThe ID of the form to listen for submissions on
Outputs (available in actions via trigger.form_submission):
OutputTypeDescription
form_idstringThe ID of the submitted form
form_namestringThe name of the submitted form
submitter_emailstringEmail of the user who submitted the form
submissionobjectMap of field IDs to submitted values
Example:
trigger:
  name: "access-request"
  type: "form-submission"
  args:
    id: "form_abc123"
Referencing form values in actions:
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"
See the Forms documentation for details on creating forms and the full Slack integration.

Action Types

send-slack-message

Sends a direct message to a Slack user or channel. Requires a Slack integration to be configured. Args:
ArgTypeRequiredDescription
textstringYesMessage text to send (supports Slack markdown)
recipient_emailstringOne ofEmail address of the Slack user (mutually exclusive with recipient_channel)
recipient_channelstringOne ofSlack channel name without # (mutually exclusive with recipient_email)
Outputs:
OutputTypeDescription
recipient_emailstringEmail of the recipient (if email was used)
recipient_channelstringName of the channel (if channel was used)
message_sentbooleanWhether the message was successfully sent
Example:
actions:
  # Send to a user by email
  - name: "notify-admin"
    type: "send-slack-message"
    args:
      text: "New resource discovered: ${{ trigger.resource.name }}"
      recipient_email: "[email protected]"

  # 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:
ArgTypeRequiredDescription
messagestringYesMessage text to display
integrationstringYesMust be "slack"
recipient_emailstringOne ofEmail of the Slack user (mutually exclusive with recipient_channel)
recipient_channelstringOne ofSlack channel name without # (mutually exclusive with recipient_email)
Outputs: The user’s response triggers a webhook that continues the workflow with action-response. Example:
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 using a machine user’s credentials. Args:
ArgTypeRequiredDescription
appstringYesService name (e.g., Resource, User, Policy)
command.namestringYesMethod name without prefix (e.g., Resource for CreateResource)
command.typestringYesMethod prefix (e.g., Create, Update, Delete, Get)
inputobjectYesRequest payload for the API call
machine_user_idstringYesID of the machine user to authenticate as
Outputs:
OutputTypeDescription
status_codeintHTTP status code of the response
bodyobjectParsed JSON response body
Example:
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

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
}
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
}
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
}
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: "[email protected]"
  YAML
}

Next Steps