# Automations & Approvals

Workflows, executions, and approval locks


Run event-driven workflows with triggers and actions, and gate sensitive changes behind approval locks.

For the worker process itself, see [Background Services](https://erxes.io/docs/architecture-background-services).

## Automations

Two halves: the API module stores definitions and history, the `automations-service` executes them.

```text
backend/core-api/src/modules/automations/
  graphql/schema/{index,queries,mutations,types}.ts, typeDef.ts
  graphql/resolvers/{queries,mutations,customResolver}
  db/models/{Automations,Executions,AutomationEmailTemplates,
            AutomationWorkflowTemplates}.ts
  db/definitions/{automationEmailTemplate,automationWorkflowTemplate}.ts
  trpc/{automations,clients,index}.ts
backend/core-api/src/meta/automations/   # core triggers/actions via startAutomations
backend/services/automations/src/
  bullmq/{initMQWorkers,triggerWorker,actionHandlerWorker,aiWorker}.ts
  executions/actions/{webhook,emailAction}/
  mongo/waitingActionsToExecute.ts
  ai/                                  # aiAgent, aiAction, provider bridge, knowledge
  trpc/, constants.ts, main.ts         # default port 3302
```

An `Automation` ([types.ts](https://github.com/erxes/erxes/blob/f91e8c6f4714c910a32c6b6e14fe085f6435c89e/backend/core-api/src/modules/automations/graphql/schema/types.ts)) holds `name`, `status`, `edgeType`, `flowDirection`, `tagIds`, `triggers: [Trigger]`, `actions: [Action]`, `workflows: [Workflow]`, `duplicatedFrom`, and resolves `createdUser`, `updatedUser`, and `approvalLockState(action)`. Each run is an `Execution` record (`db/models/Executions.ts`) exposed through `automationHistories`.

### GraphQL API

| Operation | Key arguments |
| --------- | ------------- |
| `automationsMain` / `automations` | `page`, `perPage`, `ids`, `excludeIds`, `searchValue`, `sortField`, `sortDirection`, `status`, `tagIds`, `createdByIds`, `updatedByIds`, date ranges, `triggerTypes`, `actionTypes`, cursor params |
| `automationDetail` / `cpAutomationDetail` | `_id!` |
| `automationHistories` / `automationHistoriesTotalCount` | `automationId!`, `status`, `triggerId`, `triggerType`, `targetId`, `targetIds`, `parentExecutionId`, `failedActionIds`, `errorCodes`, `waitingActionIds`, `beginDate`, `endDate`, cursor params |
| `getAutomationExecutionDetail` | `executionId!` |
| `automationStats` / `automationExecutionCounts` | `automationId!`, `beginDate`, `endDate` / `automationIds: [String!]!` |
| `automationConstants` | none; returns the merged trigger/action registry |
| `automationSetPropertyTargets` / `automationNodeOutput` / `automationReferenceFields` | `sourceType!` / `nodeType!` / `type!`, `field!` |
| `automationNotes` | `automationId!`, `triggerId`, `actionId` |
| `automationsAiAgents` / `automationsAiAgentDetail` / `automationsAiAgentHealth` | `kind` / `_id` / `agentId!` |
| `getAutomationWebhookEndpoint` | `_id!`, `waitEventActionId` |
| `automationEmailTemplates` / `automationWorkflowTemplates` | `page`, `perPage`, `searchValue` |
| `automationsAdd` / `automationsEdit` | `name`, `status`, `edgeType`, `flowDirection`, `triggers: [TriggerInput]`, `actions: [ActionInput]`, `workflows: [WorkflowInput]` (`automationsEdit` takes `_id`, `acknowledgeDuplicate`) |
| `automationsDuplicate` / `automationsRemove` / `archiveAutomations` | `_id!`, `name` / `automationIds` / `automationIds`, `isRestore` |
| `automationsSaveAsTemplate` / `automationsCreateFromTemplate` | `_id!`, `name`, `duplicate` / `_id` |
| `automationsAddNote` / `automationsEditNote` / `automationsRemoveNote` | `automationId`, `triggerId`, `actionId`, `description` |
| `automationsAiAgentAdd` / `Edit` / `Remove` / `Reindex` | `name`, `description`, `connection`, `runtime`, `context` / `_id!`, `fileId` |
| `automationEmailTemplates*` / `automationWorkflowTemplates*` | `name!`, `description`, `content!` / `entryActionId`, `actions`, `inputs` |

### How plugins register triggers and actions

A plugin passes an `AutomationConfigs` object (`meta/automations.ts` → `startPlugin`); core calls `startAutomations(app, 'core', config)` from `initAutomation` in `meta/automations/automations.ts`. The config declares:

- `constants.triggers` / `constants.actions`: the catalog `automationConstants` returns. Sales merges `salesAutomationContants` and `posAutomationConstants`.
- `receiveActions`: executes the plugin's actions during a run.
- `checkCustomTrigger`: evaluates plugin trigger conditions.
- `setProperties`, `findObject`: backs `setProperty` actions and target lookups (`findObjectTargets`, `setPropertyTargets`).
- `generateAiContext`, `loadAiKnowledgeDocumentBatch`, `lookupAiTool`: optional AI-agent hooks.

Handlers are typically composed with `createCoreModuleProducerHandler`, which dispatches by `moduleName` inside the plugin (sales routes `sales` vs `pos`).

> [!WARNING]
> **A plugin trigger is missing from the builder**
>
> The plugin's `meta/automations.ts` `constants` must be registered and the plugin enabled. `automationConstants` shows what the gateway aggregated.

### Execution path

[initMQWorkers](https://github.com/erxes/erxes/blob/f91e8c6f4714c910a32c6b6e14fe085f6435c89e/backend/services/automations/src/bullmq/initMQWorkers.ts) in the service starts three BullMQ workers (`trigger`, `action`, `aiAgent`) on the `automations` queue prefix. The API alone only stores definitions and history: with the service stopped, triggers queue but nothing executes. Waiting steps (`delay` actions, `waitEvent` webhook waits) persist in `waitingActionsToExecute` and resume via `waitingActionIds` history filters; `getAutomationWebhookEndpoint` exposes the inbound webhook URL. Built-in action types in `constants.ts`: `delay`, `if`, `setProperty`, `sendEmail`.

## Approvals

```text
backend/core-api/src/modules/approval/
  graphql/schema/{index,types,queries,mutations}.ts
  graphql/resolvers/{queries,mutations,customResolvers}.ts
  db/definitions/{approvalLocks,approvalRequests}.ts
  db/models/{ApprovalLocks,ApprovalRequests}.ts
  trpc/approval.ts
backend/erxes-api-shared/src/core-modules/approval/
  checkApprovalLock.ts, types.ts, zodSchemas.ts, constants.ts
```

An `ApprovalLock` records `contentType`, `contentId`, `lockedBy`, `ownerIdSnapshot`, `allowedUserIds`, `approverScope`, `approvalMode`, `status`, `releasedAt`/`releasedBy`/`releaseReason`. An `ApprovalRequest` tracks `lockId`, `requesterId`, `reason`, `status`, `requiredApproverIds`, per-user `decisions[]` (`userId`, `decision`, `reason`, `at`), `notificationIds`, `resolvedAt`.

| Operation | Key arguments |
| --------- | ------------- |
| `approvalLockState` | `contentType!`, `contentId!`, `ownerId`, `action`; returns `locked`, `hasAccess`, `reason`, `lock`, `pendingRequest` |
| `approvalLockStates` | `contentType!`, `contentIds: [String!]!`, `ownerIdsByContentId`, `action` |
| `approvalRequests` | `status`, `contentType`, `requesterIds`, `approverIds`, cursor params |
| `approvalRequestDetail` | `_id!` |
| `approvalLockCreate` | `input: { contentType!, contentTypeId!, ownerId!, allowedUserIds, scope, mode }` |
| `approvalLockRelease` / `approvalLockForceRelease` | `_id!` / `_id!`, `reason!` |
| `approvalRequestCreate` | `input: { contentType!, contentId!, reason }` |
| `approvalRequestApprove` / `Reject` / `Cancel` | `_id!` / `_id!`, `reason` / `_id!` |

Plugins do not query the collections. They call `checkApprovalLock.state({ subdomain, contentType, contentId, action, userId })` from [erxes-api-shared](https://github.com/erxes/erxes/blob/f91e8c6f4714c910a32c6b6e14fe085f6435c89e/backend/erxes-api-shared/src/core-modules/approval/checkApprovalLock.ts), which reaches core over tRPC (`module: 'approval'`, `action: 'state'`). If the check is unreachable it fails closed (`locked: true`, `hasAccess: false`, reason "Approval lock check unavailable"). The `Document` and `Automation` GraphQL types expose `approvalLockState(action)` so the UI can render lock state inline.

> [!WARNING]
> **Locked for everyone**
>
> If `approvalLockState` reports `locked` for every user, the tRPC call to core failed: the lock check fails closed. Check core-api health before assuming a real lock exists.

Approvals are a separate feature, not an automation step type: automations pause on `delay`/`waitEvent` actions, while gated edits go through `approvalLockCreate` → `approvalRequestCreate` → approve/reject → `approvalLockRelease`.

## Permissions

`automationsRead` (`always`), `automationsCreate`, `automationsUpdate`, `automationsDelete`, `automationsAiAgentAdd`, `automationsAiAgentEdit`, `automationsAiAgentRemove`; scope `own` (records in `createdBy`) or `all`. `approvalLocksManage` is `always: true`; `approvalLocksForceRelease` is restricted.

## Example

```graphql
query ExecutionHistory($automationId: String!) {
  automationHistories(automationId: $automationId, status: "error", limit: 20) {
    list { _id status createdAt }
    totalCount
  }
}

mutation RequestApproval {
  approvalRequestCreate(input: {
    contentType: "sales:deal"
    contentId: "<dealId>"
    reason: "Discount above threshold"
  }) { _id status }
}
```

