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.

workflows/review/code-review.md
---
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).

event payload
{
  "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"
}
number

int. The PR number. From pull_request.number.

repo

str. Owner and name, like octocat/Hello-World. From repository.full_name.

title

str. The PR title. From pull_request.title.

branch

str. The head ref (the PR's source branch). From pull_request.head.ref.

base

str. The base ref it merges into. From pull_request.base.ref.

url

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.

event payload
{
  "number":    42,
  "repo":      "octocat/Hello-World",
  "title":     "Add the widget endpoint",
  "url":       "https://github.com/octocat/Hello-World/pull/42",
  "merged_by": "octocat"
}
number

int. The PR number. From pull_request.number.

repo

str. Owner and name. From repository.full_name.

title

str. The PR title. From pull_request.title.

url

url. The PR's web page. From pull_request.html_url.

merged_by

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.

event payload
{
  "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"
}
number

int. The issue number. From issue.number.

repo

str. Owner and name. From repository.full_name.

title

str. The issue title. From issue.title.

body

str. The issue description. From issue.body (empty string if none).

author

str. Login of the opener. From issue.user.login (unknown if absent).

url

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.

event payload
{
  "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
}
repo

str. Owner and name. From repository.full_name.

issue_number

int. The issue or PR number the comment is on. From issue.number (0 if absent).

body

str. The comment text. From comment.body (empty string if none).

author

str. Login of the commenter. From comment.user.login (unknown if absent).

url

url. The comment's web anchor. From comment.html_url.

on_pull_request

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.

event payload
{
  "repo":         "octocat/Hello-World",
  "ref":          "refs/heads/main",
  "before":       "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684",
  "after":        "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c",
  "pusher":       "octocat",
  "commit_count": 3
}
repo

str. Owner and name. From repository.full_name.

ref

str. The ref that was pushed, like refs/heads/main. From ref.

before

str. The SHA before the push. From before (empty string if absent).

after

str. The SHA after the push. From after (empty string if absent).

pusher

str. Name of whoever pushed. From pusher.name (unknown if absent).

commit_count

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:

loopy.env
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.

workflows/triage/investigate.md
---
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.

event payload
{
  "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/"
}
issue_id

str. Sentry's id for the issue. From data.issue.id.

title

str. The issue title, usually the exception headline. From data.issue.title.

culprit

str. Where the error happened, like app/views.py in get_widgets. From data.issue.culprit (empty string if none).

level

enum[debug, info, warning, error, fatal]. The issue's severity. From data.issue.level; anything outside the enum is clamped to error.

project

str. The project slug. From data.issue.project.slug.

url

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.

event payload
{
  "issue_id": "1170820242",
  "title":    "ReferenceError: widget is not defined",
  "project":  "backend",
  "url":      "https://sentry.io/organizations/acme/issues/1170820242/"
}
issue_id

str. Sentry's id for the issue. From data.issue.id.

title

str. The issue title. From data.issue.title.

project

str. The project slug. From data.issue.project.slug.

url

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.

event payload
{
  "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/"
}
issue_id

str. Id of the issue the triggering event belongs to. From data.event.issue_id.

title

str. The event title, usually the exception headline. From data.event.title.

culprit

str. Where the error happened. From data.event.culprit (empty string if none).

level

enum[debug, info, warning, error, fatal]. Severity of the event. From data.event.level; anything outside the enum is clamped to error.

rule

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

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.

sensors/sensors.py
# 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.