Rystem.PlayFramework
10.1.1
dotnet add package Rystem.PlayFramework --version 10.1.1
NuGet\Install-Package Rystem.PlayFramework -Version 10.1.1
<PackageReference Include="Rystem.PlayFramework" Version="10.1.1" />
<PackageVersion Include="Rystem.PlayFramework" Version="10.1.1" />
<PackageReference Include="Rystem.PlayFramework" />
paket add Rystem.PlayFramework --version 10.1.1
#r "nuget: Rystem.PlayFramework, 10.1.1"
#:package Rystem.PlayFramework@10.1.1
#addin nuget:?package=Rystem.PlayFramework&version=10.1.1
#tool nuget:?package=Rystem.PlayFramework&version=10.1.1
Rystem.PlayFramework
Rystem.PlayFramework is the orchestration core of the AI area.
It is a named, scene-based execution framework built around Microsoft.Extensions.AI chat clients. You register one or more PlayFramework instances, attach scenes, actors, tools, cache, persistence, and optional voice, memory, telemetry, or rate-limit behavior, then execute them either programmatically or through HTTP SSE endpoints.
Installation
dotnet add package Rystem.PlayFramework
For real model access you also need one or more chat-client registrations. In practice that usually means an adapter package such as:
dotnet add package Rystem.PlayFramework.Adapters
If you want HTTP endpoints, the HTTP mapping extensions live in this package under the Rystem.PlayFramework.Api namespace. There is no separate Rystem.PlayFramework.Api library package in this repo.
Documentation
- Runtime scene and tool descriptions: public API, refresh modes, providers, resilience, observability, testing, and operational tradeoffs.
- Factory pattern
- Dynamic chaining
- Client interactions
- Telemetry
- RAG integration
- MCP integration
Architecture
The core entry points are:
AddPlayFramework(...)MapPlayFramework(...)ISceneManagerIPlayFramework
The lifecycle is:
- register a named PlayFramework instance
- attach one or more named
IChatClientregistrations - add scenes, actors, and tools
- optionally enable cache, repository persistence, memory, telemetry, rate limiting, and voice
- execute through
ISceneManager.ExecuteAsync(...)or the HTTP API
Each PlayFramework instance is factory-based. The name can be a string or Enum, and resolution happens through IFactory<ISceneManager> or the IPlayFramework wrapper.
Runtime scene and tool descriptions
Available from 10.0.11-beta.22. See the complete runtime descriptions guide for every overload and operational setting.
Scene and local-tool descriptions can be resolved from application services and refreshed without rebuilding the PlayFramework container. The runtime values are global to the named PlayFramework factory: they must not depend on the current user, tenant, conversation, or request payload.
builder.Services.AddScoped<IAiPromptSnapshot, AiPromptSnapshot>();
builder.Services.AddPlayFramework("default", framework =>
{
framework.WithRuntimeDescriptions(settings =>
{
settings.RefreshMode = RuntimeDescriptionRefreshMode.Background;
settings.BackgroundRefreshInterval = TimeSpan.FromMinutes(5);
settings.ConsistencyMode = RuntimeDescriptionConsistencyMode.Execution;
settings.FailureMode = RuntimeDescriptionFailureMode.UseFallback;
settings.SnapshotStoreMode = RuntimeDescriptionSnapshotStoreMode.Memory;
});
framework.AddScene(
"orders",
async (context, cancellationToken) =>
await context.Services.GetRequiredService<IAiPromptSnapshot>()
.GetSceneDescriptionAsync(cancellationToken),
scene => scene.WithService<IOrderService>(tools => tools.WithMethod(
service => service.SearchAsync(default!, default),
"search_orders",
async (context, cancellationToken) =>
await context.Services.GetRequiredService<IAiPromptSnapshot>()
.GetSearchToolDescriptionAsync(cancellationToken),
fallbackDescription: "Search orders")),
fallbackDescription: "Order management");
});
Every refresh uses one DI scope and resolves all descriptions sequentially into a complete immutable catalog. A successful changed catalog is persisted as last-known-good and then published atomically; requests see either the previous catalog or the new one, never a partial mixture. Use a scoped snapshot accessor, as above, when several delegates read the same remote document.
The refresh modes are:
Background(default): startup, timer, and an optional registeredIRuntimeDescriptionChangeTokenSourcerefresh outside the normal request path.Manual: resolveIFactory<IRuntimeDescriptionRefresher>, callCreate(factoryName).RefreshAsync(), and use its structured result as a deployment or evaluation barrier.EveryRequest: force one request-local resolution for deterministic tests and diagnostics. This deliberately adds provider latency and allocation cost to every request and should normally stay disabled in production.
For restart resilience, Memory is the default snapshot store. Distributed uses the host's IDistributedCache; AddRuntimeDescriptionSnapshotStore<TStore>() can provide stronger storage semantics. AiSceneResponse.RuntimeDescriptions and SceneContext.RuntimeSceneCatalog expose the exact request-local identity and declarations used by execution. Discovery only reports the current global catalog and never triggers a refresh.
The central drawback is duplication of lifecycle and memory: static ISceneFactory values remain startup templates/fallbacks, while runtime execution uses versioned materialized catalogs. Each semantic description change recreates scene/tool declarations, historical pinning consumes bounded retention, and fallback improves availability at the risk of using stale descriptions. The feature therefore trades additional state, observability, and operational choices for hot reload; applications that do not configure dynamic descriptions retain the static path and behavior.
Names, tool schemas, parameter descriptions, MCP definitions, and actor messages are not part of this catalog. Runtime description sources are privileged prompt configuration: protect write access, never select them from client input, and expose manual refresh only through an authenticated, authorized, rate-limited application endpoint if one is needed.
Example: minimal HTTP backend
This follows the real patterns used in src/AI/Test/Rystem.PlayFramework.Api/Program.cs and the factory tests.
using RepositoryFramework;
using Rystem.PlayFramework;
using Rystem.PlayFramework.Adapters;
using Rystem.PlayFramework.Api;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMemoryCache();
builder.Services.AddAdapterForAzureOpenAI("default", settings =>
{
// Resource roots and /openai/v1 endpoints are both accepted.
settings.Endpoint = new Uri(builder.Configuration["AzureOpenAI:Endpoint"]!);
settings.ApiKey = builder.Configuration["AzureOpenAI:Key"]!;
settings.Deployment = builder.Configuration["AzureOpenAI:Deployment"] ?? "gpt-4o";
});
builder.Services.AddSingleton<ICalculatorService, CalculatorService>();
builder.Services.AddPlayFramework("default", framework =>
{
framework
.WithChatClient("default")
.UseDefaultGuardrails()
.AddCache(cache =>
{
cache.WithMemory()
.WithExpiration(TimeSpan.FromMinutes(30));
})
.AddMainActor("You are a helpful assistant.")
.AddScene("Calculator", "Arithmetic operations", scene =>
{
scene
.WithDescriptionFromTools()
.WithService<ICalculatorService>(tools =>
{
tools
.WithMethod<double>(x => x.Add(default, default), "Add", "Add two numbers")
.WithMethod<double>(x => x.Multiply(default, default), "Multiply", "Multiply two numbers");
});
});
});
var app = builder.Build();
app.MapPlayFramework("default", settings =>
{
settings.BasePath = "/api/ai";
});
app.Run();
public interface ICalculatorService
{
double Add(double left, double right);
double Multiply(double left, double right);
}
public sealed class CalculatorService : ICalculatorService
{
public double Add(double left, double right) => left + right;
public double Multiply(double left, double right) => left * right;
}
With that setup, the main endpoints are:
POST /api/ai/default
POST /api/ai/default/streaming
Example: programmatic execution
The smallest runtime API is ISceneManager.
public sealed class AssistantService
{
private readonly ISceneManager _sceneManager;
public AssistantService(IFactory<ISceneManager> factory)
=> _sceneManager = factory.Create("default");
public async Task RunAsync()
{
var metadata = new Dictionary<string, object>
{
["userId"] = "user-42",
["tenantId"] = "tenant-a"
};
var settings = new SceneRequestSettings
{
ExecutionMode = SceneExecutionMode.Scene,
SceneName = "Calculator",
ConversationKey = "conversation-42"
};
await foreach (var step in _sceneManager.ExecuteAsync("What is 12 * 7?", metadata, settings))
{
Console.WriteLine($"[{step.Status}] {step.Message}");
}
}
}
If you want a lighter wrapper over the factory pattern, inject IPlayFramework and call:
Create(...)CreateOrDefault(...)Exists(...)
Example:
public sealed class MultiBotService
{
private readonly IPlayFramework _playFramework;
public MultiBotService(IPlayFramework playFramework)
=> _playFramework = playFramework;
public async Task RunAsync()
{
var manager = _playFramework.Create("default");
await foreach (var step in manager.ExecuteAsync("Hello"))
{
Console.WriteLine(step.Message);
}
}
}
Example: multi-modal input helpers
ISceneManager.ExecuteAsync(...) also accepts MultiModalInput, and the package includes convenience constructors for common cases.
var input = MultiModalInput.FromImageUrl(
text: "Describe the important details in this image.",
imageUrl: "https://example.com/photo.png",
mimeType: "image/png");
await foreach (var step in sceneManager.ExecuteAsync(input))
{
Console.WriteLine($"[{step.Status}] {step.Message}");
}
Available helpers include:
MultiModalInput.FromText(...)MultiModalInput.FromImageUrl(...)MultiModalInput.FromImageBytes(...)MultiModalInput.FromAudioUrl(...)MultiModalInput.FromAudioBytes(...)MultiModalInput.FromFileUrl(...)MultiModalInput.FromFileBytes(...)
Example: load balancing and fallback
PlayFramework can use multiple named chat clients for the same factory. The primary pool handles normal traffic, and the fallback chain is only used when the primary pool fails.
This matches the patterns covered by LoadBalancingAndFallbackTests.cs.
using Rystem.PlayFramework;
using Rystem.PlayFramework.Adapters;
builder.Services.AddAdapterForAzureOpenAI("primary-1", settings =>
{
settings.Endpoint = new Uri(builder.Configuration["AzureOpenAI:Endpoint"]!);
settings.ApiKey = builder.Configuration["AzureOpenAI:Key"]!;
settings.Deployment = "gpt-4o";
});
builder.Services.AddAdapterForAzureOpenAI("primary-2", settings =>
{
settings.Endpoint = new Uri(builder.Configuration["AzureOpenAI:Endpoint"]!);
settings.ApiKey = builder.Configuration["AzureOpenAI:Key"]!;
settings.Deployment = "gpt-4o-mini";
});
builder.Services.AddAdapterForAzureOpenAI("fallback-1", settings =>
{
settings.Endpoint = new Uri(builder.Configuration["AzureOpenAI:Endpoint"]!);
settings.ApiKey = builder.Configuration["AzureOpenAI:Key"]!;
settings.Deployment = "gpt-4o-mini";
});
builder.Services.AddPlayFramework("router", framework =>
{
framework
.WithChatClient("primary-1")
.WithChatClient("primary-2")
.WithLoadBalancingMode(LoadBalancingMode.RoundRobin)
.WithChatClientAsFallback("fallback-1")
.WithFallbackMode(FallbackMode.Sequential)
.WithRetryPolicy(maxAttempts: 2, baseDelaySeconds: 0.5)
.AddScene("General Requests", "General conversation", _ => { });
});
Important behavior:
WithChatClient(...)adds clients to the primary poolWithChatClientAsFallback(...)adds clients to the fallback chainWithLoadBalancingMode(...)controls the primary pool orderWithFallbackMode(...)controls fallback orderingWithRetry(...)andWithRetryPolicy(...)both configure transient retry behavior
Example: scenes, actors, and service tools
Scenes are the main unit of orchestration.
SceneBuilder exposes the main extension points:
WithService<TService>(...)WithEndpoint<TClient>(...)WithActors(...)WithMcpServer(...)OnClient(...)WithCacheExpiration(...)WithDescriptionFromTools()
Example with service tools:
builder.Services.AddSingleton<IWeatherService, WeatherService>();
builder.Services.AddPlayFramework("default", framework =>
{
framework
.WithChatClient("default")
.AddMainActor("Answer clearly and explain tradeoffs when useful.")
.AddScene("Weather", "Weather queries", scene =>
{
scene
.WithDescriptionFromTools()
.WithActors(actors =>
{
actors.AddActor("Use the available weather tools before guessing.");
actors.AddActor("If the user asks for a forecast, mention the requested city explicitly.");
})
.WithService<IWeatherService>(tools =>
{
tools
.WithMethod<string>(x => x.GetCurrent(default!), "GetCurrent", "Get current weather for a city")
.WithMethod<string>(x => x.GetForecast(default!, default), "GetForecast", "Get forecast for a city and number of days");
});
});
});
public interface IWeatherService
{
string GetCurrent(string city);
string GetForecast(string city, int days);
}
Important naming detail: scene names are normalized when they are registered. For example, AddScene("General Requests", ...) becomes General_Requests internally. If you later set SceneRequestSettings.SceneName, use the normalized name.
Example: force specific tools inside a scene
When you already know which scene should run and want to expose only a subset of tools for that scene, use SceneRequestSettings.ForcedTools.
Each forced tool can identify:
- the normalized
SceneName - the normalized
ToolName - the source type (
Service,Client,Mcp, orOther) - the source name (for example the DI service type name or the MCP server/factory name)
- the member name (for example the service method name or original MCP tool name)
var settings = new SceneRequestSettings
{
ExecutionMode = SceneExecutionMode.Scene,
SceneName = "Calculator",
ForcedTools =
[
new ForcedToolRequest
{
SceneName = "Calculator",
ToolName = "Add",
SourceType = PlayFrameworkToolSourceType.Service,
SourceName = "ICalculatorService",
MemberName = "Add"
},
new ForcedToolRequest
{
SceneName = "Calculator",
ToolName = "Subtract",
SourceType = PlayFrameworkToolSourceType.Service,
SourceName = "ICalculatorService",
MemberName = "Subtract"
}
]
};
await foreach (var step in sceneManager.ExecuteAsync("Calculate 20 - 5 and then 20 + 5", settings: settings))
{
}
Behavior:
- only the matching tools are exposed to the LLM for that scene
- if one forced tool remains pending, PlayFramework forces that exact tool call through
ChatToolMode - if a requested forced tool does not exist for the scene, execution stops with an error response
- MCP tools can be forced too by using
SourceType = Mcp, the MCP source name, and the original MCP tool name inMemberName
Example: client tools and continuation
OnClient(...) is how a scene asks the browser or mobile client to execute something locally, then resume the conversation.
This is the same pattern exercised in ClientInteractionTests.cs and in the TypeScript client workspace.
builder.Services.AddMemoryCache();
builder.Services.AddPlayFramework("default", framework =>
{
framework
.WithChatClient("default")
.AddCache(cache =>
{
cache.WithMemory()
.WithExpiration(TimeSpan.FromMinutes(10));
})
.AddScene("Browser Assistant", "Needs browser-side tools", scene =>
{
scene
.WithActors(actors =>
{
actors.AddActor("When the user asks for the current location, call the client tool.");
})
.OnClient(client =>
{
client
.AddTool("getCurrentLocation", "Get the user's current location", timeoutSeconds: 15)
.AddCommand("trackAnalytics", "Track a browser-side analytics event", feedbackMode: CommandFeedbackMode.OnError, timeoutSeconds: 5);
})
.WithCacheExpiration(TimeSpan.FromMinutes(5));
});
});
Important behavior:
OnClient(...)marks the scene as cache-dependent- continuation state is stored between the server request and the client response
- the HTTP or TypeScript client is expected to send
conversationKeyplusclientInteractionResultswhen resuming - the published TypeScript client in
src/AI/Rystem.PlayFramework.Client/src/rystemalready automates most of this flow
Execution modes
The runtime supports four execution modes:
DirectPlanningDynamicChainingScene
You can set a default at registration time:
builder.Services.AddPlayFramework("default", framework =>
{
framework
.WithChatClient("default")
.WithExecutionMode(SceneExecutionMode.Planning)
.WithPlanning(settings =>
{
settings.MaxRecursionDepth = 5;
})
.AddScene("Calculator", "Arithmetic operations", _ => { })
.AddScene("Weather", "Weather queries", _ => { });
});
Or override per request:
var settings = new SceneRequestSettings
{
ExecutionMode = SceneExecutionMode.Scene,
SceneName = "Calculator"
};
await foreach (var step in sceneManager.ExecuteAsync("5 * 7", settings: settings))
{
}
Use SceneExecutionMode.Scene when you already know the target scene and want to bypass scene selection.
Example: memory across requests
Memory is separate from conversation persistence. It is about loading and saving summarized context across calls.
This matches the patterns used in MemoryTests.cs.
builder.Services.AddPlayFramework("default", framework =>
{
framework
.WithChatClient("default")
.WithMemory(memory => memory
.WithDefaultMemoryStorage("userId")
.WithMaxSummaryLength(1000))
.AddScene("Assistant", "General conversation", _ => { });
});
Then pass matching metadata when executing:
var metadata = new Dictionary<string, object>
{
["userId"] = "user-42"
};
var settings = new SceneRequestSettings
{
ExecutionMode = SceneExecutionMode.Direct,
ConversationKey = "conversation-42"
};
await foreach (var _ in sceneManager.ExecuteAsync("My name is Alessandro", metadata, settings))
{
}
await foreach (var _ in sceneManager.ExecuteAsync("What is my name?", metadata, settings))
{
}
Important behavior:
WithDefaultMemoryStorage("userId")isolates memory by metadata keyWithDefaultMemoryStorage("userId", "tenantId")creates a composite key- without
WithMemory(...), no memory is loaded or saved
Example: rate limiting by metadata
Rate limiting is also metadata-driven. The test coverage shows the intended usage clearly.
builder.Services.AddPlayFramework("default", framework =>
{
framework
.WithChatClient("default")
.WithRateLimit(limit => limit
.GroupBy("userId")
.TokenBucket(capacity: 3, refillRate: 1)
.RejectOnExceeded())
.AddScene("Assistant", "General conversation", _ => { });
});
And the request metadata drives the grouping key:
var metadata = new Dictionary<string, object>
{
["userId"] = "user-42"
};
await foreach (var step in sceneManager.ExecuteAsync("Hello", metadata))
{
Console.WriteLine($"[{step.Status}] {step.Message}");
}
If you prefer waiting instead of immediate rejection:
builder.Services.AddPlayFramework("default", framework =>
{
framework
.WithChatClient("default")
.WithRateLimit(limit => limit
.GroupBy("userId")
.TokenBucket(capacity: 1, refillRate: 10)
.WaitOnExceeded(TimeSpan.FromSeconds(5)))
.AddScene("Assistant", "General conversation", _ => { });
});
HTTP API
MapPlayFramework(...) maps SSE-oriented endpoints.
Named mapping
app.MapPlayFramework("default", settings =>
{
settings.BasePath = "/api/ai";
settings.EnableConversationEndpoints = true;
settings.EnableVoiceEndpoints = true;
});
Routes:
POST /api/ai/defaultPOST /api/ai/default/streamingGET /api/ai/default/discoveryGET /api/ai/default/conversationsGET /api/ai/default/conversations/{conversationKey}DELETE /api/ai/default/conversations/{conversationKey}PATCH /api/ai/default/conversations/{conversationKey}/visibilityPOST /api/ai/default/voice
Example request body
The HTTP request model is PlayFrameworkRequest:
{
"message": "What is the weather in Milan?",
"contents": [],
"metadata": {
"userId": "123"
},
"settings": {
"executionMode": "Planning",
"maxRecursionDepth": 5,
"forcedTools": [
{
"sceneName": "Calculator",
"toolName": "Add",
"sourceType": "Service",
"sourceName": "ICalculatorService",
"memberName": "Add"
}
]
},
"conversationKey": null,
"clientInteractionResults": null
}
Example curl call against the step-by-step endpoint:
curl -N https://localhost:7248/api/ai/default \
-H "Content-Type: application/json" \
-d '{"message":"Calculate 5 * 7","settings":{"executionMode":"Scene","sceneName":"Calculator"}}' \
--insecure
Token-level streaming uses the same body shape against:
POST /api/ai/default/streaming
Discovery endpoint
The discovery endpoint exposes the normalized scenes and tools currently available for a factory:
GET /api/ai/default/discovery
It returns:
sceneswith normalized scene names and their toolsservicesgrouped by DI sourceclientsgrouped by client-side sourcemcpServersgrouped by MCP sourceendpointsgrouped by HTTP client marker typeothersfor tools that do not belong to the standard buckets
Use this endpoint from your frontend when you want to build a scene/tool picker and then feed the selected values back into SceneRequestSettings.ForcedTools.
Typical response shape:
{
"factoryName": "default",
"scenes": [
{
"name": "Calculator",
"description": "Arithmetic operations",
"tools": [
{
"sceneName": "Calculator",
"toolName": "Add",
"description": "Add two numbers",
"sourceType": "Service",
"sourceName": "ICalculatorService",
"memberName": "Add"
}
]
}
],
"services": [
{
"name": "ICalculatorService",
"sourceType": "Service",
"tools": [
{
"sceneName": "Calculator",
"toolName": "Add",
"sourceType": "Service",
"sourceName": "ICalculatorService",
"memberName": "Add"
}
]
}
],
"clients": [],
"mcpServers": [],
"others": []
}
Unnamed mapping
app.MapPlayFramework(configure: settings =>
{
settings.BasePath = "/api/ai";
});
In the current implementation this maps the base path root and internally falls back to the default factory name. It does not create a dynamic /{factoryName} route despite what some comments suggest.
Example: conversation persistence and CRUD endpoints
PlayFramework has two separate storage concepts:
- cache for in-flight execution state and client-tool continuation
- repository persistence for stored conversations
Enable stored conversations in the PlayFramework builder:
builder.Services.AddPlayFramework("default", framework =>
{
framework
.WithChatClient("default")
.UseRepository()
.AddScene("Assistant", "General conversation", _ => { });
});
Then register the matching repository separately with the same factory name:
builder.Services.AddRepository<StoredConversation, string>(repositoryBuilder =>
{
repositoryBuilder.WithInMemory(name: "default");
});
Finally, enable the HTTP endpoints:
app.MapPlayFramework("default", settings =>
{
settings.BasePath = "/api/ai";
settings.EnableConversationEndpoints = true;
});
Without that matching repository registration, conversation CRUD cannot work.
Example: voice pipeline
Enable voice in the builder:
builder.Services.AddVoiceAdapterForAzureOpenAI("default", settings =>
{
settings.Endpoint = new Uri(builder.Configuration["AzureOpenAI:Endpoint"]!);
settings.ApiKey = builder.Configuration["AzureOpenAI:Key"]!;
settings.SttDeployment = "<transcription-deployment>";
settings.TtsDeployment = "<speech-deployment>";
settings.TtsVoice = "alloy";
});
builder.Services.AddPlayFramework("default", framework =>
{
framework
.WithChatClient("default")
.WithVoice("default")
.AddScene("Assistant", "General conversation", _ => { });
});
And enable the HTTP endpoint explicitly:
app.MapPlayFramework("default", settings =>
{
settings.BasePath = "/api/ai";
settings.EnableVoiceEndpoints = true;
});
That exposes:
POST /api/ai/default/voice
Example: guardrails (operational boundaries)
Guardrails prevent the LLM from hallucinating tools or responding outside the system's declared capabilities. When enabled, a system prompt is injected at the start of every new conversation.
builder.Services.AddPlayFramework("default", framework =>
{
framework
.WithChatClient("default")
// Default prompt: operate only within registered scenes/actors/tools
.UseDefaultGuardrails()
.AddScene("Calculator", "Arithmetic operations", _ => { });
});
For domain-specific systems, replace the default prompt with a custom one:
builder.Services.AddPlayFramework("default", framework =>
{
framework
.WithChatClient("default")
.UseCustomGuardrails(
"""
You are a customer support assistant for Acme Corp.
You can ONLY help with: order status, returns, and product questions.
Do NOT discuss pricing or process refunds over $500. Escalate those to the manager team.
""")
.AddScene("Orders", "Order management", _ => { });
});
Important behavior:
- Guardrails are added only for new conversations (not when resuming from cache)
- The default prompt consumes approximately 100–150 tokens per request
- Guardrails do not replace
IAuthorizationLayer; they address prompt scope, not user permissions
Example: authorization layer
IAuthorizationLayer runs after initialization but before scene execution. It is the right place for user-specific quota checks, feature flags, and budget enforcement.
public sealed class CustomAuthorizationLayer : IAuthorizationLayer
{
private readonly IUserService _userService;
public CustomAuthorizationLayer(IUserService userService)
=> _userService = userService;
public async Task<AuthorizationResult> AuthorizeAsync(
SceneContext context,
SceneRequestSettings settings,
CancellationToken cancellationToken)
{
if (!context.Metadata.TryGetValue("userId", out var userIdObj))
return new AuthorizationResult { IsAuthorized = false, Reason = "userId not found in metadata" };
var user = await _userService.GetUserAsync(userIdObj.ToString()!, cancellationToken);
if (user.MonthlyQuota <= 0)
return new AuthorizationResult { IsAuthorized = false, Reason = "Monthly quota exceeded" };
return new AuthorizationResult { IsAuthorized = true };
}
}
Register it in the PlayFramework builder:
builder.Services.AddPlayFramework("default", framework =>
{
framework
.WithChatClient("default")
.AddAuthorizationLayer<CustomAuthorizationLayer>()
.AddScene("Assistant", "General conversation", _ => { });
});
builder.Services.AddScoped<IUserService, UserService>();
HTTP-level authorization (ASP.NET Core policies) and IAuthorizationLayer are complementary:
- HTTP policies run before any PlayFramework processing (token/claims validation)
IAuthorizationLayerruns after initialization (business logic, quotas, feature flags)
Example: request context injection (IContext)
IContext lets you inject dynamic, per-request context data into the system message at the start of every new conversation. The typical use case is enriching the LLM with information only available at runtime: the current user's profile, tenant settings, permissions, locale, or anything else that comes from the HTTP layer (JWT claims, headers, session).
Implement the interface and return any object (or a plain string). If the return value is not a string it is serialized as JSON:
public sealed class UserContextProvider : IContext
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IUserService _userService;
public UserContextProvider(IHttpContextAccessor httpContextAccessor, IUserService userService)
{
_httpContextAccessor = httpContextAccessor;
_userService = userService;
}
public async Task<dynamic?> RetrieveAsync(
SceneContext context,
SceneRequestSettings settings,
CancellationToken cancellationToken)
{
var userId = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value;
if (userId is null) return null;
var user = await _userService.GetUserAsync(userId, cancellationToken);
return new
{
user.DisplayName,
user.Email,
user.Role,
user.PreferredLanguage
};
}
}
Register it with AddContext<T>() in the PlayFramework builder:
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddPlayFramework("default", framework =>
{
framework
.WithChatClient("default")
.AddContext<UserContextProvider>()
.AddMainActor("You are a helpful assistant. Address the user by name when appropriate.")
.AddScene("Assistant", "General conversation", _ => { });
});
At the start of each new conversation the framework calls RetrieveAsync, serializes the result, and prepends the following block to the system message (before main actor instructions):
[Request Context]
{"displayName":"Alessandro","email":"a@example.com","role":"Admin","preferredLanguage":"it"}
[System Instructions]
- You are a helpful assistant. Address the user by name when appropriate.
Important behavior:
RetrieveAsyncis called only once per new conversation. When resuming a cached or stored conversation the existing context is reused.- Returning
nullskips the[Request Context]block entirely. - Returning a
stringinjects it verbatim; any other object is serialized to JSON. IContextis transient by default. Services you inject into it may be scoped (e.g.IHttpContextAccessor).- Only one
IContextimplementation can be registered per named PlayFramework instance. A second call toAddContext<T>()replaces the previous registration.
Example: director, summarization, and planning configuration
The director evaluates the execution result and may re-run the scene. Summarization compresses conversation history when it grows too large. Both are configured on the builder.
builder.Services.AddPlayFramework("default", framework =>
{
framework
.WithChatClient("default")
// Planning: multi-step orchestration across scenes
.WithPlanning(planning =>
{
planning.MaxRecursionDepth = 5;
})
// Director: post-execution evaluation and optional re-execution
.WithDirector(director =>
{
director.Enabled = true;
director.MaxReExecutions = 3;
})
// Summarization: compresses history above thresholds
.WithSummarization(summarization =>
{
summarization.Enabled = true;
summarization.CharacterThreshold = 15_000;
summarization.ResponseCountThreshold = 20;
})
.AddScene("Assistant", "General conversation", _ => { });
});
You can also override the director or summarizer with a custom implementation (see Custom extensibility below).
Example: main actors (dynamic and async variants)
Beyond a static string, AddMainActor accepts a delegate or a typed service.
builder.Services.AddPlayFramework("default", framework =>
{
framework
.WithChatClient("default")
// Static
.AddMainActor("You are a professional assistant for Acme Corp.")
// Sync delegate from request metadata
.AddMainActor(context =>
$"Current user: {context.Metadata.GetValueOrDefault("userName")}")
// Async delegate — cached after the first call for the lifetime of the request
.AddMainActor(async (context, ct) =>
{
var svc = context.ServiceProvider.GetRequiredService<IUserService>();
var user = await svc.GetUserAsync(context.Metadata["userId"].ToString()!, ct);
return $"User preferences: {user.Preferences}";
}, cacheForSubsequentCalls: true)
// Typed service that implements IActor
.AddMainActor<CustomActorService>()
.AddScene("Assistant", "General conversation", _ => { });
});
Example: RAG and web search
RAG and web search are both opt-in at framework level and can be overridden or disabled per scene.
// Register your RAG implementation
builder.Services.AddSingleton<IRagService, AzureSearchRagService>();
// Register your web search implementation
builder.Services.AddSingleton<IWebSearchService, BingWebSearchService>();
builder.Services.AddPlayFramework("default", framework =>
{
framework
.WithChatClient("default")
// Global RAG
.WithRag(rag =>
{
rag.TopK = 10;
rag.SearchAlgorithm = VectorSearchAlgorithm.CosineSimilarity;
rag.MinimumScore = 0.7;
})
// Global web search
.WithWebSearch(ws =>
{
ws.MaxResults = 10;
ws.SafeSearch = true;
})
// Scene that overrides RAG settings
.AddScene("Search", "Document search", scene =>
{
scene.WithRag(rag => { rag.TopK = 5; });
})
// Scene that disables both (e.g., pure arithmetic)
.AddScene("Calculator", "Arithmetic", scene =>
{
scene
.WithoutRag()
.WithoutWebSearch()
.WithService<ICalculatorService>(tools =>
{
tools.WithMethod<double>(x => x.Add(default, default), "Add", "Add two numbers");
});
});
});
Example: HTTP endpoint tools (WithEndpoint)
WithEndpoint<TClient> turns any HTTP endpoint into an AI tool. Register the named HTTP client on the PlayFramework builder with WithHttpClient<TClient>, then register individual endpoints on the scene builder with WithEndpoint<TClient>. TClient is a marker type — the named IHttpClientFactory key is typeof(TClient).Name.
Register the HTTP client
Simple form (configure the HttpClient only):
builder.Services.AddPlayFramework("default", framework =>
{
framework
.WithChatClient("default")
.WithHttpClient<IOrderServiceClient>(c =>
{
c.BaseAddress = new Uri("http://order-service:5001/api");
c.Timeout = TimeSpan.FromSeconds(30);
})
.AddScene("Orders", "Order management", scene =>
{
scene.WithEndpoint<IOrderServiceClient>(ep => ep
.WithAction<Order>(
"GetOrder",
HttpMethod.Get,
"/orders/{orderId}",
"Retrieve an order by its ID")
.WithAction<CreateOrderRequest, Order>(
"CreateOrder",
HttpMethod.Post,
"/orders",
"Create a new order"));
});
});
Full form (also configure the IHttpClientBuilder for message handlers, Polly resilience, etc.):
.WithHttpClient<IOrderServiceClient>(
c =>
{
c.BaseAddress = new Uri("http://order-service:5001/api");
c.Timeout = TimeSpan.FromSeconds(30);
},
b => b
.AddHttpMessageHandler<BearerTokenHandler>()
.AddStandardResilienceHandler())
WithAction overloads
| Overload | When to use |
|---|---|
WithAction<TResponse>(name, method, route, description) |
GET, DELETE, HEAD — no request body |
WithAction<TRequest, TResponse>(name, method, route, description) |
POST, PUT, PATCH — typed request body |
Route template placeholders ({orderId}) are extracted automatically as required AI parameters. Optional query-string parameters are added fluently with .WithParameter(...):
.WithAction<PagedResult<Order>>(
"ListOrders",
HttpMethod.Get,
"/orders",
"List all orders")
.WithParameter("status", "Filter by status: pending, shipped, delivered")
.WithParameter("pageSize", "Results per page", type: typeof(int))
Force endpoint tools
Endpoint tools appear in ForcedTools with SourceType = Endpoint:
var settings = new SceneRequestSettings
{
ExecutionMode = SceneExecutionMode.Scene,
SceneName = "Orders",
ForcedTools =
[
new ForcedToolRequest
{
SceneName = "Orders",
ToolName = "GetOrder",
SourceType = PlayFrameworkToolSourceType.Endpoint,
SourceName = "IOrderServiceClient",
MemberName = "GetOrder"
}
]
};
Discovery response
Endpoint tools appear in the endpoints array:
{
"factoryName": "default",
"scenes": [...],
"services": [],
"clients": [],
"mcpServers": [],
"endpoints": [
{
"name": "IOrderServiceClient",
"sourceType": "Endpoint",
"isAvailable": true,
"tools": [
{
"sceneName": "Orders",
"toolName": "GetOrder",
"description": "Retrieve an order by its ID",
"sourceType": "Endpoint",
"sourceName": "IOrderServiceClient",
"memberName": "GetOrder"
}
]
}
],
"others": []
}
Important behavior:
TClientis a marker type, not an actual client implementation. Any interface or class works.- Route template placeholders are parsed at registration time; the LLM must supply a value for each at call time.
- The HTTP response is forwarded to the LLM as a raw
JsonElement, which keeps it compatible with union types in downstream serializers. - Request body properties (from
TRequest) and route/query parameters are kept separate; only body-eligible properties are serialized as the JSON body.
Example: MCP server integration
A scene can connect to an external Model Context Protocol server to expose its tools alongside any registered services.
builder.Services.AddPlayFramework("default", framework =>
{
framework
.WithChatClient("default")
.AddScene("Dev Tools", "Development utilities", scene =>
{
scene
.WithMcpServer("mcp-server-name")
.WithService<IDevService>(tools =>
{
tools.WithMethod<string>(x => x.GetStatus(), "GetStatus", "Get system status");
});
});
});
The MCP server name must match a registered IMcpServerManager factory entry.
Example: cost tracking
Cost per call and cumulative totals are recorded in AiSceneResponse.Cost / AiSceneResponse.TotalCost when the adapter is configured with pricing. See the Rystem.PlayFramework.Adapters README for how to set AdapterSettings.CostTracking.
Per-request budget enforcement uses SceneRequestSettings.MaxBudget. When the cumulative cost exceeds it, execution stops with status BudgetExceeded:
var settings = new SceneRequestSettings
{
MaxBudget = 0.05m, // $0.05 maximum per request
};
Response cost fields:
| Field | Description |
|---|---|
AiSceneResponse.Cost |
cost of the current LLM call (null when no LLM call was made) |
AiSceneResponse.TotalCost |
cumulative cost across all calls in this request |
AiSceneResponse.InputTokens |
input tokens used in this call |
AiSceneResponse.OutputTokens |
output tokens generated in this call |
AiSceneResponse.CachedInputTokens |
cached input tokens used in this call |
Example: business hooks (BeforeExecution, AfterEachScene, OnTerminalScene)
Business hooks let you plug cross-cutting logic into the PlayFramework execution pipeline without modifying your scenes. There are three hook types, each addressing a different phase:
| Hook interface | Phase | Typical use cases |
|---|---|---|
IPlayFrameworkBeforeExecution |
Before the stream starts | Rate limiting, auth checks, prompt injection |
IPlayFrameworkAfterEachScene |
After each AiSceneResponse is produced |
Cost tracking, response filtering, audit logging |
IPlayFrameworkOnTerminalScene |
When a terminal status is detected | Session finalization, usage recording, async notifications |
Registration
Hooks are registered on builder.Business inside the AddPlayFramework configure lambda:
builder.Services.AddPlayFramework("default", framework =>
{
framework
.WithChatClient("default")
.AddScene("Assistant", "General conversation", _ => { });
framework.Business
.AddBeforeExecution<RateLimitHook>(priority: 0)
.AddBeforeExecution<AuditLogHook>(priority: 10)
.AddAfterEachScene<CostAccumulatorHook>(priority: 0)
.AddOnTerminalScene<SessionFinalizerHook>(priority: 0);
});
// Hook implementations are resolved from DI (Scoped per request)
builder.Services.AddScoped<IRateLimitService, RateLimitService>();
The priority parameter controls execution order within each hook type — lower numbers run first. All three types accept any number of registered implementations.
IPlayFrameworkBeforeExecution
Called once before the SSE stream opens. Return one of three outcomes:
public sealed class RateLimitHook : IPlayFrameworkBeforeExecution
{
private readonly IRateLimitService _rateLimiter;
public RateLimitHook(IRateLimitService rateLimiter) => _rateLimiter = rateLimiter;
public async Task<PlayFrameworkGuardResult> BeforeExecutionAsync(
PlayFrameworkExecutionContext context,
CancellationToken cancellationToken = default)
{
var userId = context.User?.FindFirst("sub")?.Value ?? "anonymous";
var allowed = await _rateLimiter.TryAcquireAsync(userId, cancellationToken);
if (!allowed)
return PlayFrameworkGuardResult.Deny(429, "Rate limit exceeded. Try again later.");
// Store data for downstream hooks via the shared Items bag
context.Items["userId"] = userId;
return PlayFrameworkGuardResult.Allow();
}
}
| Return value | Effect |
|---|---|
PlayFrameworkGuardResult.Allow() |
Proceeds to the next hook (or starts the stream) |
PlayFrameworkGuardResult.Deny(statusCode, detail) |
Endpoint returns the given HTTP error; stream never opens |
PlayFrameworkGuardResult.ShortCircuit(response) |
Stream opens with one synthetic SSE item then closes; ISceneManager is never called |
IPlayFrameworkAfterEachScene
Called for every AiSceneResponse emitted by the scene manager, before it is written to the SSE channel:
public sealed class CostAccumulatorHook : IPlayFrameworkAfterEachScene
{
private readonly ICostRepository _repo;
public CostAccumulatorHook(ICostRepository repo) => _repo = repo;
public async Task<PlayFrameworkSceneResult> AfterSceneAsync(
AiSceneResponse scene,
PlayFrameworkExecutionContext context,
CancellationToken cancellationToken = default)
{
if (scene.Cost is not null)
{
var userId = context.Items.TryGetValue("userId", out var u) ? (string)u : "anonymous";
await _repo.AccumulateAsync(userId, scene.Cost.Value, cancellationToken);
}
return PlayFrameworkSceneResult.Forward(scene);
}
}
| Return value | Effect |
|---|---|
PlayFrameworkSceneResult.Forward(scene) |
Sends the (optionally modified) item to the client |
PlayFrameworkSceneResult.Suppress() |
Discards the item; client never receives it |
PlayFrameworkSceneResult.ForwardAndInject(scene, extras) |
Sends the item, then appends extra synthetic items (extras bypass AfterEachScene hooks) |
IPlayFrameworkOnTerminalScene
Called once when a terminal status is detected (Completed, Error, BudgetExceeded, Unauthorized, Timeout, RateLimited). The terminal response is always sent to the client first; items returned by this hook are appended to the stream afterwards and bypass IPlayFrameworkAfterEachScene hooks:
public sealed class SessionFinalizerHook : IPlayFrameworkOnTerminalScene
{
private readonly ISessionRepository _sessions;
public SessionFinalizerHook(ISessionRepository sessions) => _sessions = sessions;
public async Task<IEnumerable<AiSceneResponse>?> OnTerminalAsync(
AiSceneResponse terminalScene,
PlayFrameworkExecutionContext context,
CancellationToken cancellationToken = default)
{
var userId = context.Items.TryGetValue("userId", out var u) ? (string)u : "anonymous";
var summary = await _sessions.FinalizeAsync(userId, terminalScene.Status, cancellationToken);
// Optionally inject a summary item into the SSE stream after the terminal response
return
[
new AiSceneResponse
{
Status = AiResponseStatus.FinalResponse,
Message = $"Session finalized. Total cost: {summary.TotalCost:F4} USD."
}
];
}
}
Return null or an empty enumerable when no additional items are needed.
PlayFrameworkExecutionContext
Every hook in the same request receives the same PlayFrameworkExecutionContext instance:
| Property | Type | Description |
|---|---|---|
Message |
string |
The user's text message |
Input |
MultiModalInput? |
Multi-modal input (text + images/audio/files), if used |
ConversationKey |
string? |
Conversation key from the request (null for new conversations) |
Settings |
SceneRequestSettings |
Per-request settings — mutable, can be modified by BeforeExecution hooks |
Metadata |
Dictionary<string, object>? |
HTTP metadata (userId, ipAddress, requestId, timestamp, custom keys) |
User |
ClaimsPrincipal? |
The authenticated user (null for unauthenticated requests) |
Items |
ConcurrentDictionary<string, object> |
Thread-safe bag for passing data between hooks in the same request |
Timeout
A per-request timeout can be configured in PlayFrameworkApiSettings:
builder.Services.Configure<PlayFrameworkApiSettings>(options =>
{
options.TimeoutInSeconds = 30; // abort streaming after 30 s
});
When the timeout fires:
- If the SSE stream has not yet started: the endpoint returns HTTP 504 and the connection is closed.
- If the SSE stream is already open: a synthetic
AiResponseStatus.Timeoutitem is emitted into the stream before the connection closes, so clients can detect and handle the timeout gracefully.
Important behavior:
- All three hook types are resolved from the DI container as Scoped services — one instance per HTTP request.
- Hooks of the same type run in ascending
priorityorder. Hooks with equal priority run in registration order. IPlayFrameworkBusinessManageris always registered, even when no hooks are configured (it handles the timeout and delegates toISceneManagertransparently).ForwardAndInjectextra items andOnTerminalSceneinjected items bypassIPlayFrameworkAfterEachScenehooks to prevent infinite loops.OnTerminalScenefires based on the original response status, even if the triggering item was suppressed by anAfterEachScenehook.- A
BeforeExecutionhook can mutatecontext.Settings(e.g. injectForcedToolsor changeMaxBudget) before execution begins. - If a hook throws a non-cancellation exception it is wrapped in
PlayFrameworkHookException(which carriesHookTypeName,Phase, andPriority) and propagated — the pipeline does not silently swallow hook errors. - If the same hook implementation type is registered more than once, a
LogWarningis emitted once perIPlayFrameworkBusinessManagerinstance at the firstExecuteAsynccall, reporting the type name, registration count, factory name, and all registered priorities. All duplicate registrations are kept; no registration is silently dropped.
Example: custom extensibility
Core pipeline components can be swapped out without forking the framework.
builder.Services.AddPlayFramework("default", framework =>
{
framework
.WithChatClient("default")
// Replace the built-in planner
.AddCustomPlanner<MyPlanner>()
// Replace the built-in summarizer
.AddCustomSummarizer<MySummarizer>()
// Replace the built-in director
.AddCustomDirector<MyDirector>()
// Replace the built-in JSON service (used for tool argument serialization)
.AddCustomJsonService<MyJsonService>()
// Or with a factory delegate
.AddCustomJsonService(sp => new MyJsonService(sp.GetRequiredService<IOptions<JsonOptions>>()))
// Inject additional context into SceneContext before execution
.AddContext<MyContextProvider>()
.AddScene("Assistant", "General conversation", _ => { });
});
IJsonService is the most commonly replaced component. If the default System.Text.Json-based serialization does not handle a custom type (such as AnyOf<T0, T1> with non-standard converters), provide a custom implementation here.
Per-request settings reference
SceneRequestSettings controls all per-call behavior and can be passed to both the programmatic API and the HTTP request body.
| Property | Type | Description |
|---|---|---|
ExecutionMode |
SceneExecutionMode |
Direct, Planning, DynamicChaining, Scene |
SceneName |
string? |
Target scene name (required for Scene mode). Use the normalized name (e.g. General_Requests) |
ConversationKey |
string? |
Key for multi-turn conversation state |
MaxRecursionDepth |
int |
Max planning depth |
MaxDynamicScenes |
int |
Max scenes in dynamic chaining |
EnableSummarization |
bool |
Override summarization on/off for this request |
EnableDirector |
bool |
Override director on/off for this request |
CacheBehavior |
CacheBehavior |
Default, ForceRefresh, ReadOnly |
MaxBudget |
decimal? |
Max allowed cost for this request |
ModelId |
string? |
Override model deployment |
Temperature |
float? |
Override temperature |
MaxTokens |
int? |
Override max output tokens |
IsVoiceMode |
bool |
Inject voice-style system instruction |
UserId |
string? |
Override user identity for conversation ownership |
Voice pipeline settings reference
VoiceSettings controls sentence accumulation and language behavior.
| Property | Type | Default | Description |
|---|---|---|---|
SentenceDelimiters |
string |
".!?\n" |
Characters that flush accumulated text to TTS |
MinCharsBeforeTts |
int |
20 |
Min chars before flushing to TTS |
MaxCharsBeforeTts |
int |
500 |
Max chars before forcing a flush |
LanguageInstruction |
string? |
built-in | System instruction template; {language} is replaced with the STT-detected language |
VoiceStyleInstruction |
string? |
built-in | Instructs the LLM to respond conversationally (no markdown, no tables). Set to null to disable |
Response status codes reference
All possible values of AiResponseStatus:
| Status | Description |
|---|---|
Initializing |
Initializing execution context |
LoadingCache |
Loading data from conversation cache |
ExecutingMainActors |
Executing main actors |
Planning |
Creating an execution plan |
ExecutingScene |
Engine is inside a scene |
ExecutingPlan |
Executing a plan step |
FunctionRequest |
Server-side tool call started |
FunctionCompleted |
Server-side tool call finished |
ToolSkipped |
Tool execution skipped (already executed) |
AwaitingClient |
Waiting for a client-side tool response |
CommandClient |
Fire-and-forget command sent to client |
Streaming |
Token-level streaming chunk in progress |
Running |
General processing in progress |
Summarizing |
Compressing conversation history |
DirectorDecision |
Director evaluating scene output |
GeneratingFinalResponse |
Generating aggregated final response |
FinalResponse |
Final aggregated response item |
SavingCache |
Persisting response to conversation cache |
SavingRepository |
Saving to conversation repository |
SavingMemory |
Saving to memory layer |
Completed |
Execution finished successfully |
BudgetExceeded |
Request exceeded MaxBudget |
Error |
Unhandled error during execution |
Unauthorized |
IAuthorizationLayer rejected the request |
Timeout |
Server-side timeout expired; synthetic SSE item emitted if stream was already open |
RateLimited |
Rate limit exceeded (e.g. RejectOnExceeded or a BeforeExecution hook) |
Per-tool FunctionRequest, FunctionCompleted, and tool-related Error items
populate FunctionName and FunctionArguments. The latter is a valid JSON
document and is "{}" for a call without arguments. An aggregate
FunctionRequest that only reports the number of calls has neither field.
Because tool arguments can contain sensitive data, do not log the raw value by
default.
Important caveats
Everything is factory-based
Named PlayFramework instances, chat clients, repositories, and voice adapters all rely on matching factory names. A lot of runtime errors come from mismatched names rather than missing services.
OnClient(...) requires cache support
Client-side tools depend on cached continuation state. In samples and tests this is usually IMemoryCache or IDistributedCache behind AddCache(...).
Only token-bucket rate limiting is implemented
RateLimitBuilder exposes SlidingWindow, FixedWindow, and Concurrent, but DI registration currently supports only TokenBucket.
Repository persistence is separate from memory
UseRepository() stores conversations for CRUD endpoints. WithMemory(...) stores summarized conversational memory. They solve different problems and you often need both or neither.
The HTTP voice path resolves by PlayFramework factory name first
The builder stores a VoiceAdapterFactoryName, but the HTTP endpoint effectively resolves IVoiceAdapter by PlayFramework factory name first, then unnamed default. Keep those names aligned.
Some API settings are less active than they look
For example DefaultFactoryName, EnableCompression, and MaxRequestBodySize exist on PlayFrameworkApiSettings, but the current HTTP mapping path does not meaningfully use them.
This package targets net10.0
The current project targets net10.0 only.
Grounded by source and tests
src/AI/Test/Rystem.PlayFramework.Api/Program.cssrc/AI/Test/Rystem.PlayFramework.Test/Tests/FactoryPatternTests.cssrc/AI/Test/Rystem.PlayFramework.Test/Tests/ClientInteractionTests.cssrc/AI/Test/Rystem.PlayFramework.Test/Tests/LoadBalancingAndFallbackTests.cssrc/AI/Test/Rystem.PlayFramework.Test/Tests/MemoryTests.cssrc/AI/Test/Rystem.PlayFramework.Test/Tests/RateLimitingTests.cssrc/AI/Test/Rystem.PlayFramework.Test/Tests/MultiModalTests.cs
Use this package when you want the orchestration engine itself. Add an adapter package when you want a concrete model provider, and add repository infrastructure when you want stored conversations.
| 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
- Microsoft.Extensions.AI.Abstractions (>= 10.9.0)
- OpenTelemetry (>= 1.18.0)
- OpenTelemetry.Api (>= 1.18.0)
- OpenTelemetry.Extensions.Hosting (>= 1.18.0)
- Rystem.DependencyInjection (>= 10.1.0-beta.3)
- Rystem.RepositoryFramework.Abstractions (>= 10.1.0-beta.3)
NuGet packages (2)
Showing the top 2 NuGet packages that depend on Rystem.PlayFramework:
| Package | Downloads |
|---|---|
|
Rystem.PlayFramework.Adapters
Azure OpenAI v1 adapter for Rystem.PlayFramework, powered by OpenAI 2.12.0. Supports Responses API, automatic file upload via Files API, and SHA256-based multi-level caching. |
|
|
Rystem.PlayFramework.Adapters.FoundryLocal
Foundry Local adapter for Rystem.PlayFramework. Runs AI models locally for development and testing using Microsoft.AI.Foundry.Local SDK. Automatically downloads, loads, and starts a local OpenAI-compatible web service. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 10.1.1 | 122 | 8/28/2026 |
| 10.1.0-beta.4 | 91 | 8/26/2026 |
| 10.1.0-beta.3 | 77 | 8/26/2026 |
| 10.1.0-beta.2 | 70 | 8/26/2026 |
| 10.0.11-beta.25 | 99 | 7/27/2026 |
| 10.0.11-beta.24 | 67 | 7/22/2026 |
| 10.0.11-beta.21 | 101 | 6/12/2026 |
| 10.0.11-beta.20 | 60,668 | 5/27/2026 |
| 10.0.11-beta.19 | 73 | 5/26/2026 |
| 10.0.11-beta.18 | 67 | 5/25/2026 |
| 10.0.11-beta.17 | 79 | 5/22/2026 |
| 10.0.11-beta.16 | 63 | 5/22/2026 |
| 10.0.11-beta.15 | 90 | 5/13/2026 |
| 10.0.11-beta.14 | 189 | 5/13/2026 |
| 10.0.11-beta.13 | 115 | 3/27/2026 |
| 10.0.11-beta.12 | 81 | 3/26/2026 |
| 10.0.11-beta.11 | 96 | 3/24/2026 |
| 10.0.11-beta.10 | 85 | 3/23/2026 |
| 10.0.11-beta.9 | 82 | 3/20/2026 |
| 10.0.11-beta.8 | 88 | 3/19/2026 |
Version aligned to 10.1.0-beta.5 for compatibility with Rystem.PlayFramework.Adapters 10.1.0-beta.5. This release does not change PlayFramework runtime behavior; use the matching adapter version for the Azure OpenAI v1 and OpenAI 2.12.0 migration.