Skip to content

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

  1. 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/.

  2. Normalize. The gha-event input driver converts the raw GitHub event into a NormalizedEvent — a forge-neutral struct with fields like event.entity.kind, event.transition.kind, and event.actor.role.

  3. Authorize. fullsend dispatch enforces 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 like event.actor.role if your agent has stricter requirements).

  4. Enumerate. Dispatch loads all registered agents from the merged config (agents: list in org and per-repo config.yaml, plus scaffold discovery). Each harness with a non-empty trigger field is a candidate.

  5. Evaluate. Each candidate's CEL trigger expression is evaluated with event bound to the NormalizedEvent. Every harness whose trigger returns true is selected. Multiple agents can match the same event (parallel fan-out).

  6. Launch. Matched agents are launched via fullsend run using 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 provideDispatch handles
Harness file with trigger expressionNormalizing the raw GitHub event
Agent definition (prompt, tools, model)Authorizing the actor
Registration in config.yamlEnumerating and evaluating all registered triggers
Pre/post scriptsLaunching 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:

FieldTypeDescription
event.repostringRepository path (owner/repo)
event.entity.kindstring"work_item" (issue), "change_proposal" (PR), or "conversation" (Discussion / Slack channel; ADR 0086)
event.entity.idintIssue, PR, or conversation number
event.transition.kindstringWhat happened — see transition kinds
event.transition.labelobjectPresent only when kind == "label_changed"
event.transition.commentobjectPresent only when kind == "comment_added" (conversation comments carry id and parent_id, with parent_id == id for thread roots; ADR 0086)
event.transition.reviewobjectPresent only when kind == "review_submitted"
event.actor.idstringForge login of the user or bot that triggered the event
event.actor.kindstring"human" or "bot"
event.actor.rolestringRepository permission: admin, maintain, write, triage, read, none, external
event.actor.is_entity_authorbooleanTrue when the actor is the author of the work item, change proposal, or conversation
event.state.labelslistLabel names on the entity at event time
event.state.change_proposalobjectPresent when a change proposal is involved (includes is_fork, head_ref, base_ref)
event.state.conversationobjectRequired when entity.kind == "conversation" (includes category.name; optional category.id / slug / format)
event.source.systemstring"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

KindWhen it fires
openedIssue or PR created
reopenedIssue or PR reopened after close
editedTitle/body/metadata edited (no new commits)
synchronizedPR head branch received new commits
updatedLegacy umbrella for any modification — prefer edited or synchronized for new triggers
closedIssue or PR closed
mergedPR merged into target branch
marked_readyDraft PR marked ready for review
label_changedLabel added or removed — check event.transition.label.name and .action ("added" or "removed")
comment_addedComment posted — check event.transition.comment.command for slash commands
review_submittedPR review submitted — check event.transition.review.state ("approved", "changes_requested", "commented", "dismissed")

Common trigger patterns

Run on new issues:

yaml
trigger: >
  event.entity.kind == "work_item"
    && event.transition.kind == "opened"

Run when a specific label is added:

yaml
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):

yaml
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_fork

Run when a PR is opened or updated (non-fork):

yaml
trigger: >
  event.entity.kind == "change_proposal"
    && (event.transition.kind == "opened"
        || event.transition.kind == "synchronized"
        || event.transition.kind == "marked_ready")
    && !event.state.change_proposal.is_fork

Run when review requests changes:

yaml
trigger: >
  event.transition.kind == "review_submitted"
    && event.transition.review.state == "changes_requested"

Run only when the actor has write permission:

yaml
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:

cel
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:

yaml
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.labels

Fork 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:

  1. Check field paths against the NormalizedEvent fields table and the NormalizedEvent v1 schema. Common mistakes include using event.type (does not exist) instead of event.entity.kind, or referencing event.transition.label on a non-label_changed transition.

  2. 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.

  3. 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

Content