# Triggers (https://www.agentenvframework.com/docs/environments/triggers)

> Make an environment react during a run. When a tool call, a state or a virtual time matches, the gateway calls a tool, changes a role's tools or gives an agent an instruction.

## Build a Trigger

An interactive trigger builder on `office`'s gateway (http://localhost:61854). Pick when the trigger fires (`action`, `state` or `time`), what it does (`permission`, `tool` or `nl`), and whether it has a barrier and repeats; the builder shows the request that registers it and plays an agent's run against it, with `GET /triggers/state` after every step.

The agent, calling as role `default`, runs the same four calls every time: `email_search` for the Q3 review, `contacts_add` for Priya Shah, `send` to Priya, and `contacts_add` for Omar Haddad.

**An action trigger with a barrier (the default)**

```json title="POST /triggers/register"
{"watch_roles": ["default"], "triggers": [{"id": "unlock-send", "when": {"type": "action", "tool": "contacts_add", "where": {"args.email": {"regex": "@example\\.com$"}}}, "actions": [{"type": "permission", "action": "enable", "role": "default", "tools": ["send"]}], "barrier": {"at": "provoking_call"}}]}
```

1. `send` starts disabled for `default`, the role the agent calls as, so it can’t email anyone yet.
2. The gateway registers `unlock-send` and arms it. It watches role `default`, so only calls made as `default` can set it off.
3. The agent calls `email_search` with `{"query": "Q3 review"}`. It finds an email: Priya Shah and Omar Haddad join the Q3 review.
4. `email_search` isn’t the trigger’s tool, so the answer goes straight back.
5. The agent calls `contacts_add` with `{"name": "Priya Shah", "email": "priya.shah@example.com"}`.
6. The server answers. The gateway checks the finished call against the trigger before it answers the agent.
7. `args.email` matches `@example\.com$`, so the trigger is detected and starts firing. Its barrier holds the agent’s answer until the actions are done.
8. The `permission` action enables `send` for `default`, the same rule `POST /tools/enable` sets. It is now `fired` and won’t fire again.
9. The barrier lets go and the agent gets its answer. Whatever it does next, the effect is already in place.
10. The agent sends Priya the numbers: `send` with `{"to": "priya.shah@example.com", "subject": "Q3 numbers", "body": "Attached."}`.
11. `send` is enabled now, so the call reaches `email` and succeeds.
12. The agent adds Omar with `{"name": "Omar Haddad", "email": "omar.haddad@example.com"}`.
13. Omar’s address matches, but `unlock-send` is `fired`: without `repeat`, an action trigger fires once.

Afterwards `GET /triggers/state` reports `unlock-send` as `fired` with `fire_count` 1.

**A state trigger**

```json title="POST /triggers/register"
{"watch_roles": ["default"], "triggers": [{"id": "unlock-send", "when": {"type": "state", "check": {"tool": "contacts_lookup", "args": {"name": "Priya"}, "extract": "contacts", "predicate": {"exists": true}}}, "actions": [{"type": "permission", "action": "enable", "role": "default", "tools": ["send"]}]}]}
```

1. `send` starts disabled for `default`, the role the agent calls as, so it can’t email anyone yet.
2. The gateway registers `unlock-send` and arms it. It watches role `default`, so only calls made as `default` can set it off.
3. The agent calls `email_search` with `{"query": "Q3 review"}`. It finds an email: Priya Shah and Omar Haddad join the Q3 review.
4. The answer goes straight back: a state trigger never holds a call.
5. After a watched call to a tool not marked read-only, the engine runs the check itself. No contact matches Priya yet, so `contacts` is `[]` and the trigger stays armed.
6. The agent calls `contacts_add` with `{"name": "Priya Shah", "email": "priya.shah@example.com"}`.
7. The answer goes straight back; a state check runs on its own, after the call.
8. The check runs again, and `contacts` now has Priya: the predicate `{"exists": true}` passes.
9. The trigger is detected. `provoking` names the call after which the check ran.
10. The `permission` action enables `send` for `default`, the same rule `POST /tools/enable` sets. It is now `fired` and won’t fire again.
11. The agent sends Priya the numbers: `send` with `{"to": "priya.shah@example.com", "subject": "Q3 numbers", "body": "Attached."}`.
12. `send` is enabled now, so the call reaches `email` and succeeds. Nothing held the agent, so this order isn’t guaranteed, though a fast action usually wins.
13. The agent adds Omar with `{"name": "Omar Haddad", "email": "omar.haddad@example.com"}`.
14. A state trigger fires once, so `unlock-send` stays `fired` and its check no longer runs.

Afterwards `GET /triggers/state` reports `unlock-send` as `fired` with `fire_count` 1.

**A time trigger**

```json title="POST /triggers/register"
{"watch_roles": ["default"], "triggers": [{"id": "unlock-send", "when": {"type": "time", "at": "PT15M"}, "actions": [{"type": "permission", "action": "enable", "role": "default", "tools": ["send"]}]}]}
```

1. `send` starts disabled for `default`, the role the agent calls as, so it can’t email anyone yet.
2. The gateway registers `unlock-send` and arms it. A time trigger waits for the virtual clock, which isn’t armed yet, so `next_mark` is `null`.
3. (09:00) The clock is armed at 09:00, a virtual minute per real second. Within a second the gateway resolves `at: "PT15M"` to 09:15, the trigger’s `next_mark`.
4. (09:03) The agent calls `email_search` with `{"query": "Q3 review"}`. It finds an email: Priya Shah and Omar Haddad join the Q3 review.
5. (09:03) The answer goes straight back. On each watched call the gateway also checks its time triggers, and 09:15 isn’t due yet.
6. (09:09) The agent calls `contacts_add` with `{"name": "Priya Shah", "email": "priya.shah@example.com"}`.
7. (09:09) Adding Priya matters to no time trigger: the answer goes straight back.
8. (09:15) The gateway checks time triggers every real second and on every watched call. The clock has passed 09:15, so the trigger fires with no call to provoke it.
9. (09:15) The `permission` action enables `send` for `default`, the same rule `POST /tools/enable` sets. A one-off time trigger is now `fired`.
10. (09:17) The agent sends Priya the numbers: `send` with `{"to": "priya.shah@example.com", "subject": "Q3 numbers", "body": "Attached."}`.
11. (09:17) `send` is enabled now, so the call reaches `email` and succeeds.
12. (09:20) The agent adds Omar with `{"name": "Omar Haddad", "email": "omar.haddad@example.com"}`.
13. (09:20) `unlock-send` fired at 09:15 and is done; a recurring time trigger would use `every`.

Afterwards `GET /triggers/state` reports `unlock-send` as `fired` with `fire_count` 1.

**A repeating action trigger with a templated tool action**

```json title="POST /triggers/register"
{"watch_roles": ["default"], "triggers": [{"id": "welcome", "when": {"type": "action", "tool": "contacts_add", "where": {"args.email": {"regex": "@example\\.com$"}}, "repeat": true}, "actions": [{"type": "tool", "tool": "send", "args": {"to": "${args.email}", "subject": "Welcome, ${args.name}", "body": "You are in the office contacts."}}], "barrier": {"at": "provoking_call"}}]}
```

1. The gateway registers `welcome` and arms it. It watches role `default`, so only calls made as `default` can set it off.
2. The agent calls `email_search` with `{"query": "Q3 review"}`. It finds an email: Priya Shah and Omar Haddad join the Q3 review.
3. `email_search` isn’t the trigger’s tool, so the answer goes straight back.
4. The agent calls `contacts_add` with `{"name": "Priya Shah", "email": "priya.shah@example.com"}`.
5. The server answers. The gateway checks the finished call against the trigger before it answers the agent.
6. `args.email` matches `@example\.com$`, so the trigger is detected and starts firing. Its barrier holds the agent’s answer until the actions are done.
7. The `tool` action calls `send` with `${args.email}` and `${args.name}` filled in from the provoking call: `{"to": "priya.shah@example.com", "subject": "Welcome, Priya Shah", "body": "You are in the office contacts."}`.
8. The welcome is sent. The firing log records the arguments by shape only, since they can hold personal data. Its status goes back to `armed`, since the trigger repeats.
9. The barrier lets go and the agent gets its answer. Whatever it does next, the effect is already in place.
10. The agent sends Priya the numbers: `send` with `{"to": "priya.shah@example.com", "subject": "Q3 numbers", "body": "Attached."}`.
11. The welcome went out first, so the agent’s email is the second.
12. The agent adds Omar with `{"name": "Omar Haddad", "email": "omar.haddad@example.com"}`.
13. Omar’s address matches too, and a repeating trigger is armed again, so it fires a second time.
14. Detected again, with Omar’s call as the provoking call. `${args.*}` now reads Omar’s arguments.
15. The `tool` action calls `send` with `${args.email}` and `${args.name}` filled in from the provoking call: `{"to": "omar.haddad@example.com", "subject": "Welcome, Omar Haddad", "body": "You are in the office contacts."}`.
16. The welcome is sent. The firing log records the arguments by shape only, since they can hold personal data. Its status goes back to `armed`, since the trigger repeats.
17. The barrier lets go. `fire_count` is 2.

Afterwards `GET /triggers/state` reports `welcome` as `armed` with `fire_count` 2.

**An nl action without a barrier**

```json title="POST /triggers/register"
{"watch_roles": ["default"], "executor": {"a2a_url": "http://localhost:61907", "role": "world"}, "triggers": [{"id": "dana-welcomes", "when": {"type": "action", "tool": "contacts_add", "where": {"args.email": {"regex": "@example\\.com$"}}}, "actions": [{"type": "nl", "instruction": "You are Dana Park. Email priya.shah@example.com to welcome her to the Q3 review."}]}]}
```

1. The gateway registers `dana-welcomes` and arms it. It watches role `default`, so only calls made as `default` can set it off.
2. The agent calls `email_search` with `{"query": "Q3 review"}`. It finds an email: Priya Shah and Omar Haddad join the Q3 review.
3. `email_search` isn’t the trigger’s tool, so the answer goes straight back.
4. The agent calls `contacts_add` with `{"name": "Priya Shah", "email": "priya.shah@example.com"}`.
5. The server answers, and with no barrier the gateway passes the answer straight to the agent.
6. `args.email` matches `@example\.com$`, so the trigger is detected and starts firing. The actions run in the background.
7. The `nl` action sends the instruction to the executor over A2A `message/send`, then polls `tasks/get` until the task completes.
8. The agent sends Priya the numbers: `send` with `{"to": "priya.shah@example.com", "subject": "Q3 numbers", "body": "Attached."}`.
9. The executor hasn’t sent the welcome yet, so the agent’s email is the first. A barrier would have held `contacts_add`’s answer until it had.
10. The executor sends the welcome through the gateway as role `world`, which triggers never watch, so its calls can’t set off triggers.
11. The executor’s task completes, so the action succeeded. The trigger is `fired`.
12. The agent adds Omar with `{"name": "Omar Haddad", "email": "omar.haddad@example.com"}`.
13. Omar’s address matches, but `dana-welcomes` is `fired`: without `repeat`, an action trigger fires once.

Afterwards `GET /triggers/state` reports `dana-welcomes` as `fired` with `fire_count` 1.

Options the gateway refuses with a 400, registering nothing:

- `state` with `repeat`: `{"ok": false, "error": "trigger 'unlock-send': when.repeat is only valid on action triggers"}`
- `time` with `repeat`: `{"ok": false, "error": "trigger 'unlock-send': when.repeat is only valid on action triggers (time triggers recur via 'every')"}`
- `state` with a barrier: `{"ok": false, "error": "trigger 'unlock-send': barrier.at 'provoking_call' is only valid on action triggers \u2014 they alone have a provoking call to hold"}`

Choose when the trigger fires and what it does, then play the agent's run against it. The request,
the run and `GET /triggers/state` follow the gateway's trigger engine, down to the 400s it answers
for options it refuses.

## Built Into the Gateway

The trigger engine comes with the [gateway topology](https://www.agentenvframework.com/docs/environments/gateway-topology.md#triggers) and
appears on the instance's card as `urn:agentenv:triggers/v1`, so `email` and `contacts` implement
nothing for it. It checks each call after the server has answered, and a call that returns an error
matches no trigger.

Only calls from the roles in `watch_roles` count, and the agent's role comes from its `AgentEnv-Role`
header, `default` without one. An instance without a gateway gets triggers only if its server
advertises `urn:agentenv:triggers/v1` itself.

## The Endpoints

| Endpoint                  | Body                                           | What it does                                             |
| ------------------------- | ---------------------------------------------- | -------------------------------------------------------- |
| `POST /triggers/register` | `triggers`; optional `watch_roles`, `executor` | Adds each trigger by `id` and arms it                    |
| `POST /triggers/remove`   | `ids`                                          | Disarms and deletes those triggers, ignoring unknown ids |
| `POST /triggers/clear`    | none                                           | Deletes every trigger, the settings and the firing log   |
| `GET /triggers/state`     | none                                           | Each trigger's status and counts, and the firing log     |

```bash title="Terminal"
curl -sS -X POST http://localhost:61854/triggers/register -H 'content-type: application/json' \
  -d '{"watch_roles": ["default"], "triggers": [{"id": "unlock-send", "when": {"type": "action", "tool": "contacts_add", "where": {"args.email": {"regex": "@example\\.com$"}}}, "actions": [{"type": "permission", "action": "enable", "role": "default", "tools": ["send"]}], "barrier": {"at": "provoking_call"}}]}'
```

```json title="Output"
{"ok": true, "added": ["unlock-send"], "all": ["unlock-send"]}
```

Registering adds to what is there. The same trigger sent again changes nothing, a different one under
a used `id` gets a 400, and a trigger without an `id` gets one like `trg_3f9a0c1d2e4b`.

The first register request fixes `watch_roles` (`["default"]` if you leave it out), and the first to
send an `executor` fixes that; a later request with other values gets a 400 until you clear. Once the gateway knows its tools,
a trigger that names a tool it doesn't serve gets a 400 too. `clear` is for setup and tests, not the
middle of a run.

## When: A Tool Call

An `action` trigger fires when a watched call to `tool` returns and every test in `where` passes. A
`where` key names an argument (`to`), a path into the arguments (`args.to`) or a path into the parsed
result (`result.sent`), with `[0]` for a list item, and holds one test: `regex`, `equals` or `exists`.

```json title="when"
{"type": "action", "tool": "send", "where": {"args.to": {"regex": "@example\\.com$"}}, "repeat": true}
```

It fires once, or on every match with `"repeat": true`. A match that arrives while a repeating trigger
is still firing waits its turn, and the trigger shows `queued`.

## When: A State

A `state` trigger runs its `check` after each watched call to a tool not marked read-only
(`readOnlyHint`), and fires the first time the check passes. Each step calls a tool, can `extract` a
value by path and `bind` it to a name later steps use as `${name}`, and the last step tests it with a
`predicate`:

```json title="when"
{"type": "state", "check": {"steps": [
  {"tool": "contacts_lookup", "args": {"name": "Priya"}, "extract": "contacts[0].email", "bind": "address"},
  {"tool": "email_search", "args": {"query": "${address}"}, "extract": "emails", "predicate": {"exists": true}}
]}}
```

A one-step check can skip `steps`, as in the builder. The engine calls the check's tools itself,
directly on the servers, so a check never fires another trigger.

## When: A Time

A `time` trigger fires on the [virtual clock](https://www.agentenvframework.com/docs/environments/virtual-clock.md), so nothing fires
until the clock is armed. The gateway checks time triggers every real second and on every watched
call.

| Field             | Fires                                                                                                                                                                             |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `at`              | At an RFC 3339 time, or a duration after the clock's start such as `PT15M`                                                                                                        |
| `after`, `offset` | `offset` after the trigger named in `after` has fired                                                                                                                             |
| `every`           | Again and again, every duration, or at random gaps with `{"dist": "exp", "mean": "PT1H"}` and an integer `seed`; from `at` or `after` if given, otherwise one gap after the start |
| `count`, `until`  | With `every`: stop after that many firings, or at that time                                                                                                                       |

If several due times pass between two checks, as they can on a fast clock, the trigger fires once
for each, up to 1,000 at a time. Arming the clock again moves `at` and `every` triggers onto the new
timeline and drops what was due on the old one.

## Then: Actions

A trigger's `actions` run in order, and the first one to fail stops the rest.

| `type`       | Fields                                                              | What it does                                                                                                    |
| ------------ | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `permission` | `action` (`enable` or `disable`), `role`, `tools` (a list or `"*"`) | Sets the same rules as [`/tools/enable` and `/tools/disable`](https://www.agentenvframework.com/docs/environments/rbac.md#disable-and-enable-tools) |
| `tool`       | `tool`, `args`                                                      | Calls the tool directly on the server, so role rules don't apply                                                |
| `nl`         | `instruction`                                                       | Hands the instruction to the executor agent and waits for its task to complete                                  |

In an action trigger, a `tool` action's `args` can read the provoking call with `${args.<path>}` and
`${result.<path>}`. A placeholder that is the whole value keeps its type, and `${args.cc?}` drops the
key when there is nothing to read; a required one with nothing to read fails the action.

```json title="actions"
[{"type": "tool", "tool": "send", "args": {
  "to": "${args.email}", "subject": "Welcome, ${args.name}", "body": "You are in the office contacts."
}}]
```

A `tool` or `permission` action can carry a `verify` check, shaped like a state check, and fails if
the check doesn't pass. An `nl` action with a `verify` that fails is sent to the executor once more.

## The Executor

`nl` actions need an `executor`, sent once in a register body: `{"a2a_url": "http://localhost:61907", "role": "world"}`,
plus `timeout_seconds`, 120 by default. The gateway sends the instruction with A2A `message/send` and
polls `tasks/get` until the task completes or the timeout passes.

The executor's `role` can't be in `watch_roles`, so its own tool calls never fire triggers.
Instructions are plain text, never templated, and one that contains `${args.` or `${result.` gets a
400\.

## Barriers

With `"barrier": {"at": "provoking_call"}`, the gateway holds its answer to the call that fired the
trigger until the trigger's actions are done, so the agent's next step already sees their effect.
`provoking_call` is the only value, and only action triggers can have one.

The gateway holds the call for up to `timeout_seconds`, 30 by default, and for the longest of them
when several barriers hold one call. Past that it answers anyway and writes `trigger_barrier_timeout`
to the trajectory.

## Statuses and the Firing Log

| Status   | Means                                                                              |
| -------- | ---------------------------------------------------------------------------------- |
| `armed`  | Waiting for its condition                                                          |
| `firing` | Its actions are running                                                            |
| `queued` | A repeating trigger matched again while firing; `pending` counts the calls waiting |
| `fired`  | Done: a one-off trigger that succeeded, or a time trigger past its last time       |
| `failed` | A time trigger whose action failed, now retired                                    |

An action or state trigger that fails goes back to `armed`, and `failure_count` and `last_failure_at`
record the failure. `events` lists what the engine did, in order: `added`, `removed`, `detected`,
`anchored`, `reanchored`, `action_ok`, `action_failed`, `verify_ok`, `verify_failed`, `fired`, `failed`
and `eval_error`.

The gateway keeps the first 1,000 events and the last 9,000, and `events_dropped` counts the rest.
Once the clock is armed, every event carries a `virtual_time` next to its real `ts`.

## In the Trajectory

Every firing is also a `trigger_fired` event in the env's [trajectory](https://www.agentenvframework.com/docs/environments/gateway-topology.md#gateway-topology),
and every `tool` action an `internal_tool_call` with the tool, whether it succeeded and a hash of its
result. Its arguments are recorded by shape, such as `{"redacted": {"type": "str", "len": 22}}`,
except for ids and names like `email_id` or `title`.

When a `prompt_agent` step ends, it reads `GET /triggers/state` from every env that a
`register_env_triggers` step registered triggers on. It waits up to a minute for triggers still
firing, then saves the state under `env_trigger_state/` in the object store.

## From a Task

The `register_env_triggers` step sends the register request from a task: `env_id` and `triggers`,
and optionally `watch_roles`, `executor_agent_name` and `executor_timeout_seconds` (120). It fails on
a 400, and records what it added in `metadata["env_trigger_registrations"]`.

```json title="Two steps"
[
  {"id": "world", "type": "deploy_agent", "env_ids": ["office"], "agent_name": "world", "role": "world", "depends_on": [{"task_step_id": "office"}]},
  {"id": "triggers", "type": "register_env_triggers", "env_id": "office", "watch_roles": ["default"], "executor_agent_name": "world", "triggers": [{"id": "dana-welcomes", "when": {"type": "time", "at": "PT15M"}, "actions": [{"type": "nl", "instruction": "You are Dana Park. Email priya.shah@example.com to welcome her to the Q3 review."}]}], "depends_on": [{"task_step_id": "office"}, {"task_step_id": "world"}]}
]
```

`executor_agent_name` names an agent that a `deploy_agent` step deployed with a `role` outside
`watch_roles`; the step sends that agent's A2A URL and role as the `executor`. This time trigger
fires only once a [`sync_env_clock`](https://www.agentenvframework.com/docs/tasks/more-steps.md#env-control) step arms the
clock, and the other steps that drive the gateway are in [Env control](https://www.agentenvframework.com/docs/tasks/more-steps.md#env-control).

Agents have triggers of their own, which `register_agent_triggers` registers on an agent that plays
the user; see [Agent extensions](https://www.agentenvframework.com/docs/agents/extensions.md).