diff --git a/brain-core/brain_index.py b/brain-core/brain_index.py
index 55cd6a4..010b99c 100644
--- a/brain-core/brain_index.py
+++ b/brain-core/brain_index.py
@@ -168,11 +168,39 @@ def ingest_chat(conn: sqlite3.Connection, path: Path | None = None) -> int:
return count
+def ingest_orchestration(conn: sqlite3.Connection, root: Path | None = None) -> int:
+ """Index multi-agent orchestration runs (my delegate_task fan-outs) so
+ past 'spin up N agents on a goal' runs are searchable in the brain."""
+ root = root or (BASE_DIR / "data" / "orchestration-runs")
+ if not root.exists():
+ return 0
+ count = 0
+ for f in sorted(root.glob("*.json")):
+ try:
+ d = json.loads(f.read_text(encoding="utf-8", errors="replace"))
+ except (json.JSONDecodeError, OSError):
+ continue
+ goal = d.get("goal", "")
+ synthesis = d.get("synthesis", "") or ""
+ angles = d.get("angles", {}) or {}
+ angle_blob = "\n".join(f"{a}: {p}" for a, p in angles.items())
+ agents = ", ".join(d.get("agents", []) or [])
+ content = f"GOAL: {goal}\n\nANGLES ({agents}):\n{angle_blob}\n\nSYNTHESIS:\n{synthesis}"
+ rel = str(f.relative_to(BASE_DIR))
+ upsert_doc(conn, source="orchestration", source_path=rel,
+ title=f"Orchestration: {goal[:80]}", content=content,
+ agent=d.get("converge_agent") or "orchestrate",
+ updated_at=d.get("timestamp"))
+ count += 1
+ return count
+
+
def ingest_all(conn: sqlite3.Connection) -> dict:
return {
"brain": ingest_brain(conn),
"skill-learning": ingest_skill_learnings(conn),
"chat": ingest_chat(conn),
+ "orchestration": ingest_orchestration(conn),
}
diff --git a/dashboard/api.js b/dashboard/api.js
index a25f498..bde966b 100644
--- a/dashboard/api.js
+++ b/dashboard/api.js
@@ -97,6 +97,9 @@ const api = {
getBrainIndexSearch: (q, source = '', limit = 20) => api.get(`/api/brain-index/search?q=${encodeURIComponent(q)}&limit=${limit}${source ? `&source=${encodeURIComponent(source)}` : ''}`),
getBrainIndexStats: () => api.get('/api/brain-index/stats'),
brainIndexIngest: () => api.post('/api/brain-index/ingest', {}),
+ // Multi-Agent Orchestration (fan-out + converge)
+ orchestrate: (payload) => api.post('/api/orchestrate', payload),
+ getOrchestrationRuns: (limit = 50) => api.get(`/api/orchestrate/runs?limit=${limit}`),
// Agent Registry
getAgents: () => api.get('/api/agents'),
registerAgent: (data) => api.post('/api/agents/register', data),
diff --git a/dashboard/index.html b/dashboard/index.html
index d126e21..0688684 100644
--- a/dashboard/index.html
+++ b/dashboard/index.html
@@ -51,6 +51,7 @@
πLearning Analytics
π§ Agent Insights
πBrain Search
+ πΈMulti-Agent Run
πSession Replay
β±Agent Time
diff --git a/dashboard/pages/orchestration.js b/dashboard/pages/orchestration.js
new file mode 100644
index 0000000..0f91e11
--- /dev/null
+++ b/dashboard/pages/orchestration.js
@@ -0,0 +1,92 @@
+// Multi-Agent Orchestration β fan-out N agents on one goal, then converge.
+
+async function renderOrchestration() {
+ const content = document.getElementById('pageContent');
+ content.innerHTML = `
+
+
+
+ Past runs
+
+ `;
+ await orchLoadRuns();
+}
+
+async function orchLoadRuns() {
+ const box = document.getElementById('orchRuns');
+ try {
+ const d = await api.getOrchestrationRuns(30);
+ if (!d.runs.length) { box.innerHTML = ''; return; }
+ box.innerHTML = d.runs.map(r => `
+
+
+ ${escapeHtml(r.goal || '(no goal)')}
+ ${escapeHtml((r.agents||[]).join(' + '))}
+ β ${escapeHtml(r.converge_agent||'')}
+
+
${escapeHtml(r.run_id||'')} Β· ${escapeHtml(r.timestamp||'')}
+
`).join('');
+ } catch (e) {
+ box.innerHTML = ``;
+ }
+}
+
+async function orchRun() {
+ const goal = document.getElementById('orchGoal').value.trim();
+ const btn = document.getElementById('orchRunBtn');
+ if (!goal) { alert('Enter a project goal first.'); return; }
+ const angles = {};
+ const oc = document.getElementById('orchOpencode').value.trim();
+ const gm = document.getElementById('orchGemini').value.trim();
+ const hm = document.getElementById('orchHermes').value.trim();
+ if (oc) angles.opencode = oc;
+ if (gm) angles.gemini = gm;
+ if (hm) angles.hermes = hm;
+ btn.disabled = true; btn.textContent = 'Running agentsβ¦';
+ const res = document.getElementById('orchResult');
+ res.innerHTML = 'Fanning out agents & converging⦠';
+ try {
+ const data = await api.orchestrate({ goal, angles: Object.keys(angles).length ? angles : undefined });
+ const cards = (data.outputs && Object.entries(data.outputs) || [])
+ .map(([a, o]) => `
+
+
${escapeHtml(a)}angle output
+
${escapeHtml((o||'').slice(0,2000))}
+
`).join('');
+ res.innerHTML = `
+
+
β
Synthesis (via ${escapeHtml(data.converge_agent||'')})
+
${escapeHtml(data.synthesis || '(no synthesis)')}
+
run_id: ${escapeHtml(data.run_id||'')} Β· saved to ${escapeHtml(data.run_file||'')}
+
+ Show per-agent angle outputs
+ ${cards}
+ `;
+ await orchLoadRuns();
+ } catch (e) {
+ res.innerHTML = `β
Orchestration failed
${escapeHtml(e.message)}
`;
+ } finally {
+ btn.disabled = false; btn.textContent = 'π Launch agents';
+ }
+}
diff --git a/dashboard/utils.js b/dashboard/utils.js
index 6a59491..9f27845 100644
--- a/dashboard/utils.js
+++ b/dashboard/utils.js
@@ -119,6 +119,7 @@ const PAGE_TITLES = {
cost: { title: 'Cost Analytics', breadcrumb: 'Usage & spending' },
'agent-insights': { title: 'Agent Insights', breadcrumb: 'AI usage analytics' },
'brain-search': { title: 'Brain Search', breadcrumb: 'Unified knowledge index' },
+ 'orchestration': { title: 'Multi-Agent Run', breadcrumb: 'Fan-out + converge agents' },
plugins: { title: 'Plugin Registry', breadcrumb: 'Manage plugins' },
backups: { title: 'Backups', breadcrumb: 'Disaster recovery' },
prompts: { title: 'Prompt Library', breadcrumb: 'Reusable templates' },
diff --git a/server.py b/server.py
index 2a2b833..463b485 100644
--- a/server.py
+++ b/server.py
@@ -20,6 +20,7 @@ import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
+from concurrent.futures import ThreadPoolExecutor
from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
@@ -92,6 +93,13 @@ class SkillRunRequest(BaseModel):
input: Optional[str] = ""
agent: Optional[str] = "auto"
+class OrchestrateRequest(BaseModel):
+ goal: str
+ angles: Optional[dict] = None # {agent: custom_angle_prompt}
+ agents: Optional[list] = None # override default [opencode, gemini, hermes]
+ converge_agent: Optional[str] = "hermes"
+ converge_prompt: Optional[str] = None # custom synthesis instruction
+
class SkillCreate(BaseModel):
name: str
skill_md: str = ""
@@ -915,6 +923,164 @@ def run_skill(name: str, req: Optional[SkillRunRequest] = None):
"message": f"Skill '{name}' completed via {agent_choice}",
}
+
+# βββ Routes: Multi-Agent Orchestration (fan-out + converge) βββββββββ
+ORCHESTRATION_DIR = BASE_DIR / "data" / "orchestration-runs"
+
+def _run_angle(agent: str, angle_prompt: str):
+ """Run a single agent on its angle. Returns (agent, output, error)."""
+ try:
+ out = execute_agent(agent, angle_prompt)
+ return agent, out, None
+ except Exception as e:
+ return agent, f"β Error from {agent}: {e}", str(e)
+
+@app.post("/api/orchestrate")
+def orchestrate_multi_agent(req: OrchestrateRequest):
+ """Spin up multiple agents on the same project goal from different angles,
+ then converge their outputs into a single synthesis.
+
+ Default angles (one per built-in agent):
+ - opencode -> implementation plan / code for the goal
+ - gemini -> research, analysis, risk/feasibility
+ - hermes -> coordination, memory, integration plan
+ A convergence step feeds all angle outputs to `converge_agent`
+ (default hermes) to produce the unified result.
+ """
+ if not req.goal or not req.goal.strip():
+ raise HTTPException(422, "goal is required")
+
+ agents = req.agents or ["opencode", "gemini", "hermes"]
+ # Validate agents exist in the registry
+ registry = load_agent_registry()
+ unknown = [a for a in agents if a not in registry]
+ if unknown:
+ raise HTTPException(400, f"unknown agent(s): {unknown}")
+
+ # Default angle prompts per role
+ default_angles = {
+ "opencode": (
+ f"PROJECT GOAL: {req.goal}\n\n"
+ "You are the IMPLEMENTATION agent. Produce a concrete implementation "
+ "approach: architecture, files to create/modify, key functions, and a "
+ "step-by-step build plan. Be specific and actionable. Do not do the "
+ "other agents' jobs β focus on engineering."
+ ),
+ "gemini": (
+ f"PROJECT GOAL: {req.goal}\n\n"
+ "You are the RESEARCH / ANALYSIS agent. Investigate the problem space: "
+ "relevant approaches, trade-offs, risks, feasibility, and any external "
+ "facts or references. Do not write implementation code β focus on "
+ "analysis and evidence."
+ ),
+ "hermes": (
+ f"PROJECT GOAL: {req.goal}\n\n"
+ "You are the COORDINATION / MEMORY agent. Define how the work should be "
+ "sequenced, what shared state/memory is needed, how the implementation "
+ "and research results integrate, and any scheduling/coordination plan. "
+ "Focus on orchestration, not on coding or research yourself."
+ ),
+ }
+ angle_prompts = {}
+ for a in agents:
+ if req.angles and a in req.angles and req.angles[a]:
+ angle_prompts[a] = req.angles[a]
+ else:
+ angle_prompts[a] = default_angles.get(a, f"PROJECT GOAL: {req.goal}\n\nAddress this goal from your perspective as {a}.")
+
+ # Fan-out: run all angles in parallel
+ outputs = {}
+ with ThreadPoolExecutor(max_workers=len(agents)) as ex:
+ futures = {ex.submit(_run_angle, a, angle_prompts[a]): a for a in agents}
+ for fut in futures:
+ a = futures[fut]
+ _ag, out, _err = fut.result()
+ outputs[a] = out
+
+ # Converge
+ converge = req.converge_agent or "hermes"
+ if converge not in registry:
+ converge = agents[0]
+ angle_block = "\n\n".join(
+ f"### {a.upper()} ANGLE OUTPUT:\n{outputs[a]}" for a in agents
+ )
+ synthesis_prompt = req.converge_prompt or (
+ f"PROJECT GOAL: {req.goal}\n\n"
+ "You are the CONVERGENCE agent. Below are the outputs of multiple agents "
+ "who each tackled the same project goal from a different angle. Synthesize "
+ "them into ONE coherent plan/result:\n"
+ "- Resolve contradictions between angles.\n"
+ "- Produce a unified, prioritized action plan (or final answer).\n"
+ "- Call out open risks and the single next step.\n\n"
+ f"{angle_block}"
+ )
+ synthesis, conv_err = _run_angle(converge, synthesis_prompt)
+
+ # Persist the run
+ run_id = uuid.uuid4().hex[:12]
+ timestamp = get_timestamp()
+ run_record = {
+ "run_id": run_id,
+ "timestamp": timestamp,
+ "goal": req.goal,
+ "agents": agents,
+ "angles": angle_prompts,
+ "outputs": outputs,
+ "converge_agent": converge,
+ "synthesis": synthesis,
+ "converge_error": conv_err,
+ }
+ ORCHESTRATION_DIR.mkdir(parents=True, exist_ok=True)
+ run_file = ORCHESTRATION_DIR / f"{run_id}.json"
+ write_json(run_file, run_record)
+
+ # Make the run searchable in the brain
+ try:
+ bi = _brain_index_module()
+ conn = bi.get_conn()
+ bi.upsert_doc(
+ conn, source="agent-note",
+ source_path=f"data/orchestration-runs/{run_id}.json",
+ title=f"Multi-agent run: {req.goal[:80]}",
+ content=f"GOAL: {req.goal}\n\nSYNTHESIS:\n{synthesis[:2000]}",
+ agent=f"orchestrate/{converge}", updated_at=timestamp,
+ )
+ conn.commit(); conn.close()
+ except Exception as e:
+ print(f"[orchestrate] brain upsert failed: {e}")
+
+ return {
+ "status": "completed",
+ "run_id": run_id,
+ "goal": req.goal,
+ "agents": agents,
+ "outputs": outputs,
+ "converge_agent": converge,
+ "synthesis": synthesis,
+ "converge_error": conv_err,
+ "run_file": str(run_file.relative_to(BASE_DIR)),
+ }
+
+
+@app.get("/api/orchestrate/runs")
+def list_orchestration_runs(limit: int = 50):
+ ORCHESTRATION_DIR.mkdir(parents=True, exist_ok=True)
+ files = sorted(ORCHESTRATION_DIR.glob("*.json"), reverse=True)[:limit]
+ runs = []
+ for f in files:
+ try:
+ d = read_json(f, default={})
+ runs.append({
+ "run_id": d.get("run_id"),
+ "timestamp": d.get("timestamp"),
+ "goal": d.get("goal"),
+ "agents": d.get("agents"),
+ "converge_agent": d.get("converge_agent"),
+ })
+ except Exception:
+ continue
+ return {"runs": runs}
+
@app.get("/api/skills/{name}/eval")
def get_skill_eval(name: str):
path = resolve_skill_dir(name) / "score-history.json"
diff --git a/skills/multi-agent-run/SKILL.md b/skills/multi-agent-run/SKILL.md
new file mode 100644
index 0000000..9bc3c96
--- /dev/null
+++ b/skills/multi-agent-run/SKILL.md
@@ -0,0 +1,60 @@
+---
+name: multi-agent-run
+description: Spin up multiple agents (opencode, Gemini, Hermes) on the same project goal from different angles, then converge their outputs into one synthesis. Use for parallel multi-perspective problem solving, design, or planning on a shared project.
+version: 1.0.0
+---
+
+# Multi-Agent Run β Fan-out + Converge
+
+Orchestrate the 3 built-in agents on ONE project goal, each from its own angle,
+then merge their outputs into a single coherent result.
+
+## When to use
+- A project goal benefits from multiple perspectives at once (implementation +
+ research + coordination).
+- You want a synthesized plan that reconciles engineering, analysis, and ops.
+- You want the run persisted and searchable in the brain for later review.
+
+## How it works (backend)
+`POST /api/orchestrate`:
+1. **Fan-out** β runs each agent in parallel on its angle:
+ - `opencode` β implementation approach / build plan
+ - `gemini` β research, analysis, risks, feasibility
+ - `hermes` β coordination, sequencing, integration, memory
+2. **Converge** β feeds all angle outputs to `converge_agent` (default `hermes`)
+ to produce a unified, prioritized action plan (or final answer).
+3. **Persist** β writes `data/orchestration-runs/.json` and upserts a
+ searchable doc into the brain.
+
+## Request shape
+```
+{
+ "goal": "Build a real-time sync layer between the dashboard and the brain index",
+ "angles": { // optional per-agent override
+ "opencode": "Focus on the websocket protocol and schema migration",
+ "gemini": "Survey existing real-time sync patterns and their failure modes"
+ },
+ "agents": ["opencode", "gemini", "hermes"], // optional override
+ "converge_agent": "hermes", // optional
+ "converge_prompt": "..." // optional custom synthesis
+}
+```
+
+## Response
+```
+{
+ "status": "completed",
+ "run_id": "...",
+ "outputs": { "opencode": "...", "gemini": "...", "hermes": "..." },
+ "synthesis": "...", // the converged result
+ "run_file": "data/orchestration-runs/.json"
+}
+```
+
+## Notes / caveats
+- Agents run in parallel, so a run takes ~1 agent-round-trip (not 3x).
+- If an angle agent errors, its output is captured as an error string and the
+ convergence step still proceeds (best-effort, never aborts the whole run).
+- Review past runs via `GET /api/orchestrate/runs` or the dashboard Orchestration page.
+- The converged synthesis is auto-upserted to the brain (source: agent-note) so
+ you can later find it with Brain Search.
diff --git a/skills/multi-angle-orchestration/SKILL.md b/skills/multi-angle-orchestration/SKILL.md
new file mode 100644
index 0000000..f76bc49
--- /dev/null
+++ b/skills/multi-angle-orchestration/SKILL.md
@@ -0,0 +1,66 @@
+---
+name: multi-angle-orchestration
+description: Spin up 5+ parallel subagents (delegate_task leaf workers, all on this same Hermes session β NO external/separate AIs) on one project goal from different angles, then converge. Single-source orchestration for fast deployment.
+version: 1.0.0
+---
+
+# Multi-Angle Orchestration (single-source, fast)
+
+When the user gives an end goal and says "call up some subagents," this is the
+playbook. All work is done by THIS Hermes session's own `delegate_task`
+subagents β there is NO opencode / gemini / hermes CLI invocation, no external
+agent, no second AI. Fast deployment: parallel fan-out, then converge.
+
+## Hard rules
+- **β₯5 subagents, always.** Decompose the goal into at least 5 distinct angles.
+- **All same source.** Every subagent is a leaf worker on this session's model.
+ Never call execute_agent / the 3 CLI agents / a separate AI.
+- **Parallel.** Launch in one background batch where possible. The tool runs
+ up to 3 concurrently for this user, so issue 5+ as: first batch of 3, then a
+ second batch of the remaining (still effectively parallel, fast).
+- **No human-in-the-loop inside workers.** Subagents can't clarify; give them
+ complete context up front.
+- **I converge.** When all subagents return, I synthesize their outputs into
+ one result (no separate synthesizer agent needed unless the goal is huge).
+
+## The flow
+1. **Decompose** the goal into β₯5 angles. Good angle variety (pick per goal):
+ - Implementer (what to build / change)
+ - Researcher / evidence (facts, refs, prior art)
+ - Skeptic / risk (failure modes, contradictions, what could break)
+ - Integrator (how it fits the existing system / repo)
+ - Communicator / docs (how to explain, UI copy, README, rollout)
+ - (extra) Tester / QA, Security, Performance, Ops/Deploy
+2. **Launch** each angle as a `delegate_task` with FULL context:
+ - The goal, verbatim.
+ - Its specific angle + acceptance criteria.
+ - Relevant repo context (paths, constraints, the established patterns).
+ - Instruction: return a concise, self-contained result; do not ask questions.
+3. **Converge**: reconcile contradictions, produce a unified prioritized plan
+ or final answer + the single next step.
+4. **Log the run**: write `data/orchestration-runs/.json` with
+ {goal, angles, agents:["delegate-x5"], synthesis, timestamp}. Then
+ `python3 brain-cli.py ingest` so it's searchable in Brain Search (the
+ orchestration source is indexed). Optionally surface results on the
+ Orchestration dashboard page.
+
+## delegate_task call shape (per angle)
+```
+delegate_task(
+ goal="",
+ context="""GOAL:
+ANGLE:
+CONTEXT:
+OUTPUT: concise, self-contained. No questions.""",
+)
+```
+Batch up to 3 in one `tasks=[...]` call; fire the rest in a second call.
+
+## Notes
+- Subagent summaries are self-reports; verify external side-effects (writes,
+ publishes) yourself before claiming success.
+- Keep angle prompts tight β workers get clean isolated context, no shared
+ state, so everything they need must be in `context`.
+- This is the PRIMARY orchestration workflow. The Agentic OS `/api/orchestrate`
+ backend endpoint (which fans out the 3 CLI agents) is an OPTIONAL headless
+ alternative only β not used for this single-source workflow.