# Research: platform assistant (#52) — S0 spike findings > Status: **verified 2026-10-14** on `feat/52-platform-assistant` > (`packages/server/test/assistant-spike.test.ts`, offline, no network). > Ground truth for the seams the M1 implementation rides on. > > **Superseded by the Plan B decision (same day)**: the user chose the full > service-layer refactor (#53) — assistant tools call principal-carrying > services directly. §1 below (ALS disproven, internal credential proposed) > is the HISTORICAL RECORD of why in-process identity could not ride ambient > context or self-HTTP; the mechanism itself is no longer part of the > design. §2–§4 (pi-ai/pi-agent-core seams) remain current. ## 1. Identity: AsyncLocalStorage is DEAD through `app.inject()` — internal credential instead The design's first sketch (ALS actor context opened around `app.inject()`) was **disproven**: light-my-request dispatches outside the caller's async context — the ALS store is empty inside `onRequest` hooks. Verified in bare Node (not just vitest): ```js const r = await als.run({ userId: 'u-1' }, () => app.inject({ url: '/w' })); // hook sees: no store. (outside run: also no store — correct, but the // inside-run case is the one that matters and it fails.) ``` A real loopback socket would not carry ALS either (server-side handlers run from the event loop). Settled mechanism (test-verified): - **boot-time random secret** (`randomBytes(32).base64url`), held in server memory only; **restart rotates it**; - tool executors call `app.inject()` with headers `x-hnx-internal-auth: ` + `x-hnx-actor: `; - the root auth hook's leading branch: `timingSafeEqual` on the secret, then a FRESH `findUserById` (disabled/demoted takes effect next call, matching the PAT path), then `req.user` + a `viaAssistant` audit marker; mismatch/absent → anonymous → the normal Bearer path. Network clients cannot know the secret; header names are not the gate. Cryptographically equivalent to a minted JWT but per-process, no TTL juggling, nothing to revoke, and it survives any future transport change. ## 2. pi-ai gateway provider — `createProvider` is the models.json factory `createProvider({id, name, baseUrl, auth, models, api})` builds a `Provider` from parts (the same path models.json custom providers take). Verified shape for our LlmProvider rows: - `auth.apiKey.resolve: async () => ({ auth: { apiKey: decrypted }, source: 'credential' })` — resolution is deferred per request (the decrypt happens here, not at build time); - registry models map to `ProviderModel` entries: `api` mapped 1:1 (anthropic→anthropic-messages, openai-chat→openai-completions, openai-responses→openai-responses), `name`/`contextWindow`/`maxTokens` from the #51 registry; `reasoning: false` default; `input: ['text','image']` when `vision`; - `api:` takes `{stream, streamSimple}` from the per-wire modules — import as `@earendil-works/pi-ai/api/openai-completions` (subpath; the ROOT index does NOT export the api factory functions; the `.lazy` variants export `openAICompletionsApi()`-style factories that wrap the same thing). ## 3. Agent loop + tool harness — `fauxProvider` is the test wire - `fauxProvider({models, tokensPerSecond})` + `setResponses([...])` scripts assistant messages (text/thinking/toolCall blocks via `fauxAssistantMessage`) with ZERO I/O — the AssistantService unit tests never touch the network (repo rule); - `new Agent({initialState: {systemPrompt, model, tools, messages}, streamFn: models.streamSimple.bind(models), beforeToolCall, afterToolCall})` — verified end-to-end: scripted tool call → `tool_execution_*` → scripted conclusion → `turn_end` ×2 → `agent_end`; both audit hooks fire in order; tools are pi-native typebox schemas (`piAi.Type.Object`); - `Agent.state.messages` is the rebuild-from-DB seam: assign per turn, pi owns nothing durable (platform-owned session persistence holds). ## 4. Environment `@earendil-works/{pi-agent-core,pi-ai}@1.1.0` (pinned exact) import cleanly on Node 22.22 (engines >=22.19; the #50 floor). pi-ai re-exports typebox `Type` — tool schemas need no extra dependency.