pi_bridge
BEAM runtime bridge for pi. It provides the Elixir-side Pi.* modules used by the pi-elixir extension for runtime eval, stdio transport, executable Elixir skills, LLM calls, logical agents, and bidirectional plugin UI events.
Installation
def deps do
[
{:pi_bridge, "~> 0.1", only: :dev}
]
end
pi_bridge is intended for development-time agent tooling.
Public API ergonomics
The public API intentionally separates single-call and orchestration shapes:
Pi.LLM.complete/2andPi.LLM.stream/2are low-level model calls over the active pi session.Pi.Session.start/1creates a server-owned BEAM session process for OTP-backed agent/subagent work.Pi.Agent.run/2returns a single%Pi.Agent.Result{}and is backed byPi.Sessionworkers.Pi.Agent.chain/2,Pi.Agent.parallel/2, andPi.Agent.fanout/2return%Pi.Agent.Run{}so partial results, kind, status, and errors are explicit.Pi.Pluginmodules expose optionalinit/1,handle_event/2,commands/0,handle_command/3,tool_call/3,tool_result/3,apis/0, andshutdown/1; plugin process lifecycle is handled byPi.Plugin.ManagerandPi.Plugin.Supervisor.Pi.Plugin.api/1registers API metadata at compile time and fills a default alias from the module name.Pi.Plugin.command/1registers BEAM plugin commands that the pi extension exposes as/elixir:<name>slash commands.Pi.Plugin.Manager.load/2andunload/1support dynamic plugin lifecycle changes.Pi.Plugin.Waitersprovides an ETS-backed waiter registry for interactive plugins.Pi.Plugin.Event.emit/2publishes BEAM events onto pi's TypeScript extension event bus.Pi.Session.info/1,active_tools/1,append_entry/3, andsend_message/3expose small host-session APIs back to BEAM code.
Boundary JSON examples are documented in docs/protocol.md.
Eval
Pi.Eval.run/2 is the trusted project introspection path. It evaluates inside the project BEAM with project modules and aliases available.
For untrusted snippets, use the Dune-backed sandbox:
{:ok, %{inspected: "42"}} = Pi.Eval.sandbox("40 + 2")
# Negative example: restricted system access is blocked.
{:error, message} = Pi.Eval.sandbox(~s(System.cmd("ls", [])))
The sandbox applies timeout, reduction, heap, and allowlist limits. It returns {:error, :unavailable} if the optional :dune dependency is not present.
LLM
{:ok, text} = Pi.LLM.complete("Explain this module")
stream = Pi.LLM.stream("Draft a migration plan")
Enum.each(stream.stream, &IO.write/1)
ReqLLM can route through the active pi session:
Pi.ReqLLM.install()
ReqLLM.generate_text("pi:current", "Summarize the current project")
ReqLLM may warn that pi:current is not in its public model catalog. That is expected: pi:current is a local provider/model route into the active pi session, not a hosted catalog model.
Sessions and agents
The bridge keeps one pi Node.js/TUI process and one embedded BEAM process. Subagents are not extra pi processes; they are lightweight OTP session workers supervised inside BEAM:
pi Node.js/TUI
└─ embedded BEAM
├─ Pi.LLM.Broker
└─ Pi.Session.Supervisor
├─ Pi.Session.Worker
└─ Pi.Session.Worker
Use Pi.Session when you need attachable, subscribable session state:
{:ok, root} = Pi.Session.start(name: :root)
{:ok, reviewer} = Pi.Session.child(root, name: :reviewer)
{:ok, "done"} = Pi.Session.run(reviewer, "Review this change")
{:ok, state} = Pi.Session.subscribe(reviewer)
Session snapshots are emitted as pi_session events. The extension renders active/running work as a compact live widget, then emits completed root session trees as inline transcript entries. The extension persists the latest snapshot set into pi custom entries (elixir-sessions) and reloads active BEAM snapshots on session start. Private slash commands control active sessions without adding model-facing tools. The TUI accepts either id=session_123 or the raw session_123 as the command argument:
/elixir:sessions.cancel id=session_123
/elixir:sessions.rerun id=session_123
Snapshots carry renderer-neutral semantic fields such as prompt/response previews, current activity, recent streaming output, run_count, completed_at, and timing. Streaming session runs can emit :delta events before the final assistant message:
{:ok, text} = Pi.Session.run(session, "Draft notes", stream: true)
Use Pi.Agent for convenience orchestration over those sessions:
{:ok, result} = Pi.Agent.run("Review this change", name: :reviewer)
{:ok, run} =
Pi.Agent.chain([
"Draft an implementation plan",
"Review the plan for risks"
])
{:ok, fanout} = Pi.Agent.fanout(["Review tests", "Review API", "Review docs"])
Pi.Agent.run/2 keeps the single-run shape {:ok, %Pi.Agent.Result{}} | {:error, %Pi.Agent.Result{}}. chain/2, parallel/2, and fanout/2 return {:ok, %Pi.Agent.Run{}} | {:error, %Pi.Agent.Run{}} so orchestration metadata and partial results are explicit.
Plugin command/event/hook lifecycle
- On stdio startup, BEAM sends
readywith plugin command inventory. - The TypeScript extension registers each plugin command as
/elixir:<name>. - Running the slash command sends
pi_plugin_commandto BEAM and dispatcheshandle_command/3. Pi.Plugin.Event.emit/2sends{type: "event"}back to pi and is published onpi.events.- Before a pi tool executes, the extension calls
pi_plugin_tool_call; plugintool_call/3may block or return an input-only patch. - After a pi tool result, the extension calls
pi_plugin_tool_result; plugintool_result/3may patch resultcontentorisError. - Malformed hook payloads are rejected before plugin callbacks run.
Session bridge APIs
BEAM code can ask the pi extension for small session-state snapshots, persist branch-aware custom entries, or emit a visible custom transcript message:
{:ok, info} = Pi.Session.info()
{:ok, %{tools: tools}} = Pi.Session.active_tools()
{:ok, "ok"} = Pi.Session.append_entry("demo-state", count: 1)
{:ok, "ok"} = Pi.Session.send_message("demo-message", count: 1)
Plugins
Project-local plugins live in priv/pi_plugins, .pi/plugins, or pi_plugins. Each plugin is isolated behind a Pi.Plugin.Worker process.
defmodule DemoPiPlugin do
use Pi.Plugin
def init(_opts), do: {:ok, %{events: 0}}
def handle_event(_event, state), do: {:noreply, Map.update(state, :events, 1, &(&1 + 1))}
command name: :demo, description: "Run the demo plugin command"
def handle_command(:demo, args, state), do: {{:ok, "demo #{args}"}, state}
# Negative example: block a tool call.
# Return {:block, reason} to prevent a tool call, or {:ok, patch} to merge into the tool input only.
def tool_call(%{"toolName" => "bash"}, _context, state), do: {{:block, "bash blocked"}, state}
def tool_call(_call, _context, state), do: {:ok, state}
# Return {:ok, patch} to patch a tool result. Supported TypeScript-side patches include
# string `content` and boolean `isError`.
def tool_result(%{"toolName" => "demo"}, _context, state) do
{{:ok, %{"content" => "patched by plugin"}}, state}
end
def tool_result(_result, _context, state), do: {:ok, state}
def apis do
[name: :demo_plugin, module: __MODULE__, alias: :DemoPlugin]
end
end
Examples
See examples/vibe_workflow.exs and examples/demo_plugin.exs.