AI engine
@netscript/ai is the provider-agnostic AI engine at the design center of the stack: a
zero-@netscript/*-dependency, ports-and-adapters core that owns the domain
vocabulary, the capability seams, the model registries, the tool system, the agent
loop, MCP transports, and opt-in provider adapters. It wraps @tanstack/ai* and
@standard-schema/spec and adds no schema DSL of its own.
The export map
| Subpath | Purpose |
|---|---|
. |
Composition root + model / embedding / vision registries. |
./contracts |
Domain vocabulary — pure types and the error hierarchy. |
./ports |
Capability seams (hexagonal) plus default port / registry factories. |
./tools |
Tool definition / validation / registry, plus the built-in render_ui contract. |
./agent |
The agent loop. |
./mcp |
MCP transport adapters (stdio + Streamable-HTTP). |
./anthropic |
Anthropic provider (side-effect self-register). |
./openai-compatible |
OpenAI-compatible chat provider. |
./openrouter |
OpenRouter chat provider (OpenAI-compatible base + reasoning option). |
./ollama |
Ollama local chat provider (OpenAI-compatible base + reachability preflight). |
./openai-embeddings |
OpenAI embeddings + vision provider. |
./testing |
Deterministic port fakes. |
The runtime and registries
createAiRuntime(config) is a pure wiring function: it resolves every capability
port, defaulting each to a no-op or throwing implementation, with no IO and no global
mutation. getAiRuntime(config?) is the process singleton (shaped like getKv()), with
resetAiRuntime() and isAiRuntimeInitialized() alongside. The resolved AiRuntime
exposes telemetry, tools, embeddings, vision, mcp, skills, agentLoop, and
memory, plus getModelProvider() / getModel(). AiRuntimeConfig is the same field
set, all optional, so you inject only the ports you have real implementations for.
Three registries sit at the root, each with the same register / get / list / reset shape:
| Name | Type | Description |
|---|---|---|
registerModelProvider / getModelProvider / getModel / listModelProviders / resetModelRegistry |
model |
getModel(ref, config?) resolves a ModelHandle {descriptor, providerId} from a ModelRef. |
registerEmbeddingProvider / getEmbeddingProvider / listEmbeddingProviders / resetEmbeddingRegistry |
embedding |
The embedding provider seam. |
registerVisionProvider / getVisionProvider / listVisionProviders / resetVisionRegistry |
vision |
The vision provider seam. |
Models are addressed by reference. A ModelRef is string | ModelSelector; the string
form is "<provider>:<model>", e.g. "anthropic:claude-sonnet-4-5". Errors are a small
hierarchy rooted at AiError: AiNotConfiguredError (.capability) and
ModelProviderNotFoundError (.providerId, .availableProviders).
Contracts — the domain vocabulary
@netscript/ai/contracts is pure types with no IO — the vocabulary every layer above
the engine speaks.
- Messages.
Message { role, content, name?, toolCallId?, toolCalls? }withMessageRole = "system" | "user" | "assistant" | "tool"andMessageContent = string | readonly ContentPart[]. - Multimodal content.
ContentPartis aText | Image | Audio | Video | Documentunion; aContentSourceis either a base64DataContentSource(withmimeType) or aUrlContentSource;ContentModality = "text" | "image" | "audio" | "video" | "document". - Models.
ModelDescriptor,ModelCapabilities(streaming?,tools?,vision?,embeddings?,inputModalities?, token maxima), andModelSelector { provider, model }. - Tools.
ToolDescriptor,ToolParameters,ToolCall(with a raw-JSONargumentsstring and aToolCallState), andToolResult.
The wire vocabulary the whole stack streams is the AgentChunk union:
| Name | Type | Description |
|---|---|---|
TextChunk |
text |
A span of assistant text. |
ToolCallChunk |
tool-call |
A tool invocation (streamed input). |
ToolResultChunk |
tool-result |
A tool result. |
MessageChunk |
message |
A completed message boundary. |
UsageChunk |
usage |
Token / cost usage (Usage, with prompt / completion detail and optional cost breakdown). |
ErrorChunk |
error |
A terminal error before the final done. |
DoneChunk |
done |
The final chunk of a run. |
The vocabulary also carries the generative-UI contract: RENDER_UI_TOOL_NAME = "render_ui", RenderUiResult, and a UiResource whose uri is a ui:// string
(mirroring the MCP resource shape).
Ports — the hexagonal seams
@netscript/ai/ports exposes the capability interfaces plus their registry and default
factories: TelemetryPort, ToolRegistryPort, EmbeddingProviderPort,
VisionProviderPort, McpTransportPort, SkillLoaderPort, AgentLoopPort,
AgentMemoryPort, ChatClientPort, ChatModelProviderPort, and ModelProviderPort.
Two are worth calling out:
ModelProviderPort—{ id, listModels(), getModel(), supports(), createChatClient?() }.createChatClientis optional on the base, so discovery-only providers can omit it;ChatModelProviderPort { id, createChatClient(modelId) }is the narrow seam the agent loop injects.AgentMemoryPort—append(threadId, message)andload(threadId)are the base;recall?(threadId, query)is optional andundefinedby default. There is no built-in semantic recall — callers must guardrecalland fall back toload.
ChatClientPort.stream() yields ChatClientEvents
(ChatTextEvent | ChatToolCallEvent | ChatFinishEvent | ChatErrorEvent) with a
ChatFinishReason of stop | length | tool-calls | content-filter | error | unknown.
Tools — Standard Schema, no DSL
The tool system validates with Standard Schema, so you bring any conforming validator (zod, valibot, arktype, or hand-rolled) — the core adds no schema language.
defineAiTool(name)returns a builder:.describe(),.parameters(jsonSchema),.input(schema), terminating in either.server(handler)or.client()(a deferred tool, e.g.render_ui).createToolRegistry(defs?)implementsToolRegistryPort:.define(),.getDefinition(),.listDefinitions(), and.dispatch(name, input, ctx?), which validates input and throwsToolNotFoundError/ToolInputValidationError.renderUiToolis the built-inrender_uiwire contract: schema-only and client-deferred (result.deferred === true). The engine runs no renderer — rendering is the chat UI's job.AiToolExecutionKindis"server" | "client".
The agent loop
createAgentLoop(deps) builds the loop from injected collaborators: a required
modelProvider: ChatModelProviderPort, plus optional tools, history, and
defaultMaxSteps. loop.run(input, options?) returns an AsyncIterable<AgentChunk>;
input is { model, messages, tools?, system? } and options is { signal?, maxSteps? }. The loop exposes loop.state and loop.stop().
| Name | Type | Description |
|---|---|---|
AgentLoopState |
"idle" | "running" | "awaiting-tool" | "done" | "aborted" | "errored" |
isTerminalState() narrows to TerminalAgentLoopState. |
slidingWindowHistory({ maxMessages?, preserveSystem? }) |
HistoryStrategy |
The built-in history strategy; DEFAULT_HISTORY_WINDOW = 20. |
DEFAULT_MAX_STEPS |
8 |
Tool-calling steps before the loop must settle. |
AgentMaxStepsExceededError |
error (.maxSteps) |
Thrown when maxSteps is hit without a final answer; the run settles errored, yields an error chunk, then a final done. |
MCP transports
@netscript/ai/mcp adapts remote Model Context Protocol servers.
createMcpTransport(config) takes a discriminated config —
{ kind: "stdio" } & StdioMcpTransportConfig or { kind: "streamable-http" } & StreamableHttpMcpTransportConfig — and returns an McpTransportPort.
registerMcpTools(registry, transport) surfaces the remote tools into a registry and
returns a registration whose .stop() detaches. The Streamable-HTTP transport is
reconnectable with backoff (McpConnectionState = disconnected | connecting | connected | reconnecting | closed).
Auth is injected by the app composition root — McpAuthConfig is
{ mode: "none" }, { mode: "api-token", token, headerName?, scheme? }, or
{ mode: "oauth", accessToken, tokenType? }.
Provider adapters — opt-in side-effect imports
Providers self-register on import, mirroring @netscript/kv/redis. The base engine
pulls no provider SDK; you opt in per provider.
| Name | Type | Description |
|---|---|---|
@netscript/ai/anthropic |
chat |
Registers "anthropic" + AnthropicModelProvider; catalog taken verbatim from @tanstack/ai-anthropic. Config { apiKey? (→ ANTHROPIC_API_KEY), baseURL? }; cancellation via stream(_, { signal }). |
@netscript/ai/openai-compatible |
chat |
Registers "openai-compatible" + OpenAiCompatibleModelProvider. No fixed catalog — optimistic supports() when models is unset (the remote endpoint owns its catalog); throws AiNotConfiguredError if baseURL / apiKey are missing. Config { baseURL?, apiKey?, models?, api?, name? }, api = "chat-completions" | "responses". |
@netscript/ai/openrouter |
chat |
Registers "openrouter" + OpenRouterModelProvider over the OpenAI-compatible base (reuses @tanstack/ai-openai, no new dependency). Config { apiKey? (→ OPENROUTER_API_KEY), baseURL?, models?, reasoningEffort? }; reasoningEffort is "low" | "medium" | "high", normalized to the OpenRouter { reasoning: { effort } } wire option via openRouterReasoningModelOptions. |
@netscript/ai/ollama |
chat |
Registers "ollama" + OllamaModelProvider over the OpenAI-compatible base for a local endpoint. Config { host? (→ DEFAULT_OLLAMA_HOST), models?, reachability?, fetch? }; runs a ReachabilityPort preflight against the local host — ships createHttpReachabilityPort / HttpReachabilityAdapter (or createAssumeReachablePort to skip). |
@netscript/ai/openai-embeddings |
embedding + vision |
Registers "openai-embeddings" for both seams: .embed() (/embeddings) and .analyze() (/chat/completions). Defaults text-embedding-3-small / gpt-4o-mini. |
An import is all it takes — the provider is then discoverable by its id:
import "@netscript/ai/anthropic"; // self-registers the "anthropic" provider
const model = await getModel("anthropic:claude-sonnet-4-5");
Testing — deterministic fakes
@netscript/ai/testing supplies port fakes so an agent or tool can be exercised without
a network: createFakeChatModelProvider(id, turns), createFakeAgentLoop(chunks),
createFakeAgentMemory({ recall? }), createFakeModelProvider,
createFakeEmbeddingProvider(vector), createFakeVisionProvider(text),
createInMemoryToolRegistry(), and createFakeTelemetryPort() (whose .records capture
emitted telemetry).
Generated runtime registries
netscript generate ai produces the wiring that turns your app-owned tool and agent
files into engine-ready registries. It generates two targets, and only two:
| Name | Type | Description |
|---|---|---|
ai-tools |
name-keyed registry |
Keyed by descriptor.name; values are AiToolDefinition (from @netscript/ai/tools). |
ai-agents |
stem-keyed registry |
Keyed by file stem; values are a () => AgentLoop factory map. |
There is no skills target in this cut. Like the rest of the engine, the codegen is part
of the beta.2 cut.