Agents at scale

A "swarm" is one move repeated: split a task into many pieces, run an agent on each piece at the same time, then synthesize. The intelligence is in the pieces; the hard engineering is usually the orchestration — a job queue, a worker pool, a result bus, backpressure, retries. On the Nexus that machinery is the BEAM's day job, so the whole thing is a few lines in a server block, and "five agents" and "five hundred agents" are the same code.

This lesson builds a research swarm — the distilled core of our own Search Swarm — and shows where it runs and what each agent is allowed to touch.

Where it runs

This is a server unit, so it runs on the nexus, on the BEAM. That placement is the whole reason it scales: the BEAM runs thousands of lightweight processes, so fanning out hundreds of concurrent agents needs no pool to size and no queue to configure — Task.async over a list is the scheduler.

The shape

Three steps: one LLM call decomposes the task, one agent runs per sub-question concurrently, one call folds the findings into an answer.

server :swarm do
  def run(task) do
    task
    |> plan()                                  # 1 LLM call → N focused sub-questions
    |> Enum.map(fn q -> Task.async(fn -> worker(q) end) end)
    |> Task.await_many(:infinity)              # every agent runs in parallel
    |> synthesize(task)                        # fold all findings into one answer
  end
end

The list's length is the swarm's size. For an ad-hoc fan-out the pipeline and Enum.map are the control flow; a flow block is the declarative option when the steps are named and reusable.

The worker: grounded, and capability-scoped

Each agent searches, pulls real pages, and answers only from what it read. Its powers are host capabilities, not raw access: llm (model completion) and browse (the runtime's headless browser) are brokered seams — the browser is SSRF-gated, so a worker can't be steered into the internal network, and every model call can be metered.

  defp plan(task) do
    {:ok, %{content: c}} =
      Nexus.Llm.complete([%{role: "user", content:
        "Break this into 8 distinct, focused sub-questions, one per line:\n#{task}"}], [])

    c |> String.split("\n") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == ""))
  end

  defp worker(question) do
    # one web-grounded search returns candidate source URLs…
    {:ok, %{annotations: cites}} =
      Nexus.Llm.complete([%{role: "user", content: question}], model: "…:online")

    # …pull the top pages with the nexus browser and answer from their text
    pages =
      cites
      |> Enum.take(3)
      |> Enum.flat_map(fn c -> case Nexus.Browse.read(c["url"]) do
                                 {:ok, p} -> [p.text]
                                 _ -> []
                               end end)

    {:ok, %{content: finding}} =
      Nexus.Llm.complete([%{role: "user", content:
        "Answer from these pages only, and cite them:\n#{question}\n\n#{Enum.join(pages, "\n\n")}"}], [])

    %{question: question, finding: finding}
  end

Nexus.Llm.complete (with :online for web search) and Nexus.Browse.read are the host primitives — the worker just calls them. The annotations are the real URLs the model cited; the agent then reads those pages itself before writing a word.

Synthesize — and pay the real bill

Because every model call goes through one seam, you can ask the provider for the actual cost and surface it — credits charged, not an estimate:

  defp synthesize(findings, task) do
    notes = Enum.map_join(findings, "\n\n", fn f -> "## #{f.question}\n#{f.finding}" end)

    {:ok, %{content: report, usage: u}} =
      Nexus.Llm.complete([%{role: "user", content:
        "Write one cited report answering: #{task}\n\nFindings:\n#{notes}"}], usage: %{include: true})

    %{report: report, cost_usd: u["cost"]}
  end
end

Why it stays small

Swap the worker for whatever the job is — a coding agent, an experiment — and the same fan-out runs it at scale.