Skip to content

joshrotenberg/flotta

Repository files navigation

flotta

A fleet of long-lived claude sessions on one BEAM: named, supervised, observable, and drivable over MCP.

flotta (Italian for "fleet") is a control plane for AI agents, not an orchestrator of them. It creates, names, addresses, prompts, observes, and destroys a flat set of claude instances behind a clean Elixir API and an MCP protocol layer. Workflows, logic, and hierarchy are out of scope by design; they live in the consumer. That boundary is the whole point, and the anti-spiral guard.

The shortest description: a GenServer, but every call is a prompt.

Status: v0.1, pre-release. Every turn is a real, paid claude call (the claude CLI must be installed and authenticated). The engine is feature-complete for its core design. See Status and roadmap.

Contents

Why flotta

The thesis: you can do a surprising amount with just a BEAM node and a pool of duplex claude sessions. No job queue, no agent behaviour to implement, no orchestration framework. flotta is the lean, claude-only realization of that idea:

  • One agent = one process wrapping a persistent claude session. Address it by name; it remembers across turns.
  • Two lifecycles: a long-lived session you converse with, and a single-shot run you fire at one bounded task (optionally inside an isolated git worktree).
  • Mechanism, not policy: flotta hands you the primitives (spawn, prompt, run, observe). When and why to use them is the consumer's job.

The longer game is an AI that genuinely works, not just answers. The pieces that make flotta a substrate for autonomous workers are deliberate: declarative agent config, single-shot runs that act in isolation, a durable record of what each agent did, and an opt-in scheduling layer that turns the fleet into an always-on worker (working hours, recurring routines, runtime steering) without baking when into the core.

Quick start

Requires Elixir ~> 1.20 and an authenticated claude CLI on your PATH.

git clone https://github.com/joshrotenberg/flotta && cd flotta
mix deps.get
iex -S mix
# spawn a named, read-only session and talk to it
Flotta.spawn("scout", cwd: ".")
{:ok, answer} = Flotta.ask("scout", "Read mix.exs and name the deps.")
Flotta.ask("scout", "Which one does the heavy lifting?")   # same session, it remembers

# async: fire and check back later
{:ok, turn_id} = Flotta.prompt("scout", "Summarize the supervision tree.")
Flotta.status("scout")                # %{status: :working | :idle, queued: n, ...}
Flotta.result("scout", turn_id)       # {:ok, %{text: ..., cost_usd: ...}} once done

# a single-shot run (no standing process), optionally write-capable in a worktree
{:ok, result} = Flotta.run(prompt: "List the public modules and one-line each.", cwd: ".")

# fan out to the whole fleet
Flotta.broadcast("in one line, what would you change here?")

# observe what happened
Flotta.reports(since: 0)              # the fleet-wide activity log
Flotta.list()                         # live agents
Flotta.close("scout")

In :dev, booting also stands up a small read-only fleet and an MCP server on http://localhost:4099/mcp (see Declarative fleet and MCP server).

Four shapes, one library

The BEAM lets one codebase be an environment, a library, a framework, and an application at once. flotta is shaped to be used all four ways:

Use it as ... How What you get
an environment git clone + iex -S mix a live fleet to drive interactively, your local AI workbench
a library {:flotta, "~> 0.1"} in another app's mix.exs the Flotta API + supervision tree embedded in your app
a framework run as-is, declare agents in config :flotta a fleet that boots with your release
an application mix flotta.run "..." (a packaged binary is a future option) a one-shot CLI: a task in, the answer on stdout

Same engine, four front doors.

Core concepts

Two lifecycles: sessions and runs

flotta runs agents in two shapes that share one registry, supervisor, permission model, and report log:

  1. Persistent session (Flotta.spawn). A long-lived ClaudeWrapper.DuplexSession you talk to across many turns. The conversation is the substrate; the agent is a supervised GenServer (Flotta.Agent) that wraps the session and adds an async turn lifecycle (a prompt returns a turn_id immediately and the turn runs in the background, queued behind any in-flight turn). Lives until closed or the node stops.

  2. Single-shot run (Flotta.run). Fire an agent at one bounded task: it does the task, returns a structured result, and terminates. Built on ClaudeWrapper.Session (the resume-capable, one-shot conversation), so the result carries session_id and structured_output for free. Optionally isolated in a fresh git worktree (see below). This is the request/response shape, not the conversation shape.

The same %Flotta.Spec{} configures both; a :lifecycle field (:session | :run) selects the shape. Sessions have no worktree isolation, so they default to read-only; write work belongs in a :run.

Permission as data

Tool authority is configured per agent, not hardcoded. A permission spec is one of:

  • :read_only (default) -- allow Read / Glob / Grep, deny the rest
  • :full -- allow every tool (write-capable: Edit / Write / Bash / ...)
  • {:allow, ["Read", "Edit", ...]} -- allow exactly the named tools
  • a 2-arity fun (tool, input) -> decision -- the escape hatch

Flotta.Permission turns the spec into the right thing for each lifecycle: an on_permission callback for a live session (handler/1), or permission_mode / allowed_tools flags for a run (query_opts/1). A live session has no worktree isolation, so write work belongs in a :run. The declarative fleet enforces this (Flotta.Spec.validate/1 rejects a write-capable :session, so a bad config entry is skipped at boot); the raw Flotta.spawn/2 mechanism does not (it does what you ask), so you own the risk if you spawn a write-capable session directly.

Declarative fleet

A %Flotta.Spec{} is the declarative unit: pure, defaulted data that maps onto the keyword lists spawn/run already accept (no new engine behaviour).

%Flotta.Spec{
  name: "scout",
  lifecycle: :session,        # :session (long-lived) | :run (single-shot)
  role: :member,              # :member | :manager -- a capability grant, inert until Layer 4
  model: "sonnet",
  system_prompt: nil,
  append_system_prompt: "You are a read-only scout.",
  cwd: ".",
  permission: :read_only,
  worktree: false,            # run-only; must be false for a :session
  timeout: 180_000
}

Declare a whole fleet in config; boot stands up the :session agents and skips :run templates (firing a paid turn at boot would be scheduling, which flotta keeps external):

# config/config.exs
config :flotta,
  fleet_defaults: %{model: "sonnet", permission: :read_only, cwd: "."},
  agents: [
    %{name: "scout",    append_system_prompt: "Answer questions about this repo, read-only."},
    %{name: "librarian", model: "haiku", append_system_prompt: "Index and summarize."},
    %{name: "issue-runner", lifecycle: :run, worktree: true,
      permission: {:allow, ["Read", "Glob", "Grep", "Edit", "Write", "Bash"]},
      append_system_prompt: "Work one issue in a worktree, run the checks, report."}
  ]

Flotta.Spec.from_config/2 merges each entry over :fleet_defaults (entry wins); validate/1 rejects a bad spec without raising, so one bad entry only logs a warning and never crashes boot. The shipped :dev config stands up read-only scout / librarian / reviewer plus the issue-runner :run template; :test and the published library boot an empty fleet.

API

The Flotta module is the facade over the whole system.

Function What it does
spawn(name, opts) Start a named persistent agent. Opts: :cwd, :model, :permission, :system_prompt, :append_system_prompt, :timeout.
ask(name, text, timeout \\ 180_000) Send a turn and block for the reply text.
prompt(name, text) Send a turn asynchronously; returns {:ok, turn_id} immediately.
run(opts) A single-shot run (:run lifecycle), synchronous. Opts: :prompt, :cwd, :model, :permission, :system_prompt, :append_system_prompt, :worktree, :cleanup, :name.
run_async(opts) Fire a run in the background; returns {:ok, name} immediately. The outcome lands in the report log under name.
run_result(name) An async run's outcome: {:ok, result} / {:error, :running | :not_found | payload}.
worktrees(cwd \\ ".") List the run worktrees under <cwd>/.claude/worktrees/.
gc_worktrees(cwd \\ ".", opts \\ []) Remove clean run worktrees (force: true for dirty too); returns %{removed, kept}. Or run(..., cleanup: true) to drop a run's own worktree on success.
verify(cwd \\ ".", opts) Run :checks (shell commands) against :branch in a throwaway worktree; returns the verdict (passed + per-check ok/exit/output). The deterministic review gate -- real exit codes, not a self-report.
status(name) Queue snapshot: %{status, current, queued, turns}.
result(name, turn_id \\ :latest) Read a completed turn's result, or {:error, :working | :no_result | :not_found}.
transcript(name) Full per-turn history, oldest first.
info(name) One snapshot: pid, session id, liveness, permission, role, queue.
list() Live agents as [{name, pid}].
interrupt(name) Interrupt the in-flight turn (if any).
close(name) Shut an agent down (closing its session) and free its name.
broadcast(text, timeout \\ 180_000) Ask every agent the same prompt, concurrently; returns a per-agent map.
reports(opts \\ []) Tail the fleet-wide report log: since:, agent:, kinds:. Returns {entries, next_cursor}.
findings() The worker's found-work view: every distinct task surfaced across scans, newest first, deduped by title (parsed back out of the report log).
subscribe(pid \\ self()) / unsubscribe/1 Push delivery of {:flotta_report, entry}.

Single-shot runs and worktrees

A run fires one prompt at a fresh ClaudeWrapper.Session and returns a structured result:

{:ok, r} = Flotta.run(prompt: "Read SPEC.md and list the open questions.", cwd: ".")
r.text            # the reply
r.cost_usd        # cost of the turn
r.session_id      # resume handle
r.structured_output  # parsed JSON when a schema was in effect

For write-capable work, set :permission and :worktree. The claude CLI creates a fresh git worktree under <repo>/.claude/worktrees/<name> on a worktree-<name> branch, so the agent's changes land off your working tree:

{:ok, r} = Flotta.run(
  prompt: "Fix the typo in lib/foo.ex and run the tests.",
  permission: {:allow, ["Read", "Edit", "Write", "Bash"]},
  worktree: true
)
r.worktree        # %{path: ".../.claude/worktrees/...", branch: "worktree-..."}

The same is available from the CLI:

mix flotta.run "Read mix.exs and list the deps"
mix flotta.run --permission full --worktree --cwd ../some-repo "Work this task and run the tests"

mix flotta.run prints the reply on stdout and a cost/duration footer on stderr (pipe-clean). Options: --cwd --model --name --permission --allow --worktree --system-prompt --append-system-prompt.

For fire-and-check-later (and to avoid holding an MCP connection open for the whole run), Flotta.run_async/1 returns {:ok, name} immediately and runs under a supervised task; read the outcome with Flotta.run_result(name) (or tail Flotta.reports(agent: name)) once it lands in the report log. The flotta_run MCP tool uses this shape, paired with flotta_run_result to fetch.

MCP server

flotta exposes the fleet as MCP tools over Streamable HTTP (anubis_mcp + Bandit), so any MCP client (Claude Code included) can drive it. It is off by default and enabled by config:

config :flotta, mcp: [port: 4099]   # the shipped :dev config sets this

With the node running (iex -S mix), point an MCP client at http://localhost:4099/mcp. Fourteen tools:

  • Control: flotta_spawn, flotta_run (async: returns a run-name handle, never blocks the connection), flotta_list, flotta_info, flotta_close, flotta_interrupt
  • Interaction: flotta_prompt, flotta_status, flotta_result, flotta_run_result, flotta_transcript, flotta_broadcast
  • Review / observability: flotta_verify (async: run checks against a branch, fetch the verdict via flotta_run_result), flotta_reports

Because flotta also is an MCP server, a claude agent inside the fleet can be pointed at flotta's own endpoint, an agent that manages the fleet. That reflexive shape is a planned layer.

Scheduling layer

The scheduling layer turns the standing fleet into an always-on worker: a node that wakes on working hours, fires recurring work on a cadence, and stays steerable in real time ("keep going", "work this Saturday", "pause"). It is a distinct, opt-in layer in the same shape as the MCP server, off by default, enabled by config, and droppable without touching the core. The engine (Agent / Run / Permission / ReportLog) stays time-agnostic; the scheduler reads config :flotta, :schedule and drives the public API. That is how "scheduling is external" (a SPEC principle) and "the worker schedules itself" coexist: external means a separate layer, never baked into the agent.

It is enabled by a :schedule, with optional :routines:

# config/config.exs
config :flotta,
  schedule: %{
    timezone: {:offset, -7 * 3600},      # :utc | {:offset, secs} | "America/Los_Angeles"
    hours: %{                            # working windows per weekday (wall-clock, local)
      mon: [{~T[08:47:00], ~T[17:00:00]}],
      tue: [{~T[08:47:00], ~T[17:00:00]}]
      # ...
    },
    tick_ms: 60_000,                     # heartbeat granularity
    start: :working                      # :working (schedule-driven) | :paused
  },
  routines: [
    # find actionable work each morning (read-only, deduped via the report log)
    %{name: "morning-scan", action: :scan, every: {:daily_at, ~T[09:00:00]}, focus: "bugs, stale docs, TODOs"},
    # a periodic review sweep
    %{name: "review-sweep", action: :scan, every: {:interval, 6 * 60 * 60 * 1000}},
    # do work: scan, then fire a worktree-isolated write run per task (cost-guarded)
    %{name: "work-loop", action: :dispatch_scan, every: {:interval, 30 * 60 * 1000},
      template: "issue-runner", max_dispatch: 1}
  ]

A routine is the schedule's analogue of a %Flotta.Spec{}: declarative, validated data. Its :action is :scan (find work), :run (a fixed bounded task), :dispatch_scan (scan, then a run per new task), or :prompt (a turn to a standing session). Its :every cadence is {:interval, ms} (every N ms of working time) or {:daily_at, ~T[..]} (once, when the local clock first crosses that time on a working day). As with specs, one bad routine is logged and skipped, never crashing boot.

A :scan records its findings in the report log; Flotta.findings/0 reads them back as a clean, deduplicated task list (newest first), the one-call answer to "what did my worker find?" without hand-parsing the log.

Safety is by construction. Write authority comes only from a named :run template (so write work stays worktree-isolated); an inline write permission without a worktree is rejected, the same guard a write-capable :session gets. On boot each routine's cursor is seeded to "now", so nothing back-fires the instant the node starts. The shipped :dev config runs read-only scan routines and leaves the write-capable work-loop commented out, so iex -S mix never spends on a write you did not ask for.

Find -> file (turning found work into issues). A scan answers "what should be done?"; the next step files it. The :dev config ships an issue-filer :run template (worktree-isolated, Read + Bash) whose prompt surveys for work, checks existing open issues with gh, and opens issues only for genuinely-new items, never closing, editing, or merging anything. A commented-out find-to-file routine fires it on a cadence. This is configuration and prompts over existing primitives, not new engine mechanism: flotta runs the agent, the find / triage / dedup protocol lives in the prompt, and GitHub authority is the gh token's scope. Because it writes real issues it ships disabled; dry-run it first (Flotta.run / flotta_run, report-only) before enabling.

Steer it from iex (or, later, MCP / a mix task) through the Flotta facade, no restart:

Call Effect
Flotta.scheduler_status() Mode, local time, today's windows, active overrides, running routines.
Flotta.keep_going(minutes \\ 60) Extend today's window past its end (self-expiring).
Flotta.work_today() / Flotta.work_on(date, window \\ ...) Add a one-off shift today / on a date.
Flotta.pause() / Flotta.resume() Stop pulling new work (in-flight finishes) / return to schedule.
Flotta.stop_for_today() End the current shift early.
Flotta.run_routine(name) / Flotta.scan_now(opts \\ []) Fire a routine / an ad-hoc scan now, ignoring cadence and hours.

When the scheduler is not enabled, these return {:error, :scheduler_not_enabled} rather than crashing the caller. A single Flotta.Scheduler.Worker process holds the live state (mode, overrides, per-routine cursors) behind a coarse heartbeat; its wake/stop/fire/override transitions are emitted as [:flotta, :scheduler, ...] telemetry and recorded to the report log under "scheduler", so the worker's own behaviour shows up in the same drill-down as the agents'.

Observability

Observability is a cost-ordered drill-down, three views, each more detailed than the last:

  1. Telemetry (is it working?) -- [:flotta, ...] events for metrics. flotta emits; consumers aggregate (e.g. via telemetry_metrics). Events: [:flotta, :session, :spawn | :close], [:flotta, :turn, :start | :stop | :enqueued] (a span around each turn, with duration and, when known, cost_usd), [:flotta, :run, :start | :stop], [:flotta, :report, :posted], and (when the scheduler is enabled) [:flotta, :scheduler, :wake | :stop | :fire | :override]. See Flotta.Telemetry.

  2. Report log (what did this agent do?) -- a fleet-wide, append-only, cursor-read record. A session posts :spawned / :turn_started / :result / :error / :closed as it runs; a single-shot run posts :spawned / :result / :error / :closed (no :turn_started). Read with Flotta.reports(since:, agent:, kinds:), or Flotta.subscribe/1 for push. Backed by an ETS :ordered_set (Flotta.ReportLog).

  3. Transcript (what was said?) -- the full per-turn history of one agent: Flotta.transcript(name).

The report log is the system of record for "fire a task, check in later." It is not a telemetry handler (a raising handler would be auto-detached, silently freezing the record); one internal emit/3 fans out to a reliable in-band log append plus a best-effort telemetry event.

Architecture

flotta is a thin, supervised layer over claude_wrapper. The supervision tree (Flotta.Application):

Flotta.Supervisor (one_for_one)
├── Flotta.Registry           # name -> agent pid (unique)
├── Flotta.ReportLog          # fleet-wide ETS activity log
├── Flotta.SessionSupervisor  # DynamicSupervisor: starts/stops agents
├── (when configured) Flotta.MCP.Server + a Bandit listener on :4099
└── (when configured) Flotta.Scheduler.Supervisor (rest_for_one)
    ├── Flotta.Scheduler.TaskSupervisor  # detached routine fires
    └── Flotta.Scheduler.Worker          # heartbeat + live schedule state
  • Flotta.Agent -- a GenServer wrapping one DuplexSession. Turns the blocking, one-turn-at-a-time send/3 into an async, queued prompt -> turn_id lifecycle with per-turn history.
  • Flotta.Run -- single-shot runs over ClaudeWrapper.Session.
  • Flotta.Permission -- permission-as-data.
  • Flotta.Spec + Flotta.Application boot path -- the declarative fleet.
  • Flotta.ReportLog + Flotta.Telemetry -- observability.
  • Flotta.MCP.* -- the MCP surface.
  • Flotta.Scheduler.* -- the opt-in scheduling layer (Schedule time math, Routine config, Worker heartbeat, Dispatch to the fleet).

claude_wrapper does the hard part underneath: the stream-json duplex protocol over a port, streaming events, mid-turn permission allow/deny, liveness, and the git-worktree CLI flag.

Configuration reference

All under config :flotta:

Key Shape Effect
:mcp [port: integer] or unset Start the MCP server + HTTP listener on port. Unset = no server, no open port.
:fleet_defaults map Defaults merged under every declared agent (entry keys win).
:agents list of maps / %Flotta.Spec{} Declared fleet; :session specs boot, :run specs are validated templates.
:schedule map or unset Enable the scheduling layer: :timezone, :hours, :tick_ms, :start. Unset = no scheduler.
:routines list of maps Recurring work for the scheduler: :name, :action, :every, :template/:agent, :focus, cost guards.

The shipped config gates all of the above to config_env() == :dev, so mix test and the published library boot an empty fleet on no ports and no scheduler.

Design principles

  • Claude only. No backend abstraction; claude_wrapper is the one substrate.
  • Few deps. claude_wrapper, anubis_mcp, telemetry, bandit, stdlib. A new dep must earn its place.
  • Mechanism, not framework. No workflows, logic, or hierarchy in flotta. Those are the consumer's.
  • Scheduling is a separate, opt-in layer. Deciding when to act is kept out of the core: the engine is time-agnostic. A consumer can still drive it externally (cron / calendar / MCP), or enable the built-in Flotta.Scheduler layer, which is itself droppable. Either way, when is never baked into the agent.
  • Observability is a drill-down. telemetry -> report log -> transcript, cheapest glance to fullest detail.

The authoritative design rationale, including the road not taken, lives in SPEC.md.

Status and roadmap

Built and working: the async agent + queue, single-shot runs (sync + async) with git-worktree isolation and GC, permission-as-data, the declarative fleet, the 14-tool MCP surface, the telemetry / report-log / transcript observability stack, the opt-in scheduling layer (working hours, recurring routines, runtime steering), find-work scanning, and the deterministic verify/2 check gate.

The worker loop has been dogfooded end to end against a real, unfamiliar repo: a Rust workspace with a 61-issue backlog. flotta triaged the whole backlog, then implemented and merged six issues (two non-trivial Rust changes, independently cargo-verified), and self-selected its own work with sound, risk-aware judgment. See the writeup on the north-star issue.

What is tracked next lives in GitHub Issues: bounding + sandboxing autonomous runs, a per-project .flotta/ config, cost controls, durable report-log persistence, a fuller mix-task surface, and the reflexive / capability layer.

Development

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

Tests run fully offline against an in-process claude double (no subprocess, no cost); the live session path is exercised by hand from iex. Work happens on a feature branch with a PR; conventional commits, no em dashes.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages