# Integrations

> Markdown mirror of /docs/integrations.html

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](/docs/concepts.md#sensors). 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](#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`**

```markdown
---
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`](/docs/get-started.md#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](/docs/concepts.md#sensors) 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](#pull-request-opened)
- [Github.PullRequestMerged](#pull-request-merged)
- [Github.IssueOpened](#issue-opened)
- [Github.IssueCommentCreated](#issue-comment-created)
- [Github.Push](#push)

#### `Github.PullRequestOpened`

Fires when a pull request is opened (GitHub's `pull_request` event with `action: opened`).

**`event payload`**

```json
{
  "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"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `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`**

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

| Field | Type | Description |
|-------|------|-------------|
| `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`**

```json
{
  "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"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `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`**

```json
{
  "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
}
```

| Field | Type | Description |
|-------|------|-------------|
| `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`**

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

| Field | Type | Description |
|-------|------|-------------|
| `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](#sentry-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](#sentry-custom-sensor) 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`**

```markdown
---
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](#issue-created)
- [Sentry.IssueResolved](#issue-resolved)
- [Sentry.AlertTriggered](#alert-triggered)

#### `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`**

```json
{
  "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/"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `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`**

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

| Field | Type | Description |
|-------|------|-------------|
| `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`**

```json
{
  "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/"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `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](/docs/concepts.md#sensors).

### 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](/docs/concepts.md#sensors) that maps the webhook payload onto it, and trigger workflows with `on: Incident` instead.

**`sensors/sensors.py`**

```python
# 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.
