Skip to content

feat(tools): add "webhook" toolset for outbound notifications#3641

Open
dwin-gharibi wants to merge 21 commits into
docker:mainfrom
dwin-gharibi:feat/webhook-tool
Open

feat(tools): add "webhook" toolset for outbound notifications#3641
dwin-gharibi wants to merge 21 commits into
docker:mainfrom
dwin-gharibi:feat/webhook-tool

Conversation

@dwin-gharibi

Copy link
Copy Markdown
Contributor

Closes #3640

Summary

New built-in webhook toolset — send_webhook(url, message, provider?) — that POSTs a provider-shaped JSON payload to a webhook and reports delivery status. Providers: slack, discord, ifttt, telegram, mattermost, rocketchat, googlechat, teams, generic. Reuses httpclient.NewSafeClient (refuses non-public IPs). See contribution/webhook/ISSUE.md for motivation and payload shapes.

Changes

File Change
pkg/tools/builtin/webhook/webhook.go New — toolset, pure buildPayload, injectable httpDoer.
pkg/tools/builtin/webhook/webhook_test.go New — payload shaping per provider, aliases, send success / non-2xx / network-error / validation.
pkg/teamloader/toolsets/toolsets.go Register the webhook creator.
pkg/teamloader/toolsets/catalog.go Catalog entry (enforced by the drift-guard test).
docs/configuration/tools/index.md, docs/tools/webhook/index.md Docs.

Testing

go test ./pkg/tools/builtin/webhook/ ./pkg/teamloader/toolsets/

Payload construction is a pure function and the HTTP client is injected, so every path is covered with a fake doer — no network. gofmt -l and go vet clean. (CI runs task lint / task test.)

Notes

  • Safe, message-shaped subset of an HTTP POST — not a general HTTP client (SSRF surface).
  • Config-defined named/allowlisted webhooks noted as future work.

@dwin-gharibi
dwin-gharibi requested a review from a team as a code owner July 14, 2026 14:34
@aheritier aheritier added area/docs Documentation changes area/tools For features/issues/fixes related to the usage of built-in and MCP tools kind/feat PR adds a new feature (maps to feat:). Use on PRs only. labels Jul 14, 2026
@Sayt-0
Sayt-0 marked this pull request as draft July 15, 2026 07:49
@dwin-gharibi
dwin-gharibi marked this pull request as ready for review July 15, 2026 08:01
@dwin-gharibi

Copy link
Copy Markdown
Contributor Author

@Sayt-0 Problems solved.

@Sayt-0 Sayt-0 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

New webhook toolset fills a real gap (agents currently have no way to notify anyone) with a well-chosen scope: message-shaped payloads only, SSRF-safe client, pure payload builder with an injectable HTTP doer. Registration, catalog entry, docs layout and tests follow the existing patterns.

Two blocking items, both cheap to fix:

# Issue Impact
1 Docs reference the scheduler toolset, which does not exist on main (#3632 is still open): overview prose, example config, closing tip Copy-pasting the example fails at load time with unknown toolset type: scheduler (pkg/teamloader/registry.go)
2 agent-schema.json toolset type enum not updated Editor validation (yaml-language-server, recommended in docs/configuration/overview) flags type: webhook as invalid. Precedent: the rag toolset change updated the schema in the same PR

Item 1: inline suggestions below remove the references. If #3632 is expected to merge first, rebasing on top of it works too; as long as merge order is undefined, published docs should not reference a toolset that fails to load.

Item 2: add "webhook" in two places:

  • definitions/Toolset/properties/type/enum
  • definitions/Toolset/anyOf[1]/properties/type/enum (variant listing types with no extra required field; no dedicated anyOf entry is needed since the toolset takes no options)

Non-blocking inline suggestions: reuse httpclient.DefaultToolHTTPTimeout, set the docker-agent User-Agent on outbound requests, list all providers in the tool description, consider honoring timeout: / allowed_domains: from the toolset config.

Context note: the Go workflow (ci.yml) currently fails repo-wide in 0s ("workflow file issue", setup-go with: indentation, also failing on main pushes), so task lint / task test did not run for this PR. Pre-existing breakage, not caused by this change, but Go tests were not executed by CI.

Comment thread docs/tools/webhook/index.md Outdated
Comment thread docs/tools/webhook/index.md Outdated
Comment thread docs/tools/webhook/index.md Outdated
Comment thread pkg/tools/builtin/webhook/webhook.go Outdated
Comment thread pkg/tools/builtin/webhook/webhook.go Outdated
Comment thread pkg/tools/builtin/webhook/webhook.go Outdated
Comment thread pkg/teamloader/toolsets/toolsets.go Outdated
Comment thread docs/tools/webhook/index.md Outdated
Comment thread docs/tools/webhook/index.md Outdated
@rumpl

rumpl commented Jul 15, 2026

Copy link
Copy Markdown
Member

I like the idea but as is this toolset is just an http call, and we already have a toolset for this. Let's try and figure out how this toolset can really be a webhook

@aheritier
aheritier marked this pull request as draft July 16, 2026 00:05
@aheritier aheritier added the status/needs-rebase PR has merge conflicts or is out of date with main label Jul 16, 2026
@aheritier

Copy link
Copy Markdown
Contributor

👋 This PR has merge conflicts with the base branch. Please rebase or merge the latest base branch and resolve them. I've moved it to draft and added status/needs-rebase; it'll be picked back up automatically once the conflicts are cleared.

@dwin-gharibi

Copy link
Copy Markdown
Contributor Author

I like the idea but as is this toolset is just an http call, and we already have a toolset for this. Let's try and figure out how this toolset can really be a webhook

Yes, you’re right @rumpl. I’m currently working on the architecture to make this something more flexible that we can build on and extend, rather than just another HTTP call wrapper.

I’m also thinking about capabilities like outbound webhooks and how we can make the toolset a more proper webhook-based integration point with better extensibility.

I’ll push the new implementation soon so we can review the direction and iterate on it.

@dwin-gharibi
dwin-gharibi marked this pull request as ready for review July 16, 2026 16:56
@dwin-gharibi

Copy link
Copy Markdown
Contributor Author

@rumpl @Sayt-0 Thank you both - the review changed my mind on the design, so this is a rework rather than a patch. Heads-up: the tool's interface changed (send_webhook no longer takes a url).

@rumpl - "the agent can't know the URL" / "we need at least some config"

You were right, and it is true for every provider, not just Slack: the webhook URL is the credential (Slack and Mattermost embed a secret path, Discord a token, IFTTT a key, Telegram a bot token). Having the model supply it only ever worked if a user pasted a secret into the prompt.

The destination is now deployment configuration, following the api / open_url precedent (${env.VAR}, expanded at call time, never stored):

toolsets:
  - type: webhook
    webhook_config:
      provider: slack
      url: ${env.SLACK_WEBHOOK_URL}

Token-based endpoints are covered by headers (expanded the same way), and Telegram takes its chat_id:

      provider: telegram
      url: https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/sendMessage
      chat_id: "123456789"
      provider: generic
      url: https://alerts.example.com/notify
      headers:
        Authorization: Bearer ${env.ALERTS_TOKEN}

send_webhook(message) is all the model sees; the URL is never returned to it, nor echoed into error messages. Added WebhookToolConfig to latest.Toolset plus the matching agent-schema.json definition, property and anyOf variant requiring webhook_config.

@rumpl - "as is this is just an http call, we already have a toolset for this"

Also correct, and the sharpest point: once the URL moves into config, what was left was api_config plus a payload lookup table. So the toolset now owns delivery, not the request. Verified against api.go: it fires exactly one request and contains no retry, backoff, 429 or Retry-After handling.

  1. At-least-once delivery - transient failures (429, 5xx, network) are retried with exponential backoff via pkg/backoff, honouring Retry-After (capped by backoff.MaxRetryAfterWait so a misbehaving server can't stall the agent). 4xx is classified permanent and fails immediately instead of burning retries.
  2. Non-blocking + failure callback - the structural difference. api is request/response: it cannot return before the call completes, nor tell you anything afterwards. send_webhook returns once queued, delivers in the background, and wakes the agent via Runtime.Recall only if delivery ultimately fails. When the host has no recall support it degrades to inline delivery so a verdict is never lost.
  3. Storm protection - identical message to the same destination within a window is suppressed, and deliveries are rate limited, so a looping agent can't flood a channel or burn the provider's rate limit. api has no memory between calls.
  4. Provider semantics - payload shaping per service, and the result normalised to delivered / transient / permanent rather than handing the model a raw body to interpret.

I also considered making it a real inbound webhook (receive events → wake the agent), which is the truest reading of "webhook" and the one gap api and the http_post hook don't cover. I dropped it: agents run on a user's machine with no public endpoint, so there is nothing for a provider to call back into.

There is a deliberate behaviour trade-off worth flagging: delivery is fire-and-forget, so on success the agent gets no confirmation - only failures call back. That is correct webhook semantics, but happy to make async opt-in if you'd rather the agent always receive a verdict.

@Sayt-0 - your points

  1. Docs referencing scheduler (not on main): removed from the overview, the example and the closing tip.
  2. agent-schema.json enum: added - and since webhook now requires webhook_config, it moved out of the "no extra required field" anyOf variant into its own, mirroring api.
  3. httpclient.DefaultToolHTTPTimeout: reused (local requestTimeout dropped).
  4. useragent.SetIdentity(req): added, as fetch/api do.
  5. Tool description: now lists all nine providers.
  6. timeout / allowed_domains: timeout: on the toolset is now honoured. allowed_domains / blocked_domains are not - with the destination fixed in config the model can no longer choose where data goes, so the exfiltration surface that motivated it is closed by construction. Happy to add them anyway if you still want them; I've written up the reasoning either way.

CI note

Since the Go jobs still don't run, I installed golangci-lint v2.12.2 and ran the repo's .golangci.yml locally - it surfaced four real blockers that were invisible here (3× forbidigo context.Background() in tests, 1× perfsprint fmt.Errorf without verbs), all fixed. Current state:

  • golangci-lint run ./pkg/tools/builtin/webhook/... ./pkg/teamloader/toolsets/... ./pkg/config/latest/...0 issues
  • go test ./pkg/tools/builtin/webhook/ ./pkg/teamloader/toolsets/ ./pkg/config/ → passing, and -race clean (delivery runs on a goroutine)
  • markdownlint-cli2@0.22.1 from docs/ → 0 errors

Do let me know if any change is required please.

@dwin-gharibi

Copy link
Copy Markdown
Contributor Author

Why webhook is not "just an HTTP call"

Answering the review: "as is this toolset is just an http call, and we already have a toolset for this. Let's try and figure out how this toolset can really be a webhook."

That was correct about the original design. The toolset has been reworked so the thing it owns is delivery, not the request.

The reframing

api toolset webhook toolset
Question it answers "Call this endpoint and give me the response." "Make sure this notification arrives."
Model supplies request args a message
Execution one request, synchronous queued, retried, out-of-band
Failure returns an error, agent must cope retried; agent is called back only if it ultimately fails

What api structurally cannot do

Verified against pkg/tools/builtin/api/api.go: it fires exactly one request and returns the body - it contains no retry, backoff, 429 or Retry-After handling.

1. At-least-once delivery

Transient failures (429, 5xx, network errors) are retried with exponential backoff (pkg/backoff), honouring the server's Retry-After (capped by backoff.MaxRetryAfterWait, so a misbehaving server can't stall the agent). A 4xx is classified permanent and fails immediately instead of burning retries.

With api, a transient blip means the notification is simply lost unless the model happens to notice and retry by hand - costing turns and doing it inconsistently.

2. Non-blocking delivery + failure callback - the structural difference

api is request/response: it cannot return before the call completes, and it cannot tell you anything afterwards. send_webhook returns as soon as the message is queued and delivers in the background, so a slow or retrying endpoint never stalls a turn. If delivery ultimately fails, the agent is woken via Runtime.Recall - the same primitive background_jobs uses.

This is not expressible in api_config at any setting. It is a different execution model, and it is what "webhook" means operationally: hand it over, it gets delivered, you hear about it only if it doesn't.

3. Storm protection

Agents loop. An identical message to the same destination within a short window is suppressed, and deliveries are rate limited, so a retry loop cannot flood a channel or burn the provider's rate limit. api has no memory between calls and no throttle.

4. Provider semantics

Each service's wire format is applied for the agent (Slack/Mattermost/Rocket.Chat/Google Chat/Teams {"text"}, Discord {"content"}, IFTTT {"value1..3"}, Telegram {"chat_id","text"}), and the result is normalised to delivered / transient / permanent. With api the user hand-writes each payload template and the model has to guess from a raw body whether it worked.

5. Credential shape

The destination is deployment config, not a model choice: every provider's webhook URL is the credential (Slack/Mattermost a secret path, Discord a token, IFTTT a key, Telegram a bot token). The model supplies only the message, never sees the URL, and the URL is never echoed into results or error messages. api would work here, but it leaves the endpoint choice in the tool surface.

Summary

api = "make an HTTP request." webhook = "deliver this notification reliably to a destination I configured." The overlap is the byte on the wire; everything that matters - retries, backoff, Retry-After, dedupe, rate limiting, async hand-off, failure callback, provider payloads - lives outside what api_config can express.

Every one of these is covered by unit tests with a fake HTTP doer, a fake runtime, and an injected clock - no network, no sleeping.

@aheritier aheritier removed the status/needs-rebase PR has merge conflicts or is out of date with main label Jul 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/docs Documentation changes area/tools For features/issues/fixes related to the usage of built-in and MCP tools kind/feat PR adds a new feature (maps to feat:). Use on PRs only.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a "webhook" built-in toolset for outbound notifications

4 participants