RFC-0017: Triggers & Reactive Scheduling#
- Status: Proposed
- Created: 2026-02-08
- Authors: OpenIntent Contributors
- Depends on: RFC-0001, RFC-0006, RFC-0011, RFC-0012, RFC-0013, RFC-0016
Abstract#
This RFC defines triggers — standing declarations that create intents when a condition is met. Triggers are the protocol's starting gun: they close the gap between "something happened" and "work begins." Three trigger types are specified — schedule (time-based), event (protocol-reactive), and webhook (external) — each producing intents through the same creation path. Triggers are global first-class objects with cascading namespace governance. Deduplication semantics, trigger-to-intent lineage, and YAML workflow integration are formally specified.
Motivation#
The protocol currently defines how work is coordinated (intents, leasing, graphs, governance) and who does it (agents, capabilities, lifecycle). What it does not define is what causes work to begin. Today, intents are created by external API calls — a human, a script, or another system sends a POST to /api/v1/intents. This works, but it leaves the "why did this intent appear?" question outside the protocol's audit boundary.
This creates several coordination gaps:
-
No autonomous initiation. An agent cannot express "start a compliance review every Monday." The protocol has no concept of recurring or conditional work creation. Scheduling lives outside the system, invisible to governance and audit.
-
No reactive chaining. When an intent resolves, there is no protocol-level mechanism to say "now create this follow-up." Intent graphs (RFC-0012) define decomposition within a plan, but cross-workflow reactions — "when billing completes, trigger a receipt email" — require external glue code.
-
No external event bridge. When an external system fires a webhook (a payment, a deployment, a document upload), there is no standardized way to translate that event into protocol-level work. Each implementation invents its own adapter.
-
No trigger audit trail. Even when external schedulers or event systems create intents, the causal link between "what triggered this" and "the intent that was created" is not captured. Governance (RFC-0013) cannot answer "why does this intent exist?"
-
Incomplete runloop. With RFC-0016 (agent lifecycle), agents can register, heartbeat, and drain. With RFC-0003 (leasing), they can claim work. But without triggers, the runloop has no entry point. The protocol describes a machine with no ignition.
Specification#
1. Trigger Record#
A trigger is a first-class protocol object. It declares a condition and an intent template. When the condition is met, the server creates an intent from the template.
{
"trigger_id": "trg_daily_compliance",
"name": "Daily Compliance Review",
"type": "schedule",
"enabled": true,
"condition": {
"cron": "0 9 * * MON-FRI"
},
"intent_template": {
"type": "compliance.review",
"title": "Daily compliance review",
"priority": "medium",
"context": {
"scope": "all-departments"
}
},
"deduplication": "skip",
"namespace": null,
"created_at": "2026-02-08T10:00:00Z",
"updated_at": "2026-02-08T10:00:00Z",
"last_fired_at": null,
"fire_count": 0,
"version": 1
}
1.1 Fields#
| Field | Type | Description |
|---|---|---|
trigger_id |
string | Unique identifier. Server-assigned, prefixed trg_. |
name |
string | Human-readable label. |
type |
enum | One of: schedule, event, webhook. |
enabled |
boolean | Whether the trigger is active. Disabled triggers are retained but do not fire. |
condition |
object | Type-specific condition. See Sections 2, 3, 4. |
intent_template |
object | Template for the intent to create. See Section 5. |
deduplication |
enum | How to handle firing when a matching intent is already active. One of: allow, skip, queue. Default: allow. |
namespace |
string or null | If set, the trigger only creates intents within this namespace. If null, the trigger is global. |
created_at |
datetime | When the trigger was created. |
updated_at |
datetime | Last modification time. |
last_fired_at |
datetime or null | When the trigger last created an intent. Null if never fired. |
fire_count |
integer | Total number of times this trigger has fired. |
version |
integer | Optimistic concurrency version. Incremented on every update. |
2. Schedule Triggers#
Schedule triggers fire at time-based intervals. The condition specifies when.
2.1 Condition Schema#
{
"cron": "0 9 * * MON-FRI",
"timezone": "UTC",
"starts_at": "2026-02-08T00:00:00Z",
"ends_at": null
}
| Field | Type | Description |
|---|---|---|
cron |
string | Standard 5-field cron expression. Required. |
timezone |
string | IANA timezone for cron evaluation. Default: UTC. |
starts_at |
datetime or null | Earliest time the trigger can fire. Null means immediately. |
ends_at |
datetime or null | Latest time the trigger can fire. Null means indefinitely. |
2.2 Semantics#
- The server evaluates cron expressions and fires the trigger at the next matching time.
- If the server is unavailable at the scheduled time, it fires on the next evaluation cycle. Missed firings are not retroactively created unless the implementation explicitly supports backfill.
starts_atandends_atdefine a time window. Outside this window, the trigger behaves as if disabled.
2.3 One-Time Schedules#
For one-time execution, use a cron expression that matches once, or set starts_at and ends_at to the same value. Implementations may also support a shorthand:
When at is present, cron is ignored. The trigger fires once at the specified time and is automatically disabled after firing.
3. Event Triggers#
Event triggers fire in response to protocol-observable events — state transitions, agent lifecycle changes, or subscription notifications.
3.1 Condition Schema#
{
"event": "intent.state_changed",
"filter": {
"to_state": "resolved",
"intent_type": "billing.*"
}
}
| Field | Type | Description |
|---|---|---|
event |
string | The protocol event type to listen for. Required. |
filter |
object | Key-value pairs that the event payload must match. Supports exact match and glob patterns (e.g., billing.*). Optional — if omitted, the trigger fires on every occurrence of the event type. |
3.2 Standard Event Types#
The protocol defines the following event types that event triggers can listen for:
| Event Type | Fires When | Payload Keys |
|---|---|---|
intent.created |
A new intent is created | intent_id, type, priority, namespace |
intent.state_changed |
An intent transitions state | intent_id, from_state, to_state, type |
intent.resolved |
An intent reaches resolved |
intent_id, type, resolution |
intent.failed |
An intent reaches failed |
intent_id, type, error |
intent.stalled |
An intent exceeds its expected duration | intent_id, type, stalled_since |
agent.registered |
An agent registers (RFC-0016) | agent_id, role_id, capabilities |
agent.status_changed |
An agent's status changes | agent_id, from_status, to_status |
agent.dead |
An agent is declared dead | agent_id, role_id, last_heartbeat |
lease.expired |
A lease expires without renewal | lease_id, intent_id, agent_id |
lease.claimed |
An agent claims a lease | lease_id, intent_id, agent_id |
trigger.fired |
Another trigger fires | trigger_id, created_intent_id |
Implementations may extend this list with custom event types prefixed by x-.
3.3 Cascading Event Triggers#
Event triggers can reference other triggers' firings (trigger.fired), enabling trigger chains. To prevent infinite loops, the server must enforce a maximum cascade depth (default: 10). When the depth is exceeded, the trigger is suppressed and a trigger.cascade_limit event is emitted.
Each intent created by a trigger carries a trigger_depth field in its metadata. The first trigger in a chain sets trigger_depth: 1. Each subsequent trigger increments it by 1.
4. Webhook Triggers#
Webhook triggers fire when an external HTTP request is received at a trigger-specific endpoint.
4.1 Condition Schema#
{
"path": "/hooks/stripe-payment",
"method": "POST",
"secret": "whsec_...",
"transform": {
"amount": "{{ body.data.object.amount }}",
"customer_id": "{{ body.data.object.customer }}"
}
}
| Field | Type | Description |
|---|---|---|
path |
string | URL path suffix for the webhook endpoint. The full URL is implementation-defined (e.g., https://api.example.com/webhooks/hooks/stripe-payment). Required. |
method |
string | HTTP method to accept. Default: POST. |
secret |
string or null | Shared secret for webhook signature verification. Verification method is implementation-defined. Optional. |
transform |
object | Template expressions that extract fields from the incoming request and inject them into the intent's context. Uses {{ body.field }}, {{ headers.field }}, {{ query.field }} syntax. Optional. |
4.2 Semantics#
- The server exposes webhook endpoints based on registered webhook triggers.
- When a request arrives, the server matches it to a trigger by path and method.
- If
secretis set, the server verifies the request signature. Verification failure returns HTTP 401 and does not fire the trigger. - If
transformis set, the server extracts values from the request and injects them into the intent template's context. - The server returns HTTP 202 Accepted with the created intent's ID in the response body.
- If deduplication prevents intent creation, the server returns HTTP 200 OK with a
deduplicated: truefield.
4.3 Webhook Security#
Webhook endpoints are unauthenticated by default (they are designed for external callers). Security is provided through:
- Signature verification via the
secretfield (implementation-specific — e.g., HMAC-SHA256). - Path obscurity — implementations may generate random path suffixes.
- Rate limiting — implementations should enforce per-trigger rate limits.
- IP allowlists — implementations may restrict source IPs.
The protocol does not mandate a specific verification mechanism. It requires that the secret field is never exposed in API responses (write-only).
5. Intent Template#
The intent template defines the shape of the intent created when a trigger fires.
{
"type": "compliance.review",
"title": "Weekly compliance review",
"priority": "medium",
"assignee": null,
"context": {
"scope": "all-departments",
"source_trigger": "{{ trigger.trigger_id }}",
"fired_at": "{{ trigger.fired_at }}"
},
"graph_id": null,
"tags": ["automated", "compliance"]
}
5.1 Template Expressions#
Template expressions use {{ }} syntax to inject dynamic values. Available variables:
| Variable | Description |
|---|---|
trigger.trigger_id |
The trigger's ID. |
trigger.name |
The trigger's name. |
trigger.type |
The trigger type (schedule, event, webhook). |
trigger.fired_at |
ISO 8601 timestamp of when the trigger fired. |
trigger.fire_count |
How many times the trigger has fired (including this time). |
event.* |
For event triggers: the full event payload. |
body.* |
For webhook triggers: the request body. |
headers.* |
For webhook triggers: request headers. |
query.* |
For webhook triggers: query string parameters. |
5.2 Lineage#
Every intent created by a trigger includes lineage metadata:
{
"created_by": "trigger",
"trigger_id": "trg_daily_compliance",
"trigger_type": "schedule",
"trigger_depth": 1,
"trigger_chain": ["trg_daily_compliance"]
}
created_bydistinguishes trigger-created intents from manually-created ones.trigger_chainrecords the sequence of triggers in a cascade (for chained event triggers).- This lineage is immutable and append-only.
6. Deduplication#
When a trigger fires, there may already be an active intent that matches the template. The deduplication field controls behavior:
| Mode | Behavior |
|---|---|
allow |
Always create a new intent. No deduplication check. This is the default. |
skip |
If an active (non-resolved, non-failed) intent with the same type exists within the same namespace, do not create a new one. Record a trigger.skipped event. |
queue |
If an active intent exists, defer creation. When the existing intent resolves or fails, fire the trigger again. Maximum queue depth: 1 (only one pending fire is retained). |
6.1 Deduplication Scope#
Deduplication checks are scoped to the trigger's namespace. If the trigger is global (namespace: null), deduplication checks all active intents of the matching type across all namespaces. If the trigger has a namespace, deduplication checks only within that namespace.
7. Cascading Namespace Governance#
Triggers are global first-class objects. Namespaces govern how triggers apply within their boundary.
7.1 Global vs. Namespace Triggers#
- Global triggers (
namespace: null): Fire and create intents in the default namespace, or in a namespace specified by the intent template. Subject to namespace governance rules. - Namespace triggers (
namespace: "billing"): Fire and create intents only within the specified namespace. The namespace owns the trigger.
7.2 Namespace Trigger Policy#
A namespace can declare a trigger policy that governs which triggers are allowed to create intents within it:
{
"namespace": "eu-operations",
"trigger_policy": {
"allow_global_triggers": true,
"allowed_trigger_types": ["schedule", "event"],
"blocked_triggers": ["trg_us_only_report"],
"context_injection": {
"region": "EU",
"gdpr_required": true
}
}
}
| Field | Type | Description |
|---|---|---|
allow_global_triggers |
boolean | Whether global triggers can create intents in this namespace. Default: true. |
allowed_trigger_types |
array or null | Whitelist of trigger types (schedule, event, webhook). Null means all types allowed. |
blocked_triggers |
array | Specific trigger IDs that are blocked from this namespace. |
context_injection |
object | Additional context fields injected into intents created by triggers within this namespace. Merged with the intent template's context (namespace values take precedence on conflict). |
7.3 Cascade Resolution#
When a global trigger fires:
- The server evaluates the intent template's target namespace (explicit or default).
- The server checks the target namespace's trigger policy.
- If the policy blocks the trigger (by type, by ID, or by
allow_global_triggers: false), the trigger is suppressed for that namespace. Atrigger.namespace_blockedevent is emitted. - If the policy allows the trigger, context injection is applied and the intent is created.
This ensures global triggers cascade down by default, but namespaces retain local authority.
8. API#
8.1 Endpoints#
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/triggers |
Create a trigger. |
GET |
/api/v1/triggers |
List all triggers. Supports ?type=, ?enabled=, ?namespace= filters. |
GET |
/api/v1/triggers/:trigger_id |
Get a trigger by ID. |
PATCH |
/api/v1/triggers/:trigger_id |
Update a trigger. Supports If-Match for optimistic concurrency. |
DELETE |
/api/v1/triggers/:trigger_id |
Delete a trigger. Active intents created by this trigger are not affected. |
POST |
/api/v1/triggers/:trigger_id/fire |
Manually fire a trigger (for testing and debugging). |
GET |
/api/v1/triggers/:trigger_id/history |
Get the trigger's fire history (list of created intent IDs with timestamps). |
8.2 Create Trigger#
POST /api/v1/triggers
Content-Type: application/json
{
"name": "Daily Compliance Review",
"type": "schedule",
"condition": {
"cron": "0 9 * * MON-FRI",
"timezone": "America/New_York"
},
"intent_template": {
"type": "compliance.review",
"title": "Daily compliance review",
"priority": "medium"
},
"deduplication": "skip",
"namespace": null
}
Response: 201 Created with the full trigger record.
8.3 Manual Fire#
Response: 201 Created with the created intent. This bypasses the trigger's condition but respects deduplication rules and namespace governance.
8.4 Webhook Receive#
POST /webhooks/hooks/stripe-payment
Content-Type: application/json
{ "type": "payment_intent.succeeded", "data": { "object": { "amount": 5000 } } }
Response: 202 Accepted with { "intent_id": "intent_...", "trigger_id": "trg_..." }.
9. Trigger Lifecycle#
9.1 States#
Triggers have a simple lifecycle:
| State | Description |
|---|---|
enabled |
Active and will fire when its condition is met. |
disabled |
Retained but will not fire. Can be re-enabled. |
deleted |
Permanently removed. Fire history is retained for audit. |
9.2 Pause and Resume#
Triggers can be paused (disabled) and resumed (enabled) via PATCH:
PATCH /api/v1/triggers/trg_daily_compliance
Content-Type: application/json
If-Match: "1"
{ "enabled": false }
This is useful for maintenance windows, incident response, or temporary suspension without losing configuration.
10. Integration with Other RFCs#
| RFC | Integration |
|---|---|
| RFC-0001 (Intents) | Triggers create intents through the standard creation path. All intent semantics (state machine, events, versioning) apply. |
| RFC-0006 (Subscriptions) | Subscription notifications can be event sources for event triggers. A subscription matching billing.* can trigger follow-up work. |
| RFC-0011 (Access Control) | Namespace trigger policies extend RFC-0011's permission model. Trigger creation requires appropriate namespace permissions. |
| RFC-0012 (Task Decomposition) | A trigger can create a top-level intent that initiates a plan. The trigger fires once; the plan handles decomposition. |
| RFC-0013 (Coordinator Governance) | Coordinators can define guardrails on trigger creation (e.g., "no webhook triggers in production namespace"). Trigger fire events are subject to governance audit. |
| RFC-0016 (Agent Lifecycle) | Agent lifecycle events (agent.registered, agent.dead) are event sources for event triggers. Example: "when an agent dies, create a failover intent." |
11. YAML Workflow Integration#
Triggers are declared in the triggers: block of a workflow YAML file:
version: "1.0"
name: compliance-pipeline
namespace: compliance
triggers:
daily-review:
type: schedule
cron: "0 9 * * MON-FRI"
timezone: "America/New_York"
deduplication: skip
creates:
type: compliance.review
title: "Daily compliance review"
priority: medium
context:
scope: all-departments
escalate-stalled:
type: event
when:
event: intent.stalled
filter:
priority: critical
creates:
type: escalation.review
title: "Stalled critical intent escalation"
context:
source_intent: "{{ event.intent_id }}"
payment-received:
type: webhook
path: /hooks/payment
secret: "${PAYMENT_WEBHOOK_SECRET}"
transform:
amount: "{{ body.data.object.amount }}"
customer: "{{ body.data.object.customer }}"
creates:
type: billing.process-payment
title: "Process incoming payment"
agents:
billing-processor:
capabilities: [billing, invoicing]
# ...
steps:
# ...
11.1 YAML Semantics#
- Trigger names in YAML are local identifiers (e.g.,
daily-review). The server generates thetrigger_id. - The
createsblock maps to theintent_templatefield. - Environment variable substitution (
${VAR}) is supported for secrets. - If the workflow declares a
namespace, all triggers inherit it unless they explicitly override with anamespacefield.
12. SDK Integration#
The SDK exposes triggers through a client.triggers namespace:
from openintent import Client
client = Client("https://api.example.com", api_key="...")
trigger = client.triggers.create(
name="Daily Compliance Review",
type="schedule",
condition={"cron": "0 9 * * MON-FRI"},
intent_template={
"type": "compliance.review",
"title": "Daily compliance review",
"priority": "medium",
},
deduplication="skip",
)
triggers = client.triggers.list(type="schedule", enabled=True)
client.triggers.fire(trigger.trigger_id)
client.triggers.update(trigger.trigger_id, enabled=False, version=trigger.version)
history = client.triggers.history(trigger.trigger_id)
client.triggers.delete(trigger.trigger_id)
Security Considerations#
- Webhook secrets are write-only — the API never returns them in responses. They are stored encrypted at rest.
- Trigger creation is a privileged operation. In production, it should require specific API key permissions or coordinator approval (RFC-0013).
- Cascade limits prevent denial-of-service through recursive trigger chains. The default depth of 10 should be sufficient for legitimate use cases.
- Rate limiting on webhook endpoints prevents external abuse. Implementations should enforce per-trigger and global rate limits.
- Template injection is limited to the
{{ }}expression syntax. Arbitrary code execution is not supported. Implementations must sanitize template expressions.
Rationale#
Why "intent factory" and not a workflow engine?
The protocol already has intents, leasing, agents, and governance. Triggers reuse all of this by simply creating intents. The alternative — building a trigger execution engine with its own state machine, retries, and routing — would duplicate existing protocol machinery and push the protocol toward becoming an orchestration platform rather than a coordination protocol.
Why three types and not an extensible event system?
Schedule, event, and webhook cover the three fundamental categories of "what causes work": time, internal state changes, and external signals. An extensible type system would invite implementation-specific triggers that break interoperability. Custom event types (prefixed x-) provide extensibility within the event trigger type.
Why global-first with namespace governance?
Global triggers reflect the reality that many triggers span organizational boundaries (e.g., "when any agent dies, alert the ops team"). Namespace governance ensures that teams retain control over their domain. This cascading model is consistent with RFC-0011's permission inheritance.
Why deduplication as a first-class concept?
The most common trigger footgun is "the trigger fires faster than the intent resolves." Without explicit deduplication semantics, implementations will invent inconsistent solutions. Making it a protocol-level field with three clear modes eliminates ambiguity.
Dependencies#
- RFC-0001 (Intent Model): Triggers create intents through the standard intent creation path.
- RFC-0006 (Subscriptions): Subscription notifications are event sources for event triggers.
- RFC-0011 (Access Control): Namespace trigger policies extend the access control model.
- RFC-0012 (Task Decomposition): Triggers can initiate plans by creating top-level intents.
- RFC-0013 (Coordinator Governance): Trigger creation and firing are subject to governance guardrails.
- RFC-0016 (Agent Lifecycle): Agent lifecycle events are event sources for event triggers.