rho

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.

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):

Seam Default On a phone
:data_dir: sessions, artifacts, todo lists, cost ledger, usage, settings, model catalog MOB_DATA_DIR, else the OS's per-user data dir MOB_DATA_DIR (mob sets it)
:credential_store (Rho.CredentialStore: get/put/delete of a binary by key) Rho.CredentialStore.File, a 0600 file per key the 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 sources the app's tools; agent-written ones through a source
:system_prompt, :default_model a short generic prompt; anthropic:claude-haiku-4-5 the 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 (t: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 client allow all; no cap; pi's limits; Rho.LLM.ReqLLM
:dyn (Rho.Dyn.Config): the Dyn layer and its seams, below off on, 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):

Seam Default Operator
:enabled (or Rho.Supervisor's :dyn): start Rho.Dyn.Keeper and the approval store, offer the dyn_* tools off on
:namespace: the modules the agent writes Dyn Operator.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 call Rho.Tool its file, TFLite and terminal modules
:check: the static check's ban, allow and gate lists Operator'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 / revert Rho.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 runs none mob_deliver's probation
:guide: the host's rules in Rho.Dyn.agent_guide/0 none its file roots, menu rows
Rho.Dyn.Seed: default sources the host ships, merged with the user's on each new version none its 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:

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.