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 repository's installed shim workflow calls the upstream
reusable-dispatch.ymlworkflow.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 repository-local config (the
agents:list in.fullsend/config.yaml, optionally layered over.fullsend/config.base.yaml, plus scaffold discovery). Each harness with a non-emptytriggerfield is a candidate. If a registered agent's harness cannot be resolved or loaded, dispatch logs a GitHub Actions::error::annotation and skips that agent so other agents can still run. If every registered agent fails to load,fullsend dispatchexits non-zero instead of emitting an empty matrix — a fully unreadable harness set is a configuration error, not a "no trigger matched" result.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 runin the inlineharness-runjob ofreusable-dispatch.yml, using the existing sandbox and execution infrastructure. The dispatch workflow passes the event payload, source repo, and any trigger-specific metadata to that job.
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.
On GitHub, CEL dispatch ignores the trigger of any agent registered under a built-in name (matched case-insensitively), including an override whose harness has one: that agent runs only where stage routing sends it, so it never runs twice. Jira polling, the only poll source that evaluates CEL triggers today, has no stage routing, so built-in names whose trigger matches are still dispatched. GitLab routes agents through its own built-in router and does not evaluate trigger expressions.
The review, retro, triage and prioritize agents are the exception on GitHub: a trigger you set on the agent's entry in config.yaml decides whether the built-in Review, Retro, Triage or Prioritize job starts, in place of the stage-based rules. A harness trigger: field never changes built-in routing; copy it into config.yaml to opt in. See Routing built-in agents with triggers.
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. The same expression language is used by agents[].trigger in config.yaml, but that field decides routing for the built-in review, retro, triage and prioritize agents only; CEL dispatch of custom agents and the Jira poller use the harness trigger:.
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 | Human repository permission: admin, maintain, write, triage, read, none, external. Current adapters may populate a bot's forge/project permission when lookup succeeds, or none when it is unavailable; ADR 0107 target behavior is none for bots. |
event.actor.bot_role | string or null | Optional provider-resolved canonical bot role such as review; absent/null for humans and unrecognized bots |
event.actor.role_verified | boolean | Optional lookup status. For humans, true means actor.role is a trusted forge permission; for bots, true means bot-role lookup completed, including a successful no-match result |
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" |
actor.bot_roleandactor.role_verifiedare ADR 0107 target fields and are not yet emitted by the production runtime. The target contract usesactor.role: "none"for bots, but current adapters may still populate a forge/project permission there during migration.role_verifiedis not authorization evidence by itself: for bots it only indicates lookup completion, not recognition. The permanent label-added exception does not require bot-role lookup; its provider-positive bot classification and forge-authorized label mutation remain sufficient platform evidence. CEL filters selecting a bot role must usehas(event.actor.bot_role)and compare the resolved role.
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") |
On GitHub, empty-body commented review submissions remain available to custom triggers. A reviewer may submit substantive inline comments without adding a summary body.
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 (issues and non-fork PRs):
trigger: >
event.transition.kind == "comment_added"
&& event.entity.kind == "work_item"
&& has(event.transition.comment.command)
&& event.transition.comment.command == "/my-command"
&& (!has(event.state.change_proposal) || !event.state.change_proposal.is_fork)This is exactly what fullsend agent new --on command:/my-command emits, so a generated trigger and a hand-written one stay the same expression.
event.entity.kind == "work_item" restricts this to issues and pull requests. A comment on a GitHub Discussion arrives with entity.kind of conversation, so without that clause the agent also fires on discussions.
Use !has(...) rather than != null for the fork guard. state.change_proposal is absent on a comment posted to a plain issue, not present-and-null — the NormalizedEvent schema requires only labels under state. Comparing an absent key against null raises a missing-key error, and dispatch reports that as ::error:: harness dispatch: skipping agent <name>: trigger eval failed on every issue comment in the repository, so the agent looks permanently broken. The !has(...) form evaluates cleanly on both surfaces.
Run 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.Lint the expression. Run
fullsend lintfrom the repository root. It fails when atriggerdoes not compile or does not return a boolean, whether the trigger is in a registered harness or on anagents:entry inconfig.yaml. It does not evaluate the trigger against events. For example output, see Checking a trigger before you merge.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.
Routing built-in agents with triggers
On GitHub, a trigger on the review, retro, triage or prioritize entry in .fullsend/config.yaml decides when that built-in agent runs, in place of the dispatch workflow's built-in rules. The trigger reads the same event fields as a harness trigger. This section covers what differs for each agent and how to check a trigger before you merge. For one short example per agent, see Configuring Agent Behavior.
Choosing when review runs
By default, the dispatch workflow decides when the review agent runs: when a pull request is opened, pushed to or marked ready, when the ready-for-review label is added, and on a /fs-review comment. To decide it yourself, give the review entry in .fullsend/config.yaml a trigger: a CEL expression over the event. The review still runs in the same Review job; the trigger only decides whether that job starts.
Example: review only on /fs-review
To stop the automatic review on every pull request but keep /fs-review, add this entry to .fullsend/config.yaml on your default branch:
agents:
- name: review
trigger: >-
event.transition.kind == "comment_added"
&& has(event.entity.linked_change_proposal)
&& has(event.transition.comment.command)
&& event.transition.comment.command == "/fs-review"
&& event.actor.kind == "human"has(event.entity.linked_change_proposal) keeps the trigger to comments on pull requests. The Route job evaluates the review trigger for the events the fullsend workflow receives: a pull request opened, pushed to (synchronize), marked ready for review, labeled or unlabeled, and a /fs-* comment on a pull request from a user who is not a bot. It does so only when the default rules chose review or no stage, and not for a closed pull request or a /fs-retro, /fs-triage or /fs-prioritize comment (see Which trigger decides), so a broad trigger can start reviews the default rules would not. It never starts a review from a bot's comment.
The trigger is read from your default branch, so a pull request cannot change its own routing. After the change merges, open a pull request: no review starts. Comment /fs-review on it: one review starts.
What opts a repository in
Only a trigger on the review entry in config.yaml moves review routing to CEL:
| Your configuration | Who decides |
|---|---|
No review entry, or one with only runtime, model, effort or subagents | The dispatch workflow, as before |
trigger on the review entry | The trigger |
A review entry with source: whose harness sets or inherits trigger: | The dispatch workflow, as before |
A harness trigger: field never changes when a built-in agent runs, whether your own harness writes it or inherits it through base:. To route review by a trigger you keep in a harness, copy the expression into the review entry's trigger in config.yaml. The kill switch and enabled: false on the review entry keep working as before. The Route job evaluates the trigger with the fullsend CLI built at the dispatch workflow's commit; if your workflow call pins fullsend_version, the Review job runs that version instead.
Known differences from the default rules
A trigger is checked by the same permission gate as other CEL-dispatched agents: the review harness's role: review needs at least triage access, so triage-role users can start a review as before. Some cases still differ from the dispatch workflow's built-in rules:
| Case | Default rules | Trigger |
|---|---|---|
| Pull request opened or pushed by a bot without a registered fullsend role | Reviewed | Not reviewed |
| Pull request pushed by the fix agent | Reviewed | Not reviewed until #8329 ships, so the review → fix → re-review loop stops |
| Who is checked when a pull request is opened, pushed to or marked ready | The pull request author | The user who sent the event |
| A maintainer's push to an outside contributor's fork pull request | Not reviewed | Reviewed |
WARNING
Opt in only after #8329 ships, or accept that pushes from the fix agent no longer start a re-review. Until then the trigger path does not look up a bot's role.
Checking the decision
When a trigger is set, the Route job's "Finalize stage" step logs the decision for each pull request and pull request comment. To catch an invalid trigger before it merges, see Checking a trigger before you merge.
CEL route (review): bash=review cel=skip reason=trigger did not match harness=agents@3c3081741e3cThe job summary shows review_route=cel when the trigger decided, or review_route=bash when the dispatch workflow did: the kill switch is on or the review entry is disabled.
| Log text | Meaning | Action |
|---|---|---|
cel=skip reason=trigger did not match | The trigger left this event out | Expected for events your trigger excludes; otherwise fix the trigger |
cel=skip reason=actor not authorized | The actor failed the dispatch permission check | See Known differences from the default rules for bots |
cel=skip reason=harness unavailable; actor below write | The review harness could not be fetched or loaded, so only write access passes | Check the route step's warning for the harness error |
cel=skip reason=trigger evaluation failed: … | The trigger compiles but failed on this event, for example it reads a field the event lacks without has(); this counts as no match | Guard optional fields with has() |
CEL route failed; keeping the bash stage | A CEL route step failed or the configuration is invalid, so the dispatch workflow decided | Open the failed "… CEL route" step for the error, for example an invalid trigger or another invalid agents: entry |
invalid agent config: agents[N] (review): invalid trigger in any agent job | The trigger in config.yaml does not compile; every agent run validates the whole agents: list, so all agents fail, not only review | Fix the expression in .fullsend/config.yaml; see Checking a trigger before you merge |
Choosing when retro runs
By default, the dispatch workflow starts the retro agent when a pull request is closed, merged or not, and on a /fs-retro comment on an issue or a pull request. To decide it yourself, give the retro entry in .fullsend/config.yaml a trigger. The retro still runs in the same Retro job; the trigger only decides whether that job starts. What opts a repository in, and how to check the decision, work as for review: see What opts a repository in and Checking the decision, with retro in place of review (CEL route (retro): ..., retro_route=cel). To check a retro trigger before you merge, see Checking a trigger before you merge.
Example: retro only on /fs-retro
To stop the retro on every closed pull request but keep /fs-retro, add this entry to .fullsend/config.yaml on your default branch:
agents:
- name: retro
trigger: >-
event.transition.kind == "comment_added"
&& has(event.transition.comment.command)
&& event.transition.comment.command == "/fs-retro"
&& event.actor.kind == "human"After the change merges, close a pull request: no retro starts. Comment /fs-retro on it: one retro starts.
The Route job asks the trigger only about closed pull requests and /fs-retro comments; every other event keeps the dispatch workflow's rules, and a bot's /fs-retro comment never starts a retro.
Who can start a retro
A trigger decides which events start a retro, not who may start one:
- Closing a pull request: anyone who closes or merges it, including its read-only author and bots without a registered fullsend role, as with the dispatch workflow. Only the retro agent gets this exception (the lifecycle-close exception).
/fs-retro: users with write access or above.
Choosing when triage and prioritize run
The triage and prioritize agents opt in the same way as review: give the triage or prioritize entry in .fullsend/config.yaml a trigger, and that trigger, not the dispatch workflow's built-in rules, decides whether the Triage or Prioritize job starts. The rules in What opts a repository in apply to both, with triage or prioritize in place of review. Each agent opts in on its own: a triage trigger does not change when prioritize runs. To check either trigger before you merge, see Checking a trigger before you merge.
A trigger only sees the events that reach the Route job. The workflow that fullsend installs in your repository forwards issues opened, edited and labeled, and comments that start with /fs- from people, not bots. So a prioritize trigger only decides /fs-prioritize, and on a pull request only /fs-triage asks the triage trigger.
By default the dispatch workflow starts:
- Triage when an issue is opened or edited, when the
ready-for-triagelabel is added to an issue, and on a/fs-triagecomment on an issue or a pull request. - Prioritize on a
/fs-prioritizecomment on an issue or a pull request. The scheduled scoring run (a scheduler workflow that dispatchesprioritize.yml) does not go through this routing, so a trigger does not change it.
Example: triage only on /fs-triage
To stop triaging every new or edited issue but keep /fs-triage, add this entry to .fullsend/config.yaml on your default branch:
agents:
- name: triage
trigger: >-
event.entity.kind == "work_item"
&& event.transition.kind == "comment_added"
&& has(event.transition.comment.command)
&& event.transition.comment.command == "/fs-triage"
&& event.actor.kind == "human"After the change merges, open an issue: no triage starts. Comment /fs-triage on it: one triage starts. The Route job summary shows triage_route=cel.
Leave out the ready-for-triage label at your own cost: when the triage agent splits an issue, it labels each new sub-issue ready-for-triage so that the sub-issue is triaged. Keep a label arm like this one if you rely on splits:
(event.entity.kind == "work_item"
&& event.transition.kind == "label_changed"
&& event.transition.label.name == "ready-for-triage"
&& event.transition.label.action == "added")Which trigger decides
The Route job asks one agent's trigger per event:
| Event | Agent asked |
|---|---|
| An issue opened, edited or labeled | triage |
| A pull request closed, merged or not | retro |
| A pull request opened, pushed to, marked ready or labeled | review |
A /fs- comment that the built-in rules route to review, retro, triage or prioritize | That agent |
Any other /fs-retro, /fs-triage or /fs-prioritize comment | retro, triage or prioritize |
Any other /fs- comment on a pull request | review |
Any other /fs- comment on an issue | triage |
An event the built-in rules route to another stage, such as /fs-code or the ready-to-code label, is never handed to a trigger.
Copying the built-in triage rules into a trigger
To start from the built-in rules and then change them, write them as a trigger. Keep the event.actor.kind == "human" guard on opened and edited: it stops the triage agent's own sub-issues and edits from being triaged twice once bots are recognized (#8329).
agents:
- name: triage
trigger: >-
(event.entity.kind == "work_item"
&& event.transition.kind in ["opened", "edited"]
&& event.actor.kind == "human")
|| (event.entity.kind == "work_item"
&& event.transition.kind == "label_changed"
&& event.transition.label.name == "ready-for-triage"
&& event.transition.label.action == "added")
|| (event.entity.kind in ["work_item", "change_proposal"]
&& event.transition.kind == "comment_added"
&& has(event.transition.comment.command)
&& event.transition.comment.command == "/fs-triage"
&& event.actor.kind == "human")It differs from the built-in rules in one case:
| Case | Built-in rules | This trigger |
|---|---|---|
| An issue opened or edited by a bot, for example a sub-issue the triage agent creates | Triaged when the bot holds a collaborator role | Not triaged; the sub-issue is triaged through its ready-for-triage label |
Checking a trigger before you merge
A trigger on the review, retro, triage or prioritize entry that does not compile, or does not return a boolean, still merges. After the merge, every agent run in the repository fails with invalid agent config: agents[N] (<name>): invalid trigger. To catch this before you merge, run fullsend lint from the repository root:
fullsend lintIf the configuration directory is not .fullsend in the current directory, pass its path:
fullsend lint --fullsend-dir <path>fullsend lint loads .fullsend/config.yaml, layered over .fullsend/config.base.yaml if you have one, with the same validation agent runs use. It exits 1 when it finds an error.
fullsend lint caches the agents it fetches in .fullsend/.fullsend-cache/. Add .fullsend/.fullsend-cache/ to your .gitignore, or delete the directory before you commit.
A valid trigger
This .fullsend/config.yaml holds only the review entry from Example: review only on /fs-review:
version: "1"
agents:
- name: review
trigger: >-
event.transition.kind == "comment_added"
&& has(event.entity.linked_change_proposal)
&& has(event.transition.comment.command)
&& event.transition.comment.command == "/fs-review"
&& event.actor.kind == "human"$ fullsend lint
• Fetching agent review from fullsend-ai/agents@5144c1d561a0
✓ agent review resolved from fullsend-ai/agents@main
! review: warning: env.sandbox: env.sandbox coexists with host_files entry /home/user/repo/.fullsend/.fullsend-cache/resources/sha256/e2874d7d47a7a710f89ea6dcb99ae3b8c720ac06fccb7499dfddbe14f12b5ecb/content.env (dest: /sandbox/workspace/.env.d/gcp-vertex.env); env.sandbox values take precedence on key collision
! review: warning: forge: forge is deprecated; use overlays with CEL when expressions instead (see ADR 0088)
$ echo $?
0There is no error: line, so the trigger is valid. The entry has no source:, so fullsend lint fetches the review agent from the agents repository and lints it too. The two warning: lines come from that agent, not from your config.yaml (see Reading the output).
A syntax error
This review trigger ends with a stray &&:
version: "1"
agents:
- name: review
trigger: >-
event.transition.kind == "comment_added"
&& has(event.entity.linked_change_proposal)
&& has(event.transition.comment.command)
&& event.transition.comment.command == "/fs-review"
&& event.actor.kind == "human" &&$ fullsend lint
✗ config: error: config.yaml: agents[0] (review): invalid trigger: ERROR: <input>:1:212: Syntax error: mismatched input '<EOF>' expecting {'[', '{', '(', '.', '-', '!', 'true', 'false', 'null', NUM_FLOAT, NUM_INT, NUM_UINT, STRING, BYTES, IDENTIFIER}
| event.transition.kind == "comment_added" && has(event.entity.linked_change_proposal) && has(event.transition.comment.command) && event.transition.comment.command == "/fs-review" && event.actor.kind == "human" &&
| ...................................................................................................................................................................................................................^
Error: lint found 1 error-level diagnostic(s)
$ echo $?
1agents[0] (review) names the entry. <input>:1:212 is line 1, column 212 of the trigger as YAML reads it: the >- block joins the trigger into one line. The caret marks where parsing failed, here the end of the expression.
A trigger that does not return a boolean
This review trigger is a string, not a condition:
version: "1"
agents:
- name: review
trigger: '"opened"'$ fullsend lint
✗ config: error: config.yaml: agents[0] (review): invalid trigger: trigger expression must evaluate to bool, got string
Error: lint found 1 error-level diagnostic(s)
$ echo $?
1Reading the output
| Output | Meaning | Action |
|---|---|---|
invalid trigger: ERROR: <input>:L:N: Syntax error: … | The trigger does not compile; the caret marks line L, column N of the trigger as YAML reads it | Fix the expression at the caret in .fullsend/config.yaml |
invalid trigger: trigger expression must evaluate to bool, got string | The trigger compiles but returns a string, not true or false | Write a condition, for example event.transition.kind == "opened" |
! Git token not available; private repo fetches may fail | No GH_TOKEN, GITHUB_TOKEN or gh login was found | Nothing to fix for the public agents repository; set GH_TOKEN to remove the line |
A warning: line prefixed with an agent name, for example review: warning: forge: forge is deprecated | The warning comes from the upstream harness that an entry without source: resolves from the agents repository | Nothing to fix in this repository; warnings do not fail the run unless you pass --strict |
fullsend lint does not evaluate triggers against events. A trigger that compiles but reads a field the event lacks without has() evaluates to an error at run time, which counts as no match (cel=skip); see the Guard optional fields with has() row in Checking the decision.
Running the check in CI
This GitHub Actions workflow runs fullsend lint on every pull request that changes .fullsend/config.yaml or .fullsend/config.base.yaml. It installs the CLI with the fullsend-ai/fullsend action and its agent: __install_only__ input:
name: Lint fullsend config
on:
pull_request:
paths: ['.fullsend/config.yaml', '.fullsend/config.base.yaml']
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Install fullsend CLI
uses: fullsend-ai/fullsend@vX.Y.Z
with:
agent: __install_only__
version: vX.Y.Z
- name: Lint fullsend config
env:
GH_TOKEN: ${{ github.token }}
run: fullsend lint --fullsend-dir .fullsendfullsend lint first ships in the release after v0.50.0. Replace both vX.Y.Z values with that release or a later one. The version input picks the CLI release the step installs, and it defaults to latest. If the repository vendors the CLI at .fullsend/bin/fullsend, the step uses that binary instead, so it must also be that release or later.
An entry without source: makes fullsend lint fetch the agent from the agents repository, so the job needs network access; GH_TOKEN authenticates that request. With --offline, fullsend lint uses the agents revision that an earlier online run cached in the same .fullsend directory. Run that online run with the same forge, set by --forge or detected in the same CI environment; otherwise the forge-specific files are not cached and the offline run fails.
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
