defmodule Mix.Tasks.SkillKit.Chat do
@moduledoc """
Interactive chat session with a SkillKit agent.
mix skill_kit.chat # select agent interactively
mix skill_kit.chat neve # start specific agent
mix skill_kit.chat researcher
## Webhook dev server
Each chat session starts `SkillKit.Webhook` and an HTTP listener on
`SKILL_KIT_WEBHOOK_PORT` (default 4001). The agent is loaded with
`SkillKit.Tools.Webhook, allow_unsigned: true` so you can register
ad-hoc endpoints without configuring HMAC secrets:
you> register a webhook that echoes the body back
agent> Webhook URL: http://localhost:4001/<id>
$ curl -d "hi" http://localhost:4001/<id>
# agent receives the delivery as a scoped sub-loop and responds
# in the chat.
Signed vendor skills (`webhook:github`, `webhook:stripe`,
`webhook:slack`) are also loaded but will need a host-configured
credential to actually verify inbound requests — see the kit's
moduledoc for wiring.
"""
use Mix.Task
alias Mix.SkillKit.Dotenv
alias SkillKit.AgentRef
alias SkillKit.Event.Delta
alias SkillKit.Event.Error
alias SkillKit.Event.ToolCallComplete
alias SkillKit.Kit.Local, as: LocalKit
alias SkillKit.Types.AssistantMessage
alias SkillKit.Types.ToolResult
@shortdoc "Start an interactive agent chat session"
@default_webhook_port 4001
@impl true
def run(args) do
Dotenv.load()
Mix.Task.run("app.start")
agents_dir = System.get_env("SKILL_KIT_AGENTS", "examples/agents")
webhook_port = webhook_port()
agents = list_kits(agents_dir)
agent_name =
case args do
[name | _] -> name
[] -> select_agent(agents)
end
case Enum.find(agents, &(&1.name == agent_name)) do
nil ->
Mix.shell().error("Agent not found: #{agent_name}")
Mix.shell().error("Available: #{Enum.map_join(agents, ", ", & &1.name)}")
exit({:shutdown, 1})
kit ->
run_agent(kit, agents_dir, webhook_port)
end
end
defp run_agent(kit, agents_dir, webhook_port) do
definition = kit.agent
agent_dir = Path.join(agents_dir, kit.name)
{:ok, webhook_sup} = SkillKit.Webhook.Supervisor.start_link([])
{:ok, http_sup} = start_webhook_server(webhook_port)
configure_webhook_base_url(webhook_port)
{:ok, printer} = Task.start_link(fn -> printer_loop(definition.name) end)
{:ok, agent} =
SkillKit.start_agent(agent_dir,
tools: [{SkillKit.Tools.Shell, []}],
skills: [{SkillKit.Tools.Webhook, [allow_unsigned: true]}],
caller: printer
)
print_banner(definition, webhook_port)
chat_loop(agent, definition.name)
SkillKit.stop_agent(agent)
Process.exit(printer, :shutdown)
Process.exit(http_sup, :shutdown)
Process.exit(webhook_sup, :shutdown)
end
defp webhook_port do
case System.get_env("SKILL_KIT_WEBHOOK_PORT") do
nil ->
@default_webhook_port
raw ->
{port, ""} = Integer.parse(raw)
port
end
end
defp start_webhook_server(port) do
Bandit.start_link(plug: Mix.Tasks.SkillKit.Chat.WebhookHost, port: port, scheme: :http)
end
defp configure_webhook_base_url(port) do
Application.put_env(:skill_kit, :webhook_base_url, "http://localhost:#{port}")
end
defp print_banner(definition, webhook_port) do
IO.puts(
IO.ANSI.format([
:bright,
"\n#{definition.name}",
:reset,
:faint,
" — #{definition.description}"
])
)
IO.puts(
IO.ANSI.format([
:faint,
"webhooks listening on http://localhost:#{webhook_port}",
:reset
])
)
IO.puts(IO.ANSI.format([:faint, "type 'exit' to quit\n"]))
end
defp select_agent([]) do
Mix.shell().error("No agents found.")
exit({:shutdown, 1})
end
defp select_agent([single]), do: single.name
defp select_agent(kits) do
print_agent_menu(kits)
prompt_agent_choice(kits)
end
defp print_agent_menu(kits) do
IO.puts(IO.ANSI.format([:bright, "\nAvailable agents:\n"]))
kits
|> Enum.with_index(1)
|> Enum.each(fn {kit, i} ->
IO.puts(
IO.ANSI.format([
" ",
:bright,
"#{i}",
:reset,
") #{kit.name}",
:faint,
" — #{kit.agent.description}"
])
)
end)
IO.puts("")
end
defp prompt_agent_choice(kits) do
input = String.trim(IO.gets("Select agent: "))
names = Enum.map(kits, & &1.name)
case Integer.parse(input) do
{n, ""} when n >= 1 and n <= length(kits) -> Enum.at(names, n - 1)
_ -> if input in names, do: input, else: List.first(names)
end
end
defp list_kits(agents_dir) do
case LocalKit.list_kits(dir: agents_dir <> "/*") do
{:ok, kits} ->
kits
|> Enum.filter(& &1.agent)
|> Enum.sort_by(& &1.name)
{:error, _} ->
[]
end
end
defp chat_loop(agent, agent_name) do
case IO.gets("you> ") do
:eof ->
:ok
input ->
prompt = String.trim(input)
handle_prompt(prompt, agent, agent_name)
end
end
defp handle_prompt("exit", _agent, _agent_name), do: IO.puts("Goodbye.")
defp handle_prompt("", agent, agent_name), do: chat_loop(agent, agent_name)
defp handle_prompt(prompt, agent, agent_name) do
:ok = SkillKit.send_message(agent, prompt)
chat_loop(agent, agent_name)
end
# Printer Task — receives all agent events and prints as they arrive,
# independent of the chat loop's stdin blocking. Sub-loop handling:
#
# * "<root>/skill:<name>" — activate_skill chatter. Deltas hidden
# (the final text comes back as the parent's tool_result), tool
# calls shown in cyan for visibility.
# * "<root>/delivery:<webhook_id>" — webhook event processing.
# Deltas SHOWN (this is the only visible surface for the sub-loop's
# response; there's no parent tool_result to collapse into), tool
# calls shown in cyan too.
defp printer_loop(root_name) do
receive do
%Delta{agent: agent, text: text} ->
print_delta(AgentRef.origin(agent, root_name), text)
%ToolCallComplete{agent: agent, name: name, input: input} ->
print_tool_call(AgentRef.origin(agent, root_name), name, input)
%ToolResult{agent: agent} = result ->
print_tool_result(AgentRef.origin(agent, root_name), result)
%AssistantMessage{agent: agent} ->
if agent == root_name, do: IO.puts("\n")
%Error{agent: agent, reason: reason} ->
if belongs_to?(agent, root_name) do
IO.puts("\n[error] #{inspect(reason)}\n")
end
_other ->
:ok
end
printer_loop(root_name)
end
defp print_delta(:root, text), do: IO.write(text)
defp print_delta(:delivery, text), do: IO.write(IO.ANSI.format([:cyan, text]))
defp print_delta(_origin, _text), do: :ok
defp belongs_to?(agent_name, root_name) do
agent_name == root_name or String.starts_with?(agent_name, root_name <> "/")
end
defp print_tool_call(:root, name, input) do
IO.puts(IO.ANSI.format([:faint, tool_trace(name, input)]))
end
defp print_tool_call(origin, name, input) when origin in [:skill, :delivery, :subloop] do
IO.puts(IO.ANSI.format([:cyan, tool_trace(name, input)]))
end
defp print_tool_call(:other, _name, _input), do: :ok
defp tool_trace(name, input) do
" ↳ #{name}(#{format_input(input)})"
end
defp format_input(%{"command" => command}), do: command
defp format_input(%{"name" => name}), do: name
defp format_input(input) when map_size(input) == 0, do: ""
defp format_input(input), do: inspect(input, limit: 3)
# Skip printing the final `activate_skill` tool result — the sub-loop
# already streamed that text via Delta events, so printing it again
# (truncated) just looks like a duplicate ghost message.
defp print_tool_result(_origin, %ToolResult{name: "activate_skill", is_error: false}), do: :ok
defp print_tool_result(:root, %ToolResult{is_error: true, content: content}) do
IO.puts(IO.ANSI.format([:red, " ← error: ", :reset, truncate(content)]))
end
defp print_tool_result(:root, %ToolResult{content: content}) do
IO.puts(IO.ANSI.format([:faint, " ← ", truncate(content)]))
end
defp print_tool_result(origin, %ToolResult{is_error: true, content: content})
when origin in [:skill, :delivery, :subloop] do
IO.puts(IO.ANSI.format([:red, " ← error: ", :reset, truncate(content)]))
end
defp print_tool_result(origin, %ToolResult{content: content})
when origin in [:skill, :delivery, :subloop] do
IO.puts(IO.ANSI.format([:cyan, " ← ", truncate(content)]))
end
defp print_tool_result(:other, _result), do: :ok
defp truncate(content) when is_binary(content) do
if String.length(content) > 240, do: String.slice(content, 0, 240) <> "…", else: content
end
defp truncate(content), do: inspect(content, limit: 4)
end
defmodule Mix.Tasks.SkillKit.Chat.WebhookHost do
@moduledoc false
# Top-level Plug for the chat dev server. Reads the raw body into
# `conn.assigns.raw_body` (required by HMAC verifiers) and hands the
# conn to `SkillKit.Webhook.Plug`.
@behaviour Plug
alias SkillKit.Webhook.Plug, as: WebhookPlug
@impl true
def init(_opts), do: []
@impl true
def call(conn, _opts) do
case Plug.Conn.read_body(conn, []) do
{:ok, body, conn} ->
conn
|> Plug.Conn.assign(:raw_body, body)
|> WebhookPlug.call(WebhookPlug.init([]))
{:more, _partial, conn} ->
Plug.Conn.send_resp(conn, 413, "request body too large")
{:error, _reason} ->
Plug.Conn.send_resp(conn, 400, "")
end
end
end