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

# Automations (triggers)

> Triggers that listen for an event, test conditions and run one action (a signed webhook, a tool or an owner email), with runs, retries and dry runs.

A trigger says "when this event happens, and these conditions hold, do one thing". It listens for one event Sempera emits, tests the event against zero or more conditions, and runs one action aimed at a destination the workspace has already approved. Every run is recorded, including the ones a condition skipped.

A trigger covers the whole workspace or the agents it names; an event about an agent it does not cover leaves no run. Every trigger belongs to one workspace. A trigger or run of another workspace answers `404` exactly like one that never existed, and a trigger that names an agent the credential cannot read is not listed to it.

Reads need `agents:read`. Writes need `agents:write` and the `owner` role, because a trigger sends the business's call data to another system. A dry run needs `agents:write` only. Success answers use the compatibility envelope `{"data": ..., "errors": null}`; refusals use the standard error envelope ([Errors and limits](https://docs.sempera.org/errors-and-limits.md)). Without a customer database, the two list routes answer empty lists, the events route still answers, and every other route answers `503 service_unavailable`.

## Routes

| Route | Notes |
| - | - |
| `GET /v1/automation/events` | The events, their fields and the operators each field type offers, the actions offered and the actions refused |
| `GET /v1/automation/triggers` | `{"triggers", "total", "limit", "offset"}`. `limit` 1 to 200 (default 50), `offset` from 0, `sort` `recent` (default), `runs`, `active` or `name`, and the filters `agent_id` (triggers covering that agent) and `event` |
| `POST /v1/automation/triggers` | Create. `201`. `POST /v1/automation/pipelines` creates the same record; call one of them, not both |
| `GET /v1/automation/triggers/{id}` | One trigger |
| `PATCH /v1/automation/triggers/{id}` | A partial edit: fields you leave out keep their values. `status` (`active` or `paused`) or `enabled` pauses and resumes. Optional `expected_updated_at` makes the save conditional (see Editing together) |
| `DELETE /v1/automation/triggers/{id}` | `{"id", "deleted": true}` |
| `POST /v1/automation/triggers/{id}/test` | A dry run. Nothing is sent and nothing is recorded, and a paused trigger can be tested |
| `GET /v1/automation/triggers/{id}/executions` | One trigger's runs, newest first. `limit`, `offset` and `status` |
| `GET /v1/automation/executions` | Every run the credential may read, newest first. `limit`, `offset`, `status` and `trigger_id` |
| `GET /v1/automation/executions/{id}` | One run |
| `POST /v1/automation/executions/{id}/retry` | Send a failed or stalled webhook or tool run again, once |

A `limit`, `offset`, `sort`, `event` or `status` outside the values above is refused with `400 validation_failed`, not clamped.

## Editing together

Two owners can open the same trigger and both save. Without a guard the later save wins and the earlier one is lost without a word. To avoid that, send the `updated_at` you read back with the edit:

```http theme={null}
PATCH /v1/automation/triggers/{id}
{"name": "Booked calls", "expected_updated_at": "2026-10-23T15:00:00+00:00"}
```

`expected_updated_at` is the trigger's `updated_at`, sent back unchanged (a time with a zone). The trigger is saved only if it still has that `updated_at`, and the check and the save are one step, so of two saves that carry the same value exactly one is applied. A pause or a resume (`enabled` or `status`) is an edit and takes the same field.

If another owner saved first, the answer is `409 conflict` with `reason: "stale_trigger"` and `updated_at`, the value now on file. Nothing is written and nothing is recorded in the audit log. Show the owner what changed, then either discard the edit or send it again with `expected_updated_at` set to that `updated_at` to save it over the newer version. A value that is not a time with a zone is `400 validation_failed` with `field: "expected_updated_at"`. A trigger of another workspace, or one deleted meanwhile, is `404 not_found` and names no `updated_at`.

Without the field a save behaves as it always did: the last save wins.

## A trigger

```http theme={null}
POST /v1/automation/triggers
{"name": "Long calls to the ops channel",
 "event": "call.completed",
 "conditions": [{"field": "duration_s", "operator": "greater_than", "value": 600}],
 "action": {"kind": "webhook", "endpoint_id": "<a webhook endpoint id>"},
 "agent_ids": ["northwind-barbers"],
 "enabled": true}
```

| Field | Rule |
| - | - |
| `name` | Required, up to 200 characters |
| `event` | Required: one key of the table below |
| `conditions` | Up to 20 of `{"field", "operator", "value"}`; every one must hold. `field` is one of the event's fields, `operator` one that field's type offers, and `value` matches the field's type (text up to 500 characters). `is_empty` and `is_not_empty` take no value |
| `action` | Required, one of the actions below |
| `agent_ids` | `"all"` (default) or 1 to 50 agent ids |
| `enabled` | `true` or `false`. A trigger created without it starts paused |

A trigger reads `id`, `object: "automation_trigger"`, `org_id`, `name`, `enabled`, `status`, `event`, `event_name`, `conditions`, `action`, `agent_ids`, `run_count`, `last_run_at`, `created_at` and `updated_at`.

| Type | Operators |
| - | - |
| Text | `equals`, `not_equals`, `contains`, `not_contains`, `is_empty`, `is_not_empty`; text compares without regard to case |
| Number | `equals`, `not_equals`, `greater_than`, `less_than`, `is_empty`, `is_not_empty` |
| Yes or no | `equals`, `not_equals` |
| List of text | `contains`, `not_contains`, `is_empty`, `is_not_empty` |
| Object, or a list of objects | `is_empty`, `is_not_empty` |

## Events

| Event | Fired when | Fields |
| - | - | - |
| `call.completed` | A phone call ended and its record was stored | `call_id`, `agent_id`, `direction`, `duration_s` (number), `outcome`, `summary`, `recording_stored` (yes or no), `turns` (number) |
| `call.failed` | An outbound call was not placed. A number on the do-not-contact list is a deliberate skip, not a failure | `agent_id`, `batch_id`, `batch_name`, `attempt` (number), `reason`, `will_retry` (yes or no), `number_last4` |
| `knowledge.gap_detected` | The agent searched its knowledge and nothing answered the caller | `agent_id`, `call_id`, `question`, `search_query`, `coverage` (number, 0 to 1), `documents` (list of text) |
| `alert.fired` | A metric alert rule fired or worsened ([Metric alerts](https://docs.sempera.org/alerts.md)) | `alert_id`, `alert_name`, `metric`, `severity`, `kind`, `value_pct`, `threshold_pct`, `call_volume`, `matched`, `lookback_minutes` (numbers), `agent_ids` (list of text) |
| `issue.created` | A triage issue was opened ([Triage issues](https://docs.sempera.org/triage.md)) | `issue_id`, `number` (number), `title`, `severity`, `category`, `status`, `source`, `agent_id` |
| `agent.published` | A version of an agent was published | `agent_id`, `version` (object) |
| `call.outcomes` | Dispositions were read from a finished call ([Dispositions](https://docs.sempera.org/dispositions.md)) | `agent_id`, `call_id`, `version_number` (number), `outcomes` (list of objects) |

\| `tool.success`, `tool.failure` | An attached conversation tool finished or was refused on a live phone call. A Test panel run, a simulation, a typed preview and API chat never fire them. Fixture results carry `environment: FIXTURE`; they are not live system success | `agent_id`, `call_id`, `tool`, `status` (number or null), `environment` | | `call.post_webhook` | A completed call was recorded and its post-call event is ready, not proof of webhook delivery | `agent_id`, `call_id`, `direction` | | `call.started` | A phone runtime session reached its opening | `agent_id`, `call_id`, `direction` | | `eval.completed` | An existing evaluation run announced completion or failure | `agent_id`, `test_id`, `status`, `outcome` | | `sms.received`, `sms.post_webhook` | An authenticated, non-replayed inbound SMS webhook was stored | `agent_id`, `thread_id`, `message_id` | | `call.voicemail` | The outbound runtime classified an answering machine | `agent_id`, `call_id` | | `call.inbound_received` | An authenticated inbound phone request passed admission | `agent_id`, `call_id` | | `integration.webhook_received` | An authenticated incoming integration delivery was stored for the first time | `agent_id`, `endpoint_id`, `event_id`, `event_type`, `record_type`, `record_id` |

`call.completed` never fires for a test, and on a call Sempera's own call service answered its `summary` is empty ([Webhooks](https://docs.sempera.org/webhooks.md)). `agent.published` and `call.outcomes` are the webhook events of the same name, reduced to the fields listed here. Integration events contain identifiers, never arbitrary incoming payloads or secrets. There are no invented CRM subscriptions. Tool actions triggered by automations do not recursively emit conversation tool events.

### Integration and CRM changes

Choose Integration webhook received in the trigger builder. The contact and booking templates add an event-type condition; they do not subscribe to another system. Configure your sender to deliver a JSON object to the agent's existing incoming webhook using the Sempera signature and delivery-ID headers. For example:

```json theme={null}
{"event_type":"crm.contact.updated","record_type":"contact","record_id":"contact-123"}
```

The three optional values must be identifiers of 1 to 200 ASCII characters, starting with a letter or digit and containing only letters, digits, underscores, periods, colons, slashes or hyphens. Missing or invalid values become empty strings, not coerced or truncated identifiers. All other payload fields are excluded from trigger data. Workspace and agent identity always come from the authenticated endpoint, never the submitted body. A duplicate or rejected delivery does not announce another event.

Use all-condition matching with `endpoint_id` and `event_type` to limit a trigger to one connection and one change. Tool inputs can reference `{{record_id}}` to look up or update the record through a workspace-approved API tool. Do not send credentials or whole customer records in these identifier fields. The existing incoming-event storage still retains the original accepted body under its existing retention policy.

An owner must configure the external sender or a signing adapter. Native provider webhook signatures and automatic CRM subscriptions are not implemented by this path. Receipt-to-trigger dispatch remains best effort, not a durable subscription queue: a process interruption after storage can leave an event unannounced, and redelivery is deduplicated. No live delivery proof is implied by a template or dry run.

## Actions

| `action.kind` | Fields | What it does |
| - | - | - |
| `webhook` | `endpoint_id` | POSTs the event to one of the workspace's own enabled webhook endpoints ([Webhooks](https://docs.sempera.org/webhooks.md)), signed with that endpoint's secret |
| `tool` | `tool_id`, `input` | Calls one of the workspace's own API request tools once ([Tools and integrations](https://docs.sempera.org/tools-and-integrations.md)). `input` is up to 20 named text values, each up to 2000 characters, which may use `{{field}}` for a field of the event |
| `email` | `to` | Emails every owner of the workspace with a verified address. `to` is `workspace_owners`, the only value |
| `call` | `request` | A full `POST /v1/calls` request with an explicit agent, recipient, production/version pin and consent. Uses that route's runtime dispatch, calling windows, caps and do-not-contact checks |
| `sms` | `agent_id`, `user_number`, `agent_message`, `consent_basis`, `timezone`, `window` | Sends through the agent's existing SMS handler, with texting enablement, sender ownership, limits, write policy, opt-out and do-not-contact checks. `timezone` and `window` are the recipient's sending hours, checked when saved and again before the send. Outside them, or on a trigger saved without them, nothing is sent and the run fails with `outside_sending_hours` or `sms_hours_required` |
| `slack` | `tool_id`, `input` | Uses a registered workspace HTTPS API request tool aimed at `hooks.slack.com` or `slack.com`. Credentials remain in the existing tool registry, not the trigger |
| `custom_code` | `snippet_id`, `version`, `input` | Runs an active published workspace snippet in the existing bounded, no-network sandbox. Inputs may use the same event field substitutions as tool inputs |

A bare URL is not a destination: register the address as a webhook endpoint first. An `endpoint_id` or `tool_id` that is not this workspace's own, or a tool that is not an API request tool, is a `400`. Call and SMS actions additionally require `calls:write` and outbound permission when created or edited. Their target agent must also be in the credential's scope. Choosing a `custom_code` action additionally requires `tools:write` and snippet permission. A `call` action's `timezone`, `window` and `webhook` are checked when saved, by the rules `POST /v1/calls` uses. A call or SMS action on `call.failed` or `call.outcomes` is a `400`: only inbound call events may start contact, so it could never run.

The four specialized actions are stored under the durable `tool` business-action class, so existing publication crash, retention and owner-review protections still apply. Their public action preserves its specialized kind. No schema migration is needed. Each execution has a stable one-shot dispatch key, with stale reservation recovery disabled. These actions cannot be manually retried; inspect an uncertain result before authoring new work. Tests and dry runs never send. A fixture-mode SMS or Slack action records that nothing was delivered rather than reporting a real delivery. Custom code records execution status, not arbitrary code output, in the delivery log.

A webhook action posts this body with `X-Sempera-Signature`, `X-Sempera-Event-Id` (the body's `id`, the same on every attempt), `X-Sempera-Event` and, when the workspace has turned on JWT request signing, the signing header ([Secrets and request signing](https://docs.sempera.org/secrets-and-signing.md)):

```json theme={null}
{"id": "evt_...", "object": "event", "type": "call.completed", "created_at": "2026-09-28T12:00:00+00:00",
 "workspace_id": "...", "trigger": {"id": "...", "name": "Long calls to the ops channel"}, "data": {"call_id": "...", "duration_s": 640, "...": "every field the event lists"}}
```

It makes up to three attempts (at once, 30 seconds later, then 5 minutes later) until the first `2xx`, and follows the delivery rules in [Webhooks](https://docs.sempera.org/webhooks.md). A tool is called once, because a tool that changes something is not safe to repeat unasked, through the same road and write rules as a tool step inside a call. Where the deployment answers tools from fixtures, the run ends `failed` with an error saying nothing was sent.

## Runs

A run reads `id`, `object: "automation_execution"`, `trigger_id`, `trigger_name`, `event`, `event_id`, `event_summary`, `event_data`, `agent_ids`, `status`, `matched`, `failed_condition`, `action_kind`, `action`, `stalled`, `retryable`, `started_at`, `completed_at` and `duration_ms`.

`status` is `skipped` (a condition did not hold; `failed_condition` names the first, with the value it saw), `running`, `succeeded` or `failed`. `action` is the result: for a webhook the endpoint and every attempt with its `response_code` or `error`, for a tool the tool and the outcome, for an email `recipients` and `sent`. A run still `running` well after its attempts could have finished reads `stalled: true`.

`POST .../retry` sends a failed or stalled webhook or tool run once more, now: the same event id and time, to the trigger's current destination, numbered after the attempts it already has. It answers the run. A skipped or succeeded run, an email run (its owners would get it twice), a run whose call content was deleted, a run still delivering, and a run whose trigger's action has changed since, are refused with `409 conflict` and a sentence saying why.

## Dry run

```http theme={null}
POST /v1/automation/triggers/{id}/test
{"event": {"duration_s": 700}}
```

`event` sets some of the event's fields; the event's own sample fills the rest (and a trigger that names agents uses its first). It answers `object: "automation_test"`, `dry_run: true`, `delivered: false`, the resolved `event_data`, `in_scope`, each condition with the value it saw (`actual`) and whether it `passed`, `failed_condition`, `would_run` and, when it would run, the `action` that would go out with its destination. A field the event does not have, or one of the wrong type, is a `400`.

## Refusals

| Status | When |
| - | - |
| `400 validation_failed` | A field breaks a rule above; `field` names it |
| `404 not_found` | No such trigger or run in this workspace, or one naming an agent the credential cannot read |
| `409 conflict` | Retrying a run that cannot be sent again; or an edit whose `expected_updated_at` is no longer the trigger's (`reason: "stale_trigger"`, with the `updated_at` on file) |
| `503 service_unavailable` | The deployment has no customer database to keep triggers in |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.