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:

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

  1. On stdio startup, BEAM sends ready with plugin command inventory.
  2. The TypeScript extension registers each plugin command as /elixir:<name>.
  3. Running the slash command sends pi_plugin_command to BEAM and dispatches handle_command/3.
  4. Pi.Plugin.Event.emit/2 sends {type: "event"} back to pi and is published on pi.events.
  5. Before a pi tool executes, the extension calls pi_plugin_tool_call; plugin tool_call/3 may block or return an input-only patch.
  6. After a pi tool result, the extension calls pi_plugin_tool_result; plugin tool_result/3 may patch result content or isError.
  7. 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.