Skip to content

feat(triggers): fire env triggers on requests to the env's websites - #237

Open
sarah-chitty-1 wants to merge 8 commits into
mainfrom
sarahchitty/triggers-http-when
Open

sarah-chitty-1 wants to merge 8 commits into
mainfrom
sarahchitty/triggers-http-when

Conversation

@sarah-chitty-1

@sarah-chitty-1 sarah-chitty-1 commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

What

A new trigger type, when.type: "website", that fires on requests the gateway proxies to an env's websites (/website/<app>/...).

Action triggers only see tool calls today. An agent that browses with its own browser or HTTP client (an external A2A agent, a computer-use agent with its own browser, a script) never calls the env's browser_* tools, so "when the agent opens the order page" never fires for it. Its page traffic does go through the gateway's website proxy, so the gateway can match on that.

{"id": "carrier-emails", "when": {"type": "website", "app": "porchbite", "method": "GET",
  "path": {"regex": "^/api/orders/"}}, "barrier": {"at": "provoking_call"}, "actions": [...]}
  • Fields:
    • app: required, and must be one of the env's websites (400 otherwise)
    • method: optional, upper-cased
    • path and status: optional, using the existing regex / equals / exists checks
  • Carried over from tool-call triggers: where on the request's args (query parameters plus form or JSON-object body fields; the body wins a clash), roles, repeat, ${args.*} in templates, and the provoking_call barrier, which holds the website's response until the trigger's actions have landed.
  • Status default: without a status check, only responses below 400 match, the same way a failed tool call never fires a trigger. A status check can match 4xx/5xx on purpose.
  • Firing log (GET /triggers/state): same contract. A detected event names the request as provoking: {source: "website", app, method, path, status, role}. The query string and body are never logged. The trajectory's trigger_fired also carries provoking_request.
  • Card: website is added to the triggers/v1 features list, and register_env_triggers checks it, so a website trigger on an older gateway gets the step's "does not declare ['website'] … redeploy" message.
  • Errors never break a request: if trigger evaluation fails, the request is still answered normally.
  • Explorer: website triggers and requests get readable labels in the run viewer. Before this, they showed "matched on ? (default)".

Worth a look

  1. Cost: the /website proxy hands requests to the trigger engine only while a website or a state trigger is registered; with neither, it pays one check per request. The request body is read only when a website trigger needs it, GETs never re-check state triggers, and a write schedules one background check per armed state trigger, as a tool write does. The response is never held for a state re-check.
  2. Behaviour change (state re-checks): a successful website request that changes something (anything but GET/HEAD/OPTIONS/TRACE) re-checks the state triggers, as a write through a tool does, whether or not a website trigger is registered. An env that already has websites and state triggers now re-checks them on website writes, so its state triggers can fire earlier than before.
  3. Error messages keep their old wording as a substring (for example "only valid on action triggers and website triggers"), so anything matching the old text still matches.
  4. Scope: only /website/<app>/ traffic counts. Direct backend calls through /svc/<app>/ don't. A broader http type could be a follow-up.
  5. No response data: responses stream through unread, so ${result.*} and where on the result are refused at registration. Capturing small JSON responses could be a follow-up.
  6. Roles: a plain browser sends no AgentEnv-Role header and counts as default. When watch_roles names the agent's role, give a website trigger when.roles: ["default"], or have the client (or a front door proxying to the gateway) send the header; the step's docstring says so. A role cookie is a possible follow-up.
  7. Hub UI: the hub frontend keeps its own copy of the trigger timeline parser; until it gets the same small change as the explorer here, it shows ? for a website detection.
  8. Not in this PR: a deadline's unless still matches only tool calls, and website requests themselves aren't written to the trajectory, only provoking_request on trigger_fired.
  9. With feat(gateway): name the role and call behind each change to an env's state #240's change log (/changes): a website request's triggers and state re-checks run once its response body has ended, after its change window closes, so a write made while the body is sent belongs to the request and a trigger's writes belong only to the trigger. When a barrier could hold the request, a sized body up to 8 MiB is read ahead first so the window can close before the barrier waits; a streamed or larger body keeps the window open to its last byte, so a trigger write landing meanwhile can still list both actors. Tests for each case are in changes_test.py.

Tests

  • Unit: tst/unit/env/gateway/triggers_website_test.py, 27 tests, plus the feature gate in test_register_env_triggers.py:
    • registration checks, matching, and firing-log and trajectory attribution
    • roles, repeat vs one-shot, barriers and state re-checks
    • through the real gateway app against a fake website: the barrier hold (fails without the barrier), path/role/args extraction, the error path, that a website write re-checks state triggers without parsing the body, and that with neither a website nor a state trigger the proxy never reaches the engine
  • Integration (slow): deploys the slack website env and fires a trigger through /website/slack/.
  • Results:
    • ruff clean
    • unit suite 8397 passed, 20 skipped
    • plugin-api: no break
    • uv lock --check OK
    • UI typecheck, lint and smoke clean
    • local slow integration for gateway triggers and register_env_triggers all pass

Tested end to end with an external agent's own browser

Three agents opened a stayncook link through our front door on Modal, each with its own browser: a plain Playwright script, claude-opus-5-5 driving headless Chrome, and Meta Muse's own cloud browser. Their requests went through the gateway's /website/stayncook/ proxy; none of them used the env's browser_* tools.

With Muse, the barrier held the first page about 57 ms, and the gateway sent the 200 0.3 ms after fired. Muse then found the stay under Canceled reservations, and the state grader counted the cancellation as the world's change, not the agent's (verdict: pass).

  • The trigger fired on the real GET /: provoking: {source: website, app: stayncook, method: GET, path: /, status: 200, role: psi_agent}, once (fire_count: 1).
  • The provoking_call barrier held the page until the host's cancellation and its verify step had landed, about 45 ms. The 200 went out 0.2 ms after fired in the gateway's own log.
  • Both sides saw the change: the agent's first trips fetch already showed the stay gone, and the state grader saw the cancellation as a world change.

Docs note: the provoking response is held, not re-rendered. A trigger meant to change what the agent sees should key on the page load (the HTML document), not on the data call it wants to change. Otherwise the barrier holds a response that already has the old data.

🤖 Generated with Claude Code

A `when.type: "website"` trigger matches the requests the gateway proxies
to one of its websites (`/website/<app>/...`): `app`, an optional `method`,
`path` and `status` predicates, plus `where` over the request's args (its
query and form/JSON-object body fields), `roles`, `repeat` and a
`provoking_call` barrier, which holds the website's response until the
actions have landed. Without a `status` predicate only a response below
400 matches, as only a tool call that did not fail provokes an action
trigger. An unsafe request that did not fail also prompts the state
triggers, as a non-readonly tool call does.

This lets an agent that browses with its own browser or HTTP client
provoke triggers, not only one calling the env's tools.

The firing log attributes the request (`provoking: {source: "website",
app, method, path, status, role}`), the trajectory's `trigger_fired`
carries `provoking_request`, the card declares the `website` feature, and
the explorer names website requests in the trigger timeline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…keep old error text; one-line comments

- The /website proxy feeds the trigger engine only while a website trigger is
  registered, so an env without one pays one check per request and its state
  triggers are never re-checked on website traffic. The request body is read
  inside the fail-open path.
- Error messages keep their previous wording as a substring (e.g. "only
  valid on action triggers"), so the existing test fragments are unchanged.
- Pre-existing docstrings are back as they were; every added comment and
  docstring is one line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sarah-chitty-1
sarah-chitty-1 marked this pull request as ready for review October 19, 2026 19:42
@sarah-chitty-1
sarah-chitty-1 requested a review from a team as a code owner October 19, 2026 19:42
@earakely-scale

Copy link
Copy Markdown
Collaborator

🤖 Drafted by Claude Code, reviewed by Edgar Arakelyan before posting.

Suggestion: gate the step on the new website feature. See register_env_triggers.py L144-L146.

This PR adds website to the triggers/v1 features list on the card, but the step's feature check doesn't look for it.

On a gateway image built before this PR, a website trigger still fails loudly. The error it gets is the gateway's 400, when.type must be 'action', 'state', or 'time', though. It should get the step's own message, which names the missing feature and says to redeploy on a newer gateway image. Tasks will hit this between this merging and gateway images being republished.

Before:

used = {TRIGGER_NAMED_EXECUTORS: bool(named), TRIGGER_PROVOKING_ROLES: _when_has(self.triggers, "roles"),
        TRIGGER_ACTION_DELAY: _delays(self.triggers), TRIGGER_EXECUTOR_HOURS: bool(self.executor_hours),
        TRIGGER_UNLESS: _when_has(self.triggers, "unless")}

After (with TRIGGER_WEBSITE added to the agent_env.env.gateway.constants import):

used = {TRIGGER_NAMED_EXECUTORS: bool(named), TRIGGER_PROVOKING_ROLES: _when_has(self.triggers, "roles"),
        TRIGGER_ACTION_DELAY: _delays(self.triggers), TRIGGER_EXECUTOR_HOURS: bool(self.executor_hours),
        TRIGGER_UNLESS: _when_has(self.triggers, "unless"),
        TRIGGER_WEBSITE: any(isinstance(t.get("when"), dict) and t["when"].get("type") == "website"
                             for t in self.triggers)}

A website task on an older gateway then fails with the step's existing message: "runs a gateway whose triggers/v1 does not declare ['website'] … redeploy it on a gateway image that does."

@earakely-scale

Copy link
Copy Markdown
Collaborator

🤖 Drafted by Claude Code, reviewed by Edgar Arakelyan before posting.

Suggestion: let a website write re-check state triggers even when no website trigger is registered. See gateway.py L516.

Why. Two of this PR's own tests show it. Both send the same POST /website/mail/api/messages against the same state trigger (sent: a message with "Lunch" in the subject exists):

As a task, that looks like this. An author wants "once the agent has sent Dana the lunch invite, Dana replies", so they write the natural thing: a state trigger on the sent message. An agent that sends it with the mail server's send tool gets the reply. An agent sending the same mail through the mail website in its own browser never does, unless the task also happens to register some unrelated website trigger. The PR description says a website write "also re-checks the state triggers, as a write through a tool does", and this would make that hold whether or not a website trigger is registered.

The proposal. Run the hook when a website or a state trigger is registered, and read the request body only when a website trigger needs it. The cost model stays close to "off unless used":

  • No website or state trigger: one check per request, as today.
  • The body is never read without a website trigger. A state re-check needs only the method and status.
  • GETs never re-check, so page loads and assets add nothing.
  • A write schedules one background check per armed state trigger, the same as a tool write does today. The response is never held, since state triggers can't use the provoking_call barrier, and the existing evaluating flag already dedupes concurrent checks.

The one behavior change: an env that already has websites and state triggers starts re-checking state triggers on website writes. That's arguably the intended behavior, but tasks on such envs could see a state trigger fire earlier than before.

Diff (applied to 7fbc1d8 and run locally: triggers_website_test.py 27 passed, tst/unit/env/gateway 492 passed)

The diff rewrites the first test above to the new expectation, adding a check that the body isn't parsed. It also adds a test pinning the zero-cost path: with only a time trigger registered, the proxy never reaches the engine.

diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py
index 18a6c8f..05d6185 100644
--- a/src/agent_env/env/gateway/gateway.py
+++ b/src/agent_env/env/gateway/gateway.py
@@ -510,10 +510,12 @@ class Gateway:
                              mcp_server=url_map is self.rest_proxy_urls and service_name.startswith("mcp-"))
 
     async def _proxy_website_request(self, request: Request) -> Response:
-        """Proxy a website request; with a website trigger registered, feed it to the engine and hold it for barriers."""
+        """Proxy a website request; with a website or state trigger registered, feed it to the engine and hold it for
+        barriers."""
         response = await self._proxy_rest_request(request, url_map=self.website_urls)
         app = request.path_params["service_name"]
-        if app in self.website_urls and self._trigger_engine.has_website_triggers:
+        engine = self._trigger_engine
+        if app in self.website_urls and (engine.has_website_triggers or engine.has_state_triggers):
             barrier_tasks, barrier_timeout_s = await self._fire_website_triggers(request, app, response.status_code)
             await self._await_barriers(barrier_tasks, barrier_timeout_s)
         return response
@@ -522,7 +524,8 @@ class Gateway:
                                      status: int) -> tuple[list[asyncio.Task], float | None]:
         """Feed an answered website request to the trigger engine (fail-open)."""
         try:
-            args = _request_args(request, await request.body())
+            # Only a website trigger reads the request's args; a state re-check needs just the method and status.
+            args = _request_args(request, await request.body()) if self._trigger_engine.has_website_triggers else {}
             return self._trigger_engine.on_website_request(self._request_role(request), app, request.method,
                                                            "/" + request.path_params.get("path", ""), status, args)
         except Exception:
diff --git a/src/agent_env/env/gateway/triggers.py b/src/agent_env/env/gateway/triggers.py
index 6c07bf8..f98a98a 100644
--- a/src/agent_env/env/gateway/triggers.py
+++ b/src/agent_env/env/gateway/triggers.py
@@ -943,6 +943,10 @@ class TriggerEngine:
     def has_website_triggers(self) -> bool:
         return any(t["spec"]["when"]["type"] == "website" for t in self._triggers.values())
 
+    @property
+    def has_state_triggers(self) -> bool:
+        return any(t["spec"]["when"]["type"] == "state" for t in self._triggers.values())
+
     def _provoked_by(self, trig: dict, role: str) -> bool:
         """Whether a call by ``role`` can provoke ``trig``: the roles its `when` (or a deadline's `unless`) lists, else the
         watched ones. Unlisted, an executor's own calls never provoke anything, so it cannot set off a cascade unasked."""
diff --git a/tst/unit/env/gateway/triggers_website_test.py b/tst/unit/env/gateway/triggers_website_test.py
index 08af033..59333dd 100644
--- a/tst/unit/env/gateway/triggers_website_test.py
+++ b/tst/unit/env/gateway/triggers_website_test.py
@@ -9,6 +9,7 @@ import httpx
 import pytest
 from mcp.types import CallToolResult, TextContent
 
+from agent_env.env.gateway import gateway as gateway_module
 from agent_env.env.gateway import rest_proxy as rest_proxy_module
 from agent_env.env.gateway.clock import Clock
 from agent_env.env.gateway.constants import EXT_TRIGGERS_URI, GATEWAY_EXTENSIONS, TRIGGER_WEBSITE
@@ -346,7 +347,7 @@ async def test_the_proxy_hands_the_engine_the_role_path_status_and_args_of_each_
 
 
 @pytest.mark.asyncio
-async def test_without_a_website_trigger_the_proxy_neither_reads_requests_nor_rechecks_state_triggers(monkeypatch):
+async def test_without_a_website_trigger_a_mutating_website_request_still_rechecks_the_state_triggers(monkeypatch):
     _Website(status=201).install(monkeypatch)
     calls: list[str] = []
 
@@ -358,15 +359,30 @@ async def test_without_a_website_trigger_the_proxy_neither_reads_requests_nor_re
     gw = _gateway()
     gw._trigger_engine.register({"triggers": [{"id": "sent", "actions": [], "when": {"type": "state", "check": {
         "tool": "mail_list_messages", "extract": "subjects", "predicate": {"regex": "Lunch"}}}}]})
+    parsed: list[bytes] = []
+    real_args = gateway_module._request_args
+    monkeypatch.setattr(gateway_module, "_request_args", lambda request, body: parsed.append(body) or real_args(request, body))
+
+    assert await _request(gw, "POST", "/website/mail/api/messages", headers={"content-type": "application/json"},
+                          body=json.dumps(MESSAGE).encode()) == 201
+    await asyncio.sleep(0.1)
+
+    assert parsed == [] and calls == ["mail_list_messages"] and gw._trigger_engine._triggers["sent"]["status"] == "fired"
+
+
+@pytest.mark.asyncio
+async def test_with_neither_a_website_nor_a_state_trigger_the_proxy_never_reaches_the_engine(monkeypatch):
+    _Website(status=201).install(monkeypatch)
+    gw = _gateway()
+    gw._trigger_engine.register({"triggers": [{"id": "later", "actions": [], "when": {"type": "time", "at": "PT1H"}}]})
     seen: list[tuple] = []
     real = gw._trigger_engine.on_website_request
     gw._trigger_engine.on_website_request = lambda *a: seen.append(a) or real(*a)
 
     assert await _request(gw, "POST", "/website/mail/api/messages", headers={"content-type": "application/json"},
                           body=json.dumps(MESSAGE).encode()) == 201
-    await asyncio.sleep(0.1)
 
-    assert seen == [] and calls == [] and gw._trigger_engine._triggers["sent"]["status"] == "armed"
+    assert seen == []
 
 
 @pytest.mark.asyncio

Your call on whether this belongs in this PR or a follow-up.

each other. Such a chain ends when a persona's turn makes no matching write, so give conversational nl actions no
`verify` (its retry forces a write), give persona prompts a stop condition, and prefer a `where` off-ramp. An
action's `delay` and `executor_hours` pace replies in virtual time, and a time trigger's `unless` makes it a deadline.
A `website` trigger fires on requests the gateway proxies to the env's websites, for agents with their own browser.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Drafted by Claude Code, reviewed by Edgar Arakelyan before posting.

Suggestion: say which role a browser agent's traffic counts as.

A browser sends no AgentEnv-Role header, so the gateway counts its requests as default. That works when watch_roles is left at its default. It breaks silently in the common setup where watch_roles names the agent's own role, which multi-persona tasks do: a website trigger like "when the agent opens the order page" never fires for a browser agent, with no error. Point 6 of the description covers this, but task authors read this docstring, not the PR:

Suggested change
A `website` trigger fires on requests the gateway proxies to the env's websites, for agents with their own browser.
A `website` trigger fires on requests the gateway proxies to the env's websites, for agents with their own browser.
A browser sends no AgentEnv-Role header, so its requests count as the `default` role: when `watch_roles` names the
agent's role, give a website trigger `when.roles: ["default"]`, or have the agent's client send the header.

This steers authors to the per-trigger when.roles rather than adding default to watch_roles, which would also let anything else calling as default (a setup script, say) provoke tool-call triggers.

A question. Your Meta Muse run recorded role: psi_agent, not default. How did its requests get that role? Did the front door add the header? If that's the pattern for browser agents for now, it deserves a line in the docs too.

Longer term (not for this PR): we'd like browser traffic to carry the role natively, with no front door, so we've filed a follow-up on our side. The idea is a role link, /website/<app>/?agentenv_role=<role>, that sets a cookie scoped to /website/<app>. Every later request from that browser would then carry the role, including page loads, assets and XHR. The header is unauthenticated today, so a cookie wouldn't weaken trust; an explicit header would still win.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 5423ce8: the docstring says a browser counts as default and to use when.roles: ["default"] or send the header. On your question: in our Muse run, our products-track front door proxies the portal's site requests to the gateway and sets AgentEnv-Role to the run's role (any client-sent role header is dropped); that pattern is now a line in the same docstring, and we'd welcome the role-cookie follow-up.

… on website writes, document the browser's role

Edgar's three suggestions:
- `register_env_triggers` lists `website` among the features its triggers
  use, so an older gateway gets the step's "does not declare ['website'] …
  redeploy" message instead of a 400.
- The /website hook runs when a website or a state trigger is registered; the
  request body is read only for a website trigger. A website write now
  re-checks state triggers like a tool write, with or without a website
  trigger (new zero-cost test: neither kind registered, the engine is never
  reached).
- The step's docstring says a browser's requests count as `default`, how to
  target them, and that a front door can set the header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sarah-chitty-1

Copy link
Copy Markdown
Collaborator Author

Feature gate: applied in 5423ce8: TRIGGER_WEBSITE is in the step's used dict, so a website trigger on an older gateway gets the step's "does not declare ['website'] … redeploy" message (new unit tests).

@sarah-chitty-1

Copy link
Copy Markdown
Collaborator Author

State re-checks on website writes: applied in 5423ce8: your diff as is (docstring kept to one line), with both test changes; the PR description now has the new cost model and the behaviour change you named.

Comment thread src/agent_env/env/gateway/gateway.py
… window at the backend's answer

A tool call's window already closes before its barrier; a /website request's closed after its last byte, which
the barrier delays until the trigger's actions land, so their writes read as both the trigger's and the request's.
A sized response closes its window before the barrier; a streamed one keeps it to its last byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/agent_env/env/gateway/rest_proxy.py Outdated
… rather than trusting Content-Length

Ending the window at the headers lost the request's own writes made while a sized body still went out. The body
is now read ahead (finite, so it cannot hang) and the window ends at its last byte, before any trigger fires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/agent_env/env/gateway/rest_proxy.py
…and only up to 8 MiB

A sized write no barrier matches streams as before; one a barrier could hold is read ahead (bounded), so its window
still ends at its last byte before the trigger runs. A larger body keeps its window to the last byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/agent_env/env/gateway/gateway.py Outdated
… once its body has ended

It streams as before, but its trigger and state checks now run at its last byte, so they see what it wrote while
its body went out, and its change window closes before any of them run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants