From ece4e7226e42e85bddeb03ab6e6894afadfe1b22 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Tue, 14 Jul 2026 17:56:09 +0330 Subject: [PATCH 01/23] feat(pkg/tools/builtin/webhook/webhook.go): adding up amazing webhook tool for docker agent --- pkg/tools/builtin/webhook/webhook.go | 166 +++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 pkg/tools/builtin/webhook/webhook.go diff --git a/pkg/tools/builtin/webhook/webhook.go b/pkg/tools/builtin/webhook/webhook.go new file mode 100644 index 000000000..2505dd807 --- /dev/null +++ b/pkg/tools/builtin/webhook/webhook.go @@ -0,0 +1,166 @@ +package webhook + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/docker/docker-agent/pkg/config" + "github.com/docker/docker-agent/pkg/httpclient" + "github.com/docker/docker-agent/pkg/tools" +) + +const ( + ToolNameSendWebhook = "send_webhook" + + category = "webhook" + requestTimeout = 30 * time.Second + maxRespRead = 64 << 10 +) + +type httpDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +type ToolSet struct { + client httpDoer +} + +var ( + _ tools.ToolSet = (*ToolSet)(nil) + _ tools.Instructable = (*ToolSet)(nil) +) + +func New() *ToolSet { + return &ToolSet{client: httpclient.NewSafeClient(requestTimeout, false)} +} + +func CreateToolSet(_ *config.RuntimeConfig) (tools.ToolSet, error) { + return New(), nil +} + +var supportedProviders = []string{ + "slack", "discord", "ifttt", "telegram", + "mattermost", "rocketchat", "googlechat", "teams", "generic", +} + +type SendArgs struct { + URL string `json:"url" jsonschema:"The webhook URL to POST to"` + Message string `json:"message" jsonschema:"The message text to send"` + Provider string `json:"provider,omitempty" jsonschema:"Payload format: slack, discord, ifttt, telegram, mattermost, rocketchat, googlechat, teams, or generic (default generic)"` + Value2 string `json:"value2,omitempty" jsonschema:"IFTTT value2 field (provider=ifttt only)"` + Value3 string `json:"value3,omitempty" jsonschema:"IFTTT value3 field (provider=ifttt only)"` + ChatID string `json:"chat_id,omitempty" jsonschema:"Telegram chat id (provider=telegram only)"` +} + +func normalizeProvider(p string) string { + switch p = strings.ToLower(strings.TrimSpace(p)); p { + case "": + return "generic" + case "google_chat", "gchat": + return "googlechat" + case "msteams", "microsoftteams", "microsoft_teams": + return "teams" + case "rocket.chat", "rocket_chat": + return "rocketchat" + default: + return p + } +} + +func buildPayload(provider, message, value2, value3, chatID string) (string, []byte, error) { + var payload map[string]string + switch normalizeProvider(provider) { + case "generic", "slack", "mattermost", "rocketchat", "googlechat", "teams": + payload = map[string]string{"text": message} + case "discord": + payload = map[string]string{"content": message} + case "ifttt": + payload = map[string]string{"value1": message} + if value2 != "" { + payload["value2"] = value2 + } + if value3 != "" { + payload["value3"] = value3 + } + case "telegram": + if strings.TrimSpace(chatID) == "" { + return "", nil, fmt.Errorf("telegram requires chat_id") + } + payload = map[string]string{"chat_id": chatID, "text": message} + default: + return "", nil, fmt.Errorf("unknown provider %q (supported: %s)", provider, strings.Join(supportedProviders, ", ")) + } + body, err := json.Marshal(payload) + if err != nil { + return "", nil, err + } + return "application/json", body, nil +} + +func (t *ToolSet) send(ctx context.Context, args SendArgs) (*tools.ToolCallResult, error) { + if strings.TrimSpace(args.URL) == "" { + return tools.ResultError("Error: url is required."), nil + } + if strings.TrimSpace(args.Message) == "" { + return tools.ResultError("Error: message is required."), nil + } + if u, err := url.Parse(args.URL); err != nil || (u.Scheme != "http" && u.Scheme != "https") { + return tools.ResultError("Error: url must be a valid http(s) URL."), nil + } + + contentType, body, err := buildPayload(args.Provider, args.Message, args.Value2, args.Value3, args.ChatID) + if err != nil { + return tools.ResultError("Error: " + err.Error()), nil + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, args.URL, bytes.NewReader(body)) + if err != nil { + return tools.ResultError("Error: " + err.Error()), nil + } + req.Header.Set("Content-Type", contentType) + + resp, err := t.client.Do(req) + if err != nil { + return tools.ResultError("Error: sending webhook: " + err.Error()), nil + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxRespRead)) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return tools.ResultError(fmt.Sprintf("Webhook returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))), nil + } + return tools.ResultSuccess(fmt.Sprintf("Delivered to %s webhook (HTTP %d).", normalizeProvider(args.Provider), resp.StatusCode)), nil +} + +func (t *ToolSet) Tools(context.Context) ([]tools.Tool, error) { + return []tools.Tool{ + { + Name: ToolNameSendWebhook, + Category: category, + Description: "Send a message to a webhook (Slack, Discord, IFTTT, or a generic URL). POSTs a provider-shaped JSON payload and reports delivery status.", + Parameters: tools.MustSchemaFor[SendArgs](), + OutputSchema: tools.MustSchemaFor[string](), + Handler: tools.NewHandler(t.send), + Annotations: tools.ToolAnnotations{Title: "Send Webhook"}, + AddDescriptionParameter: true, + }, + }, nil +} + +func (t *ToolSet) Instructions() string { + return `## Webhook Tool + +Send an outbound notification with send_webhook(url, message, provider?): + +- provider shapes the payload — slack, discord, ifttt, telegram, mattermost, + rocketchat, googlechat, teams, or generic (default). Telegram needs chat_id. +- Delivery is one-way; the tool reports the HTTP status, not a response body. +- Requests to non-public addresses are refused.` +} From fc16f0a87279889a44d7b75cf22996ea95d58f4b Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Tue, 14 Jul 2026 17:56:44 +0330 Subject: [PATCH 02/23] test(pkg/tools/builtin/webhook/webhook_test.go): adding proper tests for webhook tool --- pkg/tools/builtin/webhook/webhook_test.go | 172 ++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 pkg/tools/builtin/webhook/webhook_test.go diff --git a/pkg/tools/builtin/webhook/webhook_test.go b/pkg/tools/builtin/webhook/webhook_test.go new file mode 100644 index 000000000..08ccd377e --- /dev/null +++ b/pkg/tools/builtin/webhook/webhook_test.go @@ -0,0 +1,172 @@ +package webhook + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +type fakeDoer struct { + gotReq *http.Request + gotBody []byte + status int + err error +} + +func (f *fakeDoer) Do(req *http.Request) (*http.Response, error) { + f.gotReq = req + if req.Body != nil { + f.gotBody, _ = io.ReadAll(req.Body) + } + if f.err != nil { + return nil, f.err + } + status := f.status + if status == 0 { + status = http.StatusOK + } + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader("ok")), + Header: make(http.Header), + }, nil +} + +func newTestToolSet(d httpDoer) *ToolSet { + ts := New() + ts.client = d + return ts +} + +func decode(t *testing.T, body []byte) map[string]string { + t.Helper() + var m map[string]string + require.NoError(t, json.Unmarshal(body, &m)) + return m +} + +func TestBuildPayload(t *testing.T) { + t.Parallel() + + tests := []struct { + provider string + wantKey string + }{ + {"slack", "text"}, + {"discord", "content"}, + {"mattermost", "text"}, + {"rocketchat", "text"}, + {"googlechat", "text"}, + {"teams", "text"}, + {"msteams", "text"}, + {"generic", "text"}, + {"", "text"}, + } + for _, tc := range tests { + ct, body, err := buildPayload(tc.provider, "hello", "", "", "") + require.NoError(t, err, tc.provider) + require.Equal(t, "application/json", ct) + require.Equal(t, "hello", decode(t, body)[tc.wantKey], tc.provider) + } +} + +func TestBuildPayloadIFTTT(t *testing.T) { + t.Parallel() + + _, body, err := buildPayload("ifttt", "msg", "two", "three", "") + require.NoError(t, err) + m := decode(t, body) + require.Equal(t, "msg", m["value1"]) + require.Equal(t, "two", m["value2"]) + require.Equal(t, "three", m["value3"]) +} + +func TestBuildPayloadTelegram(t *testing.T) { + t.Parallel() + + _, body, err := buildPayload("telegram", "hi", "", "", "12345") + require.NoError(t, err) + m := decode(t, body) + require.Equal(t, "12345", m["chat_id"]) + require.Equal(t, "hi", m["text"]) + + _, _, err = buildPayload("telegram", "hi", "", "", "") + require.Error(t, err) +} + +func TestBuildPayloadUnknownProvider(t *testing.T) { + t.Parallel() + + _, _, err := buildPayload("webex", "x", "", "", "") + require.Error(t, err) +} + +func TestSendSuccess(t *testing.T) { + t.Parallel() + + fd := &fakeDoer{status: 200} + ts := newTestToolSet(fd) + + res, err := ts.send(context.Background(), SendArgs{ + URL: "https://hooks.slack.com/services/xxx", Message: "deploy done", Provider: "slack", + }) + require.NoError(t, err) + require.False(t, res.IsError, res.Output) + + require.Equal(t, http.MethodPost, fd.gotReq.Method) + require.Equal(t, "application/json", fd.gotReq.Header.Get("Content-Type")) + require.Equal(t, "deploy done", decode(t, fd.gotBody)["text"]) +} + +func TestSendNon2xx(t *testing.T) { + t.Parallel() + + ts := newTestToolSet(&fakeDoer{status: 404}) + res, err := ts.send(context.Background(), SendArgs{URL: "https://example.com/hook", Message: "hi"}) + require.NoError(t, err) + require.True(t, res.IsError) + require.Contains(t, res.Output, "404") +} + +func TestSendNetworkError(t *testing.T) { + t.Parallel() + + ts := newTestToolSet(&fakeDoer{err: io.ErrUnexpectedEOF}) + res, err := ts.send(context.Background(), SendArgs{URL: "https://example.com/hook", Message: "hi"}) + require.NoError(t, err) + require.True(t, res.IsError) +} + +func TestSendValidation(t *testing.T) { + t.Parallel() + + ts := newTestToolSet(&fakeDoer{}) + + r1, _ := ts.send(context.Background(), SendArgs{Message: "hi"}) + require.True(t, r1.IsError) + + r2, _ := ts.send(context.Background(), SendArgs{URL: "https://x/y"}) + require.True(t, r2.IsError) + + r3, _ := ts.send(context.Background(), SendArgs{URL: "ftp://x/y", Message: "hi"}) + require.True(t, r3.IsError) + + r4, _ := ts.send(context.Background(), SendArgs{URL: "https://x/y", Message: "hi", Provider: "webex"}) + require.True(t, r4.IsError) +} + +func TestToolSetInterfaces(t *testing.T) { + t.Parallel() + + ts := New() + require.NotEmpty(t, ts.Instructions()) + toolz, err := ts.Tools(context.Background()) + require.NoError(t, err) + require.Len(t, toolz, 1) + require.Equal(t, ToolNameSendWebhook, toolz[0].Name) +} From 373ce830dba5fdea74936f9fe47ae31a07e01baa Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Tue, 14 Jul 2026 17:57:19 +0330 Subject: [PATCH 03/23] feat(pkg/teamloader/toolsets/toolsets.go): adding the webhook tool into docker agents toolsets --- pkg/teamloader/toolsets/toolsets.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/teamloader/toolsets/toolsets.go b/pkg/teamloader/toolsets/toolsets.go index 08b1dc2e5..2ef476e96 100644 --- a/pkg/teamloader/toolsets/toolsets.go +++ b/pkg/teamloader/toolsets/toolsets.go @@ -30,6 +30,7 @@ import ( "github.com/docker/docker-agent/pkg/tools/builtin/think" "github.com/docker/docker-agent/pkg/tools/builtin/todo" "github.com/docker/docker-agent/pkg/tools/builtin/userprompt" + "github.com/docker/docker-agent/pkg/tools/builtin/webhook" "github.com/docker/docker-agent/pkg/tools/mcp" ) @@ -121,5 +122,8 @@ func DefaultToolsetCreators() map[string]teamloader.ToolsetCreator { "rag": func(ctx context.Context, toolset latest.Toolset, parentDir string, runConfig *config.RuntimeConfig, _ string) (tools.ToolSet, error) { return rag.CreateToolSet(ctx, toolset, parentDir, runConfig) }, + "webhook": func(_ context.Context, _ latest.Toolset, _ string, runConfig *config.RuntimeConfig, _ string) (tools.ToolSet, error) { + return webhook.CreateToolSet(runConfig) + }, } } From 3f40244898095ab1229e9ebe18eb48d58f874085 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Tue, 14 Jul 2026 17:58:03 +0330 Subject: [PATCH 04/23] feat(pkg/teamloader/toolsets/catalog.go): adding the webhook tool into docker agents toolset --- pkg/teamloader/toolsets/catalog.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/teamloader/toolsets/catalog.go b/pkg/teamloader/toolsets/catalog.go index 223d14ef6..94bb993ae 100644 --- a/pkg/teamloader/toolsets/catalog.go +++ b/pkg/teamloader/toolsets/catalog.go @@ -34,6 +34,7 @@ var BuiltinToolsets = []BuiltinToolsetInfo{ builtinToolset("think", "think", "Step-by-step reasoning scratchpad for planning"), builtinToolset("todo", "todo", "Manage a task list for complex multi-step workflows"), builtinToolset("user_prompt", "user-prompt", "Ask the user questions and collect interactive input"), + builtinToolset("webhook", "webhook", "Send outbound notifications (Slack, Discord, Telegram, IFTTT, Teams, and more)"), } func builtinToolset(toolsetType, docsSlug, summary string) BuiltinToolsetInfo { From 8b8b9778a1b11f9bd2a9dadeeef74a1e4a3ad3b9 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Tue, 14 Jul 2026 17:58:48 +0330 Subject: [PATCH 05/23] docs(docs/tools/webhook/index.md): feating the webhook tool docs for docker docs --- docs/tools/webhook/index.md | 51 +++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/tools/webhook/index.md diff --git a/docs/tools/webhook/index.md b/docs/tools/webhook/index.md new file mode 100644 index 000000000..7157fd1b4 --- /dev/null +++ b/docs/tools/webhook/index.md @@ -0,0 +1,51 @@ +--- +title: "Webhook Tool" +description: "Send outbound notifications to Slack, Discord, Telegram, IFTTT, and more." +keywords: docker agent, ai agents, tools, toolsets, webhook, slack, discord, telegram, ifttt +linkTitle: "Webhook" +weight: 145 +canonical: https://docs.docker.com/ai/docker-agent/tools/webhook/ +--- + +_Send outbound notifications to Slack, Discord, Telegram, IFTTT, and more._ + +## Overview +The webhook toolset lets an agent POST a message to a webhook, shaping the JSON payload for the target service. Delivery is one-way — the tool reports the HTTP status, not a response body. It uses the SSRF-safe HTTP client (requests to non-public addresses are refused), and pairs naturally with the [`scheduler`](../scheduler/index.md) tool for alerting. + +## Configuration +```yaml +toolsets: + - type: webhook +``` +No configuration options. + +## `send_webhook` +| Parameter | Required | Description | +| --- | --- | --- | +| `url` | Yes | The webhook URL to POST to. | +| `message` | Yes | The message text. | +| `provider` | No | Payload format (default `generic`). | +| `value2`, `value3` | No | IFTTT extra fields (`provider=ifttt`). | +| `chat_id` | No | Telegram chat id (`provider=telegram`). | + +## Providers +| Provider | Payload | +| --- | --- | +| `slack`, `mattermost`, `rocketchat`, `googlechat`, `teams`, `generic` | `{"text": message}` | +| `discord` | `{"content": message}` | +| `ifttt` | `{"value1": message, "value2": …, "value3": …}` | +| `telegram` | `{"chat_id": …, "text": message}` | + +## Example +```yaml +agents: + root: + model: openai/gpt-5-mini + instruction: If a scheduled check fails, notify the team via send_webhook. + toolsets: + - type: webhook + - type: scheduler +``` + +> [!TIP] +> Combine with the [`scheduler`](../scheduler/index.md): "every 15 minutes, check the build; if it broke, `send_webhook` to Slack." From 3df4bf5b7e84af1bcb1d1a2850713a346dbd4496 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Tue, 14 Jul 2026 17:59:32 +0330 Subject: [PATCH 06/23] docs(docs/configuration/tools/index.md): adding the webhook tool into toolset --- docs/configuration/tools/index.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/configuration/tools/index.md b/docs/configuration/tools/index.md index 6ffc6b840..785509741 100644 --- a/docs/configuration/tools/index.md +++ b/docs/configuration/tools/index.md @@ -40,6 +40,7 @@ Built-in tools are included with Docker Agent and require no external dependenci | `open_url` | Open a fixed URL in the user's default browser | [Open URL](../../tools/open-url/index.md) | | `transfer_task` | Delegate to sub-agents (auto-enabled) | [Transfer Task](../../tools/transfer-task/index.md) | | `background_agents` | Parallel sub-agent dispatch | [Background Agents](../../tools/background-agents/index.md) | +| `webhook` | Send outbound notifications (Slack, Discord, Telegram, IFTTT, Teams, …) | [Webhook](../../tools/webhook/index.md) | | `handoff` | Local conversation handoff to another agent in the same config (auto-enabled by `handoffs:`) | [Handoff](../../tools/handoff/index.md) | | `a2a` | A2A remote agent connection | [A2A](../../tools/a2a/index.md) | | `mcp_catalog` | Discover and activate remote MCP servers from the Docker MCP Catalog on demand | [MCP Catalog](../../tools/mcp-catalog/index.md) | From 15c633bbefab539f431792909c66d81eb241f448 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Wed, 15 Jul 2026 11:30:45 +0330 Subject: [PATCH 07/23] docs(docs/tools/webhook/index.md): updating up the doc markdown file to solve linting problems --- docs/tools/webhook/index.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/tools/webhook/index.md b/docs/tools/webhook/index.md index 7157fd1b4..6c963ed14 100644 --- a/docs/tools/webhook/index.md +++ b/docs/tools/webhook/index.md @@ -10,25 +10,34 @@ canonical: https://docs.docker.com/ai/docker-agent/tools/webhook/ _Send outbound notifications to Slack, Discord, Telegram, IFTTT, and more._ ## Overview -The webhook toolset lets an agent POST a message to a webhook, shaping the JSON payload for the target service. Delivery is one-way — the tool reports the HTTP status, not a response body. It uses the SSRF-safe HTTP client (requests to non-public addresses are refused), and pairs naturally with the [`scheduler`](../scheduler/index.md) tool for alerting. + +The webhook toolset lets an agent POST a message to a webhook, shaping the JSON +payload for the target service. Delivery is one-way — the tool reports the HTTP +status, not a response body. It uses the SSRF-safe HTTP client (requests to +non-public addresses are refused), and pairs naturally with the +[`scheduler`](../scheduler/index.md) tool for alerting. ## Configuration + ```yaml toolsets: - type: webhook ``` + No configuration options. ## `send_webhook` + | Parameter | Required | Description | | --- | --- | --- | | `url` | Yes | The webhook URL to POST to. | | `message` | Yes | The message text. | | `provider` | No | Payload format (default `generic`). | | `value2`, `value3` | No | IFTTT extra fields (`provider=ifttt`). | -| `chat_id` | No | Telegram chat id (`provider=telegram`). | +| `chat_id` | No | Telegram chat ID (`provider=telegram`). | ## Providers + | Provider | Payload | | --- | --- | | `slack`, `mattermost`, `rocketchat`, `googlechat`, `teams`, `generic` | `{"text": message}` | @@ -37,6 +46,7 @@ No configuration options. | `telegram` | `{"chat_id": …, "text": message}` | ## Example + ```yaml agents: root: @@ -48,4 +58,5 @@ agents: ``` > [!TIP] -> Combine with the [`scheduler`](../scheduler/index.md): "every 15 minutes, check the build; if it broke, `send_webhook` to Slack." +> Combine with the `scheduler`: "every 15 minutes, check the build; if it +> broke, `send_webhook` to Slack." \ No newline at end of file From cf41fad4f35cbbdd1cd7e69620feee9bf1a85b47 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Wed, 15 Jul 2026 11:35:16 +0330 Subject: [PATCH 08/23] docs(docs/tools/webhook/index.md): updating the docs again solving linting problems --- docs/tools/webhook/index.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/tools/webhook/index.md b/docs/tools/webhook/index.md index 6c963ed14..caf19d85f 100644 --- a/docs/tools/webhook/index.md +++ b/docs/tools/webhook/index.md @@ -12,10 +12,10 @@ _Send outbound notifications to Slack, Discord, Telegram, IFTTT, and more._ ## Overview The webhook toolset lets an agent POST a message to a webhook, shaping the JSON -payload for the target service. Delivery is one-way — the tool reports the HTTP +payload for the target service. Delivery is one-way—the tool reports the HTTP status, not a response body. It uses the SSRF-safe HTTP client (requests to -non-public addresses are refused), and pairs naturally with the -[`scheduler`](../scheduler/index.md) tool for alerting. +non-public addresses are refused) and pairs naturally with the `scheduler` +tool for alerting. ## Configuration @@ -58,5 +58,5 @@ agents: ``` > [!TIP] -> Combine with the `scheduler`: "every 15 minutes, check the build; if it -> broke, `send_webhook` to Slack." \ No newline at end of file +> Combine with the `scheduler`: "Every 15 minutes, check the build. If it +> broke, use `send_webhook` to notify Slack." \ No newline at end of file From a951bd9aa830086425c0628c48e801162d1b2fbe Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Wed, 15 Jul 2026 11:40:42 +0330 Subject: [PATCH 09/23] docs(docs/tools/webhook/index.md): fixing problem of MD047/single-trailing-newline in webhook doc --- docs/tools/webhook/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tools/webhook/index.md b/docs/tools/webhook/index.md index caf19d85f..d515eca3f 100644 --- a/docs/tools/webhook/index.md +++ b/docs/tools/webhook/index.md @@ -59,4 +59,4 @@ agents: > [!TIP] > Combine with the `scheduler`: "Every 15 minutes, check the build. If it -> broke, use `send_webhook` to notify Slack." \ No newline at end of file +> broke, use `send_webhook` to notify Slack." From ebce6b7643fd83a26dc6e74ce3a7ba000ece1c6c Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Wed, 15 Jul 2026 14:21:24 +0330 Subject: [PATCH 10/23] fix(agent-schema.json): fixing up the agent-schema.json --- agent-schema.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/agent-schema.json b/agent-schema.json index 35ad9a393..5aa633260 100644 --- a/agent-schema.json +++ b/agent-schema.json @@ -2021,7 +2021,8 @@ "background_agents", "scheduler", "rag", - "git" + "git", + "webhook" ] }, "instruction": { @@ -2358,7 +2359,8 @@ "model_picker", "background_agents", "scheduler", - "git" + "git", + "webhook" ] } } From 1df880e6db0ea1e1062dce44190477ca2d60db20 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Wed, 15 Jul 2026 14:22:21 +0330 Subject: [PATCH 11/23] fix(pkg/tools/builtin/webhook/webhook.go): fixing up the review problems on webhook tool --- pkg/tools/builtin/webhook/webhook.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/pkg/tools/builtin/webhook/webhook.go b/pkg/tools/builtin/webhook/webhook.go index 2505dd807..63b3ab6cc 100644 --- a/pkg/tools/builtin/webhook/webhook.go +++ b/pkg/tools/builtin/webhook/webhook.go @@ -4,24 +4,24 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" "net/url" "strings" - "time" "github.com/docker/docker-agent/pkg/config" "github.com/docker/docker-agent/pkg/httpclient" "github.com/docker/docker-agent/pkg/tools" + "github.com/docker/docker-agent/pkg/useragent" ) const ( ToolNameSendWebhook = "send_webhook" - category = "webhook" - requestTimeout = 30 * time.Second - maxRespRead = 64 << 10 + category = "webhook" + maxRespRead = 64 << 10 ) type httpDoer interface { @@ -38,7 +38,7 @@ var ( ) func New() *ToolSet { - return &ToolSet{client: httpclient.NewSafeClient(requestTimeout, false)} + return &ToolSet{client: httpclient.NewSafeClient(httpclient.DefaultToolHTTPTimeout, false)} } func CreateToolSet(_ *config.RuntimeConfig) (tools.ToolSet, error) { @@ -91,7 +91,7 @@ func buildPayload(provider, message, value2, value3, chatID string) (string, []b } case "telegram": if strings.TrimSpace(chatID) == "" { - return "", nil, fmt.Errorf("telegram requires chat_id") + return "", nil, errors.New("telegram requires chat_id") } payload = map[string]string{"chat_id": chatID, "text": message} default: @@ -125,6 +125,7 @@ func (t *ToolSet) send(ctx context.Context, args SendArgs) (*tools.ToolCallResul return tools.ResultError("Error: " + err.Error()), nil } req.Header.Set("Content-Type", contentType) + useragent.SetIdentity(req) resp, err := t.client.Do(req) if err != nil { @@ -144,7 +145,7 @@ func (t *ToolSet) Tools(context.Context) ([]tools.Tool, error) { { Name: ToolNameSendWebhook, Category: category, - Description: "Send a message to a webhook (Slack, Discord, IFTTT, or a generic URL). POSTs a provider-shaped JSON payload and reports delivery status.", + Description: "Send a message to a webhook (Slack, Discord, IFTTT, Telegram, Mattermost, Rocket.Chat, Google Chat, Teams, or a generic URL). POSTs a provider-shaped JSON payload and reports delivery status.", Parameters: tools.MustSchemaFor[SendArgs](), OutputSchema: tools.MustSchemaFor[string](), Handler: tools.NewHandler(t.send), From f7bb775a9126821c348b49fff2350eb44da67ff7 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Wed, 15 Jul 2026 14:22:51 +0330 Subject: [PATCH 12/23] fix(pkg/tools/builtin/webhook/webhook_test.go): fixing the webhook tests problem --- pkg/tools/builtin/webhook/webhook_test.go | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/pkg/tools/builtin/webhook/webhook_test.go b/pkg/tools/builtin/webhook/webhook_test.go index 08ccd377e..f8c6b648d 100644 --- a/pkg/tools/builtin/webhook/webhook_test.go +++ b/pkg/tools/builtin/webhook/webhook_test.go @@ -1,7 +1,6 @@ package webhook import ( - "context" "encoding/json" "io" "net/http" @@ -112,7 +111,7 @@ func TestSendSuccess(t *testing.T) { fd := &fakeDoer{status: 200} ts := newTestToolSet(fd) - res, err := ts.send(context.Background(), SendArgs{ + res, err := ts.send(t.Context(), SendArgs{ URL: "https://hooks.slack.com/services/xxx", Message: "deploy done", Provider: "slack", }) require.NoError(t, err) @@ -127,7 +126,7 @@ func TestSendNon2xx(t *testing.T) { t.Parallel() ts := newTestToolSet(&fakeDoer{status: 404}) - res, err := ts.send(context.Background(), SendArgs{URL: "https://example.com/hook", Message: "hi"}) + res, err := ts.send(t.Context(), SendArgs{URL: "https://example.com/hook", Message: "hi"}) require.NoError(t, err) require.True(t, res.IsError) require.Contains(t, res.Output, "404") @@ -137,7 +136,7 @@ func TestSendNetworkError(t *testing.T) { t.Parallel() ts := newTestToolSet(&fakeDoer{err: io.ErrUnexpectedEOF}) - res, err := ts.send(context.Background(), SendArgs{URL: "https://example.com/hook", Message: "hi"}) + res, err := ts.send(t.Context(), SendArgs{URL: "https://example.com/hook", Message: "hi"}) require.NoError(t, err) require.True(t, res.IsError) } @@ -147,16 +146,16 @@ func TestSendValidation(t *testing.T) { ts := newTestToolSet(&fakeDoer{}) - r1, _ := ts.send(context.Background(), SendArgs{Message: "hi"}) + r1, _ := ts.send(t.Context(), SendArgs{Message: "hi"}) require.True(t, r1.IsError) - r2, _ := ts.send(context.Background(), SendArgs{URL: "https://x/y"}) + r2, _ := ts.send(t.Context(), SendArgs{URL: "https://x/y"}) require.True(t, r2.IsError) - r3, _ := ts.send(context.Background(), SendArgs{URL: "ftp://x/y", Message: "hi"}) + r3, _ := ts.send(t.Context(), SendArgs{URL: "ftp://x/y", Message: "hi"}) require.True(t, r3.IsError) - r4, _ := ts.send(context.Background(), SendArgs{URL: "https://x/y", Message: "hi", Provider: "webex"}) + r4, _ := ts.send(t.Context(), SendArgs{URL: "https://x/y", Message: "hi", Provider: "webex"}) require.True(t, r4.IsError) } @@ -165,7 +164,7 @@ func TestToolSetInterfaces(t *testing.T) { ts := New() require.NotEmpty(t, ts.Instructions()) - toolz, err := ts.Tools(context.Background()) + toolz, err := ts.Tools(t.Context()) require.NoError(t, err) require.Len(t, toolz, 1) require.Equal(t, ToolNameSendWebhook, toolz[0].Name) From 7169c6a0435b745ec17a595f99514e486ada5641 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Wed, 15 Jul 2026 14:24:42 +0330 Subject: [PATCH 13/23] feat(docs/tools/webhook/index.md): updating up the webhook document according to latest changes --- docs/tools/webhook/index.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/docs/tools/webhook/index.md b/docs/tools/webhook/index.md index d515eca3f..3024f21f6 100644 --- a/docs/tools/webhook/index.md +++ b/docs/tools/webhook/index.md @@ -14,8 +14,7 @@ _Send outbound notifications to Slack, Discord, Telegram, IFTTT, and more._ The webhook toolset lets an agent POST a message to a webhook, shaping the JSON payload for the target service. Delivery is one-way—the tool reports the HTTP status, not a response body. It uses the SSRF-safe HTTP client (requests to -non-public addresses are refused) and pairs naturally with the `scheduler` -tool for alerting. +non-public addresses are refused). ## Configuration @@ -54,9 +53,4 @@ agents: instruction: If a scheduled check fails, notify the team via send_webhook. toolsets: - type: webhook - - type: scheduler ``` - -> [!TIP] -> Combine with the `scheduler`: "Every 15 minutes, check the build. If it -> broke, use `send_webhook` to notify Slack." From 7cabb1a5377f0a50a3038f4caf2691e2a29b990a Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Thu, 16 Jul 2026 20:16:54 +0330 Subject: [PATCH 14/23] feat(pkg/config/latest/types.go): adding up the webhook configs into docker agent --- pkg/config/latest/types.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/pkg/config/latest/types.go b/pkg/config/latest/types.go index dc688903e..652d2832b 100644 --- a/pkg/config/latest/types.go +++ b/pkg/config/latest/types.go @@ -1335,6 +1335,31 @@ type APIToolConfig struct { OutputSchema map[string]any `json:"output_schema,omitempty"` } +// WebhookToolConfig configures the `webhook` toolset: where a notification is +// delivered and how it authenticates. +// +// The destination is deployment configuration, not something the model chooses: +// every provider's webhook URL is itself a credential (Slack and Mattermost +// embed a secret path, Discord a token, IFTTT a key, Telegram a bot token), so +// the agent only supplies the message. URL and Headers are expanded at call +// time, so secrets are referenced as ${env.VAR} and never stored in the config. +type WebhookToolConfig struct { + // Provider selects the payload shape: slack, discord, ifttt, telegram, + // mattermost, rocketchat, googlechat, teams, or generic (default). + Provider string `json:"provider,omitempty"` + + // URL is the webhook endpoint. It usually embeds a secret, so prefer + // ${env.VAR} over a literal. + URL string `json:"url,omitempty"` + + // Headers are sent with the request, for endpoints that authenticate with a + // token instead of a secret URL (e.g. Authorization: Bearer ${env.TOKEN}). + Headers map[string]string `json:"headers,omitempty"` + + // ChatID is the destination chat for provider: telegram. + ChatID string `json:"chat_id,omitempty"` +} + // PostEditConfig represents a post-edit command configuration type PostEditConfig struct { Path string `json:"path"` @@ -1395,6 +1420,9 @@ type Toolset struct { APIConfig APIToolConfig `json:"api_config"` + // For the `webhook` tool - destination and credentials. + WebhookConfig WebhookToolConfig `json:"webhook_config"` + // For the `filesystem` tool - VCS integration IgnoreVCS *bool `json:"ignore_vcs,omitempty"` From 6baad0de3bac1a7ac494d7690782b3fffe4059dd Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Thu, 16 Jul 2026 20:17:58 +0330 Subject: [PATCH 15/23] fix(pkg/teamloader/toolsets/toolsets.go): fixing up the tool registration in toolset --- pkg/teamloader/toolsets/toolsets.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/teamloader/toolsets/toolsets.go b/pkg/teamloader/toolsets/toolsets.go index 2ef476e96..6357fdbf6 100644 --- a/pkg/teamloader/toolsets/toolsets.go +++ b/pkg/teamloader/toolsets/toolsets.go @@ -122,8 +122,8 @@ func DefaultToolsetCreators() map[string]teamloader.ToolsetCreator { "rag": func(ctx context.Context, toolset latest.Toolset, parentDir string, runConfig *config.RuntimeConfig, _ string) (tools.ToolSet, error) { return rag.CreateToolSet(ctx, toolset, parentDir, runConfig) }, - "webhook": func(_ context.Context, _ latest.Toolset, _ string, runConfig *config.RuntimeConfig, _ string) (tools.ToolSet, error) { - return webhook.CreateToolSet(runConfig) + "webhook": func(_ context.Context, toolset latest.Toolset, _ string, runConfig *config.RuntimeConfig, _ string) (tools.ToolSet, error) { + return webhook.CreateToolSet(toolset, runConfig) }, } } From 902ecab6165d809f982d7a900b0cc56b361dc981 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Thu, 16 Jul 2026 20:21:53 +0330 Subject: [PATCH 16/23] fix(pkg/tools/builtin/webhook/webhook.go): feating up some amazing features tunning up the webhook tool design logic --- pkg/tools/builtin/webhook/webhook.go | 415 ++++++++++++++++++++++----- 1 file changed, 342 insertions(+), 73 deletions(-) diff --git a/pkg/tools/builtin/webhook/webhook.go b/pkg/tools/builtin/webhook/webhook.go index 63b3ab6cc..962aa1cac 100644 --- a/pkg/tools/builtin/webhook/webhook.go +++ b/pkg/tools/builtin/webhook/webhook.go @@ -3,16 +3,25 @@ package webhook import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" "io" + "log/slog" "net/http" "net/url" + "strconv" "strings" + "sync" + "time" + "github.com/docker/docker-agent/pkg/backoff" "github.com/docker/docker-agent/pkg/config" + "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/httpclient" + "github.com/docker/docker-agent/pkg/js" "github.com/docker/docker-agent/pkg/tools" "github.com/docker/docker-agent/pkg/useragent" ) @@ -22,41 +31,48 @@ const ( category = "webhook" maxRespRead = 64 << 10 + + defaultMaxAttempts = 4 + defaultDedupeWindow = 30 * time.Second + defaultMinInterval = time.Second ) type httpDoer interface { Do(req *http.Request) (*http.Response, error) } -type ToolSet struct { - client httpDoer -} - -var ( - _ tools.ToolSet = (*ToolSet)(nil) - _ tools.Instructable = (*ToolSet)(nil) -) - -func New() *ToolSet { - return &ToolSet{client: httpclient.NewSafeClient(httpclient.DefaultToolHTTPTimeout, false)} -} - -func CreateToolSet(_ *config.RuntimeConfig) (tools.ToolSet, error) { - return New(), nil -} - var supportedProviders = []string{ "slack", "discord", "ifttt", "telegram", "mattermost", "rocketchat", "googlechat", "teams", "generic", } -type SendArgs struct { - URL string `json:"url" jsonschema:"The webhook URL to POST to"` - Message string `json:"message" jsonschema:"The message text to send"` - Provider string `json:"provider,omitempty" jsonschema:"Payload format: slack, discord, ifttt, telegram, mattermost, rocketchat, googlechat, teams, or generic (default generic)"` - Value2 string `json:"value2,omitempty" jsonschema:"IFTTT value2 field (provider=ifttt only)"` - Value3 string `json:"value3,omitempty" jsonschema:"IFTTT value3 field (provider=ifttt only)"` - ChatID string `json:"chat_id,omitempty" jsonschema:"Telegram chat id (provider=telegram only)"` +func textPayload(msg, _, _, _ string) map[string]string { + return map[string]string{"text": msg} +} + +var providerPayloads = map[string]func(msg, value2, value3, chatID string) map[string]string{ + "generic": textPayload, + "slack": textPayload, + "mattermost": textPayload, + "rocketchat": textPayload, + "googlechat": textPayload, + "teams": textPayload, + "discord": func(msg, _, _, _ string) map[string]string { + return map[string]string{"content": msg} + }, + "ifttt": func(msg, value2, value3, _ string) map[string]string { + p := map[string]string{"value1": msg} + if value2 != "" { + p["value2"] = value2 + } + if value3 != "" { + p["value3"] = value3 + } + return p + }, + "telegram": func(msg, _, _, chatID string) map[string]string { + return map[string]string{"chat_id": chatID, "text": msg} + }, } func normalizeProvider(p string) string { @@ -74,81 +90,331 @@ func normalizeProvider(p string) string { } } -func buildPayload(provider, message, value2, value3, chatID string) (string, []byte, error) { - var payload map[string]string - switch normalizeProvider(provider) { - case "generic", "slack", "mattermost", "rocketchat", "googlechat", "teams": - payload = map[string]string{"text": message} - case "discord": - payload = map[string]string{"content": message} - case "ifttt": - payload = map[string]string{"value1": message} - if value2 != "" { - payload["value2"] = value2 - } - if value3 != "" { - payload["value3"] = value3 - } - case "telegram": - if strings.TrimSpace(chatID) == "" { - return "", nil, errors.New("telegram requires chat_id") - } - payload = map[string]string{"chat_id": chatID, "text": message} - default: - return "", nil, fmt.Errorf("unknown provider %q (supported: %s)", provider, strings.Join(supportedProviders, ", ")) +func buildPayload(provider, message, value2, value3, chatID string) ([]byte, error) { + build, ok := providerPayloads[provider] + if !ok { + return nil, fmt.Errorf("unknown provider %q (supported: %s)", provider, strings.Join(supportedProviders, ", ")) } - body, err := json.Marshal(payload) - if err != nil { - return "", nil, err + return json.Marshal(build(message, value2, value3, chatID)) +} + +type verdict int + +const ( + delivered verdict = iota + transient + permanent +) + +type ToolSet struct { + cfg latest.WebhookToolConfig + expander *js.Expander + client httpDoer + + maxAttempts int + dedupeWindow time.Duration + minInterval time.Duration + + now func() time.Time + sleep func(ctx context.Context, d time.Duration) bool + + mu sync.Mutex + rt tools.Runtime + recent map[string]time.Time + lastSent time.Time + + cancels []context.CancelFunc + stopped bool + wg sync.WaitGroup +} + +var ( + _ tools.ToolSet = (*ToolSet)(nil) + _ tools.Startable = (*ToolSet)(nil) + _ tools.Instructable = (*ToolSet)(nil) +) + +func New(cfg latest.WebhookToolConfig, expander *js.Expander, timeout time.Duration) *ToolSet { + return &ToolSet{ + cfg: cfg, + expander: expander, + client: httpclient.NewSafeClient(timeout, false), + maxAttempts: defaultMaxAttempts, + dedupeWindow: defaultDedupeWindow, + minInterval: defaultMinInterval, + now: time.Now, + sleep: backoff.SleepWithContext, + recent: make(map[string]time.Time), } - return "application/json", body, nil } -func (t *ToolSet) send(ctx context.Context, args SendArgs) (*tools.ToolCallResult, error) { - if strings.TrimSpace(args.URL) == "" { - return tools.ResultError("Error: url is required."), nil +func CreateToolSet(toolset latest.Toolset, runConfig *config.RuntimeConfig) (tools.ToolSet, error) { + cfg := toolset.WebhookConfig + if strings.TrimSpace(cfg.URL) == "" { + return nil, errors.New("webhook tool requires a url in webhook_config") + } + if _, ok := providerPayloads[normalizeProvider(cfg.Provider)]; !ok { + return nil, fmt.Errorf("webhook tool: unknown provider %q (supported: %s)", + cfg.Provider, strings.Join(supportedProviders, ", ")) + } + if normalizeProvider(cfg.Provider) == "telegram" && strings.TrimSpace(cfg.ChatID) == "" { + return nil, errors.New("webhook tool: provider telegram requires chat_id in webhook_config") + } + + timeout := httpclient.DefaultToolHTTPTimeout + if toolset.Timeout > 0 { + timeout = time.Duration(toolset.Timeout) * time.Second } + return New(cfg, js.NewJsExpander(runConfig.EnvProvider()), timeout), nil +} + +type SendArgs struct { + Message string `json:"message" jsonschema:"The message text to deliver"` + Value2 string `json:"value2,omitempty" jsonschema:"IFTTT value2 field (provider=ifttt only)"` + Value3 string `json:"value3,omitempty" jsonschema:"IFTTT value3 field (provider=ifttt only)"` +} + +func (t *ToolSet) send(ctx context.Context, args SendArgs, rt tools.Runtime) (*tools.ToolCallResult, error) { if strings.TrimSpace(args.Message) == "" { return tools.ResultError("Error: message is required."), nil } - if u, err := url.Parse(args.URL); err != nil || (u.Scheme != "http" && u.Scheme != "https") { - return tools.ResultError("Error: url must be a valid http(s) URL."), nil + + now := t.now() + if t.isDuplicate(args, now) { + return tools.ResultSuccess("Suppressed: an identical message was already delivered to the " + + normalizeProvider(t.cfg.Provider) + " webhook within the last " + t.dedupeWindow.String() + "."), nil + } + if wait, limited := t.rateLimited(now); limited { + return tools.ResultError(fmt.Sprintf( + "Error: rate limited; wait %s before sending another notification.", wait.Round(time.Millisecond))), nil } + t.markSent(args, now) - contentType, body, err := buildPayload(args.Provider, args.Message, args.Value2, args.Value3, args.ChatID) + if rt != nil && rt.Supports(tools.CapabilityRecall) { + t.setRuntime(rt) + bg, cancel, ok := t.deliveryContext(ctx) + if ok { + t.wg.Go(func() { + defer cancel() + if msg, failed := t.deliver(bg, args); failed { + t.recall(bg, msg) + } + }) + return tools.ResultSuccess(fmt.Sprintf( + "Queued delivery to the %s webhook. You will only be notified if it ultimately fails.", + normalizeProvider(t.cfg.Provider))), nil + } + cancel() + } + + msg, failed := t.deliver(ctx, args) + if failed { + return tools.ResultError(msg), nil + } + return tools.ResultSuccess(msg), nil +} + +func (t *ToolSet) deliver(ctx context.Context, args SendArgs) (string, bool) { + provider := normalizeProvider(t.cfg.Provider) + var ( + lastErr string + retryAfter time.Duration + ) + + for attempt := range t.maxAttempts { + if attempt > 0 { + if !t.sleep(ctx, t.retryDelay(attempt, retryAfter)) { + return fmt.Sprintf("Delivery to the %s webhook was cancelled after %d attempt(s): %s", + provider, attempt, lastErr), true + } + } + + v, ra, detail := t.attempt(ctx, args) + retryAfter = ra + switch v { + case delivered: + return fmt.Sprintf("Delivered to the %s webhook (attempt %d/%d).", + provider, attempt+1, t.maxAttempts), false + case permanent: + return fmt.Sprintf("Delivery to the %s webhook failed permanently: %s", provider, detail), true + case transient: + lastErr = detail + } + } + return fmt.Sprintf("Delivery to the %s webhook failed after %d attempt(s): %s", + provider, t.maxAttempts, lastErr), true +} + +func (t *ToolSet) retryDelay(attempt int, retryAfter time.Duration) time.Duration { + if retryAfter > 0 { + if retryAfter > backoff.MaxRetryAfterWait { + return backoff.MaxRetryAfterWait + } + return retryAfter + } + return backoff.Calculate(attempt) +} + +func (t *ToolSet) attempt(ctx context.Context, args SendArgs) (verdict, time.Duration, string) { + endpoint := t.expander.Expand(ctx, t.cfg.URL, nil) + if u, err := url.Parse(endpoint); err != nil || (u.Scheme != "http" && u.Scheme != "https") { + return permanent, 0, "configured url is not a valid http(s) URL" + } + + body, err := buildPayload(normalizeProvider(t.cfg.Provider), args.Message, args.Value2, args.Value3, t.cfg.ChatID) if err != nil { - return tools.ResultError("Error: " + err.Error()), nil + return permanent, 0, err.Error() } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, args.URL, bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { - return tools.ResultError("Error: " + err.Error()), nil + return permanent, 0, err.Error() } - req.Header.Set("Content-Type", contentType) + req.Header.Set("Content-Type", "application/json") useragent.SetIdentity(req) + for k, v := range t.expander.ExpandMap(ctx, t.cfg.Headers) { + req.Header.Set(k, v) + } resp, err := t.client.Do(req) if err != nil { - return tools.ResultError("Error: sending webhook: " + err.Error()), nil + return transient, 0, "request failed: " + err.Error() } defer resp.Body.Close() respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxRespRead)) - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return tools.ResultError(fmt.Sprintf("Webhook returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))), nil + switch { + case resp.StatusCode >= 200 && resp.StatusCode < 300: + return delivered, 0, "" + case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500: + return transient, parseRetryAfter(resp.Header.Get("Retry-After")), + fmt.Sprintf("HTTP %d: %s", resp.StatusCode, snippet(respBody)) + default: + return permanent, 0, fmt.Sprintf("HTTP %d: %s", resp.StatusCode, snippet(respBody)) + } +} + +func parseRetryAfter(v string) time.Duration { + secs, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil || secs <= 0 { + return 0 + } + return time.Duration(secs) * time.Second +} + +func snippet(b []byte) string { + s := strings.TrimSpace(string(b)) + if len(s) > 200 { + s = s[:200] + "…" + } + if s == "" { + return "(no body)" + } + return s +} + +func dedupeKey(args SendArgs) string { + sum := sha256.Sum256([]byte(args.Message + "\x00" + args.Value2 + "\x00" + args.Value3)) + return hex.EncodeToString(sum[:8]) +} + +func (t *ToolSet) isDuplicate(args SendArgs, now time.Time) bool { + t.mu.Lock() + defer t.mu.Unlock() + for k, at := range t.recent { + if now.Sub(at) > t.dedupeWindow { + delete(t.recent, k) + } + } + at, ok := t.recent[dedupeKey(args)] + return ok && now.Sub(at) <= t.dedupeWindow +} + +func (t *ToolSet) rateLimited(now time.Time) (time.Duration, bool) { + t.mu.Lock() + defer t.mu.Unlock() + if t.lastSent.IsZero() { + return 0, false + } + if elapsed := now.Sub(t.lastSent); elapsed < t.minInterval { + return t.minInterval - elapsed, true + } + return 0, false +} + +func (t *ToolSet) markSent(args SendArgs, now time.Time) { + t.mu.Lock() + defer t.mu.Unlock() + t.recent[dedupeKey(args)] = now + t.lastSent = now +} + +func (t *ToolSet) recall(ctx context.Context, message string) { + rt := t.runtime() + if rt == nil { + return + } + if err := rt.Recall(ctx, "⚠️ "+message); err != nil { + slog.WarnContext(ctx, "Failed to enqueue webhook delivery-failure recall", "error", err) + } +} + +func (t *ToolSet) setRuntime(rt tools.Runtime) { + t.mu.Lock() + t.rt = rt + t.mu.Unlock() +} + +func (t *ToolSet) runtime() tools.Runtime { + t.mu.Lock() + defer t.mu.Unlock() + return t.rt +} + +func (t *ToolSet) deliveryContext(callCtx context.Context) (context.Context, context.CancelFunc, bool) { + ctx, cancel := context.WithCancel(context.WithoutCancel(callCtx)) + t.mu.Lock() + defer t.mu.Unlock() + if t.stopped { + return ctx, cancel, false + } + t.cancels = append(t.cancels, cancel) + return ctx, cancel, true +} + +func (t *ToolSet) Start(context.Context) error { + t.mu.Lock() + defer t.mu.Unlock() + t.stopped = false + return nil +} + +func (t *ToolSet) Stop(context.Context) error { + t.mu.Lock() + t.stopped = true + cancels := t.cancels + t.cancels = nil + t.mu.Unlock() + + for _, cancel := range cancels { + cancel() } - return tools.ResultSuccess(fmt.Sprintf("Delivered to %s webhook (HTTP %d).", normalizeProvider(args.Provider), resp.StatusCode)), nil + t.wg.Wait() + return nil } func (t *ToolSet) Tools(context.Context) ([]tools.Tool, error) { return []tools.Tool{ { - Name: ToolNameSendWebhook, - Category: category, - Description: "Send a message to a webhook (Slack, Discord, IFTTT, Telegram, Mattermost, Rocket.Chat, Google Chat, Teams, or a generic URL). POSTs a provider-shaped JSON payload and reports delivery status.", + Name: ToolNameSendWebhook, + Category: category, + Description: "Send a notification to the configured webhook (Slack, Discord, IFTTT, Telegram, Mattermost, " + + "Rocket.Chat, Google Chat, Teams, or a generic endpoint). The destination is set in the agent's " + + "configuration, so you only supply the message. Delivery is retried automatically; you are notified " + + "only if it ultimately fails.", Parameters: tools.MustSchemaFor[SendArgs](), OutputSchema: tools.MustSchemaFor[string](), - Handler: tools.NewHandler(t.send), + Handler: tools.NewRuntimeHandler(t.send), Annotations: tools.ToolAnnotations{Title: "Send Webhook"}, AddDescriptionParameter: true, }, @@ -158,10 +424,13 @@ func (t *ToolSet) Tools(context.Context) ([]tools.Tool, error) { func (t *ToolSet) Instructions() string { return `## Webhook Tool -Send an outbound notification with send_webhook(url, message, provider?): +send_webhook(message) delivers a notification to the destination configured for +this agent. You do not choose the destination or its credentials. -- provider shapes the payload — slack, discord, ifttt, telegram, mattermost, - rocketchat, googlechat, teams, or generic (default). Telegram needs chat_id. -- Delivery is one-way; the tool reports the HTTP status, not a response body. -- Requests to non-public addresses are refused.` +- Delivery is reliable: transient failures (rate limits, 5xx, network errors) are + retried with backoff, honouring the server's Retry-After. +- It is fire-and-forget: the call returns immediately and you are messaged only + if delivery ultimately fails. +- Identical messages sent again within a short window are suppressed, and + notifications are rate limited, so avoid resending on your own.` } From 25e87550258875892edeb468562729cacde2c693 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Thu, 16 Jul 2026 20:22:18 +0330 Subject: [PATCH 17/23] feat(pkg/tools/builtin/webhook/webhook_test.go): adding the new tests for new features of webhook tool --- pkg/tools/builtin/webhook/webhook_test.go | 381 +++++++++++++++++----- 1 file changed, 306 insertions(+), 75 deletions(-) diff --git a/pkg/tools/builtin/webhook/webhook_test.go b/pkg/tools/builtin/webhook/webhook_test.go index f8c6b648d..73451adbb 100644 --- a/pkg/tools/builtin/webhook/webhook_test.go +++ b/pkg/tools/builtin/webhook/webhook_test.go @@ -1,63 +1,127 @@ package webhook import ( + "context" "encoding/json" "io" "net/http" "strings" + "sync" "testing" + "time" "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/backoff" + "github.com/docker/docker-agent/pkg/config" + "github.com/docker/docker-agent/pkg/config/latest" + "github.com/docker/docker-agent/pkg/js" + "github.com/docker/docker-agent/pkg/tools" ) +var testNow = time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC) + type fakeDoer struct { - gotReq *http.Request - gotBody []byte - status int - err error + mu sync.Mutex + reqs []*http.Request + bodies [][]byte + statuses []int + retryAft string + err error } func (f *fakeDoer) Do(req *http.Request) (*http.Response, error) { - f.gotReq = req + f.mu.Lock() + defer f.mu.Unlock() + f.reqs = append(f.reqs, req) if req.Body != nil { - f.gotBody, _ = io.ReadAll(req.Body) + b, _ := io.ReadAll(req.Body) + f.bodies = append(f.bodies, b) } if f.err != nil { return nil, f.err } - status := f.status - if status == 0 { - status = http.StatusOK + status := http.StatusOK + if len(f.statuses) > 0 { + if len(f.reqs)-1 < len(f.statuses) { + status = f.statuses[len(f.reqs)-1] + } else { + status = f.statuses[len(f.statuses)-1] + } + } + h := make(http.Header) + if f.retryAft != "" { + h.Set("Retry-After", f.retryAft) } return &http.Response{ StatusCode: status, - Body: io.NopCloser(strings.NewReader("ok")), - Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("resp")), + Header: h, }, nil } -func newTestToolSet(d httpDoer) *ToolSet { - ts := New() - ts.client = d - return ts +func (f *fakeDoer) calls() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.reqs) } -func decode(t *testing.T, body []byte) map[string]string { +func (f *fakeDoer) lastBody(t *testing.T) map[string]string { t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + require.NotEmpty(t, f.bodies) var m map[string]string - require.NoError(t, json.Unmarshal(body, &m)) + require.NoError(t, json.Unmarshal(f.bodies[len(f.bodies)-1], &m)) return m } -func TestBuildPayload(t *testing.T) { +type fakeRuntime struct { + mu sync.Mutex + recalls []string + recall bool +} + +func (f *fakeRuntime) EmitOutput(context.Context, string) {} +func (f *fakeRuntime) Recall(_ context.Context, msg string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.recalls = append(f.recalls, msg) + return nil +} + +func (f *fakeRuntime) Supports(c tools.Capability) bool { + return f.recall && c == tools.CapabilityRecall +} + +func (f *fakeRuntime) messages() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.recalls...) +} + +func newTS(t *testing.T, d httpDoer) (*ToolSet, *[]time.Duration) { + t.Helper() + ts := New( + latest.WebhookToolConfig{Provider: "slack", URL: "https://example.com/hook", ChatID: "42"}, + js.NewJsExpander((&config.RuntimeConfig{}).EnvProvider()), + time.Second, + ) + ts.client = d + ts.now = func() time.Time { return testNow } + slept := &[]time.Duration{} + ts.sleep = func(_ context.Context, dur time.Duration) bool { + *slept = append(*slept, dur) + return true + } + return ts, slept +} + +func TestProviderPayloads(t *testing.T) { t.Parallel() - tests := []struct { - provider string - wantKey string - }{ + for _, tc := range []struct{ provider, wantKey string }{ {"slack", "text"}, - {"discord", "content"}, {"mattermost", "text"}, {"rocketchat", "text"}, {"googlechat", "text"}, @@ -65,107 +129,274 @@ func TestBuildPayload(t *testing.T) { {"msteams", "text"}, {"generic", "text"}, {"", "text"}, - } - for _, tc := range tests { - ct, body, err := buildPayload(tc.provider, "hello", "", "", "") + {"discord", "content"}, + } { + body, err := buildPayload(normalizeProvider(tc.provider), "hello", "", "", "") require.NoError(t, err, tc.provider) - require.Equal(t, "application/json", ct) - require.Equal(t, "hello", decode(t, body)[tc.wantKey], tc.provider) + var m map[string]string + require.NoError(t, json.Unmarshal(body, &m)) + require.Equal(t, "hello", m[tc.wantKey], tc.provider) } } -func TestBuildPayloadIFTTT(t *testing.T) { +func TestProviderPayloadIFTTTAndTelegram(t *testing.T) { t.Parallel() - _, body, err := buildPayload("ifttt", "msg", "two", "three", "") + body, err := buildPayload("ifttt", "msg", "two", "three", "") require.NoError(t, err) - m := decode(t, body) - require.Equal(t, "msg", m["value1"]) - require.Equal(t, "two", m["value2"]) - require.Equal(t, "three", m["value3"]) + var m map[string]string + require.NoError(t, json.Unmarshal(body, &m)) + require.Equal(t, map[string]string{"value1": "msg", "value2": "two", "value3": "three"}, m) + + body, err = buildPayload("telegram", "hi", "", "", "42") + require.NoError(t, err) + var tg map[string]string + require.NoError(t, json.Unmarshal(body, &tg)) + require.Equal(t, map[string]string{"chat_id": "42", "text": "hi"}, tg) } -func TestBuildPayloadTelegram(t *testing.T) { +func TestBuildPayloadUnknownProvider(t *testing.T) { t.Parallel() - _, body, err := buildPayload("telegram", "hi", "", "", "12345") - require.NoError(t, err) - m := decode(t, body) - require.Equal(t, "12345", m["chat_id"]) - require.Equal(t, "hi", m["text"]) + _, err := buildPayload("webex", "x", "", "", "") + require.Error(t, err) +} + +func TestCreateToolSetRequiresURL(t *testing.T) { + t.Parallel() - _, _, err = buildPayload("telegram", "hi", "", "", "") + _, err := CreateToolSet(latest.Toolset{Type: "webhook"}, &config.RuntimeConfig{}) require.Error(t, err) + require.Contains(t, err.Error(), "url") } -func TestBuildPayloadUnknownProvider(t *testing.T) { +func TestCreateToolSetRejectsUnknownProvider(t *testing.T) { t.Parallel() - _, _, err := buildPayload("webex", "x", "", "", "") + _, err := CreateToolSet(latest.Toolset{ + Type: "webhook", + WebhookConfig: latest.WebhookToolConfig{URL: "https://x/y", Provider: "webex"}, + }, &config.RuntimeConfig{}) require.Error(t, err) } -func TestSendSuccess(t *testing.T) { +func TestCreateToolSetTelegramRequiresChatID(t *testing.T) { t.Parallel() - fd := &fakeDoer{status: 200} - ts := newTestToolSet(fd) + _, err := CreateToolSet(latest.Toolset{ + Type: "webhook", + WebhookConfig: latest.WebhookToolConfig{URL: "https://x/y", Provider: "telegram"}, + }, &config.RuntimeConfig{}) + require.Error(t, err) + require.Contains(t, err.Error(), "chat_id") +} + +func TestCreateToolSetOK(t *testing.T) { + t.Parallel() - res, err := ts.send(t.Context(), SendArgs{ - URL: "https://hooks.slack.com/services/xxx", Message: "deploy done", Provider: "slack", - }) + ts, err := CreateToolSet(latest.Toolset{ + Type: "webhook", + WebhookConfig: latest.WebhookToolConfig{URL: "https://x/y", Provider: "slack"}, + }, &config.RuntimeConfig{}) + require.NoError(t, err) + toolz, err := ts.Tools(t.Context()) + require.NoError(t, err) + require.Len(t, toolz, 1) + require.Equal(t, ToolNameSendWebhook, toolz[0].Name) +} + +func TestDeliverSucceedsFirstAttempt(t *testing.T) { + t.Parallel() + + fd := &fakeDoer{statuses: []int{200}} + ts, slept := newTS(t, fd) + + msg, failed := ts.deliver(t.Context(), SendArgs{Message: "hi"}) + require.False(t, failed, msg) + require.Equal(t, 1, fd.calls()) + require.Empty(t, *slept) + require.Equal(t, "hi", fd.lastBody(t)["text"]) +} + +func TestDeliverRetriesTransientThenSucceeds(t *testing.T) { + t.Parallel() + + fd := &fakeDoer{statuses: []int{500, 503, 200}} + ts, slept := newTS(t, fd) + + msg, failed := ts.deliver(t.Context(), SendArgs{Message: "hi"}) + require.False(t, failed, msg) + require.Equal(t, 3, fd.calls()) + require.Len(t, *slept, 2) +} + +func TestDeliverHonoursRetryAfter(t *testing.T) { + t.Parallel() + + fd := &fakeDoer{statuses: []int{429, 200}, retryAft: "2"} + ts, slept := newTS(t, fd) + + _, failed := ts.deliver(t.Context(), SendArgs{Message: "hi"}) + require.False(t, failed) + require.Equal(t, []time.Duration{2 * time.Second}, *slept) +} + +func TestDeliverPermanentFailureDoesNotRetry(t *testing.T) { + t.Parallel() + + fd := &fakeDoer{statuses: []int{400}} + ts, _ := newTS(t, fd) + + msg, failed := ts.deliver(t.Context(), SendArgs{Message: "hi"}) + require.True(t, failed) + require.Contains(t, msg, "permanently") + require.Equal(t, 1, fd.calls()) +} + +func TestDeliverExhaustsAttempts(t *testing.T) { + t.Parallel() + + fd := &fakeDoer{statuses: []int{500}} + ts, _ := newTS(t, fd) + + msg, failed := ts.deliver(t.Context(), SendArgs{Message: "hi"}) + require.True(t, failed) + require.Contains(t, msg, "failed after") + require.Equal(t, ts.maxAttempts, fd.calls()) +} + +func TestRetryDelayCapsRetryAfter(t *testing.T) { + t.Parallel() + + ts, _ := newTS(t, &fakeDoer{}) + require.Equal(t, 5*time.Second, ts.retryDelay(1, 5*time.Second)) + require.Equal(t, backoff.MaxRetryAfterWait, ts.retryDelay(1, time.Hour)) + require.Positive(t, ts.retryDelay(1, 0)) +} + +func TestParseRetryAfter(t *testing.T) { + t.Parallel() + + require.Equal(t, 3*time.Second, parseRetryAfter("3")) + require.Equal(t, time.Duration(0), parseRetryAfter("")) + require.Equal(t, time.Duration(0), parseRetryAfter("Wed, 21 Oct 2026 07:28:00 GMT")) + require.Equal(t, time.Duration(0), parseRetryAfter("-1")) +} + +func TestSendIsNonBlockingAndRecallsOnFailure(t *testing.T) { + t.Parallel() + + fd := &fakeDoer{statuses: []int{500}} + ts, _ := newTS(t, fd) + rt := &fakeRuntime{recall: true} + + res, err := ts.send(t.Context(), SendArgs{Message: "alert"}, rt) require.NoError(t, err) require.False(t, res.IsError, res.Output) + require.Contains(t, res.Output, "Queued") + + require.NoError(t, ts.Stop(t.Context())) + + msgs := rt.messages() + require.Len(t, msgs, 1) + require.Contains(t, msgs[0], "failed after") +} - require.Equal(t, http.MethodPost, fd.gotReq.Method) - require.Equal(t, "application/json", fd.gotReq.Header.Get("Content-Type")) - require.Equal(t, "deploy done", decode(t, fd.gotBody)["text"]) +func TestSendAsyncSuccessDoesNotRecall(t *testing.T) { + t.Parallel() + + fd := &fakeDoer{statuses: []int{200}} + ts, _ := newTS(t, fd) + rt := &fakeRuntime{recall: true} + + _, err := ts.send(t.Context(), SendArgs{Message: "ok"}, rt) + require.NoError(t, err) + require.NoError(t, ts.Stop(t.Context())) + require.Empty(t, rt.messages()) +} + +func TestSendFallsBackToSyncWithoutRecall(t *testing.T) { + t.Parallel() + + fd := &fakeDoer{statuses: []int{200}} + ts, _ := newTS(t, fd) + + res, err := ts.send(t.Context(), SendArgs{Message: "hi"}, &fakeRuntime{recall: false}) + require.NoError(t, err) + require.False(t, res.IsError) + require.Contains(t, res.Output, "Delivered") + require.Equal(t, 1, fd.calls()) } -func TestSendNon2xx(t *testing.T) { +func TestSendSuppressesDuplicates(t *testing.T) { t.Parallel() - ts := newTestToolSet(&fakeDoer{status: 404}) - res, err := ts.send(t.Context(), SendArgs{URL: "https://example.com/hook", Message: "hi"}) + fd := &fakeDoer{statuses: []int{200}} + ts, _ := newTS(t, fd) + ts.minInterval = 0 + + _, err := ts.send(t.Context(), SendArgs{Message: "same"}, &fakeRuntime{recall: false}) + require.NoError(t, err) + + res, err := ts.send(t.Context(), SendArgs{Message: "same"}, &fakeRuntime{recall: false}) + require.NoError(t, err) + require.Contains(t, res.Output, "Suppressed") + require.Equal(t, 1, fd.calls()) +} + +func TestSendRateLimits(t *testing.T) { + t.Parallel() + + fd := &fakeDoer{statuses: []int{200}} + ts, _ := newTS(t, fd) + + _, err := ts.send(t.Context(), SendArgs{Message: "first"}, &fakeRuntime{recall: false}) + require.NoError(t, err) + + res, err := ts.send(t.Context(), SendArgs{Message: "second"}, &fakeRuntime{recall: false}) require.NoError(t, err) require.True(t, res.IsError) - require.Contains(t, res.Output, "404") + require.Contains(t, res.Output, "rate limited") + require.Equal(t, 1, fd.calls()) } -func TestSendNetworkError(t *testing.T) { +func TestSendRequiresMessage(t *testing.T) { t.Parallel() - ts := newTestToolSet(&fakeDoer{err: io.ErrUnexpectedEOF}) - res, err := ts.send(t.Context(), SendArgs{URL: "https://example.com/hook", Message: "hi"}) + ts, _ := newTS(t, &fakeDoer{}) + res, err := ts.send(t.Context(), SendArgs{Message: " "}, &fakeRuntime{recall: false}) require.NoError(t, err) require.True(t, res.IsError) } -func TestSendValidation(t *testing.T) { +func TestRequestCarriesHeadersAndUserAgent(t *testing.T) { t.Parallel() - ts := newTestToolSet(&fakeDoer{}) + fd := &fakeDoer{statuses: []int{200}} + ts, _ := newTS(t, fd) + ts.cfg.Headers = map[string]string{"Authorization": "Bearer tok"} - r1, _ := ts.send(t.Context(), SendArgs{Message: "hi"}) - require.True(t, r1.IsError) + _, failed := ts.deliver(t.Context(), SendArgs{Message: "hi"}) + require.False(t, failed) - r2, _ := ts.send(t.Context(), SendArgs{URL: "https://x/y"}) - require.True(t, r2.IsError) + req := fd.reqs[0] + require.Equal(t, http.MethodPost, req.Method) + require.Equal(t, "application/json", req.Header.Get("Content-Type")) + require.Equal(t, "Bearer tok", req.Header.Get("Authorization")) + require.NotEmpty(t, req.Header.Get("User-Agent")) +} - r3, _ := ts.send(t.Context(), SendArgs{URL: "ftp://x/y", Message: "hi"}) - require.True(t, r3.IsError) +func TestStartStopIsClean(t *testing.T) { + t.Parallel() - r4, _ := ts.send(t.Context(), SendArgs{URL: "https://x/y", Message: "hi", Provider: "webex"}) - require.True(t, r4.IsError) + ts, _ := newTS(t, &fakeDoer{}) + require.NoError(t, ts.Start(t.Context())) + require.NoError(t, ts.Stop(t.Context())) } -func TestToolSetInterfaces(t *testing.T) { +func TestInstructions(t *testing.T) { t.Parallel() - ts := New() + ts, _ := newTS(t, &fakeDoer{}) require.NotEmpty(t, ts.Instructions()) - toolz, err := ts.Tools(t.Context()) - require.NoError(t, err) - require.Len(t, toolz, 1) - require.Equal(t, ToolNameSendWebhook, toolz[0].Name) } From 7e8ee22b0405a8295007cb8a144a70d26219299a Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Thu, 16 Jul 2026 20:23:07 +0330 Subject: [PATCH 18/23] docs(docs/configuration/tools/index.md): adding the webhook tool configs into conf docs --- docs/configuration/tools/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration/tools/index.md b/docs/configuration/tools/index.md index 785509741..a766036de 100644 --- a/docs/configuration/tools/index.md +++ b/docs/configuration/tools/index.md @@ -40,7 +40,7 @@ Built-in tools are included with Docker Agent and require no external dependenci | `open_url` | Open a fixed URL in the user's default browser | [Open URL](../../tools/open-url/index.md) | | `transfer_task` | Delegate to sub-agents (auto-enabled) | [Transfer Task](../../tools/transfer-task/index.md) | | `background_agents` | Parallel sub-agent dispatch | [Background Agents](../../tools/background-agents/index.md) | -| `webhook` | Send outbound notifications (Slack, Discord, Telegram, IFTTT, Teams, …) | [Webhook](../../tools/webhook/index.md) | +| `webhook` | Reliable notifications to a configured destination, with retries (Slack, Discord, Telegram, IFTTT, Teams, …) | [Webhook](../../tools/webhook/index.md) | | `handoff` | Local conversation handoff to another agent in the same config (auto-enabled by `handoffs:`) | [Handoff](../../tools/handoff/index.md) | | `a2a` | A2A remote agent connection | [A2A](../../tools/a2a/index.md) | | `mcp_catalog` | Discover and activate remote MCP servers from the Docker MCP Catalog on demand | [MCP Catalog](../../tools/mcp-catalog/index.md) | From 836b48edff3d567129591f835a56ba226a1648d4 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Thu, 16 Jul 2026 20:23:55 +0330 Subject: [PATCH 19/23] docs(docs/tools/webhook/index.md): updating up the docs for webhook tool new design --- docs/tools/webhook/index.md | 131 ++++++++++++++++++++++++++++++------ 1 file changed, 109 insertions(+), 22 deletions(-) diff --git a/docs/tools/webhook/index.md b/docs/tools/webhook/index.md index 3024f21f6..ded710754 100644 --- a/docs/tools/webhook/index.md +++ b/docs/tools/webhook/index.md @@ -1,48 +1,128 @@ --- title: "Webhook Tool" -description: "Send outbound notifications to Slack, Discord, Telegram, IFTTT, and more." -keywords: docker agent, ai agents, tools, toolsets, webhook, slack, discord, telegram, ifttt +description: "Reliable outbound notifications to Slack, Discord, Telegram, IFTTT, and more." +keywords: docker agent, ai agents, tools, toolsets, webhook, slack, discord, telegram, ifttt, notifications linkTitle: "Webhook" weight: 145 canonical: https://docs.docker.com/ai/docker-agent/tools/webhook/ --- -_Send outbound notifications to Slack, Discord, Telegram, IFTTT, and more._ +_Reliable outbound notifications to Slack, Discord, Telegram, IFTTT, and more._ ## Overview -The webhook toolset lets an agent POST a message to a webhook, shaping the JSON -payload for the target service. Delivery is one-way—the tool reports the HTTP -status, not a response body. It uses the SSRF-safe HTTP client (requests to -non-public addresses are refused). +The webhook toolset delivers a notification to a destination **you configure**. The +agent supplies only the message text: it never sees or chooses the URL, because a +webhook URL is itself a credential (Slack and Mattermost embed a secret path, +Discord a token, IFTTT a key, Telegram a bot token). + +This is not a general HTTP client — that is the [`api`](../api/index.md) toolset. +The webhook toolset owns *delivery*: + +- **At-least-once delivery.** Transient failures (`429`, `5xx`, network errors) are + retried with exponential backoff, honouring the server's `Retry-After`. A `4xx` + is permanent and fails immediately without wasting retries. +- **Non-blocking.** The call returns as soon as the notification is queued, so a + slow or retrying endpoint never stalls the agent's turn. The agent is messaged + back **only if delivery ultimately fails**. +- **Storm protection.** An identical message to the same destination inside a short + window is suppressed, and notifications are rate limited, so a looping agent + cannot flood a channel. +- **Provider-shaped payloads.** Each service's wire format is applied for you. ## Configuration +The destination lives in `webhook_config`. Use `${env.VAR}` for anything secret — +values are expanded at call time and never stored in the config file. + ```yaml toolsets: - type: webhook + webhook_config: + provider: slack + url: ${env.SLACK_WEBHOOK_URL} ``` -No configuration options. +| Field | Required | Description | +| --- | --- | --- | +| `url` | Yes | Webhook endpoint. Usually embeds a secret — prefer `${env.VAR}`. | +| `provider` | No | Payload shape (default `generic`). | +| `headers` | No | Extra headers, for endpoints authenticating with a token. | +| `chat_id` | No | Destination chat — required for `provider: telegram`. | + +`timeout` on the toolset (seconds) overrides the per-request HTTP timeout. + +## Providers + +| Provider | Payload sent | Where the secret lives | +| --- | --- | --- | +| `slack`, `mattermost`, `rocketchat`, `googlechat`, `teams`, `generic` | `{"text": message}` | secret webhook URL | +| `discord` | `{"content": message}` | token in the webhook URL | +| `ifttt` | `{"value1": message, "value2": …, "value3": …}` | key in the webhook URL | +| `telegram` | `{"chat_id": …, "text": message}` | bot token in the URL, plus `chat_id` | + +Aliases are accepted: `msteams`/`microsoft_teams` → `teams`, `google_chat`/`gchat` +→ `googlechat`, `rocket.chat` → `rocketchat`. + +### Per-service examples + +```yaml +# Slack / Mattermost / Rocket.Chat — the URL is the credential +toolsets: + - type: webhook + webhook_config: + provider: slack + url: ${env.SLACK_WEBHOOK_URL} +``` + +```yaml +# Discord — the token is part of the webhook URL +toolsets: + - type: webhook + webhook_config: + provider: discord + url: ${env.DISCORD_WEBHOOK_URL} +``` + +```yaml +# Telegram — bot token in the URL, chat_id selects the destination chat +toolsets: + - type: webhook + webhook_config: + provider: telegram + url: https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/sendMessage + chat_id: "123456789" +``` + +```yaml +# IFTTT — the key is part of the trigger URL +toolsets: + - type: webhook + webhook_config: + provider: ifttt + url: https://maker.ifttt.com/trigger/build_failed/with/key/${env.IFTTT_KEY} +``` + +```yaml +# Generic endpoint authenticating with a bearer token +toolsets: + - type: webhook + webhook_config: + provider: generic + url: https://alerts.example.com/notify + headers: + Authorization: Bearer ${env.ALERTS_TOKEN} +``` ## `send_webhook` | Parameter | Required | Description | | --- | --- | --- | -| `url` | Yes | The webhook URL to POST to. | -| `message` | Yes | The message text. | -| `provider` | No | Payload format (default `generic`). | -| `value2`, `value3` | No | IFTTT extra fields (`provider=ifttt`). | -| `chat_id` | No | Telegram chat ID (`provider=telegram`). | - -## Providers +| `message` | Yes | The message text to deliver. | +| `value2`, `value3` | No | Extra IFTTT data fields (`provider: ifttt`). | -| Provider | Payload | -| --- | --- | -| `slack`, `mattermost`, `rocketchat`, `googlechat`, `teams`, `generic` | `{"text": message}` | -| `discord` | `{"content": message}` | -| `ifttt` | `{"value1": message, "value2": …, "value3": …}` | -| `telegram` | `{"chat_id": …, "text": message}` | +Returns immediately once queued. On success nothing further happens; if delivery +ultimately fails, the agent receives a message saying so. ## Example @@ -50,7 +130,14 @@ No configuration options. agents: root: model: openai/gpt-5-mini - instruction: If a scheduled check fails, notify the team via send_webhook. + instruction: If a check fails, notify the team with send_webhook. toolsets: - type: webhook + webhook_config: + provider: slack + url: ${env.SLACK_WEBHOOK_URL} ``` + +> [!NOTE] +> Requests to non-public addresses are refused (the SSRF-safe HTTP client), and the +> configured URL is never echoed back to the model or into error messages. From f035f3b947bbe68d77e6a13948a80c753bf6205b Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Thu, 16 Jul 2026 20:24:34 +0330 Subject: [PATCH 20/23] feat(agent-schema.json): adding the webhook tool and configs details into agent-schema.json --- agent-schema.json | 59 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/agent-schema.json b/agent-schema.json index 5aa633260..63cf1d5f2 100644 --- a/agent-schema.json +++ b/agent-schema.json @@ -2106,6 +2106,10 @@ "$ref": "#/definitions/ApiConfig", "description": "API tool configuration" }, + "webhook_config": { + "$ref": "#/definitions/WebhookConfig", + "description": "Webhook tool configuration" + }, "rag_config": { "$ref": "#/definitions/RAGConfig", "description": "RAG configuration for type: rag toolsets" @@ -2359,8 +2363,7 @@ "model_picker", "background_agents", "scheduler", - "git", - "webhook" + "git" ] } } @@ -2397,6 +2400,22 @@ } ] }, + { + "allOf": [ + { + "properties": { + "type": { + "const": "webhook" + } + } + }, + { + "required": [ + "webhook_config" + ] + } + ] + }, { "allOf": [ { @@ -2564,6 +2583,42 @@ ], "additionalProperties": false }, + "WebhookConfig": { + "type": "object", + "description": "Webhook tool configuration: delivery destination and credentials", + "properties": { + "provider": { + "type": "string", + "description": "Payload shape for the target service", + "enum": [ + "slack", + "discord", + "ifttt", + "telegram", + "mattermost", + "rocketchat", + "googlechat", + "teams", + "generic" + ] + }, + "url": { + "type": "string", + "description": "Webhook endpoint URL. Usually embeds a secret, so prefer ${env.VAR}" + }, + "headers": { + "type": "object", + "description": "HTTP headers sent with the request; use ${env.VAR} for tokens", + "additionalProperties": { + "type": "string" + } + }, + "chat_id": { + "type": "string", + "description": "Destination chat id (provider: telegram)" + } + } + }, "ApiConfig": { "type": "object", "description": "API tool configuration for making HTTP requests to external APIs", From 1a1746936d46b132947a619838ab1be1108191ad Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Fri, 17 Jul 2026 11:46:12 +0330 Subject: [PATCH 21/23] fix(pkg/tools/builtin/webhook/webhook.go): fixing the go linting problem on defaultMaxAttempts = 4 line --- pkg/tools/builtin/webhook/webhook.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/tools/builtin/webhook/webhook.go b/pkg/tools/builtin/webhook/webhook.go index 962aa1cac..16e56a83d 100644 --- a/pkg/tools/builtin/webhook/webhook.go +++ b/pkg/tools/builtin/webhook/webhook.go @@ -28,10 +28,8 @@ import ( const ( ToolNameSendWebhook = "send_webhook" - category = "webhook" maxRespRead = 64 << 10 - defaultMaxAttempts = 4 defaultDedupeWindow = 30 * time.Second defaultMinInterval = time.Second From 5e6e5da7fa29522ce2a6eb790c74cd5ed09af218 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Fri, 17 Jul 2026 13:26:27 +0330 Subject: [PATCH 22/23] fix(pkg/tools/builtin/webhook/webhook.go): fixing up the linting problems --- pkg/tools/builtin/webhook/webhook.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/tools/builtin/webhook/webhook.go b/pkg/tools/builtin/webhook/webhook.go index 16e56a83d..284840576 100644 --- a/pkg/tools/builtin/webhook/webhook.go +++ b/pkg/tools/builtin/webhook/webhook.go @@ -28,11 +28,11 @@ import ( const ( ToolNameSendWebhook = "send_webhook" - category = "webhook" - maxRespRead = 64 << 10 - defaultMaxAttempts = 4 + category = "webhook" + maxRespRead = 64 << 10 + defaultMaxAttempts = 4 defaultDedupeWindow = 30 * time.Second - defaultMinInterval = time.Second + defaultMinInterval = time.Second ) type httpDoer interface { From a33c1d2aa6a57c92564169192d24ff37bac69e8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20H=C3=A9ritier?= Date: Sun, 19 Jul 2026 20:58:27 +0200 Subject: [PATCH 23/23] fix(webhook): sanitise *url.Error to prevent secret URL leakage Use errors.As to unwrap *url.Error in attempt(), returning only the underlying cause (urlErr.Err.Error()) so the credential-embedded webhook URL is never exposed to the model. Applies to both http.NewRequestWithContext and http.Client.Do error paths. Also adds required:["url"] to WebhookConfig in agent-schema.json. --- agent-schema.json | 11 +++++++---- pkg/tools/builtin/webhook/webhook.go | 13 +++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/agent-schema.json b/agent-schema.json index 63cf1d5f2..1c4ed2cdb 100644 --- a/agent-schema.json +++ b/agent-schema.json @@ -106,7 +106,7 @@ }, "runtime": { "$ref": "#/definitions/RuntimeDefaults", - "description": "Execution-time defaults the agent author wants applied. Values act as defaults only — explicit CLI flags or user-config settings always win." + "description": "Execution-time defaults the agent author wants applied. Values act as defaults only \u2014 explicit CLI flags or user-config settings always win." } }, "additionalProperties": false, @@ -1527,7 +1527,7 @@ "description": "Whether to track usage" }, "thinking_budget": { - "description": "Controls reasoning effort/budget. Use 'none' or 0 to disable thinking. OpenAI: string levels ('none','minimal','low','medium','high','xhigh','max') — 'xhigh' requires gpt-5.2+; 'none' and 'max' require gpt-5.6+ (Sol/Terra/Luna); 'minimal' is not accepted on gpt-5.6+. Anthropic: integer token budget (1024-32768), 'adaptive' (adaptive thinking with high effort by default), 'adaptive/' where effort is low/medium/high/xhigh/max ('xhigh' is supported by Claude Opus 4.7+), or effort levels ('low','medium','high','xhigh','max') which use adaptive thinking with the given effort. Amazon Bedrock (Claude): integer token budget or effort levels ('low','medium','high') mapped to token budgets. Google Gemini 2.5: integer token budget (-1 for dynamic, 0 to disable, 24576 max). Google Gemini 3: string levels ('minimal' Flash only,'low','medium','high'). Thinking is only enabled when explicitly configured.", + "description": "Controls reasoning effort/budget. Use 'none' or 0 to disable thinking. OpenAI: string levels ('none','minimal','low','medium','high','xhigh','max') \u2014 'xhigh' requires gpt-5.2+; 'none' and 'max' require gpt-5.6+ (Sol/Terra/Luna); 'minimal' is not accepted on gpt-5.6+. Anthropic: integer token budget (1024-32768), 'adaptive' (adaptive thinking with high effort by default), 'adaptive/' where effort is low/medium/high/xhigh/max ('xhigh' is supported by Claude Opus 4.7+), or effort levels ('low','medium','high','xhigh','max') which use adaptive thinking with the given effort. Amazon Bedrock (Claude): integer token budget or effort levels ('low','medium','high') mapped to token budgets. Google Gemini 2.5: integer token budget (-1 for dynamic, 0 to disable, 24576 max). Google Gemini 3: string levels ('minimal' Flash only,'low','medium','high'). Thinking is only enabled when explicitly configured.", "oneOf": [ { "type": "string", @@ -1771,7 +1771,7 @@ "properties": { "sandbox": { "type": "boolean", - "description": "When true, run the agent inside a Docker sandbox by default — equivalent to passing --sandbox on the command line. An explicit --sandbox=false on the CLI still wins." + "description": "When true, run the agent inside a Docker sandbox by default \u2014 equivalent to passing --sandbox on the command line. An explicit --sandbox=false on the CLI still wins." }, "network_allowlist": { "type": "array", @@ -2617,7 +2617,10 @@ "type": "string", "description": "Destination chat id (provider: telegram)" } - } + }, + "required": [ + "url" + ] }, "ApiConfig": { "type": "object", diff --git a/pkg/tools/builtin/webhook/webhook.go b/pkg/tools/builtin/webhook/webhook.go index 284840576..a30be20c6 100644 --- a/pkg/tools/builtin/webhook/webhook.go +++ b/pkg/tools/builtin/webhook/webhook.go @@ -266,6 +266,12 @@ func (t *ToolSet) attempt(ctx context.Context, args SendArgs) (verdict, time.Dur req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { + // http.NewRequestWithContext may return a *url.Error; its Error() string embeds + // the full request URL which may contain secrets. + var urlErr *url.Error + if errors.As(err, &urlErr) { + return permanent, 0, urlErr.Err.Error() + } return permanent, 0, err.Error() } req.Header.Set("Content-Type", "application/json") @@ -276,6 +282,13 @@ func (t *ToolSet) attempt(ctx context.Context, args SendArgs) (verdict, time.Dur resp, err := t.client.Do(req) if err != nil { + // http.Client.Do returns *url.Error on network failures; its Error() string + // embeds the full request URL which may carry embedded secrets (e.g. Slack/Discord tokens). + // Unwrap to expose only the underlying cause and never the URL. + var urlErr *url.Error + if errors.As(err, &urlErr) { + return transient, 0, "request failed: " + urlErr.Err.Error() + } return transient, 0, "request failed: " + err.Error() } defer resp.Body.Close()