// 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 (
{active.role}
{active.model && model: {active.model}}
{active.tagline && {active.tagline}}
{active.file_path}
{active.body}
{!active.is_custom && (
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.