Webhooks#
External systems can trigger a Lens Prism agent by POSTing to a named webhook endpoint. Where the heartbeat polls on a timer and scheduled tasks run on a calendar, a webhook is push: Alertmanager fires an alert, PagerDuty opens an incident, GitHub merges a PR, and the agent reacts immediately.
Each webhook is a subscription: a named route with its own endpoint URL, an optional prompt template, and a delivery target. When an event arrives, the agent reasons over the payload — or, in deliver-only mode, the rendered template is posted directly with no LLM call — and the result is delivered to your connected channels.
Enable the receiver#
Webhooks are off by default. To enable them, open the web UI Webhooks page and click Enable Webhooks. The toggle takes effect immediately — no restart. While the receiver is disabled, every webhook endpoint answers 403.
Create a subscription#
To create a subscription, click New subscription on the Webhooks page and set its fields:
| Field | Meaning |
|---|---|
Name |
Lowercase slug ([a-z0-9][a-z0-9_-]*); becomes the endpoint path /agents/webhooks/<name>. Spaces are normalized to hyphens. |
Description |
What this webhook does (optional, shown in the list). |
Events |
Comma-separated event-type allowlist; empty accepts all. See event filtering. |
Deliver to |
Where results go: All channels (default — web chat plus Slack), Chat (web chat only), or Slack (Slack only). |
Deliver only |
Skip the agent: the rendered prompt template is posted directly as the message. Zero LLM cost — useful for plain notifications. |
Prompt |
Instructions for the agent when this webhook fires (optional). Supports payload substitution — see prompt templates. |
Each row in the subscriptions list shows the endpoint path with a copy button, an Active switch (disabled subscriptions answer 403 without running anything), and a delete action. Changes apply immediately.
The endpoint#
POST /agents/webhooks/<NAME>
Content-Type: application/json
The body must be a JSON object. Responses (event is the resolved event type — see event filtering):
| Status | Meaning |
|---|---|
202 Accepted |
Agent run dispatched: {"status":"accepted","event":"<type>","runId":"..."}. The response is delivered to your channels when the run finishes. |
200 OK |
Deliver-only message posted: {"status":"delivered","event":"<type>"} — or the event was filtered out by the Events list: {"status":"ignored","event":"<type>"}. |
400 |
Body was not a JSON object. |
403 |
Receiver disabled, or this subscription is disabled. |
404 |
No subscription with that name. |
429 |
This subscription's run queue is full — retry later. |
Agent runs are asynchronous by design — webhook senders like Alertmanager time out quickly and retry on non-2xx, so the endpoint doesn't hold the connection open while the agent works. The agent's response arrives in the conversation (prefixed **Webhook: <name>**) on the subscription's delivery target. If the run fails outright, a brief failure notice is delivered there instead, so a triggered event never disappears silently.
Runs are serialized per subscription: concurrent deliveries are accepted (202) and queued, then executed one at a time in arrival order — so rapid distinct events are never dropped, even from senders that don't retry (GitHub never redelivers automatically). The queue is capped at 10 pending; beyond that, deliveries answer 429 as a backstop against pathological floods. Deliver-only requests are not queued or limited — they cost no LLM tokens.
The runtime does not authenticate webhook calls itself — like every other runtime endpoint, auth is terminated by the platform routing in front of the container. Don't expose the runtime port directly to the internet.
Delivery targets and storage#
Each subscription's Deliver to setting controls both where the message is sent and where it's recorded:
All channels(default) — broadcast, exactly like a heartbeat alert: the message appears in web chat and is sent to Slack. On Slack that means your DM (personal mode) or the agent's main channel (team mode — the one flagged on the Channels page, else the oldest linked channel), not every linked channel.Chat— web chat only; nothing is sent to Slack.Slack— Slack only. The message is delivered to your DM (personal mode) or the main channel (team mode) and kept out of web chat: it's stored in that Slack destination's own conversation thread, which the web Recents list and CLI resume picker don't show. If no Slack DM or channel is linked, the message falls back to the web thread (and the runtime logs a warning) so a triggered event is never silently lost.
Prompt templates#
An empty Prompt sends the agent the raw payload as pretty-printed JSON with an event and route lead-in — the agent reasons over the full event. Alertmanager and PagerDuty payloads work well this way with no template at all.
A non-empty Prompt is rendered against the payload first:
{field.path}— dot-notation access into the payload, for example{commonLabels.alertname}or{pull_request.title}. Unresolvable keys are left as-is.{__raw__}— the entire payload as indented JSON (truncated to about 4000 characters).
Example, for Alertmanager:
An alert group just fired ({status}):
{__raw__}
Triage it: check the affected pods, look for an obvious cause, and summarize
what you found. Only suggest a remediation if you're confident.
In deliver-only mode the rendered template is the message — for example Alert: {commonLabels.alertname} is {status} posts "Alert: HighMemory is firing" with no agent involvement.
Event filtering#
The Events field matches the incoming event's type, resolved in this order:
| Source | Where the type comes from | Example values |
|---|---|---|
| GitHub | X-GitHub-Event header |
push, pull_request, issues |
| GitLab | X-GitLab-Event header |
Merge Request Hook |
| Generic | top-level event_type field in the payload |
whatever the sender sets |
| Anything else | — | unknown |
A non-empty Events list accepts only matching types; everything else answers 200 {"status":"ignored"} without running the agent.
Note
Leave Events empty for Alertmanager and PagerDuty. Neither sends a GitHub-style event header or a top-level event_type field, so their deliveries resolve to unknown — an Events list like firing,resolved would silently filter out every event. Put that intent in the Prompt instead ("only act on firing alerts; ignore resolved notifications").
Connect common senders#
Alertmanager — add a webhook receiver to alertmanager.yml:
receivers:
- name: prism
webhook_configs:
- url: http://<PRISM-HOST>/agents/webhooks/alertmanager
send_resolved: true
route:
receiver: prism
Alertmanager groups alerts and sends one JSON payload per group (with status, commonLabels, and an alerts array) — the raw-payload default prompt handles it well. Alertmanager retries on non-2xx and re-notifies on repeat_interval; per-subscription run serialization keeps that from fanning out into concurrent agent runs, without dropping distinct alert groups.
PagerDuty — create a v3 webhook subscription (Integrations → Developer Tools → Webhooks) pointing at http://<PRISM-HOST>/agents/webhooks/pagerduty. Events arrive as {"event": {"event_type": "incident.triggered", ...}}.
GitHub — add a repository webhook (Settings → Webhooks) with content type application/json pointing at http://<PRISM-HOST>/agents/webhooks/github. Use the Events field (for example pull_request,issues) to pick the event types you care about.
Anything else — any system that can POST JSON works:
curl -X POST http://<PRISM-HOST>/agents/webhooks/my-hook \
-H 'Content-Type: application/json' \
-d '{"event_type": "deploy.finished", "service": "api", "version": "1.4.2"}'