defmodule SkillKit.Tools.Webhook do
@moduledoc """
Kit that lets agents register webhook endpoints via skill activation.
## Host wiring
SkillKit.start_agent("agents/support",
tools: [{SkillKit.Tools.Shell, []}],
skills: [
{SkillKit.Tools.Webhook,
github: [secret_key: "GITHUB_WEBHOOK_SECRET"],
stripe: [secret_key: "STRIPE_WEBHOOK_SECRET", max_skew: 600],
slack: [secret_key: "SLACK_WEBHOOK_SECRET"],
allow_unsigned: true}
])
Each configured provider binds its signing secret to the corresponding
register skill. `allow_unsigned: true` additionally loads the
`webhook:unsigned` skill for ad-hoc endpoints protected only by URL
secrecy.
## Skill surface
Always loaded (vendor register + management):
* `webhook:github`, `webhook:stripe`, `webhook:slack` — signed
vendor skills. Each stores the webhook with its verifier module
pre-bound. If the host did not configure a vendor's secret,
registration still succeeds (the LLM sees no error) but the
resulting webhook's secret_key is `nil`; at on-hit time the
verifier returns `:misconfigured` and the Plug responds 500. This
design prevents the LLM from drifting to the unsigned skill as a
fallback when a signed vendor appears unavailable.
* `webhook:update`, `webhook:unregister`, `webhook:list` — vendor-
agnostic management. Update is prompt-only; to rotate a verifier
secret, unregister + re-register.
Opt-in:
* `webhook:unsigned` — loaded only when `allow_unsigned: true`. The
SKILL.md calls out when NOT to use it.
## Runtime
When a register skill is activated, its metadata (`verifier_module`,
`secret_key`, `max_skew`, `supervisor`) flows into the tool's
`ToolExecution.context`. The `register` op reads those directly — it
does NOT accept a `verifier` field from the LLM's tool input. The
LLM has no capability to choose credential names or verifier modules.
"""
use SkillKit.Kit, name: "webhook"
alias SkillKit.Hook
alias SkillKit.ToolExecution
alias SkillKit.Webhook
alias SkillKit.Webhook.Lifecycle
alias SkillKit.Webhook.Url
alias SkillKit.Webhook.Verifier.Github
alias SkillKit.Webhook.Verifier.None
alias SkillKit.Webhook.Verifier.Slack
alias SkillKit.Webhook.Verifier.Stripe
@provider_modules %{
github: Github,
stripe: Stripe,
slack: Slack
}
@provider_skill_names Map.new(@provider_modules, fn {atom, _} -> {"webhook:#{atom}", atom} end)
@default_max_skew 300
@impl SkillKit.Kit.Provider
def load_kits(config) do
{:ok, [kit]} = super(config)
supervisor = Keyword.get(config, :supervisor, SkillKit.Webhook)
allow_unsigned = Keyword.get(config, :allow_unsigned, false)
vendor_bindings = build_vendor_bindings(config)
unsigned_binding = build_unsigned_binding(allow_unsigned)
hook = lifecycle_hook(supervisor)
skills =
kit.skills
|> filter_skills(allow_unsigned)
|> Enum.map(&patch_skill(&1, hook, supervisor, vendor_bindings, unsigned_binding))
metadata =
Map.merge(kit.metadata, %{
tool: __MODULE__,
supervisor: supervisor
})
{:ok, [%{kit | skills: skills, metadata: metadata}]}
end
def input_schema do
%{
"type" => "object",
"properties" => %{
"operation" => %{
"type" => "string",
"enum" => ["register", "update", "unregister", "list"]
},
"prompt" => %{
"type" => "string",
"description" => """
Required for register and update. Plain-English handler brief — what the agent should DO when a delivery arrives. Write it as intent ("alert on disputes", "tag stale PRs"), not as a template. The framework gives the agent payload-reading tools and `send_message` to report back to the user; the brief drives the action, not the reporting.
"""
},
"idempotency" => %{
"type" => "object",
"description" =>
"register only, optional. Keys: key ({header: ...} or {json_path: $.field}) and ttl."
},
"id" => %{
"type" => "string",
"description" => "update / unregister. The webhook id returned at registration."
}
},
"required" => ["operation"]
}
end
# -- skill filtering + binding --------------------------------------------
defp filter_skills(skills, allow_unsigned) do
case allow_unsigned do
true -> skills
false -> Enum.reject(skills, &(&1.name == "webhook:unsigned"))
end
end
defp build_vendor_bindings(config) do
Enum.reduce(@provider_modules, %{}, fn {atom, module}, acc ->
binding = vendor_binding(module, Keyword.get(config, atom))
Map.put(acc, atom, binding)
end)
end
defp vendor_binding(module, nil) do
%{verifier_module: module, secret_key: nil, max_skew: @default_max_skew}
end
defp vendor_binding(module, opts) when is_list(opts) do
secret_key = Keyword.get(opts, :secret_key)
unless is_binary(secret_key) do
raise ArgumentError,
"webhook provider config must include :secret_key as a string, got: #{inspect(opts)}"
end
%{
verifier_module: module,
secret_key: secret_key,
max_skew: Keyword.get(opts, :max_skew, @default_max_skew)
}
end
defp vendor_binding(_module, other) do
raise ArgumentError,
"webhook provider config must be a keyword list, got: #{inspect(other)}"
end
defp build_unsigned_binding(true) do
%{verifier_module: None, secret_key: nil, max_skew: @default_max_skew}
end
defp build_unsigned_binding(false), do: nil
defp lifecycle_hook(supervisor) do
%Hook{
event: :pre_agent,
matcher: nil,
handler: {Lifecycle, %{supervisor: supervisor}}
}
end
defp patch_skill(%{name: name} = skill, hook, supervisor, vendor_bindings, unsigned_binding) do
base = skill_metadata(name, vendor_bindings, unsigned_binding)
extra = Map.put(base, :supervisor, supervisor)
metadata = Map.merge(skill.metadata, extra)
%{skill | hooks: [hook | skill.hooks], metadata: metadata}
end
defp skill_metadata(name, vendor_bindings, unsigned_binding) do
case Map.get(@provider_skill_names, name) do
nil -> skill_metadata_for(name, unsigned_binding)
vendor -> Map.fetch!(vendor_bindings, vendor)
end
end
defp skill_metadata_for("webhook:unsigned", binding) when is_map(binding), do: binding
defp skill_metadata_for(_name, _binding), do: %{}
# -- Tool dispatch -------------------------------------------------------
@impl SkillKit.Tool
def execute(%ToolExecution{input: %{"operation" => op}} = exec) do
dispatch(op, exec)
end
def execute(%ToolExecution{}), do: {:error, "missing required field: operation"}
defp dispatch("register", exec), do: register(exec)
defp dispatch("update", exec), do: update(exec)
defp dispatch("unregister", exec), do: unregister(exec)
defp dispatch("list", exec), do: list(exec)
defp dispatch(op, _exec), do: {:error, "unknown webhook operation: #{inspect(op)}"}
# -- register ------------------------------------------------------------
defp register(%ToolExecution{input: input, context: ctx}) do
case build_webhook(input, ctx) do
{:ok, webhook} -> persist_and_report(webhook, ctx)
{:error, reason} -> {:error, format_error(reason)}
end
end
defp build_webhook(input, ctx) do
with {:ok, prompt} <- require_string(input, "prompt"),
{:ok, verifier} <- resolve_verifier_from_ctx(ctx),
{:ok, idempotency} <- resolve_idempotency(input) do
{:ok,
%Webhook{
id: generate_id(),
agent_name: ctx.agent_name,
prompt: prompt,
verifier: verifier,
idempotency: idempotency,
inserted_at: DateTime.utc_now()
}}
end
end
defp resolve_verifier_from_ctx(%{verifier_module: None}) do
{:ok, {None, %{}}}
end
defp resolve_verifier_from_ctx(%{verifier_module: module, secret_key: secret, max_skew: skew})
when is_atom(module) do
config = %{secret_key: secret, max_skew: skew}
{:ok, {module, config}}
end
defp resolve_verifier_from_ctx(_ctx), do: {:error, {:invalid, "verifier context"}}
defp persist_and_report(%Webhook{} = webhook, ctx) do
case Webhook.register(webhook, supervisor: ctx.supervisor) do
:ok -> {:ok, "Webhook registered. URL: " <> Url.url(webhook)}
{:error, reason} -> {:error, "failed to persist webhook: #{inspect(reason)}"}
end
end
defp require_string(input, key) do
case Map.get(input, key) do
value when is_binary(value) and byte_size(value) > 0 -> {:ok, value}
_ -> {:error, {:missing_field, key}}
end
end
defp resolve_idempotency(input) do
case Map.get(input, "idempotency") do
nil -> {:ok, nil}
%{} = cfg -> normalize_idempotency(cfg)
end
end
defp normalize_idempotency(cfg) do
case Map.get(cfg, "key") do
%{"header" => header} when is_binary(header) ->
{:ok, %{key: %{"header" => header}, ttl: Map.get(cfg, "ttl", 86_400)}}
%{"json_path" => path} when is_binary(path) ->
{:ok, %{key: %{"json_path" => path}, ttl: Map.get(cfg, "ttl", 86_400)}}
_ ->
{:error, {:invalid, "idempotency.key"}}
end
end
defp generate_id do
Base.url_encode64(:crypto.strong_rand_bytes(24), padding: false)
end
defp format_error({:missing_field, field}), do: "missing required field: #{field}"
defp format_error({:invalid, field}), do: "invalid value for field: #{field}"
# -- update (prompt-only) ------------------------------------------------
defp update(%ToolExecution{input: %{"id" => id} = input, context: ctx}) when is_binary(id) do
dispatch_update(Webhook.get(id, supervisor: ctx.supervisor), id, input, ctx)
end
defp update(_exec), do: {:error, "missing required field: id"}
defp dispatch_update({:ok, webhook}, _id, input, ctx) do
case updated_prompt(webhook, input) do
{:ok, prompt} ->
updated = %{webhook | prompt: prompt}
:ok = Webhook.register(updated, supervisor: ctx.supervisor)
{:ok, "Webhook updated. URL: " <> Url.url(updated)}
{:error, reason} ->
{:error, format_error(reason)}
end
end
defp dispatch_update({:error, :not_found}, id, _input, _ctx) do
{:error, "webhook not found: #{id}"}
end
defp updated_prompt(_webhook, input) do
case Map.fetch(input, "prompt") do
:error -> {:error, {:missing_field, "prompt"}}
{:ok, value} when is_binary(value) and byte_size(value) > 0 -> {:ok, value}
_ -> {:error, {:invalid, "prompt"}}
end
end
# -- unregister ----------------------------------------------------------
defp unregister(%ToolExecution{input: %{"id" => id}, context: ctx}) when is_binary(id) do
case Webhook.get(id, supervisor: ctx.supervisor) do
{:ok, %Webhook{}} ->
:ok = Webhook.unregister(id, supervisor: ctx.supervisor)
{:ok, "OK"}
{:error, :not_found} ->
{:ok, "Webhook not found"}
end
end
defp unregister(_exec), do: {:error, "missing required field: id"}
# -- list ----------------------------------------------------------------
defp list(%ToolExecution{context: ctx}) do
{:ok, webhooks} =
Webhook.list(%{agent_name: ctx.agent_name}, supervisor: ctx.supervisor)
encoded =
webhooks
|> Enum.map(&summary/1)
|> Jason.encode!()
{:ok, encoded}
end
defp summary(%Webhook{} = webhook) do
{verifier_mod, _cfg} = webhook.verifier
%{
id: webhook.id,
url: Url.url(webhook),
prompt: webhook.prompt,
verifier: %{type: to_string(verifier_mod)},
inserted_at: DateTime.to_iso8601(webhook.inserted_at)
}
end
end