Skip to content
By Barak Korren, Wayne Sun, Hector Martinez Lopez, Shai Revivo
Picture of Barak Korren
Barak Korren
Picture of Wayne Sun
Wayne Sun
Picture of Hector Martinez Lopez
Hector Martinez Lopez
Picture of Shai Revivo
Shai Revivo

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 repository's installed shim workflow calls the upstream reusable-dispatch.yml workflow.

  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 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-empty trigger field 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 dispatch exits non-zero instead of emitting an empty matrix — a fully unreadable harness set is a configuration error, not a "no trigger matched" result.

  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 in the inline harness-run job of reusable-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 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.

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:

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.rolestringHuman 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_rolestring or nullOptional provider-resolved canonical bot role such as review; absent/null for humans and unrecognized bots
event.actor.role_verifiedbooleanOptional 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_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"

actor.bot_role and actor.role_verified are ADR 0107 target fields and are not yet emitted by the production runtime. The target contract uses actor.role: "none" for bots, but current adapters may still populate a forge/project permission there during migration. role_verified is 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 use has(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 ​

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")

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:

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 (issues and non-fork PRs):

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

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. Lint the expression. Run fullsend lint from the repository root. It fails when a trigger does not compile or does not return a boolean, whether the trigger is in a registered harness or on an agents: entry in config.yaml. It does not evaluate the trigger against events. For example output, see Checking a trigger before you merge.

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

  4. 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:

yaml
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 configurationWho decides
No review entry, or one with only runtime, model, effort or subagentsThe dispatch workflow, as before
trigger on the review entryThe 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:

CaseDefault rulesTrigger
Pull request opened or pushed by a bot without a registered fullsend roleReviewedNot reviewed
Pull request pushed by the fix agentReviewedNot 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 readyThe pull request authorThe user who sent the event
A maintainer's push to an outside contributor's fork pull requestNot reviewedReviewed

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.

text
CEL route (review): bash=review cel=skip reason=trigger did not match harness=agents@3c3081741e3c

The 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 textMeaningAction
cel=skip reason=trigger did not matchThe trigger left this event outExpected for events your trigger excludes; otherwise fix the trigger
cel=skip reason=actor not authorizedThe actor failed the dispatch permission checkSee Known differences from the default rules for bots
cel=skip reason=harness unavailable; actor below writeThe review harness could not be fetched or loaded, so only write access passesCheck 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 matchGuard optional fields with has()
CEL route failed; keeping the bash stageA CEL route step failed or the configuration is invalid, so the dispatch workflow decidedOpen 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 jobThe trigger in config.yaml does not compile; every agent run validates the whole agents: list, so all agents fail, not only reviewFix 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:

yaml
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-triage label is added to an issue, and on a /fs-triage comment on an issue or a pull request.
  • Prioritize on a /fs-prioritize comment on an issue or a pull request. The scheduled scoring run (a scheduler workflow that dispatches prioritize.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:

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

text
(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:

EventAgent asked
An issue opened, edited or labeledtriage
A pull request closed, merged or notretro
A pull request opened, pushed to, marked ready or labeledreview
A /fs- comment that the built-in rules route to review, retro, triage or prioritizeThat agent
Any other /fs-retro, /fs-triage or /fs-prioritize commentretro, triage or prioritize
Any other /fs- comment on a pull requestreview
Any other /fs- comment on an issuetriage

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

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

CaseBuilt-in rulesThis trigger
An issue opened or edited by a bot, for example a sub-issue the triage agent createsTriaged when the bot holds a collaborator roleNot 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] (&lt;name>): invalid trigger. To catch this before you merge, run fullsend lint from the repository root:

bash
fullsend lint

If the configuration directory is not .fullsend in the current directory, pass its path:

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

yaml
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"
text
$ 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 $?
0

There 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 &&:

yaml
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" &&
text
$ 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 $?
1

agents[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:

yaml
version: "1"
agents:
  - name: review
    trigger: '"opened"'
text
$ 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 $?
1

Reading the output ​

OutputMeaningAction
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 itFix the expression at the caret in .fullsend/config.yaml
invalid trigger: trigger expression must evaluate to bool, got stringThe trigger compiles but returns a string, not true or falseWrite a condition, for example event.transition.kind == "opened"
! Git token not available; private repo fetches may failNo GH_TOKEN, GITHUB_TOKEN or gh login was foundNothing 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 deprecatedThe warning comes from the upstream harness that an entry without source: resolves from the agents repositoryNothing 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:

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

fullsend 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 ​