Skip to content

epic: NetScript AI Stack — first-class AI runtime, chat & plugin seams (anchor #219) #238

Description

@rickylabs

Epic: NetScript AI Stack — first-class AI runtime, chat, and plugin seams

Status: architectural research complete (no implementation yet). This epic is the traceable basis from which we iterate into sub-issues → draft PRs → slices.
Anchor: #219 (durable-CHAT primitive gap + streams gzip-mislabel bug).
Research author: Fable 5 (principal-architect pass), delegating to Opus 4.8 / Sonnet 5 sub-agents. Grounded in the live eis-chat repo @ HEAD 26e1b65 (2026-07-02) and the NetScript reference plugins (auth / workers / streams / kv / sdk / fresh-ui).

Why this epic exists

eis-chat (the flagship consumer) hand-rolled a large "AI stack" — provider/model routing, agent loop, tool + MCP registry, embeddings/vision, durable-chat transport, and the whole presentational chat surface — because NetScript did not yet expose the seams for them. It also carries a live workaround for a real framework bug (#219). This epic captures the architecture for lifting that stack into NetScript better than the hand-rolled original, using the auth plugin / workers / kv patterns as the quality bar.

The plan below is Fable 5's. It is not a locked decision set — it's the strong, grounded basis we now iterate on together (rough brainstorm → sub-issues → draft PRs). Items still needing a human product call are tagged [PRODUCT SIGN-OFF].

The shape, in one paragraph

A five-home split rather than one monolithic plugins/ai: (1) a NEW @netscript/ai standalone engine core (peer to kv/sdk, provider adapters by subpath), because the AI engine is used in-process by Fresh routes, workers, and services — not service-bound like auth; (2) a NEW @netscript/fresh/ai subpath for the durable-chat client/SSR/proxy runtime; (3) extend the existing @netscript/fresh-ui ai registry for the presentational surface; (4) a thin packages/plugin-ai-core holding only the optional gateway contract; (5) an optional plugins/ai centralized AI-gateway service. The #219 gzip fix ships first and standalone (~15 LOC).


Traceability — sources, issues, PRs, external deps

Anchor & related issues

NetScript reference surfaces the plan is grounded in (emulation targets / precedents)

  • Auth plugin — packages/plugin-auth-core/, plugins/auth/, packages/auth-better-auth|auth-kv-oauth|auth-workos/ (contract-in-core + thin manifest + adapters).
  • Workers plugin — plugins/workers/ (gold-standard multi-axis manifest, verify-plugin.ts, registry code-gen).
  • Streams — plugins/streams/services/src/main.ts (proxy — the [AI-stack anchor] streams: no durable-CHAT integration — @netscript/fresh/ai seam (StreamDB shapes → Electric "Decoding failed") #219 fix site) + packages/plugin-streams-core/src/application/stream-url-resolver.ts (getStreamsUrl/getStreamsAuth/buildStreamUrl).
  • Fresh runtime — packages/fresh/src/runtime/streams/create-stream-db.ts (createNetScriptStreamDB — the sibling the new ./ai subpath mirrors).
  • Fresh-UI registry — packages/fresh-ui/registry.manifest.ts:1102-1116 (the ai collection already shipping — extend it).
  • Standalone-core precedent — packages/kv/ + packages/sdk/ (adapter-registration-by-subpath-import; the @netscript/ai shape).
  • Doctrine — docs/architecture/doctrine/{01-thesis-and-axioms,06-archetypes,07-composition-and-extension,09-anti-patterns-and-fitness-functions}.md; harness ARCHETYPE-5-plugin.md; fitness fns F-3/F-5/F-6/F-13.

External dependencies / prior art

  • @durable-streams/tanstack-ai-transport@^0.0.8durableStreamConnection, toDurableChatSessionResponse, ensureDurableChatSessionStream, materializeSnapshotFromDurableStream (ElectricSQL "Durable Sessions").
  • @tanstack/ai@0.10.x + -anthropic / -openai (/compatible) / -preact / -mcp — provider adapters + useChat + chat() agent loop.
  • @durable-streams/{client,server,state} — the underlying durable-stream transport already used by NetScript streams.
  • @modelcontextprotocol/sdk + @mcp-ui/client — MCP tool transport + ui:// widget rendering.

Live consumer reference (private)

  • eis-chat @ HEAD 26e1b65 (2026-07-02) — key files: apps/dashboard/islands/ChatPane.tsx, routes/api/chat.ts, routes/api/chat-stream.ts, routes/api/streams/[...path].ts, lib/stream-loaders.ts, lib/chat-render.ts, components/ui/{message,markdown,mcp-widget,chart-block,code-block,tool-call-card}.tsx, lib/{models,paced-reveal,mcp}.ts, services/eischat/src/{embeddings,vision}.ts, packages/channel/mod.ts. The three Accept-Encoding: identity [AI-stack anchor] streams: no durable-CHAT integration — @netscript/fresh/ai seam (StreamDB shapes → Electric "Decoding failed") #219 workaround sites (chat-stream.ts:72, streams/[...path].ts:60, stream-loaders.ts:82) are the Slice-0 acceptance test.

Related tooling PR (context, not a dependency)


Architecture plan (Fable 5, rev. 2) — verbatim

The remainder of this issue is the research artifact as delivered. Sub-issues will be carved from §7 (migration slices) and §6 (open questions).

NetScript AI Stack — Architecture Plan (rev. 2)

Author: Principal framework architect (Fable 5). Architecture/design pass only, no implementation.
Anchor: issue #219 (durable-CHAT primitive gap + gzip-mislabel bug).
References: auth plugin (emulation target), workers (gold-standard plugin), @netscript/kv (standalone-core precedent), @netscript/fresh-ui ai registry collection (already-shipping chat primitives).
Scope basis: the LIVE eis-chat repo @ HEAD 26e1b65 (2026-07-02), incl. mcp-ui DS-token theming, the react-markdown pipeline, paced-reveal streaming, hybrid KB retrieval.
Every decision below is mine, with rationale. Items needing eventual human product sign-off are tagged [PRODUCT SIGN-OFF]; I still give a recommendation.


0. The decisive re-architecture (what changed in rev. 2)

Rev. 1 put "everything in plugins/ai." That was wrong. The live-repo evidence forces three corrections:

  1. eis-chat runs the agent loop IN-PROCESS, not through a plugin HTTP service. apps/dashboard/routes/api/chat.ts calls @tanstack/ai chat({adapter, tools, mcp, agentLoopStrategy}) directly in the Fresh route, and workers jobs (workers/jobs/embed-document.ts) + the eischat oRPC service (services/eischat/src/embeddings.ts) call embeddings directly. This is fundamentally unlike auth, where the backend is only ever reached through the auth service. The AI engine primitives are runtime-agnostic library code, used in-process across Fresh routes / workers / services — not service-bound. ⇒ they belong in a standalone core package, not a plugin.

  2. @netscript/fresh-ui already ships the presentational chat surface. Its registry.manifest.ts:1102-1116 defines a named ai collection (message, tool-call-card, code-block, chart-block, citation-chip, model-selector, prompt-input, command-palette, search, theme-seed), and eis-chat's apps/dashboard/components/ui/* ARE those copy-source components — extended locally. ⇒ presentational primitives belong in the fresh-ui registry (extend the existing collection), NOT in a plugin or a new package.

  3. @netscript/kv/@netscript/sdk prove the standalone runtime-agnostic core-package pattern is idiomatic (packages/kv/deno.json, packages/sdk/deno.json — zero @netscript/* deps, adapter-registration-by-subpath-import at packages/kv/redis.ts:1-20). ⇒ the AI engine should be a peer core package @netscript/ai, with provider adapters as subpath-registered adapters (@netscript/ai/anthropic), NOT a proliferation of packages/ai-* packages.

Net topology (five homes, each justified in §3):

Capability Home Why
Provider routing, agent loop, tool + MCP registry, embeddings/vision ports + adapters, model registry, reasoning/token-cap normalization, telemetry NEW @netscript/ai (standalone core, archetype 2) runtime-agnostic; used in-process by Fresh routes, workers, services — like @netscript/kv
Durable-chat client connection, SSR snapshot/resume, the /api/chat-stream proxy recipe @netscript/fresh new ./ai subpath (archetype 4) Fresh/island client-runtime; sibling to the existing ./streams seam
Message renderer, markdown, tool-call card, chart/code block, citation chip, model selector, prompt input, mcp-ui widget, typing indicator, paced-reveal hooks @netscript/fresh-ui ai registry (archetype 4, copy-source) presentational; the collection already exists — extend it
The oRPC contract for an optional centralized AI-gateway service packages/plugin-ai-core (archetype 2, thin) only needed to back the plugin; re-exports @netscript/ai types
The optional AI-gateway plugin service (centralized BYOK key custody, shared tool/MCP registry, cross-app usage accounting, rate limiting) plugins/ai (archetype 5) multi-app / centralized-key deployment topology; NOT required for the single-app embedded case

The plugin is now optional infrastructure, not the center of gravity. The center is @netscript/ai (engine) + @netscript/fresh/ai (chat runtime) + the fresh-ui ai registry (chat UI).


1. @netscript/ai — the standalone AI engine core (NEW package)

Archetype 2 (integration): wraps @tanstack/ai-* + provider SDKs behind ports + adapters. Modeled structurally on @netscript/kv (packages/kv/deno.json:6-11): a root port/lifecycle API plus subpath-registered adapters. Zero Fresh coupling — usable in Fresh routes, workers jobs, oRPC services, or standalone scripts, exactly as eis-chat uses @tanstack/ai today.

1.1 Exports map (kv-shaped, adapter-by-subpath)

@netscript/ai
  .                     → mod.ts   — createAiRuntime(), getAiRuntime(), ports, registries, model-registry, config, presets, telemetry
  ./anthropic           → anthropic.ts          — registers 'anthropic' ModelProvider adapter (side-effect, wraps @tanstack/ai-anthropic)
  ./openai-compatible   → openai-compatible.ts  — registers 'openai-compatible' adapter (wraps @tanstack/ai-openai/compatible; OpenAI/OpenRouter/LiteLLM)
  ./openai-embeddings   → openai-embeddings.ts  — registers embedding + vision adapters (OpenAI-compatible /v1/embeddings + vision)
  ./mcp                 → mcp.ts                 — McpTransport adapters (stdio/http) wrapping @tanstack/ai-mcp
  ./tools               → tools.ts              — defineAiTool(zod-input).server(fn), createToolRegistry
  ./agent               → agent.ts              — createAgentLoop(), maxIterations, AgentLoopPort (the @tanstack/ai chat() wrapper as a state machine)
  ./contracts           → contracts/mod.ts       — runtime-agnostic message/part types shared with the plugin contract
  ./testing             → src/testing/mod.ts     — in-memory ports

The @netscript/kv/redis self-registration pattern (registerKvAdapter, packages/kv/redis.ts:1-20, consumed by getKv() auto-detect at packages/kv/mod.ts:11-22) is copied verbatim: importing @netscript/ai/anthropic self-registers the provider so createAiRuntime() resolves it, and an app that never uses Anthropic never pays for @tanstack/ai-anthropic. This is the key reason adapters are subpaths not packages — AI provider SDKs are heavy optional deps, precisely kv's situation.

1.2 Ports (the AuthBackendPort analogue, cf. packages/plugin-auth-core/src/ports/mod.ts:211-241)

export interface ModelProviderPort {
  readonly name: string;                                 // 'anthropic' | 'openai-compatible'
  readonly capabilities: ModelProviderCapabilities;      // supportsReasoning, supportsVision, ...
  buildChat(opts: BuildChatOptions): BuiltChat;          // { adapter, modelOptions } — normalizes reasoning + token caps
}
export interface AgentLoopPort {
  run(input: AgentRunInput, signal?: AbortSignal): AsyncIterable<StreamChunk>;  // A12 state machine; F-13 AbortSignal + stop()
}
export interface ToolRegistryPort { register(t: AiToolDefinition): void; list(): readonly AiToolDefinition[]; resolve(name: string): AiToolDefinition | undefined; }
export interface McpTransportPort  { connect(cfg): Promise<McpSession>; /* keep-alive pools */ }
export interface EmbeddingProviderPort { embed(texts: string[]): Promise<number[][]>; }
export interface VisionProviderPort    { transcribe(bytes: Uint8Array, mime: string): Promise<string>; }

createAiRuntime(config) is the composition root (A10, docs/architecture/doctrine/07-composition-and-extension.md:14-81): constructor-injects the registered adapters, model registry, and telemetry; no module-load side effects except the opt-in adapter self-registration. Model registry = ModelEntry {provider, id, label, reasoning?} (from apps/dashboard/lib/models.ts:17-26), env-overridable via config, replacing eis-chat's EISCHAT_MODELS scraper (lib/models.ts:43-57). Reasoning normalization (Anthropic thinking:{type:'adaptive'} + output_config.effort vs OpenAI-compat reasoning:{effort}apps/dashboard/routes/api/chat.ts:90-126) and token caps (ANSWER_MAX_TOKENS=8192/REASONING_MAX_TOKENS=16384, chat.ts:66-67) live in -core per the thinness law, hidden behind buildChat.

No provider SDK type ever leaks to userland — userland imports @netscript/ai + optionally @netscript/ai/anthropic (registration only). This is the auth invariant (adapters import only @netscript/plugin-auth-core/contracts/v1).

1.3 Why NOT fold this into packages/plugin-ai-core (the auth shape)?

Auth folds engine+contract into one -core because the auth backend is only invoked via the auth service. The AI engine is invoked in-process, independent of any service (three call sites in eis-chat prove it). Doctrine A9/A11: a package earns its own identity when its consumers are broader than one plugin. @netscript/ai has three non-plugin consumers already (Fresh routes, workers, services), so it is a peer core package, and plugin-ai-core shrinks to just the service contract that re-exports it. This is the single most important structural call in this plan.


2. @netscript/fresh/ai — the durable-chat client runtime (NEW subpath)

Archetype 4, sits at packages/fresh/src/runtime/ai/, sibling to runtime/streams/. It is the exact analogue of createNetScriptStreamDB (packages/fresh/src/runtime/streams/create-stream-db.ts:100-114): a Fresh-runtime factory that composes the streams URL/auth seam. Precedent that this dependency direction is allowed: @netscript/fresh already imports buildStreamUrl/getStreamsAuth/getStreamsUrl from @netscript/plugin-streams-core (create-stream-db.ts:18). Added to packages/fresh/deno.json exports (currently ., ./server, ./streams, ./query, ... at :6-17; new dep @durable-streams/tanstack-ai-transport@^0.0.8 + @tanstack/ai-preact; @durable-streams/state already present).

2.1 The distinction that must be documented (root-cause fix for #219)

NetScript ships two stream consumers; they are NOT interchangeable. The @module JSDoc (F-5 mandatory) must lead with this table:

StreamDB shapes (data tables) Durable Sessions (AI chat)
Surface createNetScriptStreamDB + useLiveQuery (create-stream-db.ts:100-114) NEW @netscript/fresh/ai
Pkg @durable-streams/state/db @durable-streams/tanstack-ai-transport + @tanstack/ai-preact
Shape rows keyed by PK, upsert/delete ordered append-only chunk log, offset-addressed
Consumer {data, status, error} {messages, sendMessage, isLoading, stop} + optimistic + resume + multi-tab
#219 failure if misused shapes-for-transcript ⇒ TypeError: Decoding failed (Electric TextDecoder on gzip'd multibyte)

2.2 Surface

// @netscript/fresh/ai  (island-safe, preact/compat like ./streams)

// CLIENT — wraps durableStreamConnection with NetScript URL/auth resolution
export function createNetScriptChatConnection(opts: {
  sessionId: string;
  streamPath?: (id: string) => string;   // default '/ai/sessions/${id}/messages'
  baseUrl?: string;                       // default same-origin proxy
  initialOffset?: string;                 // from resolveChatSnapshot
  outputSchema?: unknown;                 // §6-Q2 typed structured-output passthrough
}): DurableStreamConnection;              // { subscribe, send } — satisfies TanStack SubscribeConnectionAdapter

// SERVER — wraps toDurableChatSessionResponse; resolves writeUrl via buildStreamUrl + getStreamsAuth
export function toNetScriptChatResponse(opts: {
  sessionId: string;
  streamPath?: (id: string) => string;
  newMessages: DurableSessionMessage[];
  responseStream: AsyncIterable<unknown>;         // AgentLoopPort.run() output
  mode?: 'immediate' | 'await';                    // default 'await'
  authorize?: (req: Request, sessionId: string) => Promise<boolean>;   // §6-Q5 ownership hook
}): Promise<Response>;

// SSR resume/snapshot
export function resolveChatSnapshot(sessionId: string, streamPath?): Promise<{ messages: UIMessage[]; offset?: string }>;
//   internally ensureDurableChatSessionStream({writeUrl,headers,createIfMissing:true})
//   then materializeSnapshotFromDurableStream({readUrl,headers}) → {messages, offset}

// the canonical /api/chat-stream proxy recipe (one impl, replaces eis-chat's 3 hand-rolled proxies)
export function createChatStreamProxyHandler(opts?: {
  streamPath?; authorize?: (req: Request, sessionId: string) => Promise<boolean>;
}): (req: Request) => Promise<Response>;

// MCP-UI sandbox route recipe (backs the fresh-ui mcp-ui-widget; §4)
export function createMcpSandboxHandler(opts?: { csp?: string }): (req: Request) => Promise<Response>;

Verified transport API (@durable-streams/tanstack-ai-transport@0.0.8): durableStreamConnection({sendUrl,readUrl?,initialOffset?,...})→{subscribe,send}; toDurableChatSessionResponse({stream:Pick<DurableStreamTarget,'writeUrl'|'headers'|'createIfMissing'>, newMessages, responseStream, mode})→Promise<Response> (202 immediate / 200 await); ensureDurableChatSessionStream, materializeSnapshotFromDurableStream({readUrl,headers,offset?})→{messages,offset}. useChat({connection, initialMessages, live:true, forwardedProps}) (@tanstack/ai-preact@0.10.1); live:true holds the subscription open for the component lifetime (multi-tab + resume-in-progress). Offsets are opaque <read-seq>_<byte-offset>; the stream IS the log so a mid-generation reload catches in-flight tokens.

2.3 The proxy recipe (createChatStreamProxyHandler)

eis-chat hand-wrote three same-origin read proxies (routes/api/chat-stream.ts, routes/api/streams/[...path].ts, lib/stream-loaders.ts). Ship ONE that: maps ?id=buildStreamUrl(chatStreamPath(id)), forwards State-Protocol params (offset/live/handle/cursor) except id (chat-stream.ts:54-56); attaches getStreamsAuth() server-side only (the security reason the proxy exists — keeps STREAMS_SECRET off the browser); strips hop-by-hop + content-encoding/content-length on the way back (streams/[...path].ts:24-31); bridges disconnect via cancel() not req.signal (chat-stream.ts:76-114). After the §3 bug fix it drops the Accept-Encoding: identity request header but keeps the response-header strip as defense-in-depth.


3. #219 gzip-mislabel bug — root cause, fix, verification

Root cause (empirically reproduced, Deno 2.9.0): decompress-but-don't-relabel at the streams proxy hop (plugins/streams/services/src/main.ts). The engine (@durable-streams/server@0.3.7) gzips correctly only for bodies ≥1024 B; Deno's inner fetch transparently decompresses but retains content-encoding: gzip; the proxy returns it verbatim (main.ts:70-85, return await fetch(proxyReq) at :81, even CORS-exposes the header at :110-123). Compliant decoders then try to gunzip [{... → throw. Size-gated ⇒ intermittent (why it looked random). The identity workaround only makes the engine skip compression.

Fix location: plugins/streams/services/src/main.ts proxyHandler — after the inner fetch, rebuild the Response stripping content-encoding/content-length/hop-by-hop (exactly eis-chat's own client-proxy STRIP set, routes/api/streams/[...path].ts:24-31). NOT engine compression-disable (discards real wire compression, leaves the latent Deno bug), NOT a client change. The engine and client are both correct; the proxy is the wrong actor.

Verification: curl -sD - --compressed '<front>/v1/stream/netscript/<path>' on a >1KB snapshot (pre-fix: header says gzip, body magic 5b 7b not 1f 8b; post-fix: no header, valid JSON). Add a >1KB read case to the scaffold.runtime E2E with a compliant decoder. Acceptance = deleting all three identity sites (chat-stream.ts:72, stream-loaders.ts:82, streams/[...path].ts:60). Ships FIRST, ~15 LOC, standalone — unblocks eis-chat before any AI package exists.


4. @netscript/fresh-ui ai registry — the presentational surface (EXTEND existing)

These are copy-source components (netscript ui:add <item>) — an app owns the code after copying, which is exactly why eis-chat could extend them. Preact function components, class?:string merged via cn(), @layer 2, @depends theme-seed, themed only through semantic --ns-* CSS custom properties (packages/fresh-ui/registry/theme/tokens.css), never JS-side.

Already shipping (packages/fresh-ui/registry.manifest.ts:1102-1116 + registry/components/ui/): message.tsx (+TypingIndicator, renderInline, packages/fresh-ui/registry/components/ui/message.tsx:100-108), tool-call-card.tsx, code-block.tsx, chart-block.tsx, citation-chip.tsx, model-selector.tsx, prompt-input.tsx.

Gaps to add to the collection (all proven-needed by the live eis-chat build, currently hand-rolled there):

  • markdown primitive — the biggest gap. eis-chat added apps/dashboard/components/ui/markdown.tsx (react-markdown@9 under preact/compat, remark-gfm/math, rehype-katex/highlight/sanitize, a remarkCitations mdast plugin [n]<ns-citation> markdown.tsx:53-82, mid-stream stripIncompleteSyntax :287-303). fresh-ui's message.tsx renderInline is inline-only (bold/code/citation); a real Markdown renderer belongs in the registry. Decision: add markdown as a registry item that emits the sanitize-hardened pipeline, with the citations plugin wired to the existing citation-chip.
  • mcp-ui-widget primitive — eis-chat's mcp-widget.tsx + mcp-sandbox.html.ts (the new HEAD feature) render ui:// resources in a themed sandboxed iframe. The DS-token theming (?theme= query → mcp-sandbox.html.ts:351-354, guest-token <style> injection + data-theme stamp :229-251, CSP header from ?csp= :356-376, MCPAppResource keyed by theme to force remount mcp-widget.tsx:144) is generic and belongs in the registry as a copy-source widget + a companion sandbox-route recipe. Decision: add mcp-ui-widget (the island component) + ship the sandbox-proxy as the @netscript/fresh/ai createMcpSandboxHandler server helper (§2.2), since it needs the Fresh middleware + CSP-header machinery.
  • paced-reveal hooks — eis-chat's lib/paced-reveal.ts (useMinVisible/usePacedReveal, thinking-min-duration + token pacing) is a reusable streaming-UX primitive. Decision: add as a registry lib item (copy-source hook), not a runtime export, since it is presentational polish an app will want to tune.

fresh-ui is standalone (no @netscript/* runtime deps), so these additions carry no dependency-direction risk. The mcp-ui-widget + markdown do pull heavy npm deps (@mcp-ui/client, react-markdown + unified stack) — acceptable because copy-source items only add deps to the app that runs ui:add, never to the library's own published graph (F-6 safe).


5. packages/plugin-ai-core + plugins/ai — the OPTIONAL AI-gateway service

This exists for one topology: centralized BYOK key custody + shared tool/MCP registry + cross-app usage accounting + rate limiting, where multiple apps hit one AI gateway instead of each embedding provider keys. The single-app embedded case (eis-chat today) does NOT need it — it uses @netscript/ai + @netscript/fresh/ai directly. Building it emulates auth precisely:

  • packages/plugin-ai-core (archetype 2, thin): aiContract (oRPC) extends BasePluginContract with mandatory describe, spreads ...BASE_PLUGIN_CONTRACT_ROUTES (packages/plugin/src/contract-base/domain/base-contract.ts:76-122), merges oc.errors({...BASE_PLUGIN_ERRORS, ...AI_ERRORS}) — exactly auth (packages/plugin-auth-core/src/contracts/v1/auth.contract.ts:405-429). Routes under /v1/ai: POST /chat (SSE agent-loop producer), GET /models, POST /tools/:name, POST /embed, POST /transcribe, GET /describe. Re-exports message/part types from @netscript/ai/contracts — never redefines.
  • plugins/ai (archetype 5): definePlugin('@netscript/plugin-ai', v).withType('api').withService({name:'ai-api', entrypoint, port}).withContractVersions([{version:'v1', loader:'./contracts/v1/mod.ts'}]).withRuntimeConfigTopics([{name:'ai'}])...build() (auth manifest shape, plugins/auth/src/public/mod.ts:23-57). Service = createPluginService(router, {name:'ai', context: () => ({runtime: getAiRuntime(), telemetry}), ...}) (the enforced builder chain, packages/plugin/src/service/presentation/create-plugin-service.ts:125-181; auth usage plugins/auth/services/src/main.ts:70-83). The service is a thin HTTP shell over @netscript/ai — it holds keys server-side and exposes the same ports over oRPC. contracts/v1/mod.ts = export * from '@netscript/plugin-ai-core/contracts/v1' (auth pattern). verify-plugin.ts asserts service + v1 contract + ai runtime-config topic (plugins/workers/verify-plugin.ts:21-96).

Optional background-processor contribution re-declaring embed/transcribe as plugin jobs (workers idiom defineJobHandler + Object.assign(handler,{id})) if the gateway owns ingestion; otherwise those stay app-side workers jobs.


6. Open questions — resolved (my recommendations)

Q1 — Token accounting. eis-chat uses a chars/4 estimate feeding /usage (apps/dashboard/routes/api/chat.ts:203) because it reads nothing from the stream. DECISION: AgentLoopPort.run() must emit a real usage metadata chunk extracted from the @tanstack/ai-* finish event (the adapters carry provider usage). chars/4 is a defect, not a design. [PRODUCT SIGN-OFF] only on whether usage is persisted/billed and at what granularity — the seam is my call and is required.

Q2 — outputSchema vs chart-fence. Not mutually exclusive. Fenced chart is a rendering affordance (fresh-ui chart-block already parses it, model-agnostic); TanStack outputSchema on useChat is a typed data contract. DECISION: support both — keep fenced blocks as the presentational convention in the fresh-ui registry, and expose outputSchema passthrough on createNetScriptChatConnection (§2.2) for apps wanting schema-validated structured output. No human decision needed.

Q3 — Embeddings/vision one adapter or two. DECISION: one subpath @netscript/ai/openai-embeddings covering both (both hit OpenAI-compatible endpoints; services/eischat/src/{embeddings,vision}.ts share openaiKey()), but two distinct ports (EmbeddingProviderPort, VisionProviderPort) so a non-OpenAI vision provider can register independently later (A11 — name the axis now, split the package only when a second impl appears). Also fixes eis-chat's inconsistency where vision is hardcoded gpt-4o-mini (services/eischat/src/vision.ts:9) while embeddings are configurable. My call.

Q4 — VectorStorePort in v1. DECISION: OUT of v1. Ship EmbeddingProviderPort (compute) only; leave vector storage/search to the app (eis-chat's tursodb vector32/vector_distance_cos hybrid retrieval in packages/channel/mod.ts is DB-specific). Coupling a core port to a concrete store is exactly the sagas/triggers engine-lock debt to avoid (user memory plugin-core-depends-on-primitives.md). [PRODUCT SIGN-OFF]: if a first-class KB becomes a framework goal, revisit as a separate @netscript/ai/vector subpath (pgvector/libsql/tursodb adapters) or a plugins/ai-kb — I recommend deferring until a second vector backend exists.

Q5 — Chat-route authz. eis-chat has zero authz on chat/stream/session routes (no ownership checks; @workos-inc/node import-mapped but unused). DECISION: the durable-chat server helper + proxy recipe expose an authorize(req, sessionId) hook (shown in §2.2), and the docs make the secure path the easy path — the proxy already holds the streams secret server-side, so session-ownership verification is a natural extension, composing @netscript/plugin-auth session middleware. The seam is my call (hook provided, secure-by-default docs). [PRODUCT SIGN-OFF]: whether authz is hard-required vs opt-out — some deployments are single-user/local — is a product choice; I recommend hook-provided + documented-required, not enforced-required.

Q6 — Promote thinness + base-contract/base-service laws to written doctrine. They currently live only in user memory + partial code (slice 172a-2), not in docs/architecture/doctrine/. DECISION/recommendation: YES, promote both to a doctrine chapter — a new stack that must be "checked against doctrine" needs them written and enforceable. [PRODUCT SIGN-OFF] — it is the user's doctrine to amend; I recommend it strongly and can draft the chapter.


7. Migration path for eis-chat (slices, smallest-first)

Ordering respects Archetype-5 discipline (contracts → runtime → verification → integration) and front-loads highest value / lowest cost.

  • Slice 0 (~15 LOC, ship now): [AI-stack anchor] streams: no durable-CHAT integration — @netscript/fresh/ai seam (StreamDB shapes → Electric "Decoding failed") #219 gzip fix in plugins/streams/services/src/main.ts + regress test; delete the three identity workarounds. Proves the framework can own a streams-hop concern. No new packages.
  • Slice 1: @netscript/fresh/ai (subpath, not a package/plugin). Wrap the transport + reuse the streams seam; migrate eis-chat ChatPane.tsx/chat.ts/chat-stream.ts/stream-loaders.ts. Highest value, lowest structural cost.
  • Slice 2: @netscript/ai engineModelProviderPort + @netscript/ai/anthropic + @netscript/ai/openai-compatible, model registry, reasoning/token-cap normalization. Migrate eis-chat buildChat/models.ts/llm.ts; kill the .env scraper via @netscript/config. Then ./agent, ./tools, ./mcp, ./openai-embeddings; migrate the chat() call, KB tool, MCP pool, embeddings/vision.
  • Slice 3: fresh-ui ai registry additionsmarkdown, mcp-ui-widget (+ the @netscript/fresh/ai createMcpSandboxHandler server helper), paced-reveal. Migrate eis-chat's markdown.tsx/mcp-widget.tsx/mcp-sandbox.html.ts/paced-reveal.ts back to ui:add items so the app re-derives from the registry.
  • Slice 4 (optional): packages/plugin-ai-core + plugins/ai — only if/when a centralized multi-app AI gateway is wanted. verify-plugin.ts, contract re-export, createPluginService shell over @netscript/ai.
  • Slice 5 (optional): scaffold/dxplugin add ai + ui:add bundle recipe (auth adapter pattern).

Persistence/analytics stays app-owned (the durable stream is the transcript log; tursodb hybrid-KB + analytics are eis-chat domain data).


8. Doctrine conformance & quality bar

Placement vs doctrine: each home is justified by layering + thinness + the "consumers broader than one plugin ⇒ own package" rule (A9/A11). Dependency directions (all downward, acyclic): @netscript/ai@std/* + @tanstack/ai-* only (peer to kv/sdk, zero @netscript/*); @netscript/fresh/ai@netscript/plugin-streams-core + @netscript/ai/contracts (precedented — fresh already depends on plugin-streams-core, create-stream-db.ts:18); plugin-ai-core@netscript/ai + @netscript/plugin; plugins/aiplugin-ai-core + @netscript/ai; fresh-ui ai registry → standalone copy-source (no lib deps).

Fitness gates:

  • F-3 (layering): @netscript/ai uses role folders domain/ports/application/adapters; no up-imports; avoid forbidden folder names utils/helpers/common/lib; do not copy workers-core's empty application/ wart.
  • F-5 (public surface/JSDoc, "single most important"): every export gets @param/@returns/@example; @netscript/fresh/ai @module leads with the shapes-vs-sessions table (§2.1); split surfaces into subpaths (kv/auth reference, ≤20 exports per mod.ts).
  • F-6 (JSR publishability): explicit return types (root isolatedDeclarations:true), no export default in libs, no any in exported decls. Target publish WITHOUT --allow-slow-types (don't inherit the plugin-core T4 debt). Heavy provider/markdown/mcp deps stay off @netscript/ai's and fresh-ui's published graphs — providers are opt-in subpaths, UI is copy-source. Heed JSR memory traps: relative self-imports, assets via with {type:'text'}.
  • F-13 (runtime declarations): AgentLoopPort is a state machine with a terminal transition, threads AbortSignal, exposes stop(). Any plugins/ai embed/transcribe jobs use the workers .id-keyed registry code-gen discovery, not runtime FS scan; declaration types in plugin-ai-core/runtime.
  • verify-plugin.ts modeled on plugins/workers/verify-plugin.ts:21-96.

Quality bar: emulate workers (multi-axis manifest, extends BasePluginContract + ...BASE_PLUGIN_CONTRACT_ROUTES; createPluginService; denoJson.version single-source; registry code-gen; AspireNSPluginContribution). Emulate auth (contract-in-core re-exported by the plugin; base-contract extension; single-active named selection from env). Emulate kv (adapter-registration-by-subpath-import for the AI providers; runtime-agnostic core). AVOID sagas/triggers store/engine-lock coupling — no core port binds to a concrete store (drives Q4).


Delegation record

Investigation Model tier Feeds
Auth plugin architecture deep-dive Opus (high) §1.2, §5
Streams seam + #219 gzip root cause (Deno 2.9.0 empirical repro) Opus (high) §2, §3
eis-chat hand-rolled inventory (first pass) Opus (high) §1, §7
Doctrine + Archetype-5 + fitness gates + base seam Opus (high) §5, §8
Cross-plugin quality-bar (workers gold / sagas-triggers debt) Opus (high) §8
@durable-streams/tanstack-ai-transport + useChat API surface Sonnet (high) §2.2 verified signatures
Live eis-chat inventory diff @ HEAD 26e1b65 (mcp-ui theming, markdown pipeline, paced-reveal, new UI components) Opus (high) §0, §4, §6, §7
fresh-ui / fresh / kv / sdk placement survey Sonnet (high) §0, §1, §2, §4 (the whole placement re-architecture)

Two prior on-disk research files (research/eis-chat-ai-stack-map.md, research/netscript-reference-patterns.md) were read directly and corroborated.
Synthesis, the five-home placement re-architecture, all six resolved open questions, and this plan: Fable 5.


Sub-issue DAG (rev.4)

Anchor bug #239 (streams gzip-mislabel fix) ships first and standalone. Every AI-stack slice below was filed from the rev.4-E plan. Dependencies are resolved to real issue numbers; wave labels partition the release.

Widest starting front (no deps): #240 (E1), #249 (FA0), #253 (FB0), #263 (P6).

Cluster: ENGINE — @netscript/ai (new peer core, adapters by subpath)

Cluster: @netscript/fresh/ai (durable-chat client subpath)

Cluster: fresh-ui ai registry

Cluster: plugin-ai accelerator + doctrine

Release waves

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions