Integrations
Outside services whose events a workflow can trigger on. GitHub and Sentry are built in; for anything else, or when you want the payload shaped your way, you write a sensor. The two routes compose: a built-in event and a hand-written sensor can share one project, even one webhook path.
GitHub
A workflow names a Github.* event in its on: and the compiler
registers that event's contract and a single /hooks/github sensor for you.
There's no sensor to write and no registry.yml entry to add.
Five events ship ready to use, each delivering a typed payload
your prompt reads as event.*.
Trigger a workflow on a built-in event
Name the event in the workflow's on: and read its fields with
{{ event.<field> }}. Nothing else is required in the project: the compiler
injects the event contract and the /hooks/github sensor when it sees the
reference.
---
on: Github.PullRequestOpened
agent: Claude
---
Review pull request #{{ event.number }}, "{{ event.title }}", on {{ event.repo }}.
It merges branch {{ event.branch }} into {{ event.base }}; the diff is at {{ event.url }}.
Post your findings as review comments on the PR.
One piece of wiring lives on GitHub's side: GitHub only sends webhooks somewhere it has been
told to. Run loopy webhooks github once and the CLI does the telling. It creates
a webhook on each repo in registry.yml pointing at LOOPY_PUBLIC_URL
plus /hooks/github, subscribed to the events your workflows use, and it stores
the signing secret in loopy.env so the engine verifies every delivery. The
command authenticates as the GitHub App from
loopy auth github, which points you
here as its next step. Re-run it after adding a repo or
changing the URL (it updates in place, never duplicates), pass --check to report
without changing anything, and loopy doctor warns if a project listens for
GitHub events that nothing delivers.
For a source the built-ins don't cover (Linear, a timer, your own service), write a
sensor that shapes the payload into a typed event and
trigger on it the same way. loopy webhooks list prints each endpoint's full
delivery URL to paste into that service's settings.
The five built-in events
Each event below is a typed contract: a fixed set of fields with terse types. Those fields
are exactly what your workflow prompt can read as event.<field>, and the
compiler checks every reference, so {{ event.nope }} fails the build. The
payload is a flattened, dependable subset of GitHub's raw webhook, so your templates never
reach into deep nested JSON.
Github.PullRequestOpened
Fires when a pull request is opened (GitHub's pull_request event with
action: opened).
{
"number": 42,
"repo": "octocat/Hello-World",
"title": "Add the widget endpoint",
"branch": "feat/widget",
"base": "main",
"url": "https://github.com/octocat/Hello-World/pull/42"
}
int. The PR number. From pull_request.number.
str. Owner and name, like octocat/Hello-World. From repository.full_name.
str. The PR title. From pull_request.title.
str. The head ref (the PR's source branch). From pull_request.head.ref.
str. The base ref it merges into. From pull_request.base.ref.
url. The PR's web page. From pull_request.html_url.
Github.PullRequestMerged
Fires when a pull request is merged. GitHub delivers this as action: closed
with merged: true, so a plain close (merged false) does not trigger it.
{
"number": 42,
"repo": "octocat/Hello-World",
"title": "Add the widget endpoint",
"url": "https://github.com/octocat/Hello-World/pull/42",
"merged_by": "octocat"
}
int. The PR number. From pull_request.number.
str. Owner and name. From repository.full_name.
str. The PR title. From pull_request.title.
url. The PR's web page. From pull_request.html_url.
str. Login of whoever merged it. From pull_request.merged_by.login (unknown if absent).
Github.IssueOpened
Fires when an issue is opened (GitHub's issues event with
action: opened). Pull requests are a separate event, so this never fires for a PR.
{
"number": 17,
"repo": "octocat/Hello-World",
"title": "Crash when the widget list is empty",
"body": "Steps to reproduce: open the page with no widgets...",
"author": "hubot",
"url": "https://github.com/octocat/Hello-World/issues/17"
}
int. The issue number. From issue.number.
str. Owner and name. From repository.full_name.
str. The issue title. From issue.title.
str. The issue description. From issue.body (empty string if none).
str. Login of the opener. From issue.user.login (unknown if absent).
url. The issue's web page. From issue.html_url.
Github.IssueCommentCreated
Fires when a comment is created on an issue (GitHub's issue_comment event with
action: created). GitHub files comments on a pull request under this event too;
on_pull_request tells the two apart.
{
"repo": "octocat/Hello-World",
"issue_number": 17,
"body": "I can reproduce this on main.",
"author": "hubot",
"url": "https://github.com/octocat/Hello-World/issues/17#issuecomment-1234567890",
"on_pull_request": false
}
str. Owner and name. From repository.full_name.
int. The issue or PR number the comment is on. From issue.number (0 if absent).
str. The comment text. From comment.body (empty string if none).
str. Login of the commenter. From comment.user.login (unknown if absent).
url. The comment's web anchor. From comment.html_url.
bool. True when the comment is on a pull request rather than a plain issue. Derived from whether issue.pull_request is present.
Github.Push
Fires on a push to any ref (GitHub's push event). A push carries no
action field, which is how Loopy tells it apart from the others.
{
"repo": "octocat/Hello-World",
"ref": "refs/heads/main",
"before": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684",
"after": "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c",
"pusher": "octocat",
"commit_count": 3
}
str. Owner and name. From repository.full_name.
str. The ref that was pushed, like refs/heads/main. From ref.
str. The SHA before the push. From before (empty string if absent).
str. The SHA after the push. From after (empty string if absent).
str. Name of whoever pushed. From pusher.name (unknown if absent).
int. How many commits the push carried. The length of commits.
Sentry
A workflow names a Sentry.* event in its on: and the compiler
registers that event's contract and a single /hooks/sentry sensor for you.
There's no sensor to write and no registry.yml entry to add.
Three events ship ready to use: an issue's open and close,
plus an alert rule firing. Prefer to shape the event yourself? The same webhook can drive
a sensor you write instead, and both routes can run
side by side.
Point Sentry at loopy
Sentry delivers these webhooks from a Custom Integration, and loopy auth sentry
creates it for you. Give it a user auth token with org:write (it prompts, or
reads $SENTRY_AUTH_TOKEN); it registers an internal integration pointing at
LOOPY_PUBLIC_URL plus /hooks/sentry, subscribed to the
issue resource, and stores the integration's Client Secret in
loopy.env. The token bootstraps the call and is never stored. Re-run the
command any time to see the registration's status, pass --update after your
public URL changes, and --sentry-url points it at a self-hosted Sentry.
Prefer to click? Create it by hand: Settings → Developer Settings → Custom
Integrations → create an internal integration, set its webhook URL to
https://<your-host>/hooks/sentry, enable the issue
resource under Webhooks, and save. Then run loopy auth sentry --manual to
paste the Client Secret, or copy it into the environment loopy run reads
yourself:
SENTRY_WEBHOOK_SECRET=the integration's Client Secret
Sentry signs each delivery with an HMAC of the request body
(Sentry-Hook-Signature). With the secret set, loopy run verifies
it at the edge and rejects anything unsigned with a 401 before any sensor sees the payload.
Without it, the endpoint runs unverified and warns at startup: fine in local dev, not in
production.
Trigger a workflow on a built-in event
Name the event in the workflow's on: and read its fields with
{{ event.<field> }}. The compiler injects the event contract and the
/hooks/sentry sensor when it sees the reference.
---
on: Sentry.IssueCreated
agent: Investigator
---
Triage the Sentry issue "{{ event.title }}" ({{ event.level }}, project {{ event.project }}).
Reproduce it from {{ event.url }}, find the root cause near {{ event.culprit }},
and file a work item with your findings.
The three built-in events
The two issue events map Sentry's issue webhook resource, and
Sentry.AlertTriggered maps the event_alert resource; each is
told apart by the delivery's action. As with GitHub, every event is a typed
contract over a flattened, dependable subset of the raw payload, and the compiler checks
every event.<field> reference at build time.
Sentry.IssueCreated
Fires when Sentry creates a new issue (the issue resource with
action: created): the first time an error groups into something new.
{
"issue_id": "1170820242",
"title": "ReferenceError: widget is not defined",
"culprit": "app/views.py in get_widgets",
"level": "error",
"project": "backend",
"url": "https://sentry.io/organizations/acme/issues/1170820242/"
}
str. Sentry's id for the issue. From data.issue.id.
str. The issue title, usually the exception headline. From data.issue.title.
str. Where the error happened, like app/views.py in get_widgets. From data.issue.culprit (empty string if none).
enum[debug, info, warning, error, fatal]. The issue's severity. From data.issue.level; anything outside the enum is clamped to error.
str. The project slug. From data.issue.project.slug.
url. Permalink to the issue. From data.issue.permalink (empty string when Sentry omits it).
Sentry.IssueResolved
Fires when an issue is marked resolved (the issue resource with
action: resolved). Useful for the confirm side of a loop: verify the fix
held, close the work item, announce.
{
"issue_id": "1170820242",
"title": "ReferenceError: widget is not defined",
"project": "backend",
"url": "https://sentry.io/organizations/acme/issues/1170820242/"
}
str. Sentry's id for the issue. From data.issue.id.
str. The issue title. From data.issue.title.
str. The project slug. From data.issue.project.slug.
url. Permalink to the issue. From data.issue.permalink (empty string when Sentry omits it).
Sentry.AlertTriggered
Fires when an issue alert rule triggers (the event_alert resource with
action: triggered). Its fields come from the error event that set the rule
off, plus rule, the name of the rule itself. Enable this in the Custom
Integration by turning on Alert Rule Action, then add the integration as
an action on the rules you want to drive a workflow.
{
"issue_id": "1170820242",
"title": "ReferenceError: widget is not defined",
"culprit": "app/views.py in get_widgets",
"level": "error",
"rule": "High error rate",
"url": "https://sentry.io/organizations/acme/issues/1170820242/events/0b3b/"
}
str. Id of the issue the triggering event belongs to. From data.event.issue_id.
str. The event title, usually the exception headline. From data.event.title.
str. Where the error happened. From data.event.culprit (empty string if none).
enum[debug, info, warning, error, fatal]. Severity of the event. From data.event.level; anything outside the enum is clamped to error.
str. Name of the alert rule that fired: what tells an alert apart from a plain new issue. From data.triggered_rule (empty string if absent).
url. Permalink to the triggering event. From data.event.web_url (empty string when Sentry omits it).
Metric alerts (a threshold crossing rather than an issue) are a different payload and aren't built in yet: reach them with a sensor.
Or write the sensor yourself
The built-in events are one of two fully supported routes. When you want the event shaped
your way (say, a normalized Incident that Sentry, PagerDuty, and Datadog all
feed), skip the Sentry.* names: declare your event in registry.yml,
write a sensor that maps the webhook payload onto
it, and trigger workflows with on: Incident instead.
# webhook: Sentry POSTs an issue; you shape it into an Incident
@sensor(webhook="/hooks/sentry", emits="Incident")
def sentry_issues(req) -> Incident | None:
if req.json.get("action") != "created":
return None # ignore resolved/assigned deliveries
issue = req.json["data"]["issue"]
return Incident(source="sentry", issue_id=issue["id"],
title=issue["title"], link=issue["permalink"])
The Sentry side is identical: the same Custom Integration, whether
loopy auth sentry made it or you did, delivers to the same endpoint. Keep the
sensor's path under /hooks/sentry and the signature check covers it too; a
sensor on any other path runs unverified, so treat that as laptop-only. The two routes also
compose: one delivery fans out to every sensor on the path, built-in and hand-written
alike, and each emits only what matches, so on: Sentry.IssueCreated and
on: Incident can drive different workflows from the same webhook.