defmodule SkillKit do
@moduledoc """
SkillKit — an Elixir framework for building LLM agent systems.
This module is the public API for starting agents, sending messages,
and receiving streamed responses.
## Quick Start
{:ok, agent} = SkillKit.start_agent("agents/my-agent",
skills: ["skills"],
caller: self()
)
:ok = SkillKit.send_message(agent, "Hello")
receive do
%SkillKit.Event.Delta{text: text} -> IO.write(text)
%SkillKit.Types.AssistantMessage{content: text} -> IO.puts("Done.")
%SkillKit.Event.Error{reason: reason} -> IO.puts("Error")
end
SkillKit.stop_agent(agent)
## Events
The caller process receives structs directly:
* `%SkillKit.Event.Delta{agent: name, text: text}` — real-time text fragment
* `%SkillKit.Event.ToolCallStart{agent: name, id: id, name: name}` — tool call began
* `%SkillKit.Event.ToolCallComplete{agent: name, id: id, name: name, input: input}` — tool call parsed
* `%SkillKit.Types.AssistantMessage{agent: name, content: text}` — complete response at turn end
* `%SkillKit.Types.ToolResult{agent: name, content: content}` — tool result
* `%SkillKit.Event.InputRequested{agent: name, tool_call_id: id}` — tool suspended, needs input
* `%SkillKit.Event.Error{agent: name, reason: reason}` — LLM or execution error
## Configuration
config :skill_kit, SkillKit.LLM,
providers: [
anthropic: SkillKit.LLM.Anthropic
],
default_provider: :anthropic
"""
alias SkillKit.Agent
alias SkillKit.AgentRef
alias SkillKit.Event.Error, as: EventError
alias SkillKit.Runtime
alias SkillKit.Types.AssistantMessage
alias SkillKit.Types.UserMessage
@type agent :: AgentRef.t()
@valid_opts [:tools, :skills, :runtime, :scope, :conversation_store, :caller, :name]
@doc """
Starts a new agent.
The first argument identifies the agent. It accepts:
- `"path"` — string path, resolved as `{Kit.Local, dir: "path"}`
- `MyModule` — bare module, resolved as `{MyModule, []}`
- `{module, opts}` — a kit provider tuple
When the agent is loaded from a kit provider, the kit's skills and
sub-agents are automatically included in the tool pool.
Returns `{:ok, agent_ref}` where `agent_ref` is an opaque reference
used with `send_message/2` and `stop_agent/1`.
## Options
* `:tools` — list of tool providers (default: `[]`). Tools are always
available to the LLM — called directly, no activation needed.
Examples: `[{SkillKit.Tools.Shell, cwd: "."}]`.
* `:skills` — list of skill providers (default: `[]`). Skills appear
only in `activate_skill`'s enum. When the LLM activates a skill,
a child agent is forked with the skill's underlying tool module
added to its `:tools` list, so the child can execute it directly.
* `:runtime` — `{module, config}` for agent spawning (default: `{Runtime.Local, []}`)
* `:scope` — authorization scope (default: `nil`)
* `:conversation_store` — `{module, config}` for persistence (default: `nil`)
* `:caller` — pid for events (default: `self()`)
* `:name` — override agent name
"""
@spec start_agent(Agent.t() | String.t() | {module(), keyword()} | module()) ::
{:ok, agent()} | {:error, term()}
def start_agent(source) do
start_agent(source, [])
end
@spec start_agent(Agent.t() | String.t() | {module(), keyword()} | module(), keyword()) ::
{:ok, agent()} | {:error, term()}
def start_agent(source, opts) do
source
|> resolve_and_build(opts)
|> assign_caller()
|> Runtime.start_agent()
end
defp resolve_and_build(source, opts) do
Keyword.validate!(opts, @valid_opts)
agent = resolve_agent(source)
tools = normalize_providers(Keyword.get(opts, :tools, []))
skills = normalize_providers(Keyword.get(opts, :skills, []))
agent_provider = agent_as_provider(source)
all_skills = merge_agent_provider(agent_provider, skills)
%{
agent
| name: Keyword.get(opts, :name, agent.name),
tools: tools,
skills: all_skills,
runtime: Keyword.get(opts, :runtime, agent.runtime),
scope: Keyword.get(opts, :scope, agent.scope),
conversation_store: Keyword.get(opts, :conversation_store, agent.conversation_store),
caller: Keyword.get(opts, :caller),
registry: :"skill_kit_registry_#{:erlang.unique_integer([:positive])}"
}
end
defp assign_caller(%{caller: nil} = agent) do
%{agent | caller: self()}
end
defp assign_caller(agent), do: agent
# -------------------------------------------------------------------
# Agent resolution
# -------------------------------------------------------------------
defp resolve_agent(%Agent{} = definition), do: definition
defp resolve_agent(path) when is_binary(path) do
resolve_agent({SkillKit.Kit.Local, dir: path})
end
defp resolve_agent(module) when is_atom(module) do
resolve_agent({module, []})
end
defp resolve_agent({module, config}) do
case module.load_kits(config) do
{:ok, kits} ->
kits
|> Enum.map(& &1.agent)
|> Enum.find(& &1) ||
raise "No agent (AGENT.md) found in agent: provider #{inspect(module)}"
{:error, reason} ->
raise "Failed to load agent from #{inspect(module)}: #{inspect(reason)}"
end
end
# -------------------------------------------------------------------
# Skills normalization (string/module/tuple sugar)
# -------------------------------------------------------------------
defp normalize_providers(providers) do
Enum.map(providers, &normalize_provider_entry/1)
end
defp normalize_provider_entry(path) when is_binary(path), do: {SkillKit.Kit.Local, dir: path}
defp normalize_provider_entry(module) when is_atom(module), do: {module, []}
defp normalize_provider_entry({module, config}), do: {module, config}
# -------------------------------------------------------------------
# Auto-include agent kit's tools
# -------------------------------------------------------------------
defp agent_as_provider(%Agent{}), do: nil
defp agent_as_provider(path) when is_binary(path), do: {SkillKit.Kit.Local, dir: path}
defp agent_as_provider(module) when is_atom(module), do: {module, []}
defp agent_as_provider({module, config}), do: {module, config}
defp merge_agent_provider(nil, skills), do: skills
defp merge_agent_provider(provider, skills), do: [provider | skills]
@doc """
Sends a user message to the agent referenced by `agent`.
Returns `:ok` if the message was delivered, or `{:error, :not_found}`
if the agent's mailbox process cannot be found.
"""
@spec send_message(agent(), String.t()) :: :ok | {:error, :not_found}
def send_message(%AgentRef{} = agent, content) when is_binary(content) do
message = %UserMessage{content: content}
try do
case Registry.lookup(agent.registry, {agent.name, :mailbox}) do
[{pid, _}] ->
GenServer.cast(pid, {:message, message})
:ok
[] ->
{:error, :not_found}
end
rescue
ArgumentError -> {:error, :not_found}
end
end
@doc """
Dispatches a discrete event to the agent for processing as a bounded task.
Events are processed in an isolated sub-loop: the content is treated as
a user message, the sub-loop runs with a scoped tool set and a
configurable initial message history, and intermediate tool calls,
reasoning, and sub-agent events stay inside the sub-loop. The parent
agent's `state.messages` is NOT mutated by the cast itself.
Surfacing back to the main conversation is opt-in via the `send_message`
tool. Every event sub-loop is injected with `SkillKit.Tools.SendMessage`
bound to the parent agent; if the sub-loop's LLM calls it, that
delivers a `UserMessage` to the parent's mailbox, which then runs a
normal turn and produces an assistant response visible in the main
conversation. If the sub-loop finishes without calling `send_message`,
the event is handled silently and nothing reaches the main conversation.
This contrasts with `send_message/2`, which adds to the ongoing
conversation and produces regular assistant turns with all intermediate
steps visible.
Primary caller today is `SkillKit.Webhook.Inbox.Memory.put/2`, which
emits webhook deliveries to their bound agent with a scoped tool set
(webhook config tool stripped, `webhook_inbox` injected). Future callers
include cron-like schedulers, admin-initiated runs, and any other
event-driven trigger that wants isolated processing.
## Options
* `:system_append` (string, required) — appended to the parent agent's
system prompt for the duration of the sub-loop
* `:initial_messages` (`:empty | :forked | [msg]`, default `:empty`) —
the message history the sub-loop starts with; `:forked` copies the
parent's history (dropping a trailing `activate_skill` tool use),
`:empty` starts fresh
* `:tools_add` (list of `{module, context}`) — extra tools injected
into the sub-loop
* `:tools_remove` (list of modules) — parent tools stripped from the
sub-loop. Does not apply to the auto-injected `send_message` tool.
* `:skills_remove_prefix` (string) — skill namespace prefix to hide from
`activate_skill` during the sub-loop (e.g. `"webhook:"`)
* `:allow_activate_skill` (boolean, default `false`) — whether the
`activate_skill` meta-tool is exposed in the sub-loop
* `:sub_agent_name` (string, required) — tag applied to events forwarded
to the parent's caller so chat printers can attribute them
Returns `:ok` if the cast was delivered, or `{:error, :not_found}` if
the agent's server process cannot be found.
"""
@spec send_event(agent(), String.t(), keyword()) :: :ok | {:error, :not_found}
def send_event(%AgentRef{} = agent, content, opts)
when is_binary(content) and is_list(opts) do
case Registry.lookup(agent.registry, {agent.name, :server}) do
[{pid, _}] ->
GenServer.cast(pid, {:process_event, content, opts})
:ok
[] ->
{:error, :not_found}
end
rescue
ArgumentError -> {:error, :not_found}
end
@doc """
Responds to a suspended tool call with input.
When a tool returns `{:pending, state}`, the caller receives an
`%Event.InputRequested{}` event. Call `respond/3` with the
`tool_call_id` from the event and the answer to resume execution.
"""
@spec respond(agent(), String.t(), any()) :: :ok | {:error, :not_found}
def respond(%AgentRef{} = agent, tool_call_id, answer) do
case Registry.lookup(agent.registry, {agent.name, :pending_tool, tool_call_id}) do
[{pid, _}] ->
send(pid, {:resume, answer})
:ok
[] ->
{:error, :not_found}
end
rescue
ArgumentError -> {:error, :not_found}
end
@doc """
Sends a message and blocks until the agent responds.
Returns `{:ok, text}` on success, `{:error, reason}` on LLM error,
or `{:error, :timeout}` if the turn doesn't complete within `timeout` ms.
Must be called from the process registered as `:caller` in `start_agent/2`.
Intermediate events (`:delta`, `:tool_call`, `:tool_result`) remain in
the caller's mailbox and are not consumed.
## Examples
{:ok, "Hello!"} = SkillKit.send_message_sync(agent, "Hi")
{:error, :timeout} = SkillKit.send_message_sync(agent, "Hi", 100)
"""
@spec send_message_sync(agent(), String.t(), timeout()) ::
{:ok, AssistantMessage.t()} | {:error, term()}
def send_message_sync(%AgentRef{} = agent, content, timeout \\ 5000) do
case send_message(agent, content) do
:ok -> await_response(agent.name, timeout)
{:error, reason} -> {:error, reason}
end
end
defp await_response(agent_name, timeout) do
receive do
%AssistantMessage{agent: ^agent_name} = msg -> {:ok, msg}
%EventError{agent: ^agent_name, reason: reason} -> {:error, reason}
after
timeout -> {:error, :timeout}
end
end
@doc """
Stops a running agent and all its child processes.
"""
@spec stop_agent(agent()) :: :ok
def stop_agent(%AgentRef{supervisor_pid: pid}) do
Supervisor.stop(pid)
end
end