Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/control-kody/references/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Then drive the surface with `login`, `request`, `preview`, and `health`. See the
- [mcp-servers](./mcp-servers.md) — `/account/mcp-servers`
- [jobs](./jobs.md) — `/account/jobs`
- [workflows](./workflows.md) — `/account/workflows`
- [webhooks](./webhooks.md) — `/account/webhooks`
- [activity](./activity.md) — `/account/activity`
- [waiting](./waiting.md) — `/account/waiting`
- [memories](./memories.md) — `/account/memories`
Expand Down
40 changes: 40 additions & 0 deletions .agents/skills/control-kody/references/features/webhooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Webhooks

Owner-only home for inbound webhook URLs: every webhook a saved package declares
(`package.json#kody.webhooks`) joined with its minted state. Mint, reveal +
copy, rotate, disable, and enable happen here; MCP never returns the credential
URL.

## How to get there

`/account/webhooks` (account rail → Webhooks) →
`/account/webhooks/:packageKodyId/:webhookName` expands one row.

## Drive it

```bash
node tools/control-kody.ts login
node tools/control-kody.ts request GET /account/webhooks.json
node tools/control-kody.ts request POST /account/webhooks.json \
--json '{"intent":"mint","packageKodyId":"<kodyId>","webhookName":"<name>"}'
```

## APIs

- `GET /account/webhooks.json` — `{ ok, username, webhooks[] }`; no URL, no
secret. `urlRecoverable` is false for mints that predate encrypted storage.
- `POST /account/webhooks.json` —
`{ intent: 'mint' | 'rotate' | 'reveal' | 'enable' | 'disable', packageKodyId, webhookName }`.
`mint`, `rotate`, and `reveal` add `revealed: { id, handle, url }`; the URL
origin follows the request so previews show their own host.

## Gotchas

- Seed users own no packages, so the list is empty until a saved package
declares a webhook. Publish one with `kody.webhooks` first (the MCP
`webhookList` capability sees the same rows).
- `mint` on an already-minted webhook is a 400 (“Rotate it”); the UI only shows
Mint for unminted rows. Rotate and Disable are double-check buttons.
- `reveal` on a legacy mint without `url_secret_encrypted` is a 400; the page
offers Rotate instead.
- Every intent writes an `account` audit event (`webhook_url_reveal`, …).
5 changes: 5 additions & 0 deletions docs/contributing/architecture/primitives.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,11 @@ primitives:
- packages/worker/src/webhooks/
- packages/worker/src/mcp/capabilities/webhooks/
- packages/worker/src/app/handlers/webhook-ingress.ts
- packages/worker/src/app/handlers/account-webhooks.ts
- packages/worker/src/app/account-webhooks-data.ts
- packages/worker/client/routes/account-webhooks.tsx
- packages/worker/client/routes/account-webhooks-detail.tsx
- packages/worker/client/routes/account-webhooks-shared.ts
- packages/worker/src/package-registry/
docs:
- docs/use/webhooks.md
Expand Down
25 changes: 22 additions & 3 deletions docs/contributing/architecture/webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,28 @@ consumer's 15-minute wall-clock limit before later messages are acknowledged.
Delivery history lives in run records and is covered with the rest of `RunLog`
export/deletion. Export redacts `url_secret_hash` and `url_secret_encrypted`.
- Plaintext URL secrets and verification secrets are never logged. URL secrets
are hashed for ingress and stored encrypted for `webhookUrlApply`. MCP mint,
rotate, list, and apply never return the credential URL. Verification secrets
stay in the secrets primitive.
are hashed for ingress and stored encrypted for `webhookUrlApply` and the
owner reveal on `/account/webhooks`. MCP mint, rotate, list, and apply never
return the credential URL. Verification secrets stay in the secrets primitive.

## Owner UI

`/account/webhooks` (`packages/worker/client/routes/account-webhooks.tsx`,
handlers in `packages/worker/src/app/handlers/account-webhooks.ts`) is the
signed-in owner's surface for minted URLs. `GET /account/webhooks.json` returns
`listWebhooksForUser` joined with `urlRecoverable` and never the URL. `POST`
intents `mint`, `rotate`, and `reveal` return the refreshed list plus a
`revealed` entry built by `revealWebhookUrlForWebsite` (decrypt
`url_secret_encrypted`, rebuild the ingress path from the request origin);
`enable` / `disable` return the list only. Every intent writes an `account`
audit event (`webhook_url_mint`, `webhook_url_rotate`, `webhook_url_reveal`,
`webhook_enable`, `webhook_disable`). `mint` refuses an already-minted webhook
so a stray click cannot rotate a provider's URL; `reveal` refuses mints without
`url_secret_encrypted` and points at Rotate. The client keeps revealed URLs in
memory only and drops them on row navigation or Hide.

`revealWebhookUrlForWebsite` is website-only: no MCP capability, execute
binding, or apply result may return the credential URL.

## Storage

Expand Down
4 changes: 3 additions & 1 deletion docs/guides/triggers.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ request to the package export that owns it.
2. Store the signing secret with `secretSet` under that name.
3. Mint a handle with `webhookUrlMint` (returns a `handle`, not the credential)
and register it with `webhookUrlApply`. Treat the URL as a credential; tool
output never includes it.
output never includes it. For a provider apply does not cover, the owner
copies the URL from `/account/webhooks` (account rail → Webhooks), where they
can also reveal, rotate, disable, or enable it.

Declaring a webhook does not open ingress; minting does. Deliveries are
rate-limited per webhook (default 60 per minute, at most 600), and body-only
Expand Down
42 changes: 35 additions & 7 deletions docs/use/webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
Kody inbound webhooks are **package-centered**: you declare them in
`package.json#kody.webhooks`, mint an opaque handle with `webhookUrlMint`, then
register the credential with `webhookUrlApply` (GitHub repository hooks today)
or configure another provider through a website-only path. MCP and execute never
return the credential URL or `url_secret`. Each delivery invokes the bound
or copy the URL yourself from the
[Webhooks](#manage-webhook-urls-in-the-account-ui) account page. MCP and execute
never return the credential URL or `url_secret`. Each delivery invokes the bound
package export.

This is the HTTP sibling of [email primitives](./email-primitives.md). Webhooks
Expand All @@ -17,8 +18,10 @@ There is no `*` / multi-export URL. One declared webhook name binds one export.

Treat every minted URL as a **credential**. Mint and rotate return an opaque
`handle` (and `url_host`), never the URL or `url_secret`. Register the URL with
`webhookUrlApply` so the credential stays inside Kody. MCP and execute never
return the URL.
`webhookUrlApply` so the credential stays inside Kody, or open
[`/account/webhooks`](#manage-webhook-urls-in-the-account-ui) and copy it
yourself for providers without an apply adapter. MCP and execute never return
the URL; the owner does that from the account UI.

## Declare a webhook in the package manifest

Expand Down Expand Up @@ -84,7 +87,9 @@ Use the MCP `webhooks` domain:
name is not known) and `webhookName`.
4. Call `webhookUrlApply` with the returned `handle` and a first-class
destination. GitHub repository hooks use the connected `github` integration
(or a host-approved GitHub token).
(or a host-approved GitHub token). For any other provider, tell the owner to
copy the URL from
[`/account/webhooks`](#manage-webhook-urls-in-the-account-ui).

Other capabilities: `webhookList` (declarations joined with minted handle /
enabled state), `webhookUrlRotate`, `webhookEnable`, `webhookDisable`, and
Expand All @@ -93,6 +98,27 @@ delivery history also appears under [Activity](./activity.md)
(`/account/activity` and the `runs` capabilities). List, mint, rotate, and apply
never return the credential URL.

## Manage webhook URLs in the account UI

The signed-in owner manages webhook URLs at `/account/webhooks` (account rail →
Webhooks). It lists every webhook declared by a saved package, joined with its
minted state, and expands one row at a time:

- **Mint URL** issues the first credential for a declared webhook and shows the
URL once so you can paste it into the provider.
- **Reveal URL** shows a minted URL again, with a copy button. Each reveal is
written to the account audit log.
- **Rotate URL** replaces the secret. The previous URL stops working
immediately; rerun `webhookUrlApply` (same handle) or paste the new URL into
the provider.
- **Disable** / **Enable** toggle ingress without deleting the mint. Disabled
webhooks answer 404.

The URL is the human path. Agents get the handle and `url_host` through MCP and
either apply the handle to a supported destination or ask the owner to copy the
URL here. Mints that predate encrypted secret storage cannot be shown; the page
offers Rotate for those.

### Apply a handle to a destination

`webhookUrlApply` resolves the handle inside Kody and registers the URL through
Expand All @@ -118,7 +144,8 @@ await kody.webhooks.webhookUrlApply({
Authorize with the user's GitHub OAuth integration (default `github`) or a
host-approved GitHub token (`secretName`). Additional vendor adapters can follow
the same pattern (known host + known API). Other providers still POST to the
minted ingress URL; apply does not register them.
minted ingress URL; apply does not register them — the owner copies the URL from
`/account/webhooks` instead.

Returns `{ ok, url_host, http_status, remote_id, error }`. Remote bodies that
echo the hook URL are stripped.
Expand Down Expand Up @@ -299,7 +326,8 @@ Republishing a package that removes or renames a webhook deactivates that
ingress (unknown name → 404). Disable with `webhookDisable` without deleting the
mint; re-enable with `webhookEnable`. Rotate the URL secret with
`webhookUrlRotate` when a credential may have leaked, then call
`webhookUrlApply` again with the same handle so providers get the new URL.
`webhookUrlApply` again with the same handle so providers get the new URL. The
same disable, enable, and rotate actions are on `/account/webhooks`.

## Related

Expand Down
1 change: 1 addition & 0 deletions e2e/account-navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ test('account section switches keep the current page on screen (no loading flash
{ link: 'Secrets', heading: 'Secrets' },
{ link: 'Connections', heading: 'Connections' },
{ link: 'Workflows', heading: 'Workflows' },
{ link: 'Webhooks', heading: 'Webhooks' },
{ link: 'Overview', heading: 'Account' },
{ link: 'Jobs', heading: 'Jobs' },
],
Expand Down
2 changes: 2 additions & 0 deletions packages/worker/client/lazy-route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,8 @@ registerPreloadPatterns(
routePattern(routes.accountJobDetail),
routePattern(routes.accountWorkflows),
routePattern(routes.accountWorkflowDetail),
routePattern(routes.accountWebhooks),
routePattern(routes.accountWebhookDetail),
routePattern(routes.accountActivity),
routePattern(routes.accountActivityDetail),
routePattern(routes.accountMemories),
Expand Down
2 changes: 2 additions & 0 deletions packages/worker/client/routes/account-area.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export {
AccountWorkflowsRoute,
accountWorkflowsRouteLoader,
} from './account-workflows.tsx'
export { AccountWebhooksRoute } from './account-webhooks.tsx'
export { accountWebhooksRouteLoader } from './account-webhooks-shared.ts'
export { AccountActivityRoute } from './account-activity.tsx'
export { accountActivityRouteLoader } from './account-activity-shared.ts'
export {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ test('account rail lists Connections and Packages at the same level as the other
'Activity',
'Jobs',
'Workflows',
'Webhooks',
'Secrets',
'Integrations',
'MCP servers',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import { packageShareGrantsFlagKey } from '#universal/feature-flags/registry.ts'
import { EntityExplainer, resolveEntityExplainer } from './entity-explainer.tsx'
import {
AccountManagementInlineLinkNav,

Check warning on line 25 in packages/worker/client/routes/account-management-components.tsx

View workflow job for this annotation

GitHub Actions / 🧹 Static

eslint(no-unused-vars)

packages/worker/client/routes/account-management-components.tsx:25:2: Identifier 'AccountManagementInlineLinkNav' is imported but never used.
AccountManagementLinkNav,
accountManagementNarrowMq,
} from './account-management-link-nav.tsx'
Expand Down Expand Up @@ -377,6 +377,7 @@
{ href: '/account/activity', label: 'Activity' },
{ href: '/account/jobs', label: 'Jobs' },
{ href: '/account/workflows', label: 'Workflows' },
{ href: routes.accountWebhooks.href(), label: 'Webhooks' },
{ href: '/account/secrets', label: 'Secrets' },
{ href: '/account/integrations', label: 'Integrations' },
{ href: '/account/mcp-servers', label: 'MCP servers' },
Expand Down
Loading
Loading