An agent core for Elixir apps, on a computer or on a phone: the turn loop, sessions, context compaction, tools, and model sign-in, on req_llm.

  • Loop (Rho.Loop): a GenServer per session with pi-agent-core's lifecycle: streamed replies, the reply's tool calls in parallel with results in call order, steering and follow-ups while it runs, stop, retries with backoff, rate limits waited out within a budget then a fallback model, a per-day cost cap, a wrap-up turn at the iteration limit, and a reminder when the session's todo list still has open items.
  • Sessions (Rho.Session): omp/pi's JSONL session format (version 3), so a session moves between omp on a computer and a rho app on a phone.
  • Compaction (Rho.Compaction): pi's local summary method, before a call that would overflow the window and after a provider rejects one.
  • Tools (Rho.Tool, Rho.ToolRunner, Rho.ToolRegistry): plain modules; each call in its own task with a timeout and a gate, oversized output spilled to an artifact the agent pages through.
  • Models (Rho.LLM, Rho.LLM.ReqLLM, Rho.Models, Rho.Usage): Anthropic, OpenAI, OpenAI Codex and Google, with subscription usage windows and per-model counters.
  • Auth (Rho.Auth, Rho.Auth.Login): Claude Pro/Max and ChatGPT (Codex) subscriptions through omp's OAuth PKCE flows, with refresh coordination and refresh-token rotation; or an API key for Anthropic, OpenAI or Gemini.
  • Dyn (Rho.Dyn, optional): tools and screens the agent writes, compiles and runs on the device, one approved generation at a time, on probation and reverted when they crash.

Lineage

The loop, session format, compaction and sign-in flows are ports of pi (pi-agent-core, pi-ai, pi-coding-agent) as oh-my-pi (omp) runs them, first written in Elixir for Operator, an omp-style agent that runs on the phone. rho is that core taken out of Operator so other apps (Operator itself, and Bezalel, a Bible-study app) can share it. Comments name the pi source each piece follows.

Install

def deps do
  [{:rho, "~> 0.1"}]
end

A minimal host

rho has no OTP application callback (mob apps start their code by hand), so the host starts Rho.Supervisor in its own tree:

defmodule MyApp.Tools.Weather do
  @behaviour Rho.Tool

  @impl true
  def name, do: "weather"
  @impl true
  def description, do: "Today's weather for a city."
  @impl true
  def parameter_schema do
    %{
      "type" => "object",
      "properties" => %{"city" => %{"type" => "string"}},
      "required" => ["city"]
    }
  end

  @impl true
  def run(%{"city" => city}, _ctx), do: {:ok, "Sunny in #{city}."}
end

children = [
  {Rho.Supervisor, tools: [MyApp.Tools.Weather | Rho.ToolRegistry.builtin_tools()]}
]

Supervisor.start_link(children, strategy: :one_for_one)

:ok = Rho.Auth.put_api_key(:google, System.fetch_env!("GEMINI_API_KEY"))
{:ok, loop} = Rho.start_loop(model: "google:gemini-2.5-flash", system_prompt: "Be brief.")
:ok = Rho.Loop.subscribe(loop)
:ok = Rho.Loop.prompt(loop, "Weather in Lisbon?")

# The subscriber receives the run's events (`Rho.Events` lists them all):
# {:rho, session_id, %{type: :message_update, kind: :text, delta: "..."}} ...
# {:rho, session_id, %{type: :agent_end, reason: :done}}

A subscription sign-in instead: Rho.Auth.Login.begin(:anthropic) opens the provider's page (the callback comes back to a listener on localhost), then {:rho_login, :anthropic, :ok} arrives.

What a host provides

Every coupling to the app around it is a behaviour or a config :rho key, each with a default that works on a computer (Rho.Config lists them all):

SeamDefaultOn a phone
:data_dir: sessions, artifacts, todo lists, cost ledger, usage, settings, model catalogMOB_DATA_DIR, else the OS's per-user data dirMOB_DATA_DIR (mob sets it)
:credential_store (Rho.CredentialStore: get/put/delete of a binary by key)Rho.CredentialStore.File, a 0600 file per keythe Keystore / Keychain plugin (MobSecureStore fits as it is)
:settings (Rho.Settings: fallback model, daily cost cap)Rho.Settings.File (settings.json)the app's own settings, or the default
:tools, :tool_sources (Rho.ToolRegistry.Source: tool sets that change at run time)Rho.Tools.ReadArtifact, Rho.Tools.Todo; no sourcesthe app's tools; agent-written ones through a source
:system_prompt, :default_modela short generic prompt; anthropic:claude-haiku-4-5the app's
:open_url (the sign-in page)open / xdg-open / start{Mob.Device, :open_url, []}
:namespace (customType of the loop's notices in a session)"rho"
:app_name, :hints (where the user signs in or switches model, in messages; how they approve a Dyn generation, in the agent's Dyn text)"the app", "the app's settings", "on the phone with the screen lock"
Attachments (Rho.Session.attachment/0: pictures, text, PDFs with a user message)none: the host builds them from its pickers and camera
Loop options (Rho.Loop.start_link/1): gate before each tool call, budget, guards, model clientallow all; no cap; pi's limits; Rho.LLM.ReqLLM
:dyn (Rho.Dyn.Config): the Dyn layer and its seams, belowoffon, with mob screens

Rho.Supervisor also points llm_db at a model catalog trimmed to Anthropic, OpenAI and Google (Rho.LLMCatalog), on every platform: a phone doesn't ship llm_db's packaged one, and the trimmed one loads fast. A host that uses llm_db (or req_llm) for other providers starts it with llm_catalog: false.

Dyn: code the agent writes on the device

Rho.Dyn lets the agent change the app it runs in without a release: it writes Elixir tools (and, in a UI app, screens) in a staging copy with the dyn_* tools, and dyn_propose statically checks, compiles and selftests them as a new generation. Nothing runs until the human approves it with the screen lock; then it is on probation: 3 crashes in 60 s, or a launch that dies before it is stable, revert it to its parent, and two failed launches in a row start the next one in safe mode (no Dyn code loaded), so the fixed Core can always repair it. Generation n compiles the agent's Dyn.Foo as Dyn.G<n>.Foo, next to the running one rather than over it; old generations unload only once nothing runs their code. Activations and reverts change the loop's tools from its next model call (Rho.Dyn.Source, a Rho.ToolRegistry.Source).

It is off unless the host turns it on:

# config/config.exs
config :rho, :dyn,
  enabled: true,
  namespace: MyApp.Dyn,
  app: :my_app,
  core: [MyApp],
  allowed: [MyApp.Files],
  screens: Rho.Dyn.Screens.Mob

# the host's boot, once Rho.Supervisor is up (it starts the Keeper):
Rho.Dyn.boot()

Its seams (Rho.Dyn.Config documents each):

SeamDefaultOperator
:enabled (or Rho.Supervisor's :dyn): start Rho.Dyn.Keeper and the approval store, offer the dyn_* toolsoffon
:namespace: the modules the agent writesDynOperator.Dyn
:app, :core: the Core's modules (a new Core rebuilds every generation; Dyn code may not call them)rho's:operator, [Operator]
:allowed: Core modules Dyn code may callRho.Toolits file, TFLite and terminal modules
:check: the static check's ban, allow and gate listsOperator's (Rho.Dyn.Check.defaults/0)the defaults, plus its own file module's message and caller-side functions
:screens (Rho.Dyn.Screens): what a screen is, its selftest, the first-frame hook, app lifecycle, "is it on screen yet"Rho.Dyn.Screens.None (no screens)Rho.Dyn.Screens.Mob plus its front
:approval (Rho.Dyn.Approval): tokens for activate / revertRho.Dyn.Approval.Biometric (the host's UI confirms after the screen-lock prompt)the same, fed by its approve chip
:on_stable, Rho.Dyn.boot/1's core: / core_rolled_back:: a launch reaching stable, the Core update it runsnonemob_deliver's probation
:guide: the host's rules in Rho.Dyn.agent_guide/0noneits file roots, menu rows
Rho.Dyn.Seed: default sources the host ships, merged with the user's on each new versionnoneits default front

The default check keeps Dyn code to Mob's Elixir modules: Mob's native layer :mob_nif is banned (banned_erlang, and as a value in forging, so it can't be held in a variable and called), and the host's :gated and :allowed lists say which of Mob's modules Dyn code may use. A host whose Dyn code must call :mob_nif drops both: check: [banned_erlang: %{mob_nif: nil}, forging: %{mob_nif: nil}].

Threat model. The check is a best-effort deny-list against the model's mistakes (a built tool that crashes, hangs, or calls something it shouldn't by accident), not a sandbox against deliberately hostile code. Known ways around it: atoms or funs handed in as data, raw messages to OTP processes (a report_cb sent to :logger_proxy), modules the lists don't name (Logger.App, Logger.Backends.*, LoggerBackends), starting a child under a trusted supervisor or :alarm_handler, and CPU, memory or message floods; Rho.Dyn.Check keeps the list. A host with stronger needs must isolate Dyn code itself (another node or OS process).

Within that model, rho keeps a built tool's failures out of the processes that run the app (the loop, the Keeper, the logger). Like the check, these are best effort:

  • Crashes are formatted where they happen (Rho.Untrusted): in the tool's task, the selftest or the compile, by a helper with a time and heap bound that dies with its caller. The loop, the Keeper and a proposal's caller only describe the reasons they receive, without calling an exception's message/1 or a protocol (Inspect, String.Chars, Jason.Encoder) a Dyn struct implements; integers past 1024 bits print as a placeholder. Crash events' reason is a string.
  • Answers become plain data: a Dyn tool's result and details are turned into plain terms (structs as maps) in its own task.
  • Declarations are read once: a tool's name, description, schema, timeout and concurrency are read in one bounded throwaway process and kept. A tool whose declaration fails is left out; one that ran over is read again after 1, 5 and 30 minutes, then stays out.
  • Logs are described (Rho.Dyn.LogGuard, installed by the Keeper): a log event that holds a Dyn struct is turned into text before Elixir's translator sees it, so a Dyn process's crash report runs none of its code in :logger_proxy. It looks at most 10,000 terms; a bigger event passes through unchanged.
  • Host processes: a host watches its own Dyn processes (a built screen, say) with Rho.Dyn.watch/2 and Rho.Dyn.unwatch/1; one that exits {:rho_crash, text} (Rho.Untrusted.crash/3) is reported with text.

Known limit: without LogGuard, a Dyn exception whose message/1 fails can make OTP drop Elixir's logger translator VM-wide. LogGuard keeps that call out of the translator, except for an event too big to check (more than 10,000 terms ahead of the Dyn term) or after Logger is restarted, which removes the filter.

On a phone, Dyn needs the Elixir compiler at run time: the app's build must ship :elixir's compiler modules and :compiler, so a slim build that strips them can't use Dyn.

Development

mix deps.get
mix format --check-formatted
mix credo --strict
mix compile --warnings-as-errors
mix test

Tests never reach a network: the model client is a scripted fake, and the OAuth and usage endpoints are answered in-process.

License

MIT, see LICENSE, which also carries pi's copyright notice for the ported parts.