CEL Triggers Reference
This reference documents how fullsend dispatches custom agents and how to write CEL trigger expressions. For the step-by-step guide to building and registering a custom agent, see Bring Your Own Agent.
How custom agents are dispatched
When you register a custom agent and give it a trigger expression, fullsend handles the rest — no per-agent workflow file required. Here is how an event reaches your agent on GitHub:
The dispatch flow
Event arrives. A GitHub webhook fires (issue opened, label added, comment posted, PR submitted, etc.). The installed shim workflow forwards the event to the centralized dispatch workflow in
.fullsend/.Normalize. The
gha-eventinput driver converts the raw GitHub event into aNormalizedEvent— a forge-neutral struct with fields likeevent.entity.kind,event.transition.kind, andevent.actor.role.Authorize.
fullsend dispatchenforces the platform authorization gate before any agent is considered. Authorization is a platform-level decision — your CEL trigger does not need to implement permission checks (though you can add guards likeevent.actor.roleif your agent has stricter requirements).Enumerate. Dispatch loads all registered agents from the merged config (
agents:list in org and per-repoconfig.yaml, plus scaffold discovery). Each harness with a non-emptytriggerfield is a candidate.Evaluate. Each candidate's CEL
triggerexpression is evaluated witheventbound to theNormalizedEvent. Every harness whose trigger returnstrueis selected. Multiple agents can match the same event (parallel fan-out).Launch. Matched agents are launched via
fullsend runusing the existing sandbox and execution infrastructure. The dispatch workflow passes the event payload, source repo, and any trigger-specific metadata to the agent workflow.
What you configure vs. what dispatch handles
| You provide | Dispatch handles |
|---|---|
Harness file with trigger expression | Normalizing the raw GitHub event |
| Agent definition (prompt, tools, model) | Authorizing the actor |
Registration in config.yaml | Enumerating and evaluating all registered triggers |
| Pre/post scripts | Launching matched agents in the sandbox |
Coexistence with built-in agents
Built-in agents (triage, code, review, fix, retro, prioritize) are routed by the dispatch workflow's stage-based routing logic. Custom agents with CEL triggers run alongside them — the two mechanisms coexist. A single event can trigger both a built-in agent via stage routing and one or more custom agents via CEL matching.
You can also keep a hand-written workflow that invokes fullsend run with a fixed harness path. CEL-based dispatch and explicit harness invocation may run side by side in the same installation.
Writing CEL triggers
The harness trigger field is a CEL boolean expression evaluated against the incoming event. The expression has access to a single root variable, event, which is a NormalizedEvent object.
A harness with no trigger field (or an empty trigger) is manual-only — it runs via fullsend run but is never selected by dispatch.
NormalizedEvent fields
The event variable has the following top-level fields:
| Field | Type | Description |
|---|---|---|
event.repo | string | Repository path (owner/repo) |
event.entity.kind | string | "work_item" (issue), "change_proposal" (PR), or "conversation" (Discussion / Slack channel; ADR 0086) |
event.entity.id | int | Issue, PR, or conversation number |
event.transition.kind | string | What happened — see transition kinds |
event.transition.label | object | Present only when kind == "label_changed" |
event.transition.comment | object | Present only when kind == "comment_added" (conversation comments carry id and parent_id, with parent_id == id for thread roots; ADR 0086) |
event.transition.review | object | Present only when kind == "review_submitted" |
event.actor.id | string | Forge login of the user or bot that triggered the event |
event.actor.kind | string | "human" or "bot" |
event.actor.role | string | Repository permission: admin, maintain, write, triage, read, none, external |
event.actor.is_entity_author | boolean | True when the actor is the author of the work item, change proposal, or conversation |
event.state.labels | list | Label names on the entity at event time |
event.state.change_proposal | object | Present when a change proposal is involved (includes is_fork, head_ref, base_ref) |
event.state.conversation | object | Required when entity.kind == "conversation" (includes category.name; optional category.id / slug / format) |
event.source.system | string | "github", "gitlab", "jira", "manual", or "schedule" |
This table covers the most common trigger fields. For the complete field list — including event.entity.url, event.entity.key, event.source.raw_type, and all event.state.change_proposal sub-fields — see the NormalizedEvent v1 schema.
Transition kinds
| Kind | When it fires |
|---|---|
opened | Issue or PR created |
reopened | Issue or PR reopened after close |
edited | Title/body/metadata edited (no new commits) |
synchronized | PR head branch received new commits |
updated | Legacy umbrella for any modification — prefer edited or synchronized for new triggers |
closed | Issue or PR closed |
merged | PR merged into target branch |
marked_ready | Draft PR marked ready for review |
label_changed | Label added or removed — check event.transition.label.name and .action ("added" or "removed") |
comment_added | Comment posted — check event.transition.comment.command for slash commands |
review_submitted | PR review submitted — check event.transition.review.state ("approved", "changes_requested", "commented", "dismissed") |
Common trigger patterns
Run on new issues:
trigger: >
event.entity.kind == "work_item"
&& event.transition.kind == "opened"Run when a specific label is added:
trigger: >
event.transition.kind == "label_changed"
&& event.transition.label.name == "ready-for-my-agent"
&& event.transition.label.action == "added"Run on a slash command (on a PR, non-fork):
trigger: >
event.transition.kind == "comment_added"
&& has(event.transition.comment.command)
&& event.transition.comment.command == "/my-command"
&& event.entity.kind == "work_item"
&& event.state.change_proposal != null
&& !event.state.change_proposal.is_forkRun when a PR is opened or updated (non-fork):
trigger: >
event.entity.kind == "change_proposal"
&& (event.transition.kind == "opened"
|| event.transition.kind == "synchronized"
|| event.transition.kind == "marked_ready")
&& !event.state.change_proposal.is_forkRun when review requests changes:
trigger: >
event.transition.kind == "review_submitted"
&& event.transition.review.state == "changes_requested"Run only when the actor has write permission:
trigger: >
event.entity.kind == "work_item"
&& event.transition.kind == "opened"
&& event.actor.role in ["admin", "maintain", "write"]Guarding optional fields with has()
Some NormalizedEvent fields are optional — they are present only for certain transition kinds. For example, event.transition.comment.command is set only when the comment contains a slash command. Accessing an absent field in CEL produces a missing-key error. Use has() to guard access:
has(event.transition.comment.command)
&& event.transition.comment.command == "/my-command"Fields that require has() guards include event.transition.comment.command and any other field tagged as optional in the NormalizedEvent v1 schema. Fields like event.transition.label and event.transition.comment are inherently scoped by event.transition.kind checks and do not need has() when the trigger already filters on the correct transition kind.
Checking a label on the entity
Use event.state.labels to check labels on the issue or PR at event time:
trigger: >
event.entity.kind == "work_item"
&& event.transition.kind == "comment_added"
&& has(event.transition.comment.command)
&& event.transition.comment.command == "/analyze"
&& "needs-analysis" in event.state.labelsFork safety
Write-capable agents that push commits or open PRs must guard against fork PRs. Use !event.state.change_proposal.is_fork in your trigger or rely on the platform authorization gate. Read-only agents (analysis, review) may run on fork PRs when policy allows.
Verifying your trigger
Before deploying, verify your trigger expression is correct:
Check field paths against the NormalizedEvent fields table and the NormalizedEvent v1 schema. Common mistakes include using
event.type(does not exist) instead ofevent.entity.kind, or referencingevent.transition.labelon a non-label_changedtransition.Walk through example events. The NormalizedEvent examples directory contains fixtures for common GitHub events (issue opened, label added, PR opened, slash command, review submitted). Open the fixture that matches your intended trigger and manually evaluate your CEL expression against its fields to confirm the result is
true.Test end-to-end by applying the triggering action (e.g., adding a label, posting a slash command) in a test repository where your agent is registered. Check the dispatch workflow run in GitHub Actions to confirm your agent was selected.
See also
- Bring Your Own Agent — step-by-step guide to building and registering custom agents
- NormalizedEvent v1 spec — full schema and examples for CEL trigger input
