Skip to content

feat(agent_api_keys): one-call webhook-trigger setup endpoint#329

Open
danielmillerp wants to merge 3 commits into
mainfrom
dm/agentex-webhook-trigger
Open

feat(agent_api_keys): one-call webhook-trigger setup endpoint#329
danielmillerp wants to merge 3 commits into
mainfrom
dm/agentex-webhook-trigger

Conversation

@danielmillerp

@danielmillerp danielmillerp commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

What

Adds POST /agent_api_keys/webhook-trigger — wires a webhook trigger in one call:

  • registers a github/slack signature-verification key for the agent (auto-generates the signing secret if not provided),
  • returns the ready-to-paste forward webhook URL + the secret (shown once).
POST /agent_api_keys/webhook-trigger
{ "agent_name": "golden-agent", "source": "github",
  "name": "<owner/repo>", "forward_path": "github-pr/<config-id>" }
→ { key_id, secret, webhook_path, webhook_url }

Why

The pieces to trigger an agent from a webhook already exist (the /agents/forward ingress verifies the signature against an agent key, and POST /agent_api_keys registers keys). This bundles key-create + webhook-URL composition so a UI (or a curl) can set up a trigger in a single step instead of two — the backend for the self-serve "Add trigger" button. The webhook then flows through the existing forward ingress unchanged.

Before — wiring a trigger was two manual steps, and the caller had to know the agent's internal id, invent a secret, and hand-compose the forward URL:

# 1. register the signing key (need the agent's internal id + your own secret)
POST /agent_api_keys
{ "agent_id": "<look-up-first>", "api_key": "<generate-yourself>",
  "name": "owner/repo", "api_key_type": "github" }

# 2. hand-build the forward URL from the convention you have to know
webhook_url = f"{public_url}/agents/forward/name/{agent_name}/github-pr/{config_id}"

After — one call, by agent name, returns the URL + secret ready to paste:

POST /agent_api_keys/webhook-trigger
{ "agent_name": "golden-agent", "source": "github",
  "name": "owner/repo", "forward_path": "github-pr/<config-id>" }

→ { "key_id": "...", "secret": "ab3f…",            # generated for you, shown once
    "webhook_path": "/agents/forward/name/golden-agent/github-pr/<config-id>",
    "webhook_url":  "https://<host>/agents/forward/name/golden-agent/github-pr/<config-id>" }

No new ingress, no migration — built on the existing agent_api_keys + forward mechanism.

On the signing secret

This matches how GitHub webhooks actually work. A GitHub webhook's Secret is a value you supply (GitHub doesn't generate one) — you type a high-entropy string into the webhook's Secret field, and GitHub uses it to HMAC each payload into X-Hub-Signature-256: sha256=…, which the receiver re-computes and compares. It's a shared secret that must exist on both sides (GitHub's config + the verifying server), and it's optional-but-recommended (no secret → no signature header at all). Refs: GitHub docs validating-webhook-deliveries, creating-webhooks.

Given that, the endpoint's secret handling is deliberate:

  • omit secret (default) → the endpoint generates a strong one (secrets.token_hex(32)), stores it on the agent's verification key, and returns it once to paste into GitHub's Secret field — so the caller never has to invent entropy.
  • supply secret → for when the GitHub webhook already has a secret set and the agent side just needs to match it.

Testing

  • 4 unit tests (URL composition, provided vs generated secret, 409 on duplicate, 400 on non-webhook source). ruff clean.

🤖 Generated with Claude Code

Greptile Summary

  • Adds POST /agent_api_keys/webhook-trigger to create webhook verification keys by agent name.
  • Supports GitHub secret generation and Slack signing-secret setup for trigger registration.
  • Returns the existing forward ingress path and public webhook URL for pasteable webhook configuration.
  • Adds schema and unit test coverage for generated/provided secrets, duplicates, invalid sources, and Slack secret handling.

Confidence Score: 4/5

The endpoint is close but should not merge until URL construction and the Slack request contract are corrected.

Focused endpoint and schema coverage are present, and runtime checks confirm the two main compatibility issues in the new setup flow.

agentex/src/api/routes/agent_api_keys.py and agentex/src/api/schemas/agent_api_keys.py

T-Rex T-Rex Logs

What T-Rex did

  • Reproduced the raw forward_path delimiter routing issue by running a focused FastAPI pytest against /agent_api_keys/webhook-trigger, then posting to the returned webhook_url and observing that the mode suffix is treated as part of the query string rather than the forwarded path.
  • Validated the OpenAPI contract and Slack webhook behavior with a FastAPI TestClient, confirming the schema marks secret as optional while a missing secret still yields a 400 due to validation rules.
  • Observed the endpoint’s before/after state: initially the route returned 404 Not Found with missing CreateWebhookTriggerRequest/Response, then after change it returned 200 OK with key_id, secret, webhook_path, and webhook_url, with duplicate creation returning 409 and Slack webhook requests succeeding with a provided signing secret.

View all artifacts

T-Rex Ran code and verified through T-Rex

Fix All in Cursor Fix All in Claude Code Fix All in Codex

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
agentex/src/api/routes/agent_api_keys.py:166-167
**Raw path breaks routing**

`forward_path` is inserted into the returned URL as raw text, so URL delimiters can change what the forward route receives. For example, `forward_path="github-pr/cfg-9?mode=review"` returns `/agents/forward/name/<agent>/github-pr/cfg-9?mode=review`; when GitHub posts to it, FastAPI receives `github-pr/cfg-9` as the path and `mode=review` as the query string, so the agent never gets the configured subpath. `#`, spaces, and other reserved characters have similar effects. Encode the path segment or reject query, fragment, and control characters before returning the pasteable URL.

### Issue 2 of 2
agentex/src/api/schemas/agent_api_keys.py:77-80
**Slack contract is stale**

The route now returns `400` when `source` is `slack` and `secret` is omitted, but this request schema still documents `secret` as optional and generated when unset. Generated clients and UI built from this schema can omit the Slack signing secret, then every Slack setup call fails instead of completing the one-call trigger flow.

Reviews (5): Last reviewed commit: "fix(agent_api_keys): require provided se..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

@danielmillerp danielmillerp requested a review from a team as a code owner June 22, 2026 05:16
Comment thread agentex/src/api/routes/agent_api_keys.py Outdated
Comment thread agentex/src/api/routes/agent_api_keys.py Outdated
Add POST /agent_api_keys/webhook-trigger: registers a github/slack signature key
for an agent and returns the ready-to-paste forward webhook URL + secret in one
call. Bundles the existing key-create with webhook-URL composition so a UI (or a
curl) can wire a trigger in a single step; the webhook then flows through the
existing /agents/forward ingress that verifies the signature against this key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@danielmillerp danielmillerp force-pushed the dm/agentex-webhook-trigger branch from fdadd80 to 6ca6f70 Compare June 22, 2026 05:27
@github-actions

github-actions Bot commented Jun 22, 2026

Copy link
Copy Markdown

✱ Stainless preview builds

This PR will update the agentex-sdk SDKs with the following commit messages.

openapi

feat(api): add webhook trigger endpoint to agent_api_keys

python

chore(internal): regenerate SDK with no functional changes

typescript

chore(internal): regenerate SDK with no functional changes

Edit this comment to update them. They will appear in their respective SDK's changelogs.

agentex-sdk-typescript studio · code · diff

Your SDK build had at least one new note diagnostic, which is a regression from the base state.
generate ⚠️build ⏭️ (prev: build ✅) → lint ⏭️ (prev: lint ✅) → test ✅

New diagnostics (1 note)
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /agent_api_keys/webhook-trigger`
agentex-sdk-openapi studio · code · diff

Your SDK build had at least one new note diagnostic, which is a regression from the base state.
generate ✅

New diagnostics (1 note)
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /agent_api_keys/webhook-trigger`
agentex-sdk-python studio · code · diff

Your SDK build had at least one new note diagnostic, which is a regression from the base state.
generate ⚠️build ⏭️ (prev: build ✅) → lint ⏭️ (prev: lint ✅) → test ✅

New diagnostics (1 note)
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /agent_api_keys/webhook-trigger`

This comment is auto-generated by GitHub Actions and is automatically kept up to date as you push.
If you push custom code to the preview branch, re-run this workflow to update the comment.
Last updated: 2026-06-23 02:18:15 UTC

the signature against this key. Bundles the existing key-create + URL composition so
a UI (or a curl) can set up a trigger without two steps.
"""
if request.source not in (AgentAPIKeyType.GITHUB, AgentAPIKeyType.SLACK):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there other webhooks we want to scope out (linear, notion, pager duty, datadog?) Is there a way to make it easier to add a key than updating the code?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring to follow-ups, keeping this PR scoped to github/slack. Each new source (Linear, Notion, PagerDuty, Datadog) needs its own signature-verification scheme wired into the forward path — Linear is HMAC-SHA256 like GitHub, but PagerDuty/Datadog/Notion differ — so it's real per-source work rather than just an enum value. When we add the next source we'll introduce a small {source: signature_scheme} registry so adding one becomes a single entry instead of scattered changes. Tracking that as the extension point.

@declan-scale

Copy link
Copy Markdown
Collaborator

Will we want a rotate path as well should we want to rotate the api key?

Comment thread agentex/src/api/routes/agent_api_keys.py
@danielmillerp

Copy link
Copy Markdown
Collaborator Author

Will we want a rotate path as well should we want to rotate the api key?

Good call — deferring a dedicated rotate path to a follow-up, keeping this PR to the create flow. Today rotation works by delete + recreate: the forward URL/path is derived from agent name + forward_path (not the secret), so deleting the key and re-creating the trigger yields a new secret to paste while the webhook URL stays the same. A dedicated rotate (regenerate-secret-in-place, returned once, URL unchanged) is cleaner and avoids the brief gap between delete and recreate — worth adding alongside the M2M owner-key work where key rotation matters more. Tracking it as a follow-up.

),
)

secret = request.secret or secrets.token_hex(32)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Empty string for secret is falsy in Python, so request.secret or secrets.token_hex(32) will generate a new random secret rather than treating the empty string as-is. A caller who sends "secret": "" intending to set up a no-secret GitHub webhook (GitHub allows omitting the secret, in which case it sends no X-Hub-Signature-256 header) would receive a generated secret, register it on the key, but configure GitHub with no secret — causing every delivery to be rejected with an invalid-signature failure. Replacing or with an explicit None check avoids the silent coercion.

Suggested change
secret = request.secret or secrets.token_hex(32)
secret = request.secret if request.secret is not None else secrets.token_hex(32)
Prompt To Fix With AI
This is a comment left during a code review.
Path: agentex/src/api/routes/agent_api_keys.py
Line: 157

Comment:
Empty string for `secret` is falsy in Python, so `request.secret or secrets.token_hex(32)` will generate a new random secret rather than treating the empty string as-is. A caller who sends `"secret": ""` intending to set up a no-secret GitHub webhook (GitHub allows omitting the secret, in which case it sends no `X-Hub-Signature-256` header) would receive a generated secret, register it on the key, but configure GitHub with no secret — causing every delivery to be rejected with an invalid-signature failure. Replacing `or` with an explicit `None` check avoids the silent coercion.

```suggestion
    secret = request.secret if request.secret is not None else secrets.token_hex(32)
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Cursor Fix in Claude Code Fix in Codex

Slack signs requests with the app's existing Signing Secret, not a per-webhook
secret we can generate. Auto-generating one for a Slack trigger stored a random
value that would never match, so every real Slack delivery failed signature
verification. Now Slack requires the caller to supply 'secret'; GitHub still
auto-generates. Addresses Greptile P1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@danielmillerp danielmillerp force-pushed the dm/agentex-webhook-trigger branch from f3a13f8 to 949cc2d Compare June 23, 2026 02:15
Comment on lines +166 to +167
forward_path = request.forward_path.lstrip("/")
webhook_path = f"/agents/forward/name/{request.agent_name}/{forward_path}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Raw path breaks routing

forward_path is inserted into the returned URL as raw text, so URL delimiters can change what the forward route receives. For example, forward_path="github-pr/cfg-9?mode=review" returns /agents/forward/name/<agent>/github-pr/cfg-9?mode=review; when GitHub posts to it, FastAPI receives github-pr/cfg-9 as the path and mode=review as the query string, so the agent never gets the configured subpath. #, spaces, and other reserved characters have similar effects. Encode the path segment or reject query, fragment, and control characters before returning the pasteable URL.

Artifacts

Repro: focused FastAPI pytest for raw forward_path delimiter routing

  • Contains supporting evidence from the run (text/x-python; charset=utf-8).

Repro: verbose pytest output showing webhook-trigger and forward route HTTP status and JSON responses

  • Keeps the command output available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

Prompt To Fix With AI
This is a comment left during a code review.
Path: agentex/src/api/routes/agent_api_keys.py
Line: 166-167

Comment:
**Raw path breaks routing**

`forward_path` is inserted into the returned URL as raw text, so URL delimiters can change what the forward route receives. For example, `forward_path="github-pr/cfg-9?mode=review"` returns `/agents/forward/name/<agent>/github-pr/cfg-9?mode=review`; when GitHub posts to it, FastAPI receives `github-pr/cfg-9` as the path and `mode=review` as the query string, so the agent never gets the configured subpath. `#`, spaces, and other reserved characters have similar effects. Encode the path segment or reject query, fragment, and control characters before returning the pasteable URL.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Cursor Fix in Claude Code Fix in Codex

Comment on lines +77 to +80
secret: str | None = Field(
None,
description="Optional signing secret; if unset, one is generated and returned.",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Slack contract is stale

The route now returns 400 when source is slack and secret is omitted, but this request schema still documents secret as optional and generated when unset. Generated clients and UI built from this schema can omit the Slack signing secret, then every Slack setup call fails instead of completing the one-call trigger flow.

Artifacts

Repro: FastAPI TestClient schema and endpoint harness

  • Contains supporting evidence from the run (text/x-python; charset=utf-8).

Repro: OpenAPI schema excerpt and Slack endpoint 400 response output

  • Keeps the command output available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

Prompt To Fix With AI
This is a comment left during a code review.
Path: agentex/src/api/schemas/agent_api_keys.py
Line: 77-80

Comment:
**Slack contract is stale**

The route now returns `400` when `source` is `slack` and `secret` is omitted, but this request schema still documents `secret` as optional and generated when unset. Generated clients and UI built from this schema can omit the Slack signing secret, then every Slack setup call fails instead of completing the one-call trigger flow.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Cursor Fix in Claude Code Fix in Codex

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants