diff --git a/cortex/model_registry.py b/cortex/model_registry.py
index fe2ea03..b47d021 100644
--- a/cortex/model_registry.py
+++ b/cortex/model_registry.py
@@ -415,6 +415,24 @@ def get_best_local_model(username: str, role: str = "chat") -> dict | None:
return None
+def set_role_config(username: str, role: str, system_append: str, tools: list[str] | None) -> None:
+ """Save system_append and tools allow-list for a role.
+
+ tools=None clears the allow-list (role uses all accessible tools).
+ tools=[] would mean no tools at all — validate in the caller if that's undesired.
+ """
+ data = _load(username)
+ roles = data.setdefault("roles", {})
+ if role not in roles:
+ roles[role] = {}
+ roles[role]["system_append"] = system_append.strip()
+ if tools is None:
+ roles[role].pop("tools", None)
+ else:
+ roles[role]["tools"] = [t for t in tools if t]
+ _save(username, data)
+
+
def get_role_config(username: str, role: str) -> dict:
"""
Return supplemental config for a role: system_append and tools.
diff --git a/cortex/routers/local_llm.py b/cortex/routers/local_llm.py
index b2d55d4..4475b39 100644
--- a/cortex/routers/local_llm.py
+++ b/cortex/routers/local_llm.py
@@ -26,6 +26,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from auth_utils import COOKIE_NAME, decode_token
from config import settings as app_settings
import model_registry as reg
+from tools import TOOL_CATEGORIES
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -285,13 +286,42 @@ def _render(username: str, success: str = "", error: str = "") -> str:
f'data-slot="{slot}" title="{slot_label}">\n{model_opts}\n'
)
role_rows += f'
{slot_label}{sel}
'
- role_rows += ''
+ role_rows += (
+ f''
+ f''
+ f''
+ f''
+ f'
'
+ f''
+ f''
+ f'
'
+ f'
'
+ f'
'
+ f'
'
+ f'
'
+ f'
'
+ f''
+ f''
+ f'
'
+ f'
'
+ )
role_data_js = _json.dumps({
role: {slot: (roles.get(role, {}).get(slot) or "") for slot in reg.PRIORITY_KEYS[:3]}
for role in app_settings.get_defined_roles()
})
+ role_config_data_js = _json.dumps({
+ role: {
+ "system_append": roles.get(role, {}).get("system_append", ""),
+ "tools": roles.get(role, {}).get("tools") or None,
+ }
+ for role in app_settings.get_defined_roles()
+ })
+ tool_categories_js = _json.dumps(TOOL_CATEGORIES)
+
# ── Catalog data + Google accounts for JS ─────────────────────────────────
google_accounts_js = _json.dumps(reg.get_google_accounts(username))
google_catalog_js = _json.dumps(reg.get_catalog("google"))
@@ -305,8 +335,10 @@ def _render(username: str, success: str = "", error: str = "") -> str:
"{{ host_rows }}": host_rows,
"{{ model_rows }}": model_rows,
"{{ host_options }}": host_options,
- "{{ role_rows }}": role_rows,
- "{{ role_data_js }}": role_data_js,
+ "{{ role_rows }}": role_rows,
+ "{{ role_data_js }}": role_data_js,
+ "{{ role_config_data_js }}": role_config_data_js,
+ "{{ tool_categories_js }}": tool_categories_js,
"{{ google_accounts_js }}": google_accounts_js,
"{{ google_catalog_js }}": google_catalog_js,
"{{ anthropic_catalog_js }}": anthropic_catalog_js,
@@ -510,6 +542,36 @@ async def set_role(request: Request) -> JSONResponse:
return JSONResponse({"ok": True})
+@router.post("/api/models/role-config")
+async def set_role_config(request: Request) -> JSONResponse:
+ """AJAX: save system_append and tool allow-list for a role.
+
+ Body: {"role": "coder", "system_append": "...", "tools": ["web_search", ...] | null}
+ tools=null clears the allow-list (role uses all accessible tools).
+ """
+ username = _get_user(request)
+ if not username:
+ return JSONResponse({"error": "Not authenticated"}, status_code=401)
+ try:
+ body = await request.json()
+ except Exception:
+ return JSONResponse({"error": "Invalid JSON"}, status_code=400)
+
+ role = body.get("role", "").strip()
+ system_append = body.get("system_append", "")
+ tools = body.get("tools") # list[str] or None
+
+ if not role:
+ return JSONResponse({"error": "role is required"}, status_code=400)
+ if tools is not None and not isinstance(tools, list):
+ return JSONResponse({"error": "tools must be a list or null"}, status_code=400)
+
+ reg.set_role_config(username, role, system_append, tools)
+ logger.info("role config saved: %s %s (tools=%s)", username, role,
+ len(tools) if tools is not None else "all")
+ return JSONResponse({"ok": True})
+
+
@router.get("/api/local-llm/fetch-models")
async def fetch_models(request: Request, host_id: str = "") -> JSONResponse:
"""Proxy to the host's models endpoint. host_id selects which host."""
diff --git a/cortex/static/TOOLS.md b/cortex/static/TOOLS.md
index 7c4c29b..ee743bf 100644
--- a/cortex/static/TOOLS.md
+++ b/cortex/static/TOOLS.md
@@ -1,6 +1,6 @@
# Tool Reference
-> This reference covers all 30 orchestrator tools available when the ⚡ toggle is on.
+> This reference covers all 39 orchestrator tools available when the ⚡ toggle is on.
> Tools are invoked automatically by the orchestrator — you don't call them directly.
¹ **Admin only** — requires the `admin` role. Invisible to regular users.
diff --git a/cortex/static/local_llm.html b/cortex/static/local_llm.html
index 77e1323..5431e60 100644
--- a/cortex/static/local_llm.html
+++ b/cortex/static/local_llm.html
@@ -223,7 +223,6 @@
display: flex; align-items: flex-start; gap: 1rem;
padding: 0.6rem 0; border-bottom: 1px solid var(--pg-border-deep);
}
- .role-row:last-child { border-bottom: none; }
.role-name { font-size: 0.82rem; font-weight: 600; color: #a78bfa; min-width: 6rem; padding-top: 0.45rem; }
.role-slots { display: flex; flex-wrap: wrap; gap: 0.5rem; flex: 1; }
.role-slot { display: flex; flex-direction: column; gap: 0.2rem; flex: 1; min-width: 8rem; }
@@ -238,6 +237,36 @@
.role-select.saved { border-color: #166534; }
.role-select.saving { border-color: #92400e; }
.role-select.err { border-color: #7f1d1d; }
+ .role-cfg-btn {
+ flex-shrink: 0; padding: 0.3rem 0.55rem; font-size: 0.8rem;
+ background: none; border: 1px solid var(--pg-border); border-radius: 6px;
+ color: var(--pg-dim); cursor: pointer; transition: color 0.15s, border-color 0.15s;
+ margin-top: 0.35rem;
+ }
+ .role-cfg-btn:hover { color: #a78bfa; border-color: #a78bfa; }
+ .role-cfg-btn.active { color: #a78bfa; border-color: #a78bfa; background: rgba(167,139,250,0.08); }
+ /* Role config panel */
+ .role-config-panel {
+ display: none; margin: 0 0 0.75rem 7rem;
+ border: 1px solid var(--pg-border); border-radius: 8px;
+ background: var(--pg-surface); padding: 1rem;
+ }
+ .role-config-panel.open { display: block; }
+ .rcp-field { margin-bottom: 0.75rem; }
+ .rcp-label { display: block; font-size: 0.75rem; font-weight: 600; color: var(--pg-muted); margin-bottom: 0.35rem; text-transform: uppercase; letter-spacing: 0.04em; }
+ .rcp-hint { font-weight: 400; text-transform: none; letter-spacing: 0; color: var(--pg-dimmer); }
+ .rcp-textarea {
+ width: 100%; resize: vertical; min-height: 4rem;
+ background: var(--pg-bg); border: 1px solid var(--pg-border); border-radius: 6px;
+ color: var(--pg-text); font-family: inherit; font-size: 0.85rem;
+ padding: 0.5rem 0.6rem; outline: none; transition: border-color 0.15s;
+ }
+ .rcp-textarea:focus { border-color: #7c3aed; }
+ .rcp-tools { display: flex; flex-wrap: wrap; gap: 0.4rem 1rem; }
+ .rcp-cat { width: 100%; margin: 0.4rem 0 0.1rem; font-size: 0.7rem; font-weight: 600; color: var(--pg-dimmer); text-transform: uppercase; letter-spacing: 0.05em; }
+ .rcp-check { display: flex; align-items: center; gap: 0.35rem; font-size: 0.8rem; color: var(--pg-bright); cursor: pointer; }
+ .rcp-check input { accent-color: #a78bfa; cursor: pointer; }
+ .rcp-actions { display: flex; gap: 0.5rem; padding-top: 0.25rem; }
/* Model select picker */
#model-select-wrap { display: none; margin-bottom: 0.75rem; }
@@ -496,6 +525,8 @@