Skip to content

Commit 71d57d9

Browse files
authored
Merge pull request #1035 from kelos-dev/kelos-task-1033
docs: Document GenericWebhook TaskSpawner source
2 parents 6445dab + 92104a0 commit 71d57d9

6 files changed

Lines changed: 378 additions & 53 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,7 @@ See the [full AgentConfig spec](docs/reference.md#agentconfig) for plugins, skil
454454

455455
Kelos integrates with external systems in two ways:
456456

457-
**TaskSpawner** — Kelos natively watches external sources and automatically creates Tasks. Supports GitHub Issues, GitHub Pull Requests, GitHub Webhooks, Jira, and Cron schedules. No glue code needed.
457+
**TaskSpawner** — Kelos natively watches external sources and automatically creates Tasks. Supports GitHub Issues, GitHub Pull Requests, GitHub Webhooks, Linear Webhooks, Jira, Cron schedules, and Generic Webhooks (for arbitrary HTTP POST sources like Sentry, Notion, or Slack). No glue code needed.
458458

459459
```yaml
460460
spec:

docs/integration.md

Lines changed: 105 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,84 @@ Then configure a webhook in Linear (Settings → API → Webhooks) pointing to `
269269

270270
**Linear-specific variables:** `{{.Type}}` (resource type), `{{.State}}` (workflow state), `{{.Action}}` (webhook action), `{{.IssueID}}` (parent issue ID for Comment events), `{{.Labels}}`, `{{.Payload}}` (full payload access).
271271

272+
### Generic Webhooks
273+
274+
React to arbitrary HTTP POST events from any system that can deliver a JSON payload — Sentry, Notion, Slack, Drata, PagerDuty, internal services, or anything else. Unlike the GitHub and Linear webhook sources, the generic webhook source has no built-in knowledge of any particular schema; you describe how to extract fields and what to filter on using JSONPath expressions.
275+
276+
```yaml
277+
apiVersion: kelos.dev/v1alpha1
278+
kind: TaskSpawner
279+
metadata:
280+
name: sentry-error-responder
281+
spec:
282+
when:
283+
webhook:
284+
source: sentry # URL: /webhook/sentry
285+
fieldMapping:
286+
id: "$.data.event.event_id" # required — used for deduplication and task naming
287+
title: "$.data.event.title"
288+
url: "$.data.url"
289+
level: "$.data.event.level"
290+
filters:
291+
- field: "$.data.event.level"
292+
value: "error"
293+
- field: "$.data.event.platform"
294+
pattern: "^(python|go|node)"
295+
taskTemplate:
296+
type: claude-code
297+
workspaceRef:
298+
name: my-workspace
299+
credentials:
300+
type: oauth
301+
secretRef:
302+
name: claude-oauth-token
303+
promptTemplate: |
304+
A new Sentry error was reported.
305+
306+
Title: {{.Title}}
307+
Level: {{.level}}
308+
URL: {{.URL}}
309+
310+
Investigate the stack trace in the payload and open a PR with a fix.
311+
branch: "sentry-{{.ID}}"
312+
maxConcurrency: 3
313+
```
314+
315+
**Setup:** Enable the `generic` source on `kelos-webhook-server` in your Helm values:
316+
317+
```yaml
318+
# Helm values
319+
webhookServer:
320+
sources:
321+
generic:
322+
enabled: true
323+
```
324+
325+
The webhook URL is `https://your-webhook-domain/webhook/<source>` (e.g., `/webhook/sentry`).
326+
327+
> [!WARNING]
328+
> **The generic webhook endpoint is currently unauthenticated.** The handler does not validate request signatures, so any client that can reach `/webhook/<source>` and matches a registered TaskSpawner can trigger Task creation. Until per-source HMAC validation is implemented (tracked in [#1040](https://github.com/kelos-dev/kelos/issues/1040)), restrict access at the network layer:
329+
>
330+
> - Use a `NetworkPolicy` to limit ingress to known sender CIDRs.
331+
> - Front the endpoint with an Ingress / Gateway that enforces IP allowlisting or mTLS.
332+
> - Avoid exposing the webhook Service as `LoadBalancer` on a public network unless ingress is otherwise restricted.
333+
>
334+
> The `webhookServer.sources.generic.secretName` Helm value is reserved for future HMAC validation; it currently mounts env vars that no code reads.
335+
336+
**Configuration:**
337+
338+
- **`source`** *(required)* — short identifier (lowercase alphanumeric with optional hyphens) that determines the URL path (`/webhook/<source>`).
339+
- **`fieldMapping`** *(required)* — map of template variable name → JSONPath expression evaluated against the request body. Each key becomes `{{.Key}}` in `promptTemplate` and `branch`. Lowercase keys `id`, `title`, `body`, and `url` are also exposed under their canonical uppercase aliases (`{{.ID}}`, `{{.Title}}`, `{{.Body}}`, `{{.URL}}`) for compatibility with templates written for the GitHub or Linear sources. The **`id` key is required** — it is used for delivery deduplication and Task naming. Missing fields produce empty strings (no error); only malformed JSONPath expressions fail.
340+
- **`filters[]`** *(optional)* — list of conditions that must ALL match for a delivery to trigger a Task (AND semantics across filters). Each filter has a `field` (JSONPath) and exactly one of:
341+
- `value` — exact string match against the extracted value
342+
- `pattern` — Go [regexp](https://pkg.go.dev/regexp/syntax) match against the extracted value
343+
344+
When `filters` is empty, every delivery triggers a Task. A filter whose `field` is missing in the payload fails (the delivery is skipped).
345+
346+
**Generic-webhook variables:** `{{.Kind}}` is always `"GenericWebhook"`, `{{.Payload}}` is the full parsed JSON body (use it for advanced templating like `{{.Payload.data.event.platform}}`), and every key from `fieldMapping` becomes a top-level variable. Standard fields `{{.ID}}`, `{{.Title}}`, `{{.Body}}`, and `{{.URL}}` always exist (empty if not mapped).
347+
348+
See [example 13](../examples/13-taskspawner-generic-webhook/) for a full setup walkthrough.
349+
272350
### Cron
273351

274352
Run agents on a schedule — dependency updates, code health checks, or periodic maintenance.
@@ -300,31 +378,33 @@ spec:
300378

301379
All `promptTemplate` and `branch` fields support Go `text/template` syntax. Available variables depend on the source:
302380

303-
| Variable | GitHub Issues | GitHub PRs | GitHub Webhook | Jira | Linear Webhook | Cron |
304-
|----------|--------------|------------|----------------|------|----------------|------|
305-
| `{{.ID}}` | Issue number (string) | PR number (string) | Issue/PR number or commit ID | Issue key (e.g., `ENG-42`) | Linear resource ID | Date-time string |
306-
| `{{.Number}}` | Issue number (int) | PR number (int) | Issue/PR number | `0` | Empty | `0` |
307-
| `{{.Title}}` | Issue title | PR title | Issue/PR title | Issue summary | Resource title | Trigger time (RFC3339) |
308-
| `{{.Body}}` | Issue body | PR body | Issue/PR/comment body | Issue description | Empty | Empty |
309-
| `{{.URL}}` | Issue URL | PR URL | Issue/PR URL | Issue URL | Empty | Empty |
310-
| `{{.Labels}}` | Comma-separated | Comma-separated | Empty | Comma-separated | Comma-separated | Empty |
311-
| `{{.Comments}}` | Issue comments | PR comments | Empty | Issue comments | Empty | Empty |
312-
| `{{.Kind}}` | `"Issue"` | `"PR"` | `"webhook"` | Jira issue type | `"LinearWebhook"` | `"Issue"` |
313-
| `{{.Event}}` | Empty | Empty | Event type (e.g., `"issues"`) | Empty | Empty | Empty |
314-
| `{{.Action}}` | Empty | Empty | Action (e.g., `"opened"`) | Empty | Action (e.g., `"create"`, `"update"`) | Empty |
315-
| `{{.Sender}}` | Empty | Empty | Event sender username | Empty | Empty | Empty |
316-
| `{{.Branch}}` | Empty | PR head branch | PR/push branch | Empty | Empty | Empty |
317-
| `{{.Ref}}` | Empty | Empty | Git ref (e.g., `"refs/heads/main"`) | Empty | Empty | Empty |
318-
| `{{.Repository}}` | Empty | Empty | `owner/repo` format | Empty | Empty | Empty |
319-
| `{{.RepositoryOwner}}` | Empty | Empty | Repository owner login | Empty | Empty | Empty |
320-
| `{{.RepositoryName}}` | Empty | Empty | Repository name only | Empty | Empty | Empty |
321-
| `{{.Payload}}` | Empty | Empty | Full webhook payload | Empty | Full Linear webhook payload | Empty |
322-
| `{{.ReviewState}}` | Empty | `approved` / `changes_requested` | Empty | Empty | Empty | Empty |
323-
| `{{.ReviewComments}}` | Empty | Inline review comments | Empty | Empty | Empty | Empty |
324-
| `{{.Type}}` | Empty | Empty | Empty | Empty | Resource type (e.g., `"Issue"`, `"Comment"`) | Empty |
325-
| `{{.State}}` | Empty | Empty | Empty | Empty | Workflow state (e.g., `"Todo"`, `"In Progress"`) | Empty |
326-
| `{{.IssueID}}` | Empty | Empty | Empty | Empty | Parent issue ID (Comment events only) | Empty |
327-
| `{{.Time}}` | Empty | Empty | Empty | Empty | Empty | Trigger time (RFC3339) |
381+
| Variable | GitHub Issues | GitHub PRs | GitHub Webhook | Jira | Linear Webhook | Generic Webhook | Cron |
382+
|----------|--------------|------------|----------------|------|----------------|-----------------|------|
383+
| `{{.ID}}` | Issue number (string) | PR number (string) | Issue/PR number or commit ID | Issue key (e.g., `ENG-42`) | Linear resource ID | Mapped `id` field (required) | Date-time string |
384+
| `{{.Number}}` | Issue number (int) | PR number (int) | Issue/PR number | `0` | Empty | Empty | `0` |
385+
| `{{.Title}}` | Issue title | PR title | Issue/PR title | Issue summary | Resource title | Mapped `title` field (if present) | Trigger time (RFC3339) |
386+
| `{{.Body}}` | Issue body | PR body | Issue/PR/comment body | Issue description | Empty | Mapped `body` field (if present) | Empty |
387+
| `{{.URL}}` | Issue URL | PR URL | Issue/PR URL | Issue URL | Empty | Mapped `url` field (if present) | Empty |
388+
| `{{.Labels}}` | Comma-separated | Comma-separated | Empty | Comma-separated | Comma-separated | Empty | Empty |
389+
| `{{.Comments}}` | Issue comments | PR comments | Empty | Issue comments | Empty | Empty | Empty |
390+
| `{{.Kind}}` | `"Issue"` | `"PR"` | `"webhook"` | Jira issue type | `"LinearWebhook"` | `"GenericWebhook"` | `"Issue"` |
391+
| `{{.Event}}` | Empty | Empty | Event type (e.g., `"issues"`) | Empty | Empty | Empty | Empty |
392+
| `{{.Action}}` | Empty | Empty | Action (e.g., `"opened"`) | Empty | Action (e.g., `"create"`, `"update"`) | Empty | Empty |
393+
| `{{.Sender}}` | Empty | Empty | Event sender username | Empty | Empty | Empty | Empty |
394+
| `{{.Branch}}` | Empty | PR head branch | PR/push branch | Empty | Empty | Empty | Empty |
395+
| `{{.Ref}}` | Empty | Empty | Git ref (e.g., `"refs/heads/main"`) | Empty | Empty | Empty | Empty |
396+
| `{{.Repository}}` | Empty | Empty | `owner/repo` format | Empty | Empty | Empty | Empty |
397+
| `{{.RepositoryOwner}}` | Empty | Empty | Repository owner login | Empty | Empty | Empty | Empty |
398+
| `{{.RepositoryName}}` | Empty | Empty | Repository name only | Empty | Empty | Empty | Empty |
399+
| `{{.Payload}}` | Empty | Empty | Full webhook payload | Empty | Full Linear webhook payload | Full parsed JSON body | Empty |
400+
| `{{.ReviewState}}` | Empty | `approved` / `changes_requested` | Empty | Empty | Empty | Empty | Empty |
401+
| `{{.ReviewComments}}` | Empty | Inline review comments | Empty | Empty | Empty | Empty | Empty |
402+
| `{{.Type}}` | Empty | Empty | Empty | Empty | Resource type (e.g., `"Issue"`, `"Comment"`) | Empty | Empty |
403+
| `{{.State}}` | Empty | Empty | Empty | Empty | Workflow state (e.g., `"Todo"`, `"In Progress"`) | Empty | Empty |
404+
| `{{.IssueID}}` | Empty | Empty | Empty | Empty | Parent issue ID (Comment events only) | Empty | Empty |
405+
| `{{.Time}}` | Empty | Empty | Empty | Empty | Empty | Empty | Trigger time (RFC3339) |
406+
407+
> **Generic Webhook only:** any additional keys you declare in `fieldMapping` are also exposed as top-level variables. For example, `fieldMapping: {severity: "$.level"}` makes `{{.severity}}` available in templates.
328408

329409
## Direct Task Creation: Workflow Integration
330410

0 commit comments

Comments
 (0)