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