OTP-based LLM agent.
Each agent is a GenServer that drives the LLM loop:
stream a response → collect tool calls → execute them concurrently →
append results → re-stream until the model stops.
Roles
An agent's role is derived from its tool list at start time:
- Orchestrator — has a tool named
"spawn_agent"in its list. Owns ateam_id; all agents sharing thatteam_idare terminated when this agent exits. - Worker — no
"spawn_agent"tool. Receives tasks and reports back.
Events
Subscribers receive {:agent_event, type, payload} messages:
| Event | Payload keys |
|---|---|
:turn_start | index |
:turn_end | message, usage |
:text_delta | text |
:thinking_delta | text |
:usage_delta | delta (input_tokens, output_tokens, cost), total (input_tokens, output_tokens, cost), context_tokens |
:tool_start | id, name, args |
:tool_end | id, name, result, error |
:worker_spawned | — |
:worker_exit | pid, reason |
:error | reason |
:message_cancelled | id |
Example
{:ok, pid} = DynamicSupervisor.start_child(
Planck.Agent.AgentSupervisor,
{Planck.Agent,
id: "agent-1",
model: model,
system_prompt: "You are helpful.",
tools: [read_tool]}
)
Planck.Agent.subscribe(pid)
Planck.Agent.prompt(pid, "What is in lib/app.ex?")
Summary
Types
A reference to a running agent — pid, registered name, or via-tuple.
Internal GenServer state for an agent.
Functions
Cancel in-flight streaming and tool execution. Blocks until the agent has
returned to :idle (or started a follow-up turn for any queued messages).
Add a tool at runtime.
Cancel a message still queued (unpersisted) in the agent's in-memory message list.
Replace the model used for subsequent LLM turns without interrupting the current state.
Insert a summary checkpoint into the agent's conversation.
Delete all messages in the session and reset the agent's in-memory history.
Dispatch a custom slash command to the agent.
Force a compaction pass on demand, bypassing the compactor's own trigger heuristic.
Estimate the number of tokens currently in the agent's context window —
system prompt, tool schemas, and conversation, not just the conversation.
Reflects the request built for the most recently started LLM call (see
state.context.context_tokens's own doc), not a fresh recomputation.
Lightweight summary: id, name, description, type, role, status, turn_index, usage.
Synchronous state snapshot.
Appends an assistant message containing a {:tool_call, id, name, %{}} content
part followed by a :tool_result message with result. Both are persisted.
No new turn is triggered.
Load a skill into the agent's context.
Trigger the agent to run an LLM turn without adding a new user message.
Send a user message and kick off the agent loop. Returns once the agent status is :streaming.
Remove a tool by name at runtime.
Truncate the session to strictly before message_id, then reload the
agent's in-memory message history from the DB (the source of truth).
turn_checkpoints is rebuilt from the reloaded message list.
Start an agent under a supervisor.
Stop the agent. Cancels any in-flight work and removes it from the supervisor.
Subscribe the calling process to {:agent_event, type, payload} messages.
Resolve an agent id to its pid, anywhere in the connected distributed
Erlang cluster — not just the calling node's own Registry.
Types
A reference to a running agent — pid, registered name, or via-tuple.
@type t() :: %Planck.Agent{ available_models: [Planck.AI.Model.t()], context: Planck.Agent.Context.t(), hooks: Planck.Agent.Hooks.t(), identity: Planck.Agent.Identity.t(), turn: Planck.Agent.Turn.t() }
Internal GenServer state for an agent.
Identity fields (grouped in identity):
id— unique agent identifiername/description/type— display metadata set at start timeteam_id— registry namespace shared by all agents in the same teamteam_name— stable team alias (directory name);"default"for dynamic teamssession_id— SQLite session this agent persists messages to;nilfor ephemeral agentsdelegator_id— id of the orchestrator that spawned this worker;nilfor orchestratorsrole—:orchestrator(hasspawn_agenttool) or:workermodel— thePlanck.AI.Modelthe agent is configured to use
Context fields:
system_prompt— prepended to every LLM contextcwd— working directory for the session; used to locateAGENTS.mdmessages— full in-memory conversation history (Message.t()list)tools— map of tool name →Tool.t()available to this agentskills— frozen skill index for system prompt building and tool dispatchusage— accumulated token counts and cost for this sessioncontext_tokens— estimated size (system prompt + messages + tool schemas) of the request built for the most recently started LLM call; seePlanck.AI.Context.estimate_tokens/1opts— pass-through keyword options (e.g.tool_timeout)
Turn fields (grouped in turn):
status—:idle,:streaming, or:executing_toolsindex/checkpoints— monotonically increasing turn counter and checkpoint stackstream_task/stream_ref— in-flight async LLM streamstream_start— length ofcontext.messageswhen the current stream began; used to detect messages appended during streaming that the LLM did not seebuffer_text/buffer_thinking/buffer_calls— accumulated deltas during streamingrunning/results/loop_counts— in-flight tool tasks, accumulated results, loop detection
Hook fields (grouped in hooks):
compactor— resolved module atom for context compaction;niluses the built-in LLM-based compactorpersistence— resolved module atom for conversation persistence;niluses the built-in SQLite-backed storeprompt— resolved module atom for per-turn system prompt injection (prepend/append);nilmeans no injectionturn_end— resolved module atom called after every turn ends;nilmeans no post-turn reflectionsidecar_node— connected sidecar node; shared by all hook dispatch callsavailable_models— model catalog used bylist_modelsandspawn_agent
Functions
@spec abort(agent()) :: :ok
Cancel in-flight streaming and tool execution. Blocks until the agent has
returned to :idle (or started a follow-up turn for any queued messages).
@spec add_tool(agent(), Planck.Agent.Tool.t()) :: :ok
Add a tool at runtime.
@spec cancel_queued(agent(), String.t() | non_neg_integer()) :: :ok | {:error, :not_found} | {:error, :already_sent}
Cancel a message still queued (unpersisted) in the agent's in-memory message list.
Covers queued user messages, custom-command messages, and the
{:custom, :clear} / {:custom, :compact} primitive markers. Returns
{:error, :already_sent} if the message has already been flushed to the
session (its id is now a db id, not the agent's string id), or
{:error, :not_found} if no message with that id is in the list. No
broadcast — the caller removes its own UI entry on :ok.
@spec change_model(agent(), Planck.AI.Model.t()) :: :ok
Replace the model used for subsequent LLM turns without interrupting the current state.
Insert a summary checkpoint into the agent's conversation.
Builds a {:custom, :summary} message with summary_text, persists it,
and appends it to in-memory history. The agent's next LLM call will only
see the checkpoint and any messages after it via messages_since_last_summary.
Works regardless of the agent's current status.
@spec clear(agent()) :: :ok
Delete all messages in the session and reset the agent's in-memory history.
The session process itself stays alive and its metadata is preserved. No LLM call is made. If the agent is busy, the clear is queued and executed at the next turn boundary, before any queued user messages.
@spec command(agent(), Planck.Agent.Command.t(), String.t() | nil) :: :ok | {:error, :already_sent}
Dispatch a custom slash command to the agent.
Enqueues a {:custom, :command} message carrying command_meta and the
rendered command body. When the agent is idle, a new turn starts
immediately; when busy, the message stacks in state.context.messages (broadcast
as :message_queued with role: :command) and starts a turn at the next
turn boundary.
Force a compaction pass on demand, bypassing the compactor's own trigger heuristic.
args is a map that may contain :prompt — a user-supplied string (from
/compact [prompt]) that can steer the summarization. When the agent is
idle, compaction runs immediately. When busy, it is queued and runs at
the next turn boundary, before any queued user message starts a new turn.
Returns :ok immediately — the actual compaction runs in a
handle_continue so the caller's GenServer.call doesn't timeout while
the delegate agent summarises (which can take tens of seconds). Progress
is reported via :compacting / :compacted PubSub events.
@spec estimate_tokens(agent()) :: non_neg_integer()
Estimate the number of tokens currently in the agent's context window —
system prompt, tool schemas, and conversation, not just the conversation.
Reflects the request built for the most recently started LLM call (see
state.context.context_tokens's own doc), not a fresh recomputation.
Lightweight summary: id, name, description, type, role, status, turn_index, usage.
Synchronous state snapshot.
Appends an assistant message containing a {:tool_call, id, name, %{}} content
part followed by a :tool_result message with result. Both are persisted.
No new turn is triggered.
The name does not need to be in the agent's callable tool list — it appears
only as a history entry. Used by the sidecar SkillReflector to signal skill
creation/update back to the parent agent so the LLM and UI see it passively.
@spec load_skill(agent(), Planck.Agent.Skill.t(), String.t() | nil) :: :ok
Load a skill into the agent's context.
Reads the skill's SKILL.md, enqueues a {:custom, :skill} message
carrying the Skill.t() struct and the rendered content in metadata.
If user_text is non-empty, it is enqueued as a :user message after
the skill message. When the agent is idle a turn starts immediately;
when busy both messages stack and run at the next turn boundary.
User-initiated skill loading does not record usage — only the
autonomous load_skill tool path records via on_skill_use.
Replaces the previous inject_tool_result + prompt pair used for
slash-command skill loading. The LLM sees the skill content as a
:user message (via Message.to_ai_messages/1).
@spec nudge(agent()) :: :ok
Trigger the agent to run an LLM turn without adding a new user message.
Used after session resume when a recovery context message is already present in the agent's history and just needs to be acted upon.
@spec prompt(agent(), content, keyword()) :: :ok | {:error, :already_sent} when content: String.t() | [Planck.AI.Message.content_part()]
Send a user message and kick off the agent loop. Returns once the agent status is :streaming.
Pass edit: id to instead replace the text of a message with that id —
only succeeds if it's still the last, unpersisted message queued while
the agent was busy; fails with {:error, :already_sent} once it's been
flushed to the session (the caller decides how to fall back, e.g. the
normal rewind-based edit, once the message has a real db id).
Remove a tool by name at runtime.
@spec rewind_to_message(agent(), pos_integer() | String.t()) :: :ok
Truncate the session to strictly before message_id, then reload the
agent's in-memory message history from the DB (the source of truth).
turn_checkpoints is rebuilt from the reloaded message list.
Only meaningful for agents with a session_id. A no-op for ephemeral agents.
@spec start_link(keyword()) :: GenServer.on_start()
Start an agent under a supervisor.
@spec stop(agent()) :: :ok
Stop the agent. Cancels any in-flight work and removes it from the supervisor.
Subscribe the calling process to {:agent_event, type, payload} messages.
Accepts either an agent id string or a pid/name. The pid form resolves the id
via get_info/1 — prefer passing the id directly when available.
Resolve an agent id to its pid, anywhere in the connected distributed
Erlang cluster — not just the calling node's own Registry.
Checks the calling node's own Registry first (fast, no RPC — and the
only thing that can find it when the caller and the agent share a node,
which every call site originally assumed). Falls back to asking every
node in Node.list/0, in order, stopping at the first hit.
The fallback matters for code that runs on a different node than
planck_agent's own processes — concretely, sidecar tools and hooks
(Sidecar.Tools.Beads, Sidecar.Tools.UpdateMemory,
Sidecar.SkillReflector.Runner), which execute on the sidecar node while
every real Planck.Agent GenServer runs on the connected
planck_headless node. A local-only lookup there always returned
{:error, :not_found} regardless of whether the agent was actually
running — Registry is per-node and does not replicate across a
distributed Erlang connection, confirmed by this failing in exactly that
shape in real (non-test) use. Every one of those call sites' own unit
tests passed anyway, because a unit test starts a real Planck.Agent in
the same test process/node — the one topology where the gap was never
going to show up.
Headless-side callers (inter-agent tools, planck_cli's session/event
code) always target a same-node agent, so the fallback is a no-op there
in the success case; on a genuine miss it costs one extra, fast RPC round
trip to the connected sidecar (which never has a matching entry) before
returning {:error, :not_found} — a small, bounded cost worth paying to
have one function that's simply always correct, rather than a second,
easy-to-forget name for callers that happen to run elsewhere.