Behaviour and utilities for sidecar applications that extend planck_headless over distributed Erlang.
Behaviour
A sidecar entry-point module implements one required callback:
tools/0— returns[Planck.Agent.Tool.t()]with fullexecute_fnclosures. These run locally on the sidecar node.
Module-level utilities
Planck.Agent.Sidecar itself provides module-level functions that
planck_headless calls on the sidecar node via :rpc.call/5. Because
planck_agent is a dependency of both planck_headless and the sidecar, these
are available on both nodes:
discover/0— finds the module implementing this behaviour (cached in:persistent_termafter the first call).list_tools/0— discovers the entry module and returns its tools as[Planck.AI.Tool.t()](no closures, serialisable across nodes).list_tools/1— same but takes an explicit module; intended for tests.execute_tool/3— discovers the entry module and executes a named tool.execute_tool/4— same but takes an explicit module; intended for tests.list_widgets/0— discovers the entry module and returns the widget modules paired with its tools via each tool's:widgetfield. There is no separatewidgets/0callback; seePlanck.Agent.Widget.list_widgets/1— same but takes an explicit module; intended for tests.widget_render/2— discovers the entry module and renders a widget by id.widget_action/3— discovers the entry module and dispatches an action to a widget by id.widget_container/1— discovers the entry module and returns a widget's declared container type by id (:modalif unset).set_locale/1— caches the current UI locale in:persistent_term, idempotently. Not tied to any i18n library — see below.get_locale/0— returns the cached locale,"en"if never set.
planck_headless calls:
:rpc.call(sidecar_node, Planck.Agent.Sidecar, :list_tools, [])
:rpc.call(sidecar_node, Planck.Agent.Sidecar, :execute_tool,
[tool_name, agent_id, args], timeout)
:rpc.call(sidecar_node, Planck.Agent.Sidecar, :list_widgets, [])
:rpc.call(sidecar_node, Planck.Agent.Sidecar, :widget_render,
[widget_id, myself])
:rpc.call(sidecar_node, Planck.Agent.Sidecar, :widget_action,
[widget_id, action, args])
:rpc.call(sidecar_node, Planck.Agent.Sidecar, :widget_container,
[widget_id])
:rpc.call(sidecar_node, Planck.Agent.Sidecar, :set_locale, [locale])Minimal example
defmodule MySidecar.Planck do
use Planck.Agent.Sidecar
@impl true
def tools do
[
Planck.Agent.Tool.new(
name: "run_tests",
description: "Run the test suite. Pass timeout_ms to override the default.",
parameters: %{
"type" => "object",
"properties" => %{
"timeout_ms" => %{
"type" => "integer",
"description" => "Max milliseconds to wait (default 120000)"
}
}
},
execute_fn: fn _agent_id, _id, args ->
timeout = Map.get(args, "timeout_ms", 120_000)
case System.cmd("mix", ["test"], timeout: timeout) do
{output, 0} -> {:ok, output}
{output, _} -> {:error, output}
end
end
)
]
end
endSee specs/sidecar.md for the full sidecar design, and specs/widgets.md
for the widget mechanism specifically.
Summary
Callbacks
Return the sidecar's tools as Planck.Agent.Tool structs (with execute_fn).
Functions
Convenience macro for implementing the Planck.Agent.Sidecar behaviour.
Discover the module in the current node that implements Planck.Agent.Sidecar.
Discover the entry module and execute a named tool.
Execute a named tool via an explicit sidecar module's tools/0 list.
Returns the cached current UI locale, "en" if set_locale/1 was never
called (e.g. before planck_headless's sidecar connects, or in tests).
Discover the sidecar entry module and return its tools as [Planck.AI.Tool.t()].
Convert module.tools() to [Planck.AI.Tool.t()] — serialisable, no closures.
Discover the sidecar entry module and return the widget modules paired with
its tools via their :widget field.
Return the widget modules paired with an explicit module's tools/0.
Caches the sidecar-wide current UI locale (e.g. "en", "es").
Dispatch an action to a widget by id, via the discovered entry module.
Return a widget's declared container type by id — :modal, :drawer, or
:fullscreen. See Planck.Agent.Widget.container/0. Defaults to
:modal if the widget doesn't implement container/0.
Render a widget by id, via the discovered entry module.
Callbacks
@callback tools() :: [Planck.Agent.Tool.t()]
Return the sidecar's tools as Planck.Agent.Tool structs (with execute_fn).
This is the only required callback. execute_fn closures run locally on the
sidecar node — they are never serialised or called on planck_headless.
Each tool should accept an optional "timeout_ms" argument in its parameter
schema so the AI can hint at how long to wait for the tool call.
Functions
Convenience macro for implementing the Planck.Agent.Sidecar behaviour.
use Planck.Agent.Sidecar injects:
@behaviour Planck.Agent.Sidecar— marks the module as a sidecar entry point.- A default
tools/0returning[]— override this to provide tools.
Usage
defmodule MySidecar.Planck do
use Planck.Agent.Sidecar
@impl true
def tools do
[
Planck.Agent.Tool.new(
name: "run_tests",
description: "Run the test suite.",
parameters: %{"type" => "object", "properties" => %{}},
execute_fn: fn _agent_id, _id, _args ->
{out, 0} = System.cmd("mix", ["test"])
{:ok, out}
end
)
]
end
endThe tools/0 function is the only thing you normally need to override.
list_tools/0, discover/0, execute_tool/3, and execute_tool/4 are
not injected here — they are module-level functions on
Planck.Agent.Sidecar itself that planck_headless calls on the sidecar node:
:rpc.call(node, Planck.Agent.Sidecar, :list_tools, [])
:rpc.call(node, Planck.Agent.Sidecar, :execute_tool,
[tool_name, agent_id, args], timeout)This design keeps the dispatch logic in planck_agent (available on both
nodes) rather than requiring each sidecar module to implement it. No config
is needed — list_tools/0 discovers the entry module automatically via
discover/0.
@spec discover() :: module() | nil
Discover the module in the current node that implements Planck.Agent.Sidecar.
Scans modules across all loaded OTP applications and returns the first one
whose @behaviour attribute includes Planck.Agent.Sidecar, or nil if
none is found. Only Elixir modules (names starting with "Elixir.") are
checked; Erlang modules are skipped.
Successful results are cached in :persistent_term. nil results are not
cached — the next call will retry the scan, which is useful when the sidecar
entry module is loaded after discover/0 is first called.
Called by planck_headless on the sidecar node via list_tools/0. You
normally do not need to call this directly.
Discover the entry module and execute a named tool.
Called by planck_headless on the sidecar node:
:rpc.call(sidecar_node, Planck.Agent.Sidecar, :execute_tool,
[tool_name, agent_id, tool_call_id, args], timeout)The timeout is read from args["timeout_ms"] by the planck_headless RPC
wrapper, not by this function.
@spec execute_tool(module(), String.t(), String.t(), String.t(), map()) :: {:ok, term()} | {:error, term()}
Execute a named tool via an explicit sidecar module's tools/0 list.
Intended for tests. Production code should use execute_tool/3.
@spec get_locale() :: String.t()
Returns the cached current UI locale, "en" if set_locale/1 was never
called (e.g. before planck_headless's sidecar connects, or in tests).
@spec list_tools() :: [Planck.AI.Tool.t()]
Discover the sidecar entry module and return its tools as [Planck.AI.Tool.t()].
Combines discover/0 and list_tools/1. Returns [] if no entry module is
found.
Called by planck_headless on the sidecar node:
:rpc.call(sidecar_node, Planck.Agent.Sidecar, :list_tools, [])
@spec list_tools(module()) :: [Planck.AI.Tool.t()]
Convert module.tools() to [Planck.AI.Tool.t()] — serialisable, no closures.
@spec list_widgets() :: [module()]
Discover the sidecar entry module and return the widget modules paired with
its tools via their :widget field.
Combines discover/0 and list_widgets/1. Returns [] if no entry module
is found. There is no separate widgets/0 callback — a widget is declared
by setting a Planck.Agent.Tool.t()'s :widget field, and this just
filters tools/0 for tools that have one.
Called by planck_headless on the sidecar node:
:rpc.call(sidecar_node, Planck.Agent.Sidecar, :list_widgets, [])
Return the widget modules paired with an explicit module's tools/0.
Intended for tests. Production code should use list_widgets/0.
@spec set_locale(String.t()) :: :ok
Caches the sidecar-wide current UI locale (e.g. "en", "es").
Called by planck_headless (via Planck.Headless.Locale.set/1) on the
sidecar node whenever Planck.Web.Locale.Plug resolves a locale — on
every request, in practice, since the plug has no cheap way to know
whether this call would be a no-op before making it. So this function is
itself idempotent instead: absent, it's set; present and equal, no-op;
present and different, updated. A widget module decides what to do with
the value — this function knows nothing about Gettext or any other i18n
library, only that it's tracking "the current locale" as a plain string.
:rpc.call(sidecar_node, Planck.Agent.Sidecar, :set_locale, [locale])
Dispatch an action to a widget by id, via the discovered entry module.
:rpc.call(sidecar_node, Planck.Agent.Sidecar, :widget_action,
[widget_id, action, args])
@spec widget_container(String.t()) :: {:ok, Planck.Agent.Widget.container()} | {:error, term()}
Return a widget's declared container type by id — :modal, :drawer, or
:fullscreen. See Planck.Agent.Widget.container/0. Defaults to
:modal if the widget doesn't implement container/0.
:rpc.call(sidecar_node, Planck.Agent.Sidecar, :widget_container, [widget_id])
Render a widget by id, via the discovered entry module.
myself is passed through opaquely — see Planck.Agent.Widget.render/1.
:rpc.call(sidecar_node, Planck.Agent.Sidecar, :widget_render,
[widget_id, myself])