Files
hermes-agent/web/src/lib/api.ts
Teknium b571ec298d feat(dashboard): full administration panel — MCP, pairing, webhooks, credentials, memory, gateway, ops (#36704)
* feat(dashboard): backend API for MCP, pairing, webhooks, credential pool, memory, gateway lifecycle

Adds REST endpoints so a remote admin can manage these without CLI access:
- MCP servers: list/add/remove/test (config.yaml parity with hermes mcp)
- Pairing: list/approve/revoke/clear-pending messaging codes
- Webhooks: list/subscribe/remove (hot-reloaded JSON store)
- Credential pool: list/add/remove rotation keys (via CredentialPool API)
- Memory provider: status/select/disable/reset
- Gateway lifecycle: start/stop (restart+update already existed)

Secrets redacted on read; usable values only reach the agent at session start.
All endpoints sit behind the existing dashboard auth gate.

* feat(dashboard): backend API for ops + skills hub

- Ops actions (spawned, log-tailed via /api/actions): doctor, security audit,
  backup, import, checkpoints prune
- Ops reads (structured JSON): hooks list + allowlist status, checkpoints list
  with per-session size
- Skills hub actions (spawned): install / uninstall / update
- Registers new action log files for all spawn-based endpoints

All gated by the existing dashboard auth middleware.

* feat(dashboard): admin pages for MCP, pairing, webhooks, and system ops

Adds four new dashboard pages + nav entries so a remote admin can manage
Hermes without CLI access:
- MCP: list/add/remove/test MCP servers
- Webhooks: list/create/delete subscriptions (one-time secret reveal)
- Pairing: approve/revoke/clear messaging pairing codes
- System: gateway start/stop/restart, memory provider + reset, credential
  pool add/remove, ops (doctor/audit/backup/import/skills update) with a
  live action-log viewer, checkpoints prune, shell-hooks status

api.ts: client methods + types for all new endpoints.
App.tsx: routes + sidebar nav (plain labels, no i18n key required).

Verified: tsc -b clean, production build succeeds, new pages lint clean,
zero new eslint errors in App.tsx.

* test(dashboard): cover admin API endpoints

20 tests across MCP, credential pool, memory, pairing, webhooks, ops, plus
an auth-gate parametrize that asserts every admin endpoint requires the
session token. Asserts request contract + CLI-config parity, not catalog
values (per the no-change-detector-tests rule).

* docs(dashboard): document MCP, Webhooks, Pairing, and System admin pages

Adds Pages sections for the four new admin tabs and an Admin-endpoints table
to the REST API reference. Updates the page description to reflect the
dashboard's expanded role as a full administration panel.
2026-06-01 02:58:02 -07:00

1278 lines
40 KiB
TypeScript

// The dashboard can be served either at the root of its host (e.g.
// https://kanban.tilos.com/) or under a URL prefix when reverse-proxied
// (e.g. https://mission-control.tilos.com/hermes/). The Python backend
// injects ``window.__HERMES_BASE_PATH__`` into index.html based on the
// incoming ``X-Forwarded-Prefix`` header so the SPA can address its own
// ``/api/...`` and ``/dashboard-plugins/...`` URLs correctly without a
// rebuild. Empty string means "served at root".
function readBasePath(): string {
if (typeof window === "undefined") return "";
const raw = window.__HERMES_BASE_PATH__ ?? "";
if (!raw) return "";
// Normalise: ensure leading slash, strip trailing slash.
const withLead = raw.startsWith("/") ? raw : `/${raw}`;
return withLead.replace(/\/+$/, "");
}
export const HERMES_BASE_PATH = readBasePath();
const BASE = HERMES_BASE_PATH;
import type { DashboardTheme } from "@/themes/types";
// Ephemeral session token for protected endpoints.
// Injected into index.html by the server — never fetched via API.
declare global {
interface Window {
__HERMES_SESSION_TOKEN__?: string;
__HERMES_BASE_PATH__?: string;
/** Server-injected flag: ``true`` when the dashboard's OAuth gate is
* engaged (public bind, no ``--insecure``). Toggles the SPA's
* WS-upgrade path from legacy ``?token=`` to single-use ``?ticket=``
* fetched via :func:`getWsTicket`. */
__HERMES_AUTH_REQUIRED__?: boolean;
}
}
let _sessionToken: string | null = null;
const SESSION_HEADER = "X-Hermes-Session-Token";
function setSessionHeader(headers: Headers, token: string): void {
if (!headers.has(SESSION_HEADER)) {
headers.set(SESSION_HEADER, token);
}
}
export async function fetchJSON<T>(
url: string,
init?: RequestInit,
options?: FetchJSONOptions,
): Promise<T> {
// Inject the session token into all /api/ requests.
const headers = new Headers(init?.headers);
const token = window.__HERMES_SESSION_TOKEN__;
if (token) {
setSessionHeader(headers, token);
}
const res = await fetch(`${BASE}${url}`, {
...init,
headers,
// ``credentials: 'include'`` so the cookie-auth path (gated mode) works
// for any fetch routed through here. Loopback mode is unaffected — the
// server doesn't read cookies and the legacy session-token header is
// already attached above.
credentials: init?.credentials ?? "include",
});
if (res.status === 401) {
// Phase 6: the gated middleware emits a structured envelope so the
// SPA can full-page-navigate to /login on session expiry. Parse it,
// and only redirect on the known error codes — domain-level 401s
// (e.g. "you don't have permission to read this monitor") bubble
// up as regular errors so callers can handle them.
let body: { error?: string; login_url?: string } = {};
try {
body = await res.clone().json();
} catch {
/* non-JSON 401 — let it fall through */
}
if (
(body.error === "unauthenticated" || body.error === "session_expired") &&
body.login_url
) {
// Preserve where the user was so /auth/callback can land them back
// after re-auth. The gate's login_url already carries a ``next=``
// built from the request path, but the SPA may be deep inside a
// SPA route the gate never saw — e.g. a hash route or a client-side
// /sessions/<id> deep link. Save the current location as a
// fallback the post-login handler can read.
try {
sessionStorage.setItem(
"hermes.lastLocation",
window.location.pathname + window.location.search,
);
} catch {
/* SSR / privacy mode — ignore */
}
window.location.assign(body.login_url);
// Never resolve — the page is about to unload.
return new Promise<T>(() => {});
}
// Loopback mode: ``_SESSION_TOKEN`` rotates on every server restart
// (``hermes update``, ``hermes gateway restart``, etc.). A tab kept
// open across the restart holds the OLD token in
// ``window.__HERMES_SESSION_TOKEN__`` from the previous HTML render,
// so every fetch returns 401. The HTML is served ``Cache-Control:
// no-store`` so a reload picks up the freshly-injected token. Trigger
// that reload once on the first stale-token 401 — gated mode is
// handled above, so reaching here in gated mode means a real
// middleware failure that should not reload-loop.
if (!window.__HERMES_AUTH_REQUIRED__ && !options?.allowUnauthorized) {
let alreadyReloaded = false;
try {
alreadyReloaded =
sessionStorage.getItem("hermes.tokenReloadAttempted") === "1";
} catch {
/* SSR / privacy mode — fall through to throw */
}
if (!alreadyReloaded) {
try {
sessionStorage.setItem("hermes.tokenReloadAttempted", "1");
} catch {
/* SSR / privacy mode — best effort */
}
window.location.reload();
return new Promise<T>(() => {});
}
}
}
if (res.ok) {
// Clear the stale-token reload guard: a successful 2xx proves the
// current ``window.__HERMES_SESSION_TOKEN__`` is valid, so the next
// 401 — if any — should be allowed to trigger its own reload cycle.
try {
sessionStorage.removeItem("hermes.tokenReloadAttempted");
} catch {
/* SSR / privacy mode — ignore */
}
}
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new Error(`${res.status}: ${text}`);
}
return res.json();
}
/** Encode a plugin registry key for URL paths (preserves `/` segment separators). */
function pluginPath(name: string): string {
return name.split("/").map(encodeURIComponent).join("/");
}
async function getSessionToken(): Promise<string> {
if (_sessionToken) return _sessionToken;
const injected = window.__HERMES_SESSION_TOKEN__;
if (injected) {
_sessionToken = injected;
return _sessionToken;
}
throw new Error("Session token not available — page must be served by the Hermes dashboard server");
}
/**
* Fetch a single-use ticket for a WebSocket upgrade in gated mode.
*
* The dashboard's gated-mode WS auth (``hermes_cli.web_server._ws_auth_ok``)
* rejects the legacy ``?token=<_SESSION_TOKEN>`` path and only accepts
* ``?ticket=<minted>`` consumed against the in-memory ticket store. Browsers
* can't set ``Authorization`` on a WS upgrade, so this round-trip via the
* authenticated REST endpoint is the bridge from cookie auth to WS auth.
*
* Tickets are single-use and TTL=30s — every WS connect attempt must
* fetch a fresh ticket.
*/
export async function getWsTicket(): Promise<{ ticket: string; ttl_seconds: number }> {
const res = await fetch(`${BASE}/api/auth/ws-ticket`, {
method: "POST",
credentials: "include",
});
if (!res.ok) {
throw new Error(`/api/auth/ws-ticket: HTTP ${res.status}`);
}
return res.json();
}
/**
* Resolve the auth query-param pair (``[name, value]``) for a WebSocket
* connect. In gated mode mints a fresh single-use ticket; in loopback
* mode returns the injected session token.
*/
export async function buildWsAuthParam(): Promise<[string, string]> {
if (window.__HERMES_AUTH_REQUIRED__) {
const { ticket } = await getWsTicket();
return ["ticket", ticket];
}
const token = window.__HERMES_SESSION_TOKEN__ ?? "";
return ["token", token];
}
export const api = {
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
/**
* Identity probe for the dashboard auth gate (Phase 7).
*
* Returns the verified Session as JSON when gated mode is active and a
* valid cookie is attached. Loopback mode is unaffected — the endpoint
* still exists but is never useful there (no Session, no cookie). The
* AuthWidget component swallows 401s from this call: if the gate isn't
* engaged, /api/auth/me returns 401 and the widget renders nothing.
*
* ``allowUnauthorized`` is load-bearing: in loopback mode this endpoint
* 401s by design, and fetchJSON's default loopback behaviour treats a
* 401 as a rotated session token and full-page-reloads to pick up a
* fresh one. Because every *other* dashboard request succeeds (and so
* clears the one-shot reload guard), that turns this expected 401 into
* an infinite reload loop. Opting out keeps the 401 a plain throw the
* widget can catch.
*/
getAuthMe: () =>
fetchJSON<AuthMeResponse>("/api/auth/me", undefined, {
allowUnauthorized: true,
}),
logout: () =>
fetch(`${BASE}/auth/logout`, {
method: "POST",
credentials: "include",
}).then((r) => {
// /auth/logout returns 302 → /login. Follow that with a full-page
// navigation rather than letting fetch() opaquely consume the
// redirect — the SPA needs to leave the protected area.
window.location.assign("/login");
return r;
}),
getSessions: (limit = 20, offset = 0) =>
fetchJSON<PaginatedSessions>(`/api/sessions?limit=${limit}&offset=${offset}`),
getSessionMessages: (id: string) =>
fetchJSON<SessionMessagesResponse>(`/api/sessions/${encodeURIComponent(id)}/messages`),
getSessionLatestDescendant: (id: string) =>
fetchJSON<SessionLatestDescendantResponse>(
`/api/sessions/${encodeURIComponent(id)}/latest-descendant`,
),
deleteSession: (id: string) =>
fetchJSON<{ ok: boolean }>(`/api/sessions/${encodeURIComponent(id)}`, {
method: "DELETE",
}),
getLogs: (params: { file?: string; lines?: number; level?: string; component?: string }) => {
const qs = new URLSearchParams();
if (params.file) qs.set("file", params.file);
if (params.lines) qs.set("lines", String(params.lines));
if (params.level && params.level !== "ALL") qs.set("level", params.level);
if (params.component && params.component !== "all") qs.set("component", params.component);
return fetchJSON<LogsResponse>(`/api/logs?${qs.toString()}`);
},
getAnalytics: (days: number) =>
fetchJSON<AnalyticsResponse>(`/api/analytics/usage?days=${days}`),
getModelsAnalytics: (days: number) =>
fetchJSON<ModelsAnalyticsResponse>(`/api/analytics/models?days=${days}`),
getConfig: () => fetchJSON<Record<string, unknown>>("/api/config"),
getDefaults: () => fetchJSON<Record<string, unknown>>("/api/config/defaults"),
getSchema: () => fetchJSON<{ fields: Record<string, unknown>; category_order: string[] }>("/api/config/schema"),
getModelInfo: () => fetchJSON<ModelInfoResponse>("/api/model/info"),
getModelOptions: () => fetchJSON<ModelOptionsResponse>("/api/model/options"),
getAuxiliaryModels: () => fetchJSON<AuxiliaryModelsResponse>("/api/model/auxiliary"),
setModelAssignment: (body: ModelAssignmentRequest) =>
fetchJSON<ModelAssignmentResponse>("/api/model/set", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
saveConfig: (config: Record<string, unknown>) =>
fetchJSON<{ ok: boolean }>("/api/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ config }),
}),
getConfigRaw: () => fetchJSON<{ yaml: string }>("/api/config/raw"),
saveConfigRaw: (yaml_text: string) =>
fetchJSON<{ ok: boolean }>("/api/config/raw", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ yaml_text }),
}),
getEnvVars: () => fetchJSON<Record<string, EnvVarInfo>>("/api/env"),
setEnvVar: (key: string, value: string) =>
fetchJSON<{ ok: boolean }>("/api/env", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, value }),
}),
deleteEnvVar: (key: string) =>
fetchJSON<{ ok: boolean }>("/api/env", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key }),
}),
revealEnvVar: async (key: string) => {
const token = await getSessionToken();
return fetchJSON<{ key: string; value: string }>("/api/env/reveal", {
method: "POST",
headers: {
"Content-Type": "application/json",
[SESSION_HEADER]: token,
},
body: JSON.stringify({ key }),
});
},
// Cron jobs
getCronJobs: (profile = "all") =>
fetchJSON<CronJob[]>(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`),
createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string }, profile = "default") =>
fetchJSON<CronJob>(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(job),
}),
pauseCronJob: (id: string, profile = "default") =>
fetchJSON<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}/pause?profile=${encodeURIComponent(profile)}`, { method: "POST" }),
resumeCronJob: (id: string, profile = "default") =>
fetchJSON<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}/resume?profile=${encodeURIComponent(profile)}`, { method: "POST" }),
triggerCronJob: (id: string, profile = "default") =>
fetchJSON<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}/trigger?profile=${encodeURIComponent(profile)}`, { method: "POST" }),
deleteCronJob: (id: string, profile = "default") =>
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${encodeURIComponent(id)}?profile=${encodeURIComponent(profile)}`, { method: "DELETE" }),
// Profiles (minimal)
getProfiles: () =>
fetchJSON<{ profiles: ProfileInfo[] }>("/api/profiles"),
createProfile: (body: { name: string; clone_from_default: boolean }) =>
fetchJSON<{ ok: boolean; name: string; path: string }>("/api/profiles", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
renameProfile: (name: string, newName: string) =>
fetchJSON<{ ok: boolean; name: string; path: string }>(
`/api/profiles/${encodeURIComponent(name)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_name: newName }),
},
),
deleteProfile: (name: string) =>
fetchJSON<{ ok: boolean }>(
`/api/profiles/${encodeURIComponent(name)}`,
{ method: "DELETE" },
),
getProfileSetupCommand: (name: string) =>
fetchJSON<{ command: string }>(
`/api/profiles/${encodeURIComponent(name)}/setup-command`,
),
getProfileSoul: (name: string) =>
fetchJSON<{ content: string; exists: boolean }>(
`/api/profiles/${encodeURIComponent(name)}/soul`,
),
updateProfileSoul: (name: string, content: string) =>
fetchJSON<{ ok: boolean }>(
`/api/profiles/${encodeURIComponent(name)}/soul`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
},
),
// Skills & Toolsets
getSkills: () => fetchJSON<SkillInfo[]>("/api/skills"),
toggleSkill: (name: string, enabled: boolean) =>
fetchJSON<{ ok: boolean }>("/api/skills/toggle", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, enabled }),
}),
getToolsets: () => fetchJSON<ToolsetInfo[]>("/api/tools/toolsets"),
// Session search (FTS5)
searchSessions: (q: string) =>
fetchJSON<SessionSearchResponse>(`/api/sessions/search?q=${encodeURIComponent(q)}`),
// OAuth provider management
getOAuthProviders: () =>
fetchJSON<OAuthProvidersResponse>("/api/providers/oauth"),
disconnectOAuthProvider: async (providerId: string) => {
const token = await getSessionToken();
return fetchJSON<{ ok: boolean; provider: string }>(
`/api/providers/oauth/${encodeURIComponent(providerId)}`,
{
method: "DELETE",
headers: { [SESSION_HEADER]: token },
},
);
},
startOAuthLogin: async (providerId: string) => {
const token = await getSessionToken();
return fetchJSON<OAuthStartResponse>(
`/api/providers/oauth/${encodeURIComponent(providerId)}/start`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
[SESSION_HEADER]: token,
},
body: "{}",
},
);
},
submitOAuthCode: async (providerId: string, sessionId: string, code: string) => {
const token = await getSessionToken();
return fetchJSON<OAuthSubmitResponse>(
`/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
[SESSION_HEADER]: token,
},
body: JSON.stringify({ session_id: sessionId, code }),
},
);
},
pollOAuthSession: (providerId: string, sessionId: string) =>
fetchJSON<OAuthPollResponse>(
`/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}`,
),
cancelOAuthSession: async (sessionId: string) => {
const token = await getSessionToken();
return fetchJSON<{ ok: boolean }>(
`/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`,
{
method: "DELETE",
headers: { [SESSION_HEADER]: token },
},
);
},
// Gateway / update actions
restartGateway: () =>
fetchJSON<ActionResponse>("/api/gateway/restart", { method: "POST" }),
updateHermes: () =>
fetchJSON<ActionResponse>("/api/hermes/update", { method: "POST" }),
getActionStatus: (name: string, lines = 200) =>
fetchJSON<ActionStatusResponse>(
`/api/actions/${encodeURIComponent(name)}/status?lines=${lines}`,
),
// Dashboard plugins
getPlugins: () =>
fetchJSON<PluginManifestResponse[]>("/api/dashboard/plugins"),
rescanPlugins: () =>
fetchJSON<{ ok: boolean; count: number }>("/api/dashboard/plugins/rescan"),
getPluginsHub: () => fetchJSON<PluginsHubResponse>("/api/dashboard/plugins/hub"),
installAgentPlugin: (body: AgentPluginInstallRequest) =>
fetchJSON<AgentPluginInstallResponse>("/api/dashboard/agent-plugins/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...body }),
}),
enableAgentPlugin: (name: string) =>
fetchJSON<{ ok: boolean; name: string; unchanged?: boolean }>(
`/api/dashboard/agent-plugins/${pluginPath(name)}/enable`,
{ method: "POST" },
),
disableAgentPlugin: (name: string) =>
fetchJSON<{ ok: boolean; name: string; unchanged?: boolean }>(
`/api/dashboard/agent-plugins/${pluginPath(name)}/disable`,
{ method: "POST" },
),
updateAgentPlugin: (name: string) =>
fetchJSON<AgentPluginUpdateResponse>(
`/api/dashboard/agent-plugins/${pluginPath(name)}/update`,
{ method: "POST" },
),
removeAgentPlugin: (name: string) =>
fetchJSON<{ ok: boolean; name: string }>(
`/api/dashboard/agent-plugins/${pluginPath(name)}`,
{ method: "DELETE" },
),
savePluginProviders: (body: PluginProvidersPutRequest) =>
fetchJSON<{ ok: boolean }>("/api/dashboard/plugin-providers", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
setPluginVisibility: (name: string, hidden: boolean) =>
fetchJSON<{ ok: boolean; name: string; hidden: boolean }>(
`/api/dashboard/plugins/${pluginPath(name)}/visibility`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hidden }),
},
),
// Dashboard themes
getThemes: () =>
fetchJSON<DashboardThemesResponse>("/api/dashboard/themes"),
setTheme: (name: string) =>
fetchJSON<{ ok: boolean; theme: string }>("/api/dashboard/theme", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
}),
// ── Admin: MCP servers ──────────────────────────────────────────────
getMcpServers: () => fetchJSON<{ servers: McpServer[] }>("/api/mcp/servers"),
addMcpServer: (body: McpServerCreate) =>
fetchJSON<McpServer>("/api/mcp/servers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
removeMcpServer: (name: string) =>
fetchJSON<{ ok: boolean }>(`/api/mcp/servers/${encodeURIComponent(name)}`, {
method: "DELETE",
}),
testMcpServer: (name: string) =>
fetchJSON<McpTestResult>(
`/api/mcp/servers/${encodeURIComponent(name)}/test`,
{ method: "POST" },
),
// ── Admin: Pairing ──────────────────────────────────────────────────
getPairing: () => fetchJSON<PairingResponse>("/api/pairing"),
approvePairing: (platform: string, code: string) =>
fetchJSON<{ ok: boolean; user: PairingUser }>("/api/pairing/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ platform, code }),
}),
revokePairing: (platform: string, user_id: string) =>
fetchJSON<{ ok: boolean }>("/api/pairing/revoke", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ platform, user_id }),
}),
clearPendingPairing: () =>
fetchJSON<{ ok: boolean; cleared: number }>("/api/pairing/clear-pending", {
method: "POST",
}),
// ── Admin: Webhooks ─────────────────────────────────────────────────
getWebhooks: () => fetchJSON<WebhooksResponse>("/api/webhooks"),
createWebhook: (body: WebhookCreate) =>
fetchJSON<WebhookRoute & { secret: string }>("/api/webhooks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
deleteWebhook: (name: string) =>
fetchJSON<{ ok: boolean }>(`/api/webhooks/${encodeURIComponent(name)}`, {
method: "DELETE",
}),
// ── Admin: Credential pool ──────────────────────────────────────────
getCredentialPool: () =>
fetchJSON<{ providers: CredentialPoolProvider[] }>("/api/credentials/pool"),
addCredentialPoolEntry: (
provider: string,
api_key: string,
label?: string,
) =>
fetchJSON<{ ok: boolean; provider: string; count: number }>(
"/api/credentials/pool",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, api_key, label }),
},
),
removeCredentialPoolEntry: (provider: string, index: number) =>
fetchJSON<{ ok: boolean; provider: string; count: number }>(
`/api/credentials/pool/${encodeURIComponent(provider)}/${index}`,
{ method: "DELETE" },
),
// ── Admin: Memory provider ──────────────────────────────────────────
getMemory: () => fetchJSON<MemoryStatus>("/api/memory"),
setMemoryProvider: (provider: string) =>
fetchJSON<{ ok: boolean; active: string }>("/api/memory/provider", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider }),
}),
resetMemory: (target: "all" | "memory" | "user") =>
fetchJSON<{ ok: boolean; deleted: string[] }>("/api/memory/reset", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target }),
}),
// ── Admin: Gateway lifecycle ────────────────────────────────────────
startGateway: () =>
fetchJSON<ActionResponse>("/api/gateway/start", { method: "POST" }),
stopGateway: () =>
fetchJSON<ActionResponse>("/api/gateway/stop", { method: "POST" }),
// ── Admin: Operations ───────────────────────────────────────────────
runDoctor: () =>
fetchJSON<ActionResponse>("/api/ops/doctor", { method: "POST" }),
runSecurityAudit: () =>
fetchJSON<ActionResponse>("/api/ops/security-audit", { method: "POST" }),
runBackup: (output?: string) =>
fetchJSON<ActionResponse>("/api/ops/backup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ output }),
}),
runImport: (archive: string) =>
fetchJSON<ActionResponse>("/api/ops/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ archive }),
}),
getHooks: () => fetchJSON<HooksResponse>("/api/ops/hooks"),
getCheckpoints: () => fetchJSON<CheckpointsResponse>("/api/ops/checkpoints"),
pruneCheckpoints: () =>
fetchJSON<ActionResponse>("/api/ops/checkpoints/prune", { method: "POST" }),
// ── Admin: Skills hub ───────────────────────────────────────────────
installSkillFromHub: (identifier: string) =>
fetchJSON<ActionResponse>("/api/skills/hub/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identifier }),
}),
uninstallSkillFromHub: (name: string) =>
fetchJSON<ActionResponse>("/api/skills/hub/uninstall", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
}),
updateSkillsFromHub: () =>
fetchJSON<ActionResponse>("/api/skills/hub/update", { method: "POST" }),
};
/** Identity payload returned by ``GET /api/auth/me`` (Phase 7).
*
* Returned by the dashboard's gated middleware when a valid session cookie
* is attached. ``email`` and ``display_name`` are empty strings under the
* Nous Portal contract V1 (the access token has no email/name claims —
* see Contract Anchor C4 in the plan). The AuthWidget surfaces a
* truncated ``user_id`` instead.
*/
export interface AuthMeResponse {
user_id: string;
email: string;
display_name: string;
org_id: string;
provider: string;
expires_at: number;
}
export interface ActionResponse {
name: string;
ok: boolean;
pid: number | null;
error?: string;
message?: string;
update_command?: string;
}
// ── Admin types ───────────────────────────────────────────────────────
export interface McpServer {
name: string;
transport: "http" | "stdio" | "unknown";
url: string | null;
command: string | null;
args: string[];
env: Record<string, string>;
auth: string | null;
enabled: boolean;
tools: string[] | null;
}
export interface McpServerCreate {
name: string;
url?: string;
command?: string;
args?: string[];
env?: Record<string, string>;
auth?: string;
}
export interface McpTestResult {
ok: boolean;
error?: string;
tools: Array<{ name: string; description: string }>;
}
export interface PairingUser {
platform: string;
user_id: string;
user_name?: string;
code?: string;
age_minutes?: number;
}
export interface PairingResponse {
pending: PairingUser[];
approved: PairingUser[];
}
export interface WebhookRoute {
name: string;
description: string;
events: string[];
deliver: string;
deliver_only: boolean;
prompt: string;
skills: string[];
created_at: string | null;
url: string;
secret_set: boolean;
}
export interface WebhooksResponse {
enabled: boolean;
base_url: string;
subscriptions: WebhookRoute[];
}
export interface WebhookCreate {
name: string;
description?: string;
events?: string[];
prompt?: string;
skills?: string[];
deliver?: string;
deliver_only?: boolean;
deliver_chat_id?: string;
}
export interface CredentialPoolEntry {
index: number;
id: string | null;
label: string | null;
auth_type: string | null;
source: string | null;
priority: number;
last_status: string | null;
request_count: number;
token_preview: string;
has_refresh: boolean;
}
export interface CredentialPoolProvider {
provider: string;
entries: CredentialPoolEntry[];
}
export interface MemoryProviderInfo {
name: string;
description: string;
configured: boolean;
}
export interface MemoryStatus {
active: string;
providers: MemoryProviderInfo[];
builtin_files: { memory: number; user: number };
}
export interface HookEntry {
event: string;
matcher: string | null;
command: string | null;
timeout: number | null;
allowed: boolean;
}
export interface HooksResponse {
hooks: HookEntry[];
allowlist: string[];
}
export interface CheckpointSession {
session: string;
files: number;
bytes: number;
}
export interface CheckpointsResponse {
sessions: CheckpointSession[];
total_bytes: number;
}
/** Per-call overrides for {@link fetchJSON}. */
interface FetchJSONOptions {
/** When true, a 401 response is surfaced as a normal thrown error rather
* than triggering the loopback stale-token page reload. Use for probes
* whose 401 is an expected signal (e.g. /api/auth/me in non-gated mode)
* rather than evidence of a rotated session token. */
allowUnauthorized?: boolean;
}
export interface ActionStatusResponse {
exit_code: number | null;
lines: string[];
name: string;
pid: number | null;
running: boolean;
}
export interface PlatformStatus {
error_code?: string;
error_message?: string;
state: string;
updated_at: string;
}
export interface StatusResponse {
active_sessions: number;
/** Phase 7: ``true`` when the dashboard's OAuth gate is engaged
* (public bind, no ``--insecure``). Read alongside ``auth_providers``
* to render a "gated / loopback" badge. */
auth_required?: boolean;
/** Phase 7: registered ``DashboardAuthProvider`` names (e.g. ``["nous"]``).
* Empty in loopback mode; empty + ``auth_required=true`` is a
* fail-closed state (the dashboard will refuse to bind). */
auth_providers?: string[];
config_path: string;
config_version: number;
env_path: string;
gateway_exit_reason: string | null;
gateway_health_url: string | null;
gateway_pid: number | null;
gateway_platforms: Record<string, PlatformStatus>;
gateway_running: boolean;
gateway_state: string | null;
gateway_updated_at: string | null;
hermes_home: string;
latest_config_version: number;
release_date: string;
version: string;
}
export interface SessionInfo {
id: string;
source: string | null;
model: string | null;
title: string | null;
started_at: number;
ended_at: number | null;
last_active: number;
is_active: boolean;
message_count: number;
tool_call_count: number;
input_tokens: number;
output_tokens: number;
preview: string | null;
parent_session_id?: string | null;
}
export interface SessionLatestDescendantResponse {
requested_session_id: string;
session_id: string;
path: string[];
changed: boolean;
}
export interface PaginatedSessions {
sessions: SessionInfo[];
total: number;
limit: number;
offset: number;
}
export interface EnvVarInfo {
is_set: boolean;
redacted_value: string | null;
description: string;
url: string | null;
category: string;
is_password: boolean;
tools: string[];
advanced: boolean;
}
export interface SessionMessage {
role: "user" | "assistant" | "system" | "tool";
content: string | null;
tool_calls?: Array<{
id: string;
function: { name: string; arguments: string };
}>;
tool_name?: string;
tool_call_id?: string;
timestamp?: number;
}
export interface SessionMessagesResponse {
session_id: string;
messages: SessionMessage[];
}
export interface LogsResponse {
file: string;
lines: string[];
}
export interface AnalyticsDailyEntry {
day: string;
input_tokens: number;
output_tokens: number;
cache_read_tokens: number;
reasoning_tokens: number;
estimated_cost: number;
actual_cost: number;
sessions: number;
api_calls: number;
}
export interface AnalyticsModelEntry {
model: string;
input_tokens: number;
output_tokens: number;
estimated_cost: number;
sessions: number;
api_calls: number;
}
export interface AnalyticsSkillEntry {
skill: string;
view_count: number;
manage_count: number;
total_count: number;
percentage: number;
last_used_at: number | null;
}
export interface AnalyticsSkillsSummary {
total_skill_loads: number;
total_skill_edits: number;
total_skill_actions: number;
distinct_skills_used: number;
}
export interface AnalyticsResponse {
daily: AnalyticsDailyEntry[];
by_model: AnalyticsModelEntry[];
totals: {
total_input: number;
total_output: number;
total_cache_read: number;
total_reasoning: number;
total_estimated_cost: number;
total_actual_cost: number;
total_sessions: number;
total_api_calls: number;
};
skills: {
summary: AnalyticsSkillsSummary;
top_skills: AnalyticsSkillEntry[];
};
}
export interface ProfileInfo {
name: string;
path: string;
is_default: boolean;
model: string | null;
provider: string | null;
has_env: boolean;
skill_count: number;
}
export interface ModelsAnalyticsModelEntry {
model: string;
provider: string;
input_tokens: number;
output_tokens: number;
cache_read_tokens: number;
reasoning_tokens: number;
estimated_cost: number;
actual_cost: number;
sessions: number;
api_calls: number;
tool_calls: number;
last_used_at: number;
avg_tokens_per_session: number;
capabilities: {
supports_tools?: boolean;
supports_vision?: boolean;
supports_reasoning?: boolean;
context_window?: number;
max_output_tokens?: number;
model_family?: string;
};
}
export interface ModelsAnalyticsResponse {
models: ModelsAnalyticsModelEntry[];
totals: {
distinct_models: number;
total_input: number;
total_output: number;
total_cache_read: number;
total_reasoning: number;
total_estimated_cost: number;
total_actual_cost: number;
total_sessions: number;
total_api_calls: number;
};
period_days: number;
}
export interface CronJob {
id: string;
profile?: string | null;
profile_name?: string | null;
hermes_home?: string | null;
is_default_profile?: boolean;
name?: string | null;
prompt?: string | null;
script?: string | null;
schedule?: { kind?: string; expr?: string; display?: string };
schedule_display?: string | null;
enabled: boolean;
state?: string | null;
deliver?: string | null;
last_run_at?: string | null;
next_run_at?: string | null;
last_error?: string | null;
}
export interface SkillInfo {
name: string;
description: string;
category: string;
enabled: boolean;
}
export interface ToolsetInfo {
name: string;
label: string;
description: string;
enabled: boolean;
configured: boolean;
tools: string[];
}
export interface SessionSearchResult {
session_id: string;
snippet: string;
role: string | null;
source: string | null;
model: string | null;
session_started: number | null;
}
export interface SessionSearchResponse {
results: SessionSearchResult[];
}
// ── Model info types ──────────────────────────────────────────────────
export interface ModelInfoResponse {
model: string;
provider: string;
auto_context_length: number;
config_context_length: number;
effective_context_length: number;
capabilities: {
supports_tools?: boolean;
supports_vision?: boolean;
supports_reasoning?: boolean;
context_window?: number;
max_output_tokens?: number;
model_family?: string;
};
}
// ── Model options / assignment types ──────────────────────────────────
export interface ModelOptionProvider {
name: string;
slug: string;
models?: string[];
total_models?: number;
is_current?: boolean;
is_user_defined?: boolean;
source?: string;
warning?: string;
}
export interface ModelOptionsResponse {
model?: string;
provider?: string;
providers?: ModelOptionProvider[];
}
export interface AuxiliaryTaskAssignment {
task: string;
provider: string;
model: string;
base_url: string;
}
export interface AuxiliaryModelsResponse {
tasks: AuxiliaryTaskAssignment[];
main: { provider: string; model: string };
}
export interface ModelAssignmentRequest {
scope: "main" | "auxiliary";
provider: string;
model: string;
/** For auxiliary: task slot name, "" for all, "__reset__" to reset all. */
task?: string;
}
export interface ModelAssignmentResponse {
ok: boolean;
scope?: string;
provider?: string;
model?: string;
tasks?: string[];
reset?: boolean;
}
// ── OAuth provider types ────────────────────────────────────────────────
export interface OAuthProviderStatus {
logged_in: boolean;
source?: string | null;
source_label?: string | null;
token_preview?: string | null;
expires_at?: string | null;
has_refresh_token?: boolean;
last_refresh?: string | null;
error?: string;
}
export interface OAuthProvider {
id: string;
name: string;
/** "pkce" (browser redirect + paste code), "device_code" (show code + URL),
* or "external" (delegated to a separate CLI like Claude Code or Qwen). */
flow: "pkce" | "device_code" | "external";
cli_command: string;
docs_url: string;
status: OAuthProviderStatus;
}
export interface OAuthProvidersResponse {
providers: OAuthProvider[];
}
/** Discriminated union — the shape of /start depends on the flow. */
export type OAuthStartResponse =
| {
session_id: string;
flow: "pkce";
auth_url: string;
expires_in: number;
}
| {
session_id: string;
flow: "device_code";
user_code: string;
verification_url: string;
expires_in: number;
poll_interval: number;
};
export interface OAuthSubmitResponse {
ok: boolean;
status: "approved" | "error";
message?: string;
}
export interface OAuthPollResponse {
session_id: string;
status: "pending" | "approved" | "denied" | "expired" | "error";
error_message?: string | null;
expires_at?: number | null;
}
// ── Dashboard theme types ──────────────────────────────────────────────
export interface DashboardThemeSummary {
description: string;
label: string;
name: string;
/** Full theme definition for user themes; undefined for built-ins
* (which the frontend already has locally). */
definition?: DashboardTheme;
}
export interface DashboardThemesResponse {
active: string;
themes: DashboardThemeSummary[];
}
// ── Dashboard plugin types ─────────────────────────────────────────────
export interface PluginManifestResponse {
name: string;
label: string;
description: string;
icon: string;
version: string;
tab: {
path: string;
position?: string;
override?: string;
hidden?: boolean;
};
slots?: string[];
entry: string;
css?: string | null;
has_api: boolean;
source: string;
}
export interface HubAgentPluginRow {
name: string;
version: string;
description: string;
source: string;
runtime_status: "disabled" | "enabled" | "inactive";
has_dashboard_manifest: boolean;
dashboard_manifest: PluginManifestResponse | null;
path: string;
can_remove: boolean;
can_update_git: boolean;
auth_required: boolean;
auth_command: string;
user_hidden: boolean;
}
export interface PluginsHubProviders {
memory_provider: string;
memory_options: Array<{ name: string; description: string }>;
context_engine: string;
context_options: Array<{ name: string; description: string }>;
}
export interface PluginsHubResponse {
plugins: HubAgentPluginRow[];
orphan_dashboard_plugins: PluginManifestResponse[];
providers: PluginsHubProviders;
}
export interface AgentPluginInstallRequest {
identifier: string;
force?: boolean;
enable?: boolean;
}
export interface AgentPluginInstallResponse {
ok: boolean;
plugin_name?: string;
warnings?: string[];
missing_env?: string[];
after_install_path?: string | null;
enabled?: boolean;
error?: string;
}
export interface AgentPluginUpdateResponse {
ok: boolean;
name?: string;
output?: string;
unchanged?: boolean;
error?: string;
}
export interface PluginProvidersPutRequest {
memory_provider?: string;
context_engine?: string;
}