ToolUp.AI.Server
0.23.0
Prefix Reserved
dotnet add package ToolUp.AI.Server --version 0.23.0
NuGet\Install-Package ToolUp.AI.Server -Version 0.23.0
<PackageReference Include="ToolUp.AI.Server" Version="0.23.0" />
<PackageVersion Include="ToolUp.AI.Server" Version="0.23.0" />
<PackageReference Include="ToolUp.AI.Server" />
paket add ToolUp.AI.Server --version 0.23.0
#r "nuget: ToolUp.AI.Server, 0.23.0"
#:package ToolUp.AI.Server@0.23.0
#addin nuget:?package=ToolUp.AI.Server&version=0.23.0
#tool nuget:?package=ToolUp.AI.Server&version=0.23.0
ToolUp.AI
Companion package providing the AI assistant integration for applications built on ToolUp.Platform. Ships the agent loop, SSE streaming, conversation persistence, tool registry, and system-prompt composition — everything except the provider itself (Claude lives in a sub-companion at src/AIProviders/Claude/, and other providers can be added the same way).
For deep technical detail, see TECHNICAL_GUIDE.md. This README covers the shape of the package, how to enable it in a deployment, and the extension points.
Why a separate companion
Two reasons it doesn't live in ToolUp.Platform:
- AI is an optional platform capability. Deployments that don't use AI shouldn't pay for its types, dependencies, or runtime. Stripping the
ToolUp.AIreference removes all AI surface from the app. - The runtime surface is substantial. Agent loop, SSE plumbing, conversation persistence, tool registry, system-prompt composition — keeping this in core would conflate platform infrastructure with feature code. Companion packages (the same pattern as
AgGridEnterprise) keep the boundary clean.
What stays in core:
IAIProvider(src/ToolUp.Platform/Shared/IAIProvider.fs) — extension point interface. Analogous toIBlobStorageandIAuthProvider. Providers implement it; the agent loop depends on it.AIToolDefinitionandToolParameterSchema(src/ToolUp.Platform/Shared/ModuleAITypes.fs) — module-facing tool declarations. Modules declare tools without referencingToolUp.AI. The runtime (registry, agent, execution) lives in this companion.
Authentication requirement (deployment design decision)
ToolUp.AI is designed for authenticated platform modes (AuthenticatedEphemeral, Individual, Team, MultiTeam). Deployments running in Anonymous mode (no sign-in, public/demo) typically should not enable AI access.
The reason is cost control. LLM API calls cost money per request. Without an authenticated identity, a deployment cannot:
- Attribute calls per user
- Enforce per-user rate limits
- Apply per-tenant cost ceilings
A public Anonymous-mode deployment with AI enabled is a wide-open cost surface — anyone with the URL can drive arbitrary token consumption against the deployment's API key.
Since Phase 6m this is enforced at startup, not merely documented. AnonymousAIModeValidator — registered automatically by the AI compose branch, so it is present whenever AI is composed and absent from every platform-only deployment — refuses to start when all three of these hold:
- some entry in
ServerConfig.SurfacesisAnonymous(a mixedAnonymous + Individualdeployment counts — anonymous requests still resolve toAnonymousKindand still reach the AI routes); - no
ServerConfig.RateLimitpolicy resolves forAnonymousKind— checked withRateLimitConfig.policyFor, notisEnabled, so a deployment that limits onlyUserKindis caught rather than waved through; and - the composed
IAIProviderFactoryreports at least one entry inPlatformDescriptors— i.e. the deployment has wired a provider it pays for.
There are three exits, and the refusal message names all of them:
- Rate-limit the anonymous surface —
ServerConfig.RateLimit = RateLimitConfig.uniform { PermitLimit = 100; WindowSeconds = 60; QueueLimit = 20 }, or aPerShapepolicy coveringAnonymousKind(TOOLUP_RATE_LIMIT_PERMITS=N). Anonymous traffic partitions on client IP. - Use a BYOK-only
IAIProviderFactory— wire no platform providers, so every call is funded by a key the user supplied. This is the documented legitimate Anonymous + AI shape, and it is exempt by construction rather than by exception: with no platform-paid provider, resolution for an anonymous caller (who has no secret scope to hold a key) can only ever returnNoProviderConfigured. - Attest that the cost is bounded upstream —
ServerConfig.AcceptAnonymousModeWithAI = true(TOOLUP_ACCEPT_ANONYMOUS_MODE_WITH_AI=1) for a deployment whose per-IP gating or request budgets live at the proxy / CDN / WAF. LikeAcceptStickyRoutedAiInMultiInstance, this degrades the refusal to aWarningrather than clearing it: upstream rate limiting is an assertion about infrastructure the SDK cannot verify, so the residual exposure stays visible in the HealthMonitorUI Preflight tab and the/dev/inspectValidators panel. The deployment boots — onlyErrorrefuses.
Single-user local development is unaffected in practice: a local shell that has wired no platform provider never reaches the rule, and one that has can set the env var.
The full escape-hatch family — every preflight refusal in the SDK, what trips it, and the field that attests it — is tabulated in ToolUp.Platform/technical-guide/07-module-communication-and-portability.md.
What this package ships
Shared types — Shared/AITypes.fs
Compiled into ToolUp.AI.dll. Referenced by both server and client.
| Type | Purpose |
|---|---|
AIAssistantBranding |
Client-visible name, icon, side-panel toggle |
AIAssistantMode |
NoAIAssistant \| DefaultAIAssistant \| ConfiguredAIAssistant of Branding |
AIMessageRequest |
Record on the SubmitMessage API — carries ConversationId, Content, and the user's ActiveModule |
ModuleAIContext |
{ ModuleName; SystemPrompt } — a module's private domain-expert prompt, registered at compose time |
AIAssistantApi |
ToolUp.Remoting API: SubmitMessage, GetConversation, ListConversations, GetAvailableTools, GetTaskStatus, DeleteConversation |
AIStreamEvent |
SSE payloads: MessageDelta, ToolCallStarted, ToolCallCompleted, TaskStatusChanged, MessageComplete, StreamError |
AIProviderMessage, AIProviderToolCall, AIProviderToolResult, AIProviderToolDef, AIProviderResponse |
Provider-level protocol types — used by IAIProvider implementations |
Conversation, ConversationMessage, Participant, AITask, AITaskStatus |
Persistence and task-tracking types |
Server-side runtime — Server/
Injected into the consuming server project via ToolUp.AI.Server.props. Compiles alongside the Platform server files.
| File | Purpose |
|---|---|
SystemPromptBuilder.fs |
PromptContext, SystemPromptBuilder, fromStatic, activeModuleContext, compose, AIAssistantServerConfig |
AIToolRegistry.fs |
RegisteredTool, AIToolRegistry, createTool, toProviderDef |
SSEHandler.fs |
SSEConnection, SSEConnectionManager (zombie-aware), sseHandler Giraffe endpoint |
AIAgentEngine.fs |
runAgentLoop (multi-turn with tool dispatch), ToolInvocationError |
AIAssistantHandler.fs |
AIAssistantApi implementation — SubmitMessage, conversation persistence, background agent execution |
AICompose.fs |
composeWithAI + AIServerApp record — drop-in replacement for Server.compose / ServerApp. AIServerApp wraps a ServerApp.Base and adds an AIProviderFactory, AIConfigStore, AITools, AIConfig, and ModuleAIContexts; AIServerApp.run calls composeWithAI internally via ComposeExtensions |
Client-side UI — Client/
Injected into the consuming client project via ToolUp.AI.Client.props. The Platform shell (ToolUp.Platform.Client.Client) is AI-agnostic; this companion layers the AI MVU + chrome back on via an Elmish outer-program wrapper (AIClientConfig.withAIAssistant).
| File | Purpose |
|---|---|
SSEClient.fs |
EventSource wrapper with mode-aware query parameter |
ConversationPanel.fs |
Reusable chat panel component |
AIAssistantUI.fs |
Built-in AI assistant module page (full conversation view) |
AIClientConfig.fs |
SidePanelModel / SidePanelMsg / sidePanelUpdate, OuterModel/OuterMsg, appendAssistantModule, withSidePanel, withAIAssistant — the outer-program composition that wraps the shell's Elmish program |
How to enable AI in a deployment
1. Reference the companion
Server project (ToolupApp-Server.fsproj):
<Import Project="..\ToolUp.Platform\ToolUp.Platform.Server.props" />
<Import Project="..\ToolUp.AI\ToolUp.AI.Server.props" />
<Import Project="..\AIProviders\Claude\ClaudeAIProvider.Server.props" />
<ItemGroup>
<ProjectReference Include="..\ToolUp.Platform\ToolUp.Platform.fsproj" />
<ProjectReference Include="..\ToolUp.AI\ToolUp.AI.fsproj" />
</ItemGroup>
Client project (ToolupApp-Client.fsproj) — add the companion's client props after the Platform's:
<Import Project="..\ToolUp.Platform\ToolUp.Platform.Client.props" />
<Import Project="..\ToolUp.AI\ToolUp.AI.Client.props" />
2. Wire a provider factory and run via AIServerApp
In the server entry point:
open ToolUp.Platform.Server
open ToolUp.AI
open ToolUp.AI.AICompose
let secretStore = FileSecretStore.FileSecretStore() :> ISecretStore
let blobStorage = LocalFileStorage.LocalFileStorage("data") :> IBlobStorage
let logger = ConsoleLogger.ConsoleLogger()
// BYOK-capable factory — registers one builder per provider.
// Each builder reads the API key from the platform IProviderProfile
// store (falling back to the `_platform` scope secret store for the
// platform-default provider).
let aiProviderFactory =
DefaultAIProviderFactory.create
[ claudeBuilder; openAiBuilder ]
providerProfile // IProviderProfile
secretStore
PlatformOnly // AIFallbackPolicy
platformProviders
None // IPlatformAIKeyStore option
AIServerApp.createFrom aiProviderFactory providerProfile (
ServerApp.empty
|> ServerApp.withConfig config
|> ServerApp.withAuth authProvider
|> ServerApp.withLogger logger
|> ServerApp.withStorage blobStorage
|> ServerApp.addModules modules) // each module as a ServerModule
|> AIServerApp.run
Deployments that don't want AI use ServerApp.run directly (no AIServerApp wrapper). The factory indirection is what lets users configure per-user BYOK providers via the AI Settings UI without changing server wiring.
AIProviderFactory and ProviderProfile are constructor parameters of AIServerApp.create / createFrom rather than optional fields — the wrapper exists precisely because AI needs both and the core ServerApp cannot reasonably default them.
3. Wire the client wrapper
In the client entry point, wrap the shell Program with AIClientConfig.withAIAssistant:
open ToolUp.Elmish
open ToolUp.Elmish.React
open ToolUp.Platform
let aiMode =
ConfiguredAIAssistant {
Name = "Claude"
Icon = Icon.ofUrl "/svg/claude.svg"
ShowSidePanel = true
}
let config = { ClientConfig.defaults with (* ... *) }
let modules = [ (* module registrations *) ]
AIClientConfig.run aiMode config modules
Apps without AI drop the ToolUp.AI.Client.props import (step 1) and call Client.run config modules instead — zero AI surface, zero AI types leaked into shell state.
Branding fields only — no system-prompt content here. Prompt composition is server-side.
Team-, module-, and session-aware system prompts
The agent loop builds its system prompt per-request via a SystemPromptBuilder, not a static string. The builder receives a PromptContext:
type PromptContext = {
Access: AccessContext // user + team + mode + permissions
ActiveModule: string option // which module the user is viewing
ModuleContexts: Map<string, ModuleAIContext> // compose-time module contributions
}
type SystemPromptBuilder = PromptContext -> Async<string>
Three built-in helpers:
SystemPromptBuilder.fromStatic "..."— constant prefixSystemPromptBuilder.activeModuleContext— injects the active module'sSystemPromptwhen one is registeredSystemPromptBuilder.compose [...]— layers multiple builders; parallel-resolved, joined by blank lines
Module-contributed private prompts
Each module can export a ModuleAIContext:
// In NBDDirichlet/Server.fs
let aiContext : ModuleAIContext = {
ModuleName = "NBDDirichlet"
SystemPrompt = """You are helping with NBD-Dirichlet category analysis.
Typical inputs: penetration, average buy rate.
Key outputs: expected brand duplication, heavy-buyer share."""
}
The app collects them:
let moduleAIContexts = [
NBDDirichlet.Server.aiContext
MediaOptimisation.Server.aiContext
SkuAnalysis.Server.aiContext
// PriceElasticity, SOVSM skip — no domain prompt needed
]
AIServerApp.createFrom aiProviderFactory providerProfile serverApp
|> AIServerApp.withModuleAIContexts moduleAIContexts
|> AIServerApp.run
When the user chats from the NBDDirichlet view, the client attaches ActiveModule = Some "NBDDirichlet" to the request. The activeModuleContext builder looks up the module's contribution and injects it. The user never sees this in their chat history — it's metadata to the model.
Team-private context
For Team mode deployments, team-specific context is loaded per request:
let teamAwarePrompt =
SystemPromptBuilder.compose [
SystemPromptBuilder.fromStatic "You are ToolUp, an analytics assistant..."
SystemPromptBuilder.activeModuleContext
fun ctx -> async {
match ctx.Access.TeamId with
| None -> return ""
| Some teamId ->
let! profile = teamStore.GetTeamProfile teamId
return $"The current team is {profile.Name}, category {profile.Category}."
}
]
let aiConfig = Some {
Branding = { Name = "Claude"; Icon = "/svg/claude.svg"; ShowSidePanel = true }
SystemPrompt = Some teamAwarePrompt
}
AIServerApp.createFrom aiProviderFactory providerProfile serverApp
|> AIServerApp.withAIConfig aiConfig
|> AIServerApp.withModuleAIContexts moduleAIContexts
|> AIServerApp.run
The builder runs per request. AccessContext.TeamId is scope-validated upstream by ScopeResolutionMiddleware — Team A's context can never leak to Team B's conversation.
The SDK has no mechanism for the user to send invisible prompts. "Private" always means module-registered at compose time — anything that feeds the model is either visible in chat history or declared at the deployment boundary.
Writing a new AI provider
Follow the pattern in src/AIProviders/Claude/ and src/AIProviders/OpenAI/. Minimum:
Implement
IAIProvider(insrc/ToolUp.Platform/Shared/IAIProvider.fs):Capabilities : AIProviderCapabilities— declare Streaming, ToolUse, Vision, ProviderName, ModelSendMessage— single request/response turn, honours theRetryPolicy
Expose a factory function,
createWithApiKeyAndModel (apiKey: string) (model: string) : IAIProvider, that builds a provider instance from a resolved key + model.Export an
AIProviderDescriptor(provider id, display name, default model, capability hints) and pair it with the factory in anAIProviderBuilder:let descriptor: AIProviderDescriptor = { (* provider id, name, defaults *) } let builder: AIProviderBuilder = { Descriptor = descriptor Build = fun apiKey model -> createWithApiKeyAndModel apiKey model }Create a
.fsprojand.Server.propsinsrc/AIProviders/<Name>/. Deployments pull the builder intoDefaultAIProviderFactory.create [ claudeBuilder; openAIBuilder; yourBuilder ] ...— no other wiring changes needed.
The factory is what selects the correct builder per-request: it reads the user's configured AIProviderInstance.ProviderId (or the platform-default when PlatformOnly), pulls the API key from the appropriate scope of ISecretStore, and invokes the builder. The agent loop, tool dispatch, system prompt building, SSE streaming, and conversation persistence stay provider-agnostic.
A new provider only needs to translate the AIProviderMessage / AIProviderResponse protocol.
Observability and metrics (Phase 9 / 9e delegations)
AIServerApp mirrors the ServerApp observability surface so AI deployments can wire health probes, config validators, and metrics sinks fluently:
AIServerApp.withHealthCheck(Phase 9k) — register a companion-contributedIHealthCheck(e.g.ClaudeAIProviderHealth.create secretStore).AIServerApp.withConfigValidator(Phase 9m) — register a companion-contributedIConfigValidatorfor startup preflight.AIServerApp.withMetricsSink(Phase 9e) — register a companion-contributedIMetricsSink(e.g.OtelMetricsSink.create regs logger) alongside the in-process Prometheus default. The fan-out wrapper makes a singleIncrementcall dispatch to every registered sink. WireMetricsEndpoint = EnabledMetricsEndpointonServerConfigto mount/metricsand activate emission.
Each helper delegates to its ServerApp counterpart — see src/ToolUp.Platform/README.md and TECHNICAL_GUIDE.md for the full contract.
Auto-registered AI config validators (Phase 9m.A)
AICompose wires two always-on IConfigValidators and one opt-in network probe so operator-typo classes of misconfiguration surface at startup instead of at first chat request:
| Validator | Env var(s) consulted | Default outcome |
|---|---|---|
AIProviderEnvValidator |
TOOLUP_AI_PROVIDER |
Self-skips with Ok when unset. Warning when set to a value not in IAIProviderFactory.Available ∪ PlatformDescriptor. |
AIModelEnvValidator |
TOOLUP_AI_MODEL (+ TOOLUP_AI_PROVIDER for scoping) |
Self-skips with Ok when unset. Warning when set to a model not in the relevant descriptor's SupportedModels. |
AIProviderProbeValidator |
TOOLUP_AI_PROBE_ON_STARTUP=1 to enable; reads ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY to probe |
Not registered when probe disabled. Warning for refused keys / model-not-in-access-list / unknown providers. Error (startup abort) when the provider is unreachable. |
All three are GP 13 lightweight defaults — deployments that don't set the env vars pay nothing. See docs/companions/ai-providers.md for the operator-facing table.
Deferred follow-ups
- Dynamic module AI contributions. Today
ModuleAIContext.SystemPromptis a static string. AModel -> stringform (reading runtime module state) is possible but needs special type-erasure handling. Deferred.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- ToolUp.AI.Core (>= 0.23.0)
- ToolUp.Platform.Core (>= 0.23.0)
- ToolUp.Platform.Server (>= 0.23.0)
NuGet packages (10)
Showing the top 5 NuGet packages that depend on ToolUp.AI.Server:
| Package | Downloads |
|---|---|
|
ToolUp.RAG.Server
ToolUp.RAG Server — chunking, default in-memory vector store + BM25 index, retrieval pipeline, ingestion + reembedding services, RAGPromptBuilder, RAGCompose. Depends on ToolUp.Platform.Server + ToolUp.AI.Server (RAGCompose wraps AIServerApp). |
|
|
ToolUp.AIProviders.OpenAI
OpenAI IAIProvider implementation for ToolUp.AI. BYOK-capable; reads API keys from ISecretStore. |
|
|
ToolUp.KnowledgeBase.Server
ToolUp.KnowledgeBase Server — document upload + multi-format extraction (PDF / PPTX / DOCX / XLSX / CSV), ingestion observer, narrative-commit, notes / AI-context API. Depends on ToolUp.AI.Server + ToolUp.RAG.Server. |
|
|
ToolUp.AICookbooks.AgChart
Community AG Chart + AG Grid prompt-builder companion for ToolUp.AI. Composes the Community COOKBOOK.md's constraints + shortest-possible-chart into a deployment's system prompt so the in-app AI assistant authors charts/grids in F#. Opt-in; ~600 tokens. |
|
|
ToolUp.AIProviders.Copilot
Azure OpenAI ("Microsoft Copilot") IAIProvider implementation for ToolUp.AI. Supports static api-key and Microsoft Entra ID (Azure.Identity TokenCredential) auth. BYOK-capable; reads api-keys from ISecretStore. |
GitHub repositories
This package is not used by any popular GitHub repositories.