Modern .NET client for OBS Studio WebSocket v5, with generated protocol types and DI-first integration.
net11.0net10.0net9.0
dotnet add package ObsWebSocket.CoreOBS WebSocket v5 only (OBS Studio 28+). Enable the server via Tools → WebSocket Server Settings in OBS.
appsettings.json:
{
"ConnectionStrings": {
"obs": "ws://localhost:4455?password=secret"
}
}Program.cs:
using ObsWebSocket.Core;
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.AddObsWebSocketClient("obs") // endpoint from ConnectionStrings:obs
.WithAutoConnect(); // connect on start, disconnect on stop
builder.Services.AddHostedService<Worker>();
await builder.Build().RunAsync();Worker.cs:
using ObsWebSocket.Core;
using ObsWebSocket.Core.Events.Generated;
public sealed class Worker(ObsWebSocketClient client) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
var version = await client.General.GetVersionAsync(ct);
Console.WriteLine($"Connected to OBS {version.ObsVersion}");
await foreach (var e in client.Scenes.CurrentProgramSceneChangedStream(cancellationToken: ct))
{
Console.WriteLine($"Scene changed: {e.EventData.SceneName}");
}
}
}The client mirrors the categories the OBS protocol defines. Requests, event streams and the conveniences this library adds all sit in the group their category owns, so there is one way to reach anything:
await client.Scenes.GetSceneListAsync(new(), ct); // generated request
await client.Scenes.SwitchProgramSceneAndWaitAsync("Intro", cancellationToken: ct); // convenience
await client.Inputs.SetInputVolumeDbAsync("Mic", -6, ct);
await client.SceneItems.SetSceneItemEnabledAsync("Intro", "Logo", false, ct);
client.Scenes.CurrentProgramSceneChanged += (_, e) => { }; // classic event
await foreach (var e in client.Scenes.CurrentProgramSceneChangedStream(cancellationToken: ct)) { break; }The groups are Canvases, Config, Filters, General, Inputs, MediaInputs, Outputs,
Record, SceneItems, Scenes, Sources, Stream, Transitions and Ui. They come from the
protocol definition, so a refresh that recategorises a request moves it here too.
WaitForEventAsync and CallBatchAsync stay directly on the client, since neither belongs to one
category.
Alongside the generated request per protocol request, each group carries conveniences for things
that otherwise take several calls or a lookup. Every typed settings helper has two overloads: an
implicit one for library-registered types, and an explicit one taking a JsonTypeInfo<T> for
consumer-provided types. Use the explicit overload to stay AOT-safe.
Settings read and write
| Helper | Notes |
|---|---|
Inputs.GetInputSettingsAsync<T> / SetInputSettingsAsync<T> |
Input settings; Set supports overlay |
Inputs.GetInputDefaultSettingsAsync<T> |
Defaults for a given input kind |
Filters.GetSourceFilterSettingsAsync<T> / SetSourceFilterSettingsAsync<T> |
Filter settings; Set supports overlay |
Filters.GetSourceFilterDefaultSettingsAsync<T> |
Defaults for a given filter kind |
Transitions.GetCurrentSceneTransitionSettingsAsync<T> / SetCurrentSceneTransitionSettingsAsync<T> |
Transition settings |
Outputs.GetOutputSettingsAsync<T> / SetOutputSettingsAsync<T> |
Output settings |
Config.GetStreamServiceSettingsAsync<T> / SetStreamServiceSettingsAsync<T> |
Stream service settings |
Most take optional parameters ahead of the cancellation token, so pass it as cancellationToken: ct.
Scenes and scene items
Scenes.SwitchProgramSceneAsync(scene, ct)andScenes.SwitchPreviewSceneAsync(scene, ct)switch a scene. OptionaltransitionNameandtransitionDurationMsapply to that switch only.Scenes.SwitchProgramSceneAndWaitAsyncandScenes.SwitchPreviewSceneAndWaitAsyncdo the same and wait for the event confirming it.SceneItems.SetSceneItemEnabledAsync(scene, sourceName, isEnabled, ct)returns the resulting state. LeaveisEnablednull to toggle. An overload takes the numeric item id instead.SceneItems.FindSceneItemIdAsync(scene, sourceName, ct)returnsint?, null rather than throwing when the item is not in the scene.Sources.SourceExistsAsync(name, ct)andScenes.SceneExistsAsync(name, ct)check existence.
Inputs and filters
Inputs.SetInputTextAsync(name, text, ct)is shorthand for updating text source content.Inputs.SetInputVolumeDbAsync(name, db, ct)andInputs.SetInputVolumeMulAsync(name, mul, ct)each pick one unit. The underlying request accepts either and fails when given neither.Inputs.SetInputMutesAsync(inputMutes, ct)sets many mute states in one batch and returns the results, so a caller sees which inputs OBS rejected.Inputs.CreateInputAsync<T>(kind, name, settings, ...)creates an input with typed settings.Filters.CreateSourceFilterAsync<T>(source, filterName, kind, settings, ct)adds a typed filter.
Media
MediaInputs.PlayMediaAsync,PauseMediaAsync,StopMediaAsyncandRestartMediaAsyncare shorthands overTriggerMediaActionAsync(name, MediaInputAction, ct).
Screenshots
Sources.GetSourceScreenshotBytesAsync(source, ...)returns decoded image bytes.Sources.GetSourceScreenshotOnCanvasBytesAsync(source, ...)does the same at canvas dimensions.Sources.SaveSourceScreenshotToFileAsync(source, filePath, ...)writes straight to disk.
Outputs
Record.SetRecordActiveAndWaitAsync(activate, timeout, ct),Stream.SetStreamActiveAndWaitAsync(...)andOutputs.SetVirtualCamActiveAndWaitAsync(...)start or stop the output and wait for OBS to confirm, returning the resultingOutputState.Record.IsRecordActiveAsync(ct),Stream.IsStreamActiveAsync(ct)andOutputs.IsVirtualCamActiveAsync(ct)read current state.
Application state
Config.EnsureProfileActiveAsync(name, ct)andConfig.EnsureSceneCollectionActiveAsync(name, ct)switch only if needed, returning whether the target is active rather than throwing when it does not exist.General.TriggerHotkeyAsync(hotkeyName, ct)fires a hotkey by name.
Every OBS event is exposed as an async sequence on its category group. The stream subscribes for the lifetime of the loop and unsubscribes when it ends, so there is no handler bookkeeping:
await foreach (var e in client.Scenes.CurrentProgramSceneChangedStream(cancellationToken: ct))
{
Console.WriteLine($"Program scene is now {e.EventData.SceneName}");
}Streams buffer a bounded number of events and drop the oldest when a consumer falls behind, so a
slow loop cannot stall the receive loop. Pass capacity to change that.
The classic handler sits on the same group, so subscribing and streaming read alike and there is no second place to look:
client.Scenes.CurrentProgramSceneChanged += (_, e) =>
Console.WriteLine($"Program scene is now {e.EventData.SceneName}");Both work over the same event at once. The group's event is the client's event, so a handler added
through one can be removed through the other; client.CurrentProgramSceneChanged remains for the
low-level path, the way CallAsync remains alongside the generated requests.
Connection lifecycle events stay on the client, since Connected, Disconnected,
ConnectionFailed and AuthenticationFailure belong to no protocol category.
To wait for a single occurrence, use WaitForEventAsync. It subscribes before returning, so you can
start the wait and then trigger the action without racing it:
var changed = await client.WaitForEventAsync<CurrentProgramSceneChangedEventArgs>(ct);
var intro = await client.WaitForEventAsync<CurrentProgramSceneChangedEventArgs>(
e => e.EventData.SceneName == "Intro",
TimeSpan.FromSeconds(5),
ct
);It throws ObsWebSocketTimeoutException when the wait elapses, the same type a request
timeout raises, so one catch (ObsWebSocketException) covers both.
await client.Inputs.SetInputTextAsync("NewsTicker", "Breaking: Live now!", ct);
// or several properties at once, with a typed settings object
var settings = new TextGdiPlusInputSettings(Text: "Breaking: Live now!", WordWrap: true);
await client.Inputs.SetInputSettingsAsync("NewsTicker", settings, cancellationToken: ct);TextGdiPlusInputSettings is a built-in library type, as are TextFreetype2InputSettings,
BrowserSourceSettings and the filter settings types, which live in
ObsWebSocket.Core.Protocol.Common.InputSettings and .FilterSettings.
var status = await client.Outputs.GetReplayBufferStatusAsync(ct);
if (status.OutputActive)
{
await client.Outputs.SaveReplayBufferAsync(ct);
}var current = await client.Inputs.GetInputSettingsAsync<BrowserSourceSettings>("StreamOverlay", ct);
Console.WriteLine($"Current URL: {current?.Url}");
await client.Inputs.SetInputSettingsAsync(
"StreamOverlay",
new BrowserSourceSettings(Url: "https://myoverlay.example.com", Width: 1920, Height: 1080),
cancellationToken: ct
);Define your own type to target exactly what you need, and stay AOT-safe by passing its JsonTypeInfo:
[JsonSerializable(typeof(OverlaySettings))]
internal partial class MyContext : JsonSerializerContext { }
internal sealed record OverlaySettings(
[property: JsonPropertyName("url")] string? Url = null,
[property: JsonPropertyName("css")] string? Css = null
);
await client.Inputs.SetInputSettingsAsync(
"StreamOverlay",
new OverlaySettings(Url: "https://myoverlay.example.com"),
MyContext.Default.OverlaySettings,
cancellationToken: ct
);Both Set overloads take overlay before the cancellation token. It defaults to true, merging
your values onto the existing settings; pass overlay: false to replace them.
byte[]? png = await client.Sources.GetSourceScreenshotBytesAsync("Intro", "png", cancellationToken: ct);
await client.Sources.SaveSourceScreenshotToFileAsync("Intro", "shot.png", cancellationToken: ct);Send several requests in one round trip. Each returns a reference carrying its response type:
ObsBatchBuilder batch = new();
BatchRef<GetVersionResponseData> version = batch.General.GetVersion();
BatchRef<GetSceneListResponseData> scenes = batch.Scenes.GetSceneList(new());
_ = batch.General.Sleep(new(sleepMillis: 100));
_ = batch.Inputs.SetInputMute(new() { InputName = "Mic", InputMuted = false });
BatchResults results = await client.CallBatchAsync(
batch,
executionType: RequestBatchExecutionType.SerialRealtime,
haltOnFailure: false,
cancellationToken: ct
);
Console.WriteLine(results.Get(version).ObsVersion);
Console.WriteLine(results.Get(scenes).Scenes?.Count);results.Get(reference) restates neither the position nor the type, so a request type may appear
many times in one batch and each reference still resolves to its own result.
Sleep is only valid inside a batch, and pairs with SerialRealtime to pace a sequence.
TryGet reports a failed or missing result instead of throwing, and Get throws
ObsWebSocketRequestException carrying the OBS status code when that request was rejected:
if (!results.AllSucceeded())
{
foreach (var failed in results.GetFailures())
{
Console.WriteLine($"{failed.RequestType}: {failed.RequestStatus.Comment}");
}
}With haltOnFailure: true OBS stops at the first failure, so fewer results come back than requests
were sent; reading a reference past that point throws, and Count reports how many ran.
Add covers anything the generated methods do not, including a raw JsonElement, and an overload
taking a JsonTypeInfo<T> keeps a custom payload AOT-safe:
batch.Add("GetStats");
batch.Add("SetInputSettings", myJsonElement);RequestBatchExecutionType.Parallel works, but OBS mislabels what comes back. It collects results
in completion order and labels them from the submission order, so on any one row the
requestType and requestId belong to a different request than the requestStatus and
responseData beside them. That happens before the response leaves OBS, so it cannot be corrected
here. See #16.
Only the labelling is wrong. requestStatus and responseData come from the same object, so each
row's status does belong to the payload beside it; it is the requestType and requestId on that
row that name a different request. Get and the indexer therefore throw rather than hand back data
under the wrong reference, and TryGet reports false.
Nothing is lost, though, and Raw still reaches all of it. GetData<T> reads the payload without
consulting the label, so every response is recoverable as a set:
BatchResults results = await client.CallBatchAsync(
batch, executionType: RequestBatchExecutionType.Parallel, haltOnFailure: false, cancellationToken: ct);
foreach (RequestResponsePayload<object> row in results.Raw)
{
if (!row.RequestStatus.Result)
{
Console.WriteLine($"one request failed with {row.RequestStatus.Code}");
continue; // the code is right, the requestType naming it is not
}
// Correct data, from one of the requests in the batch. Which one is not knowable.
GetSceneItemListResponseData? data = row.GetData<GetSceneItemListResponseData>();
}That works when every request in the batch returns the same type, so it does not matter which row is which, and when the order is not what you needed.
A parallel batch of different request types is harder, because nothing on a row tells you which
type its payload really is. GetData<T> will not invent an answer, though: it checks the payload
against the fields T expects and throws ObsWebSocketSerializationException when the payload
carries none of them. So you can try each type you expect and let the mismatch tell you.
That check rejects rather than identifies. Two records that share field names cannot be told apart
this way, and a payload overlapping the target only partly still passes with the rest of the
properties left at their defaults. Where two records are field for field identical, which happens
for five shapes including GetInputMute and ToggleInputMute, reading one as the other gives the
right values anyway.
So a heterogeneous parallel batch is possible to unpick but never reliable. Use a serial batch, or concurrent requests.
Anything that does not depend on which row is which stays exact:
ObsBatchBuilder batch = new();
foreach (string input in inputs)
{
_ = batch.Inputs.SetInputMute(new(inputName: input, inputMuted: true));
}
BatchResults results = await client.CallBatchAsync(
batch, executionType: RequestBatchExecutionType.Parallel, cancellationToken: ct);
bool everythingWorked = results.AllSucceeded(); // reliable: order does not change the verdict
int failureCount = results.GetFailures().Count(); // reliable count, unreliable namesSo Parallel suits a set of writes you want applied as fast as possible, where you only need to
know whether they all took. It does not suit reading anything back.
When you need results attributed, use concurrent requests rather than a parallel batch. The client multiplexes on the request id, so anything in flight at once is matched back to its own caller:
Task<GetVersionResponseData> version = client.General.GetVersionAsync(ct);
Task<GetStatsResponseData> stats = client.General.GetStatsAsync(ct);
Task<GetSceneItemListResponseData>[] perScene =
[
.. sceneNames.Select(n => client.SceneItems.GetSceneItemListAsync(new(sceneName: n), ct)),
];
await Task.WhenAll([version, stats, .. perScene.Cast<Task>()]);
Console.WriteLine(version.Result.ObsVersion); // each result belongs to its own requestThat costs one round trip per request rather than one for the set. Use a serial batch when the round trip is what you are saving, and concurrent requests when you need the answers attributed.
Nothing above is a wall. Every generated request is a thin wrapper over the same primitives, and they stay available for a request this build does not model, an OBS newer than this library, or a vendor plugin:
// A request with a reference type response.
GetVersionResponseData? v = await client.CallAsync<GetVersionResponseData>("GetVersion", null, cancellationToken: ct);
// A value type response, JsonElement included. CallAsync is constrained to classes, so a struct
// response goes through CallAsyncValue.
JsonElement? raw = await client.CallAsyncValue<JsonElement>("GetStats", null, cancellationToken: ct);
// Request data is written through a source generated context, so it must be a JsonElement, a type
// the library knows, or a type you supply metadata for. An anonymous object has no metadata
// anywhere and throws ObsWebSocketSerializationException.
// Your own type, with your own context. AOT safe, and nothing to hand build.
[JsonSerializable(typeof(MyRequest))]
internal sealed partial class MyContext : JsonSerializerContext;
JsonElement? answer = await client.CallAsyncValue<JsonElement>(
"SomeNewRequest", new MyRequest(1), MyContext.Default.MyRequest, cancellationToken: ct);
// Or a JsonElement built by hand, when a one-off payload does not deserve a type.
using JsonDocument body = JsonDocument.Parse("""{"someField":1}""");
JsonElement? viaElement = await client.CallAsyncValue<JsonElement>(
"SomeNewRequest", body.RootElement, cancellationToken: ct);
// A batch assembled by hand, without the typed builder.
List<RequestResponsePayload<object>> results = await client.CallBatchAsync(
[new BatchRequestItem("GetVersion", null), new BatchRequestItem("GetStats", null)],
executionType: RequestBatchExecutionType.SerialRealtime,
cancellationToken: ct);
foreach (RequestResponsePayload<object> result in results)
{
GetVersionResponseData? data = result.GetData<GetVersionResponseData>();
}Those two are the AOT-safe ways to build a payload. JsonSerializer.SerializeToElement without a
JsonTypeInfo, and the JsonNode and JsonObject routes, all work at runtime but carry IL2026
and IL3050, so they are not options under Native AOT.
The same applies to events and enums: client.SceneCreated remains alongside
client.Scenes.SceneCreated, and ToWireValue() / FromWireValue() convert an enum to and from
the protocol string when you are building a payload by hand.
The protocol definition is looser than C#: it has one numeric type because JSON does, and it types enum-valued fields as plain strings. The generated surface narrows both, so callers get the C# type rather than the wire representation.
Numbers. A scene item id and a volume multiplier are both Number with a >= 0 restriction, so
which ones are integral is not recoverable from the definition. Fields holding whole numbers are
generated as int or long, from an explicit list in the generator rather than a rule over field
names, so a volume can never be truncated by a naming coincidence:
int id = await client.SceneItems.FindSceneItemIdAsync("Intro", "Logo", ct) ?? throw new(...);
await client.SceneItems.SetSceneItemIndexAsync(new(sceneItemId: id, sceneItemIndex: 0, sceneName: "Intro"), ct);
long bytes = (await client.Stream.GetStreamStatusAsync(ct)).OutputBytes;
double volume = (await client.Inputs.GetInputVolumeAsync(new("Mic"), ct)).InputVolumeMul;Enums. Fields carrying a protocol enum are that enum, on both the read and the write side, so there is nothing to convert at the call site:
client.Outputs.StreamStateChanged += (_, e) =>
{
string what = e.EventData.OutputState switch
{
OutputState.Started => "live",
OutputState.Starting or OutputState.Reconnecting => "coming up",
OutputState.Stopped or OutputState.Stopping => "going down",
OutputState.Unknown => "in a state this build does not recognise",
_ => "in between",
};
};
await client.MediaInputs.TriggerMediaActionAsync("Stinger", MediaInputAction.Restart, ct);A value OBS sends that this build does not know maps to the enum's zero member rather than throwing, so a state added by a newer OBS does not fail the whole message.
This covers the enums the protocol declares. mediaState, monitorType, sceneItemBlendMode and
inputKind carry fixed vocabularies too, but the protocol types them as strings and never lists
their values, so they stay strings rather than being given an enum this library would have to keep
correct by hand.
The wire values also remain as const strings on ObsOutputState and ObsMediaInputAction, and
ToWireValue() converts an enum back, for payloads built by hand. See
Dropping to the low level.
builder.AddObsWebSocketClient("obs") // reads ConnectionStrings:obs
.WithAutoConnect() // connects on start, disconnects on stop
.WithHealthCheck();The password may travel in the connection string or be set on the options; either way it is kept off
ServerUri. A connection that cannot be established at startup is logged rather than thrown, because
OBS is often started after the application, and reconnect takes over from there.
Options are read through IOptionsMonitor, so editing configuration takes effect without a restart.
Timeouts and reconnect settings apply to the next call that uses them; changing the endpoint,
password or transport reconnects, which WithAutoConnect performs.
To configure in code instead:
builder.Services.AddObsWebSocketClient(o =>
{
o.ServerUri = new Uri("ws://localhost:4455");
o.Password = "secret";
o.Format = SerializationFormat.MsgPack;
});Options are validated when the client is resolved, so a missing or malformed ServerUri fails at
startup with the offending option named, rather than on the first connection attempt.
Register clients by name and resolve them with [FromKeyedServices]:
builder.Services.AddObsWebSocketClient("main", o => o.ServerUri = new Uri("ws://localhost:4455"))
.WithAutoConnect()
.WithHealthCheck();
builder.Services.AddObsWebSocketClient("booth", o => o.ServerUri = new Uri("ws://booth:4455"))
.WithAutoConnect();
public sealed class Worker(
[FromKeyedServices("main")] ObsWebSocketClient main,
[FromKeyedServices("booth")] ObsWebSocketClient booth);Each client gets its own options instance, its own connection service and a health check named after its key, so the two do not collide.
Failures are typed, so they can be caught by category rather than matched by message:
try
{
await client.Ui.SetStudioModeEnabledAsync(new(true), ct);
}
catch (ObsWebSocketRequestException ex)
{
Console.WriteLine($"{ex.RequestType} failed with {ex.StatusCode}: {ex.Comment}");
}
catch (ObsWebSocketTimeoutException)
{
// No response within the request timeout.
}StatusCode reports the status as the RequestStatusCode enum, so a filter can name the reason
instead of a number:
using ObsWebSocket.Core.Protocol.Generated;
catch (ObsWebSocketRequestException ex) when (ex.StatusCode is RequestStatusCode.ResourceNotFound)
{
// The scene, input or filter does not exist.
}ObsWebSocketSerializationException covers payloads that cannot be written or read, and all three
derive from ObsWebSocketException.
Requests return their response data non-nullable; a successful request that carries no payload
raises ObsWebSocketException rather than handing back null.
Reconnect delays grow by ReconnectBackoffMultiplier, are capped at MaxReconnectDelayMs, and carry
jitter so several clients recovering from one outage do not retry in lockstep. Authentication
failures are never retried, since they cannot succeed on a second attempt.
WithReconnectPipeline() registers the default pipeline explicitly, which is worth doing when a
host has its own resilience configuration and you want this client's to be visible alongside it:
builder.AddObsWebSocketClient("obs")
.WithAutoConnect()
.WithReconnectPipeline();To replace the policy outright rather than tune those options, register your own pipeline under
ObsWebSocketResilience.ReconnectPipelineKey after adding the client.
Traces and metrics are published under the name ObsWebSocket.Core, inert until something
subscribes:
builder.Services.AddOpenTelemetry()
.WithTracing(t => t.AddSource(ObsWebSocketDiagnostics.ActivitySourceName))
.WithMetrics(m => m.AddMeter(ObsWebSocketDiagnostics.MeterName));One activity per request, and one per batch rather than per item. Counters cover requests sent,
requests failed, events received and reconnect attempts, plus a request-duration histogram. The
instruments are created from IMeterFactory, so they belong to the container that built them.
Timeouts and reconnect delays run on an injectable TimeProvider, so tests can drive them with
FakeTimeProvider instead of waiting.
JSON and MessagePack are both supported, selected with Format. Everything in this document behaves
identically on either, and the validation suite exercises both.
ObsWebSocket.Example is a host-based sample with configuration and DI.
- Interactive mode: command loop (
help,version,scene,watch,media,status,batch-example, and more) - Transport validation mode: exercises the surface on JSON and MessagePack, then enters the interactive loop
- One-shot mode:
ObsWebSocket.Example run-transport-tests
run-transport-tests creates its own scene and input, so it does not depend on a particular OBS
layout, and removes them afterwards. It runs the same checks on JSON and on MessagePack, asserting
real values rather than that a call returned: the three settings modes, event streams and their
buffering, WaitForEventAsync, the typed batch builder including duplicate request types, partial
failure and truncation, a parallel batch and what survives its mispairing, concurrent requests
keeping their own results, the low level Add and CallAsync path, typed protocol enums, integer
fields round tripping in both directions, screenshots in memory and on disk, and the scene, preview,
input, mute, volume, media, transition and output helpers.
dotnet publish ObsWebSocket.Example/ObsWebSocket.Example.csproj -c Release -r win-x64 --self-contained trueContributions are welcome. See CONTRIBUTING.md.
MIT. See LICENSE.txt.