Rystem.PlayFramework 10.1.1

dotnet add package Rystem.PlayFramework --version 10.1.1
                    
NuGet\Install-Package Rystem.PlayFramework -Version 10.1.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Rystem.PlayFramework" Version="10.1.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Rystem.PlayFramework" Version="10.1.1" />
                    
Directory.Packages.props
<PackageReference Include="Rystem.PlayFramework" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Rystem.PlayFramework --version 10.1.1
                    
#r "nuget: Rystem.PlayFramework, 10.1.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Rystem.PlayFramework@10.1.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Rystem.PlayFramework&version=10.1.1
                    
Install as a Cake Addin
#tool nuget:?package=Rystem.PlayFramework&version=10.1.1
                    
Install as a Cake Tool

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

Architecture

The core entry points are:

  • AddPlayFramework(...)
  • MapPlayFramework(...)
  • ISceneManager
  • IPlayFramework

The lifecycle is:

  1. register a named PlayFramework instance
  2. attach one or more named IChatClient registrations
  3. add scenes, actors, and tools
  4. optionally enable cache, repository persistence, memory, telemetry, rate limiting, and voice
  5. 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 registered IRuntimeDescriptionChangeTokenSource refresh outside the normal request path.
  • Manual: resolve IFactory<IRuntimeDescriptionRefresher>, call Create(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 pool
  • WithChatClientAsFallback(...) adds clients to the fallback chain
  • WithLoadBalancingMode(...) controls the primary pool order
  • WithFallbackMode(...) controls fallback ordering
  • WithRetry(...) and WithRetryPolicy(...) 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, or Other)
  • 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 in MemberName

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 conversationKey plus clientInteractionResults when resuming
  • the published TypeScript client in src/AI/Rystem.PlayFramework.Client/src/rystem already automates most of this flow

Execution modes

The runtime supports four execution modes:

  • Direct
  • Planning
  • DynamicChaining
  • Scene

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 key
  • WithDefaultMemoryStorage("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/default
  • POST /api/ai/default/streaming
  • GET /api/ai/default/discovery
  • GET /api/ai/default/conversations
  • GET /api/ai/default/conversations/{conversationKey}
  • DELETE /api/ai/default/conversations/{conversationKey}
  • PATCH /api/ai/default/conversations/{conversationKey}/visibility
  • POST /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:

  • scenes with normalized scene names and their tools
  • services grouped by DI source
  • clients grouped by client-side source
  • mcpServers grouped by MCP source
  • endpoints grouped by HTTP client marker type
  • others for 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)
  • IAuthorizationLayer runs 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:

  • RetrieveAsync is called only once per new conversation. When resuming a cached or stored conversation the existing context is reused.
  • Returning null skips the [Request Context] block entirely.
  • Returning a string injects it verbatim; any other object is serialized to JSON.
  • IContext is transient by default. Services you inject into it may be scoped (e.g. IHttpContextAccessor).
  • Only one IContext implementation can be registered per named PlayFramework instance. A second call to AddContext<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", _ => { });
});

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:

  • TClient is 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.Timeout item 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 priority order. Hooks with equal priority run in registration order.
  • IPlayFrameworkBusinessManager is always registered, even when no hooks are configured (it handles the timeout and delegates to ISceneManager transparently).
  • ForwardAndInject extra items and OnTerminalScene injected items bypass IPlayFrameworkAfterEachScene hooks to prevent infinite loops.
  • OnTerminalScene fires based on the original response status, even if the triggering item was suppressed by an AfterEachScene hook.
  • A BeforeExecution hook can mutate context.Settings (e.g. inject ForcedTools or change MaxBudget) before execution begins.
  • If a hook throws a non-cancellation exception it is wrapped in PlayFrameworkHookException (which carries HookTypeName, Phase, and Priority) and propagated — the pipeline does not silently swallow hook errors.
  • If the same hook implementation type is registered more than once, a LogWarning is emitted once per IPlayFrameworkBusinessManager instance at the first ExecuteAsync call, 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.cs
  • src/AI/Test/Rystem.PlayFramework.Test/Tests/FactoryPatternTests.cs
  • src/AI/Test/Rystem.PlayFramework.Test/Tests/ClientInteractionTests.cs
  • src/AI/Test/Rystem.PlayFramework.Test/Tests/LoadBalancingAndFallbackTests.cs
  • src/AI/Test/Rystem.PlayFramework.Test/Tests/MemoryTests.cs
  • src/AI/Test/Rystem.PlayFramework.Test/Tests/RateLimitingTests.cs
  • src/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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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
Loading failed

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.