A chat surface inside ShadowBrain that unifies three capabilities through a single
Next.js SSE hub (/api/chat):
- Knowledge-grounded chat — ask questions answered from the ShadowBrain SQLite knowledge base (retrieval-augmented).
- General chat — plain LLM conversation with no grounding.
- Server administration — drive the running Hermes agent to execute commands, manage services, and read logs, with tool-progress visibility and human-in-the-loop approval for destructive actions.
The hub talks to two OpenAI-compatible backends, selected per conversation:
- Hermes agent —
http://localhost:8642/v1, bearer keyHERMES_API_KEY. A full agent with native tools (terminal,process, file/web/vision, cron, MCP). Used for admin and as a conversation partner. - OpenCode Go models —
https://opencode.ai/zen/go/v1, subscription keyOPENCODE_GO_API_KEY. Raw general chat acrossglm/kimi/deepseek/mimo(andqwen/minimaxon the Anthropic-compatible endpoint — future).
Grounding is a toggleable retrieval layer in the Next.js process, so it works identically regardless of backend.
- One
/chatpage backed by one streaming/api/chatroute. - Switch conversation target between Hermes and any OpenCode Go model.
- Toggle RAG grounding on/off per thread (and per send).
- Persist threads and messages in the ShadowBrain DB.
- Insert content into ShadowBrain from chat (explicit action + model-driven tool).
- Full Hermes admin with tool-progress surfacing and approval prompts for destructive actions (core reliability requirement, not deferred).
- All provider keys stay server-side.
- Unified tool-grounding for Hermes via MCP (approach C) — future enhancement.
- OpenCode Go models on the Anthropic-compatible
/v1/messagesendpoint — v1 uses the/v1/chat/completionsGo-model subset only. - Surfacing Hermes Runs beyond the current turn (no cross-turn run chaining) —
each user message starts a new run; history comes from our
chat_messages. - Voice / TTS, image generation UI, mobile PWA packaging.
Browser (/chat) ──SSE──► Next.js /api/chat (the hub)
│
├─ RAG retrieval (optional) ─► ShadowBrain SQLite
├─ load/persist ───────────► chat_threads / chat_messages
├─ insert tool ───────────► content_items (+ FTS) via /api/items
│
├─ target=hermes ──► Hermes Runs API (POST /v1/runs, events SSE, approval)
└─ target=go-model ► OpenCode Go /v1/chat/completions (AI SDK streamText)
The hub is the single streaming endpoint the UI talks to. It normalizes two different backend transports into one SSE protocol toward the browser (see SSE Event Protocol).
- Go-model targets use the standard OpenAI Chat Completions stream via the
Vercel AI SDK (
streamText). Token deltas + our-layer tool calls. Simple. - Hermes target uses Hermes's Runs API —
POST /v1/runsreturns arun_id;GET /v1/runs/{run_id}/eventsis an SSE stream of tool-call progress (hermes.tool.progress), token deltas, lifecycle events, and pending-approval states;POST /v1/runs/{run_id}/approvalresolves a pending approval. This is the only Hermes path that exposes tool-progress and approval correctly, which is required for safe admin use.
Registers OpenAI-compatible providers and exposes a typed target model:
{ provider: 'hermes' | 'opencode-go', model: string }. Helper to list models
per provider (cached GET /v1/models). Hermes lists hermes-agent; OpenCode Go
lists the model catalog.
Given the latest user message, runs FTS retrieval (reusing the Phase 1 search
helper / sanitizeFts5Query) with tag/type filters, and respects the
two-level visibility model (per the App Security Baseline spec §2):
is_hidden = 1 items are included in RAG by default (they are AI-OK);
is_private = 1 items are excluded by default and are only included when
the current thread has opted in via the per-thread "Include private in AI"
control (chat_threads.include_private_in_ai = 1, overridable per send).
Returns top-K items (CHAT_RAG_TOP_K, default 8). Renders a ## Retrieved context block for injection. Returns empty → hub proceeds without context
(no hard failure).
Thin client for the Hermes Runs API: createRun, streamEvents(runId) (async
iterator yielding normalized events), resolveApproval(runId, decision, ...).
Maps Hermes event types onto the hub's SSE protocol. The exact Runs request body
(how full message history is passed) is verified against the running Hermes
instance during implementation — the docs describe the lifecycle but not the
complete request schema.
Request: { threadId, target, grounded: boolean, allowModelSave: boolean, message }.
Flow:
- Persist the user message to
chat_messages. - Load thread history from
chat_messages. - If
grounded: build context viaretrieval.ts; prepend as a system block. - Branch on
target.provider:opencode-go:streamText({ model, messages, tools }). Registersave_to_shadowbraintool only whenallowModelSaveis true (executed locally by the AI SDK). Stream token deltas.hermes:createRunwith the assembled messages, thenstreamEvents. Forward tool-progress and approval events to the client; on approval-requested, pause for the client's decision and callresolveApproval.
- On completion, persist the assistant message (content + any tool-call
records) to
chat_messages.
CRUD for threads (list, create, rename, delete). Messages are read/written
through the hub and a GET /api/chat/threads/[id]/messages endpoint.
Builds on the Phase 3 design system (#20) and markdown rendering (#25).
- Left rail: new chat + recent threads.
- Center: streaming message list (markdown), input box, send.
- Per-thread controls: Target selector (Hermes / Go model), Grounding
toggle, Allow-model-to-save toggle, Include-private-in-AI toggle (off
by default; gates whether
is_privateitems are included in the RAG context for this thread / message, per the App Security Baseline two-level visibility model). - Per-message: "Save to ShadowBrain" action (type picker →
POST /api/items). - Hermes activity: collapsible tool-progress blocks; when target = Hermes, show an "Admin mode (Hermes)" indicator. Approval requests render inline with Approve / Deny buttons.
- Explicit button: client calls
POST /api/itemswith chosen type. Works for both targets. - Model-driven tool:
save_to_shadowbrain({ type, content, title?, tags? }), active only for Go-model targets (AI SDK executes it locally in our process). Not active for Hermes (see Non-Goals / Open Questions) — Hermes-side grounding/insert is via the explicit button, or future MCP.
The hub emits a single SSE stream carrying typed parts:
text-delta— token text.tool-progress—{ tool, label, status }(e.g.terminalrunningls -la).approval-requested—{ runId, summary, command? }→ client shows Approve/Deny.approval-resolved— outcome after the user decides.tool-result/saved— confirmation that an insert produced a content item.error— provider/auth/stream failures with a retry signal.
Go-model targets emit text-delta, tool-*, saved, error. Hermes targets
emit the full set including tool-progress, approval-requested/resolved.
Two tables, following docs/schema.md conventions:
CREATE TABLE chat_threads (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
target_provider TEXT NOT NULL, -- 'hermes' | 'opencode-go'
target_model TEXT NOT NULL,
grounded INTEGER NOT NULL DEFAULT 1, -- RAG on/off
allow_model_save INTEGER NOT NULL DEFAULT 0,
include_private_in_ai INTEGER NOT NULL DEFAULT 0, -- per-thread "Include private in AI" opt-in (gates is_private in RAG)
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE chat_messages (
id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL REFERENCES chat_threads(id) ON DELETE CASCADE,
role TEXT NOT NULL, -- 'user' | 'assistant' | 'system' | 'tool'
content TEXT NOT NULL,
tool_calls TEXT, -- JSON array, nullable
tool_call_id TEXT, -- nullable
target_provider TEXT,
target_model TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_chat_messages_thread ON chat_messages (thread_id, created_at);Chat messages are not knowledge items and do not enter FTS. Anything
saved via insert becomes a normal content_item (and is then searchable).
Environment / settings table:
HERMES_API_BASE(defaulthttp://localhost:8642/v1)HERMES_API_KEY(= HermesAPI_SERVER_KEY)OPENCODE_GO_API_BASE(defaulthttps://opencode.ai/zen/go/v1)OPENCODE_GO_API_KEYCHAT_RAG_TOP_K(default 8),CHAT_RAG_INCLUDE_PRIVATE(default 0)
Hermes must run with API_SERVER_ENABLED=true and its API server bound to an
address reachable from the Next.js process (localhost by default). For
browser-direct calls we do not rely on Hermes CORS — the Next.js hub calls
Hermes server-to-server, so no CORS allowlist is required.
- Provider unreachable / 401 / rate-limited →
errorevent in-thread, no crash, retry button. Provider key missing → clear "not configured" message. - RAG retrieval throws or returns nothing → proceed without context; never block chat.
- Hermes run awaiting approval times out → mark turn as
awaiting-approval, resumable. - Interrupted stream → persist the partial assistant message; mark
truncated. - Tool/insert failure → return error to the model so it can recover; explicit button failure → inline message, input preserved.
- All provider keys server-side only (env /
settings); never sent to the client. - Hermes admin is powerful: keep Hermes localhost-bound;
HERMES_API_KEYrequired; the hub is the only thing calling Hermes. - RAG is read-only on local SQLite; excludes
is_privateby default (matchesdocs/hermes-integration.mdprivacy rules). - Destructive Hermes actions are gated by the approval flow; UI shows explicit Admin mode (Hermes) indicator whenever the Hermes toolset is live.
- Unit: retrieval (FTS + private exclusion), provider target mapping, Hermes event normalization, SSE protocol shaping.
- Integration (mocked backends): Go-model stream round-trip; Hermes run with a tool-progress event and an approval-requested → resolved sequence; grounded vs ungrounded context injection; insert via tool and via explicit button.
- Persistence: thread/message CRUD, cascade delete, history reload.
Vertical slices, each independently shippable:
- Provider abstraction + hub + minimal UI + persistence — talk to a Go model end-to-end: streamed response, threads saved, history reloads.
- RAG grounding + toggle — retrieval injection, per-thread/per-send toggle.
- Insert — explicit "Save to ShadowBrain" button (both targets) + Go-model
save_to_shadowbraintool. - Hermes target via Runs API — tool-progress surfacing + approval prompts/resolution (core). Admin-mode indicator.
- UI polish — target selector, grounding/allow-save controls, tool-progress & approval rendering, markdown, save action.
(Dependencies: 2–4 build on 1. 4 is independent of 2–3.)
- Hermes-side insert/grounding via MCP: should we later expose ShadowBrain search/insert as an MCP server registered with Hermes, so Hermes can ground & save natively? (Currently out of scope; revisit after v1.)
- Approval policy defaults: auto-deny patterns for clearly destructive commands server-side, even before prompting the user? (v1: always prompt.)
- Go-model tool support: undocumented for OpenCode Go — if a selected model ignores tools, the explicit save button still works; confirm per-model in v1.