# Design: platform assistant — a server-embedded agent that operates Harness Nexus as the user > Issue #52 (depends on #50 Node floor, #51 provider model registry). > Status: **M1 SHIPPED 2026-10-14, M2 + M3 SHIPPED 2026-10-15** — M1: the > skeleton (data model, services, pi runtime with the v1 tool groups, > `assistant:*` realtime, config page + Chat 端 integration). M2: the MCP > bridge (config `mcpServerIds` → pooled tools as `__` > AgentTools via the registry, lazy seam; unavailable rows note honestly) > and skills wiring (`skillResourceIds` → prompt index + `read_skill`; > save-time visibility/kind/source rules, turn-time skip-with-note), plus > the web pickers (client-dial greyed). M3: the write-tool approval gate > (`WRITE_TOOLS` surface ACP `permission_request` cards; respond/timeout/ > cancel all deny with a readable reason), the per-config daily token > budget (UTC-day sum over its sessions' turns, refused honestly at turn > start), and busy assistant turns as channel-bar tabs (× cancels the > turn; the session row persists). The #52 feature is complete as > scoped — no open follow-ups. ## 1. What it is A platform-native AI assistant: users chat with it in the web console and it operates Harness Nexus itself — create/update skill resources, manage profiles, search the skill hub, inspect machines, call MCP tools — always acting AS the owning user, never with elevated permissions. Over time most platform operations should be reachable through it. Runtime: `@earendil-works/pi-agent-core` (agent loop, tool execution, mid-conversation toolset/prompt changes) + `@earendil-works/pi-ai` (model protocol) embedded **in the server**, pinned exact versions. NOT the full `pi-coding-agent` SDK — it drags coding tools (bash/edit) and a resource loader; a server process runs neither. **New stance, stated explicitly** (the one-line version for AGENTS.md): the "server never runs an agent loop" rule was always about MACHINE-side agents (ACP adapters belong to the daemon). The platform's OWN assistant is a platform feature and runs in-process — per-user, per-session, no machine needs to be online. ## 2. Identity — the actor is an explicit principal parameter (Plan B) > Settled 2026-10-14 by user decision: the platform gets a full service > layer first (#53, `design-service-layer.md`); the assistant's tools call > principal-carrying services directly. Two earlier sketches are DEAD and > kept only as the historical record in > [`research-platform-assistant.md`](research-platform-assistant.md): the > AsyncLocalStorage context (disproven — light-my-request dispatches outside > the caller's async context) and the boot-time internal credential + > `x-hnx-actor` headers (superseded by this decision). No tokens of any kind, no self-HTTP, no auth-hook internal branch. The tool executor calls the service with the session owner as an explicit principal: ```ts // a tool's execute(), in full const page = await svcResources.list({ uow }, { userId: session.ownerId, role }, { kind: params.kind, q: params.q, }); ``` The service layer is the single authorization enforcement point (#36 rules, 404 existence-hiding) — the assistant exercises exactly the same code the REST door does, with zero duplication and nothing to forge. The principal's `role` is re-read from the DB at turn start (and on agent rebind), so disable/demote takes effect on the next turn. `beforeToolCall` enforces the group allowlist + writes an audit row per call (`assistant_tool_calls`). Why services and not `app.inject()` self-REST: consistency — one door for authorization truth, direct typed calls instead of a mock-HTTP hop, and the service layer has independent value (HTTP-free unit tests, future CLI/SDK reuse). Java analogy: `@Service` methods taking an `Authentication` parameter, controllers thin — rather than calling MockMvc from production code. ## 3. Data model (all owner-only rows) - `assistant_configs` — one row per named assistant, many per user: `{ id, ownerId, name (unique per owner), description?, providerId, defaultModel, systemPromptOverride?, enabledToolGroups[], mcpServerIds[], skillResourceIds[], createdAt, updatedAt }`. SQLite migration N+1 + memory driver. Soft-delete: config vanishes from pickers, sessions stay readable. - `assistant_sessions` — `{ id, ownerId, assistantId, title, createdAt, updatedAt, lastMessageAt }` + messages (`assistant_messages`: role-bearing JSON items — user / assistant / toolCall / toolResult / usage / note). **Platform-owned session persistence** is the explicit W7 counter-exception: W7's "the platform persists nothing session-shaped" is about machine agents; the assistant is platform data. The pi `Agent` state is rebuilt per turn from the DB — server restarts lose nothing. - `assistant_tool_calls` — audit: `{ id, sessionId, userId, tool, args (truncated), status, durationMs, createdAt }`. ## 4. Model & provider Rides #51: config references `providerId` + `defaultModel`; the sender's Model select lists that provider's `type='chat'` models. The pi-ai custom provider is built per turn from the resolved credential (`findCredentialForOwner` → `decryptSecret`; api mapping 1:1 as W16 verified: anthropic→anthropic-messages, openai-chat→openai-completions, openai-responses→openai-responses; baseUrl verbatim). temperature/topP/ serverTools/extraBody apply from the model's registry entry at request build (our own `streamFn` wrapper is the injection seam if pi-ai's options surface falls short — S0 spike verifies). Server→LLM outbound calls are outbound HTTP surface **#3**, governed by credential rules like `provider-models.ts`, not by the marketplace egress allowlist. ## 5. Tools v1 groups (all read-heavy; **no delete tools, no credential tools — destructive ops stay human**): | group | surface | | --- | --- | | `resources` | list/get/create/update (incl. skill bodies) | | `profiles` | list/get/create/update | | `mcp` | list servers / status / list tools (read) | | `skills-hub` | multi-source search | | `machines` | machines / agent instances / jobs (read) | | `system` | info (read) | Defaults: reads on everywhere, writes on for resources/profiles. Tools are typebox-parameterized `AgentTool`s (pi-native; the cross-package zod schemas stay the wire source — tool parameter schemas never leave the server). ## 6. MCP & skills - **MCP**: reuse the server-side `McpRegistry` pool. Config lists `mcpServerIds` the user can see; at session build each server's tools wrap as `AgentTool`s named `__`, executed via `registry.callTool` (credential resolution already lives there). Server-dialed rows only — client-dialed rows are honestly unavailable (greyed with a reason). Offline servers skip with a note (registry posture). - **Skills**: platform skill resources (inline / inline-bundle) → a skills INDEX section in the system prompt (name + description) + a `read_skill` tool for full bodies incl. bundle files. Progressive disclosure, Agent Skills-shaped, nothing lands on disk, single source of truth stays the resource store. ## 7. Streaming & transport `/app` socket namespace, rooms `chan:`, event family `assistant:*` (`session.open` → `session.ready`, `message.send`, `abort`, `event {sessionId, event}`, `history`). Event payloads REUSE the `ChatStreamEvent` shapes (`message_delta`, `thought_delta`, `tool_call`, `usage`, `turn_result`, `queue_state`, `user_message`) — pi-agent-core events map ~1:1, and the web's composer/tool cards/usage components come for free. Per-user busy gate + depth-1 queue (LiveSender semantics). `assistant:config.set {kind:'option', configId:'agent'|'model', value}` — opaque values, writable mid-turn, NEXT-TURN effect. Switching the agent mid-session is natively supported (pi diffs the toolset per request and announces changes in a system message; prompt patches ride system-message sections) — the transcript records a system note on rebind. ## 8. Web UI - **Chat page integration** (not a separate destination): the assistant is a pinned-first 端. Entry lands directly on the SESSION LIST of the default 端 (initial default: the assistant); the `- All agents` back-link position becomes a 端 dropdown (assistant pinned top, machine agents grouped with state lamps); a star next to the session-refresh button pins the default 端 (`localStorage hnx.chat.defaultEnd` — no server per-user pref store exists and none is created). The agent-cards screen retires (dropdown rows carry the state) — flagged as a reviewable call. `AgentSession` generalizes to an endpoint descriptor `{type:'assistant'} | {type:'agent', agentInstanceId}`: shared skeleton, branch points = socket family, new-session flow (no cwd), gates (no machine-online gate; per-user busy instead). Sender on the assistant 端 gains Agent + Model selects (machine 端 unchanged, W9). - **Config page** = new nav destination (one `nav.ts` declaration): `DataTable` + create button + shared `FormDialog` + two-stage delete. Dialog sections: basic (name/description), model (provider dropdown + registry models, default model), prompt (platform template prefilled, overridable) — tool groups and MCP/skills pickers grow in follow-ups. Empty assistant 端 → `EmptyState` + "create assistant" CTA. - **i18n** en/zh throughout; assistant → 助理; the chat-端 term is kept visually distinct from dial-site 拨号端 (the dropdown shows the 端's name). ## 9. Verification S0 spike (done, `packages/server/test/assistant-spike.test.ts`): pi-ai custom provider shape against a gateway baseUrl; pi-agent-core custom tools + `beforeToolCall` + event mapping on the scripted `fauxProvider`; Node 22 static import health. (The spike's identity arm — ALS vs internal credential — is historical; Plan B removed both.) Then: shared schema tests, service unit tests (scope rules without HTTP), AssistantService tests with the faux stream — the network is never touched — plus authz tests through the service seam (404 hiding, admin gates), agent-browser acceptance over the full Chat-page path, quartet on the Node 22 floor before merge. ## Post-ship: #54 — lazy session rows + the rich welcome (2026-10-15) Two review-driven changes: - **Lazy persistence.** `assistant:session.open` (no rejoinId) no longer writes a session row — the fresh open lives only as an in-memory live channel (`LiveChannel.pending`). `startTurn` saves the pending row at the FIRST message (the queue-flush path included), so an open-then-abandon conversation leaves nothing behind a refresh. The snapshot (`snapshotFor`) hides legacy empty rows (`lastMessageAt === null` and no live channel) — pre-#54 empties can no longer be created. Tests: `packages/server/test/assistant-lifecycle.test.ts` (3). - **Rich welcome (`WelcomePane`).** With no session open, BOTH chat pages (machine agent + assistant) render a big greeting, the new-session action, and the most recent sessions as a vertical card list (title / workspace cwd for client agents / relative time) — desktop ~5 cards, phones ~3 (excess hidden below md). The sender stays HIDDEN in the welcome state; the button and cards are the only entries.