// Phase 2B — Custom Agents panel. // // A focused CRUD UI on top of the existing /api/vault/note plumbing. Lists // every agent in brain/0-Agents/ with metadata, lets the user create new // custom agents, edit their bodies, and delete the ones they own. Built-in // agents (the 10 ships-with-the-repo personas) are flagged read-only here; // the Brain tab editor can still modify them if the user really wants. // // On save, we POST/PUT to /api/agents/* which writes the markdown file with // proper YAML frontmatter (`type: agent-prompt`, role, title, created_by, // created_at, optional model + tagline) so load_roster() picks it up on the // next team run. The backend reload_prompt is called automatically. const { useState: agUseState, useEffect: agUseEffect, useCallback: agUseCallback, useRef: agUseRef } = React; function AgentsPanel({ open, onClose }) { if (!open) return null; const [roster, setRoster] = agUseState([]); const [loading, setLoading] = agUseState(false); const [err, setErr] = agUseState(''); const [activeRole, setActiveRole] = agUseState(null); const [mode, setMode] = agUseState('view'); // 'view' | 'edit' | 'new' const [draft, setDraft] = agUseState({ title: '', tagline: '', model: '', body: '' }); const [saving, setSaving] = agUseState(false); const refresh = agUseCallback(async () => { setLoading(true); setErr(''); try { const r = await fetch('/api/agents/roster'); if (!r.ok) { // Surface the actual response body so the user sees WHY the // request failed (e.g. 'token expired' vs 'invalid token: …'). // The fetch interceptor in index.html already retries 401s // with refreshed tokens — if we still got a non-200 here, the // soft-fallback didn't catch it either, which is informative. let detail = ''; try { const txt = await r.text(); // Detail might be JSON {"detail":"..."} or plain text try { detail = (JSON.parse(txt) || {}).detail || txt; } catch { detail = txt; } } catch {} throw new Error(`HTTP ${r.status}${detail ? ` · ${String(detail).slice(0, 140)}` : ''}`); } const j = await r.json(); const agents = j.agents || []; if (!Array.isArray(agents)) { throw new Error(`bad response shape — agents field is ${typeof agents}`); } setRoster(agents); if (agents.length === 0) { // Backend returned 200 but with no personas. The brain volume // probably hasn't been seeded with the persona files. Surface // a hint that points at the fix instead of leaving the user // staring at an empty list. setErr('Roster is empty — backend returned 200 but found 0 personas. Try POST /api/admin/sync_brain to copy the image\'s 0-Agents/ files onto the volume.'); } } catch (e) { setErr(`Failed to load roster — ${e.message || e}`); } finally { setLoading(false); } }, []); agUseEffect(() => { if (open) refresh(); }, [open, refresh]); const active = roster.find(a => a.role === activeRole) || null; const startNew = () => { setActiveRole(null); setMode('new'); setDraft({ title: '', tagline: '', model: '', body: 'You are a [DESCRIBE THE AGENT] analyst. Your lens:\n\n- [Lens point 1]\n- [Lens point 2]\n- [Lens point 3]\n\nWhen you analyze a thesis:\n1. [How you start]\n2. [What you weigh]\n3. [How you conclude]\n\nVoice: [terse / discursive / contrarian / etc.]\n', }); }; const startEdit = (agent) => { setActiveRole(agent.role); setMode('edit'); setDraft({ title: agent.title || '', tagline: agent.tagline || '', model: agent.model || '', body: agent.body || '', }); }; const cancelEdit = () => { setMode('view'); setDraft({ title: '', tagline: '', model: '', body: '' }); }; const save = async () => { if (saving) return; setSaving(true); setErr(''); try { const body = (draft.body || '').trim(); if (body.length < 40) { throw new Error('Body needs at least 40 characters — describe how this agent reasons.'); } if (mode === 'new') { if (!draft.title.trim()) throw new Error('Title is required.'); const r = await fetch('/api/agents/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: draft.title.trim(), body, tagline: draft.tagline.trim() || undefined, model: draft.model.trim() || undefined, created_by: (window.TIBEB_USER && window.TIBEB_USER.email) || 'user', }), }); if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`Create failed (${r.status}): ${t.slice(0, 140)}`); } const j = await r.json(); await refresh(); setActiveRole(j.role); setMode('view'); } else if (mode === 'edit' && activeRole) { const r = await fetch(`/api/agents/${encodeURIComponent(activeRole)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body, tagline: draft.tagline.trim(), model: draft.model.trim(), }), }); if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`Save failed (${r.status}): ${t.slice(0, 140)}`); } await refresh(); setMode('view'); } } catch (e) { setErr(e.message || String(e)); } finally { setSaving(false); } }; const deleteAgent = async () => { if (!active || !active.is_custom) return; if (!window.confirm(`Delete "${active.title}"? This removes the markdown file from brain/0-Agents/. The next team run won't include it.`)) return; setSaving(true); setErr(''); try { const r = await fetch(`/api/agents/${encodeURIComponent(active.role)}`, { method: 'DELETE' }); if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`Delete failed (${r.status}): ${t.slice(0, 140)}`); } setActiveRole(null); setMode('view'); await refresh(); } catch (e) { setErr(e.message || String(e)); } finally { setSaving(false); } }; const builtIn = roster.filter(a => !a.is_custom); const custom = roster.filter(a => a.is_custom); return (
{ if (e.target.classList.contains('agents-overlay')) onClose(); }}>
Custom analysts · brain/0-Agents/
Build your own analyst team
Each agent is a Markdown file with a personality + lens. The team rotates through every agent in the roster on Mad Max + Team runs. Built-ins lock you in to a specific framework lineup; custom agents let you add your own (e.g. "TMT contrarian", "energy specialist", "crypto skeptic").
{/* Left: roster list */}
{loading &&
Loading roster…
} {err && !loading && (
{err}
)} {custom.length > 0 && ( <>
Yours ({custom.length})
{custom.map(a => ( ))} )}
Built-in ({builtIn.length})
{builtIn.map(a => ( ))}
{/* Right: detail / editor */}
{mode === 'new' && ( )} {mode === 'edit' && active && ( )} {mode === 'view' && active && (
{active.title}
role: {active.role} {active.model && model: {active.model}} {active.tagline && {active.tagline}}
{active.created_by && (
By {active.created_by}{active.created_at ? ` · ${active.created_at}` : ''}
)}
{active.file_path}
{active.is_custom && ( )}
{active.body}
{!active.is_custom && (
Built-in agent. Edits ship for everyone — only do it if you're improving the framework. To make a one-off, click + New custom agent and copy the body as a starting point.
)}
)} {mode === 'view' && !active && (
Select an agent on the left to view its prompt.
Each agent is a markdown file in brain/0-Agents/ with a frontmatter contract. The TeamRunner auto-discovers them and the Mad Max overlay rotates through every analyst, one per iteration. Build your own when you want a lens the built-ins don't cover.
)}
); } function AgentEditor({ draft, setDraft, isNew, isCustom, saving, err, onSave, onCancel }) { const titleRef = agUseRef(null); agUseEffect(() => { if (isNew && titleRef.current) titleRef.current.focus(); }, [isNew]); return (
{isNew ? 'New custom agent' : 'Edit agent'}
{!isNew && !isCustom && (
⚠ This is a built-in agent. Edits affect every team run for every user.
)}