Optimizely.Opal.AgentSdk 0.9.1

Prefix Reserved
dotnet add package Optimizely.Opal.AgentSdk --version 0.9.1
                    
NuGet\Install-Package Optimizely.Opal.AgentSdk -Version 0.9.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="Optimizely.Opal.AgentSdk" Version="0.9.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Optimizely.Opal.AgentSdk" Version="0.9.1" />
                    
Directory.Packages.props
<PackageReference Include="Optimizely.Opal.AgentSdk" />
                    
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 Optimizely.Opal.AgentSdk --version 0.9.1
                    
#r "nuget: Optimizely.Opal.AgentSdk, 0.9.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 Optimizely.Opal.AgentSdk@0.9.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=Optimizely.Opal.AgentSdk&version=0.9.1
                    
Install as a Cake Addin
#tool nuget:?package=Optimizely.Opal.AgentSdk&version=0.9.1
                    
Install as a Cake Tool

Optimizely.Opal.AgentSdk

Invoke Opal agents from .NET — run them one-shot or stream their output, hold multi-turn conversations, trigger workflows, browse and install agents from the Agent Directory, and manage Virtual Teammates.

Status: 0.9.1. All eight namespaces are implemented: Agents (Specialized / Workflow / Catalog), Executions (including socket.io subscribe), Skills, Canvas, Memories, Pats, Directory, and Teammates.

Targets net8.0. On the same version line as the Python, TypeScript and Java Opal Agent SDKs.

Install

dotnet add package Optimizely.Opal.AgentSdk

Quickstart

using Optimizely.Opal.AgentSdk;
using Optimizely.Opal.AgentSdk.Auth;

// Instance and customer identity are read from the PAT's own claims —
// nothing else to configure.
await using var client = new OpalClient(new PatAuth(Environment.GetEnvironmentVariable("OPAL_PAT")!));

var result = await client.Agents.Specialized.RunAsync(
    "your-agent-id",
    new Dictionary<string, object?> { ["query"] = "Where is order #1234?" });

Console.WriteLine(result.OutputText);

OpalClient implements IAsyncDisposable, so await using closes the HTTP and socket.io transports for you.

Authentication

The SDK authenticates with a Personal Access Token. Create one in the Opal UI under Settings → Developer → Personal Access Tokens; the secret is shown once at creation, so copy it then.

var client = new OpalClient(new PatAuth(pat));

A PAT is scoped to one Opal instance, and optionally to specific product connections, at creation time. The SDK reads that scope from the token, so a client built from a PAT already knows which instance it is talking to.

  • An Optimizely product backend that already holds the signed-in user's Opti ID access token uses TokenExchangeAuth instead of a PAT: see Token exchange (product backends).

Sign in with your browser

For a CLI or desktop app that a person runs, AuthorizationCodeAuth signs in with Opti ID in the browser instead of a PAT. The SDK keeps its own short-lived token and refreshes it before each request and socket connect.

await using var auth = new AuthorizationCodeAuth(AuthorizationCodeOptions.FromEnvironment());
var identity = await auth.LoginAsync();          // opens the browser only when needed
Console.WriteLine($"{identity.Subject} on {identity.InstanceId}");

await using var client = new OpalClient(auth);   // instance and customer come from the token
var result = await client.Agents.Specialized.RunAsync("agent-id",
    new Dictionary<string, object?> { ["query"] = "Hello" });
  • LoginAsync() reuses a stored session when it is usable and opens the browser only when it is not; LoginAsync(force: true) always signs in, for example to pick another instance. ResumeAsync() never opens a browser: it throws OpalLoginRequiredException instead, for scripts that must not pop one mid-job. LogoutAsync() revokes the session and deletes it locally.
  • Sessions live in credentials.json (FileTokenStore, 0600 in a 0700 directory), shared with the Python, Java and TypeScript SDKs: a sign-in from any of them serves the others. Pass SessionStore (and ClientStore) to keep them elsewhere, or Profile to hold sessions for several instances.
  • An AuthorizationCodeAuth stays bound to the issuer, user and instance of the first session it loads or creates. If another process signs in as someone else, or picks another instance, under the same profile, this auth throws OpalSessionIdentityException (an OpalAuthException) on that and every later call rather than switching under a live OpalClient, and leaves the stored session alone; create a new auth and client to use it. A LoginAsync(force: true) on a bound auth that picks another instance saves that session and throws the same way. LogoutAsync ends the binding. To hold several identities at once, use one auth per Profile.
  • A web app that owns its redirect uses a pre-registered ClientId (OAuthClients.RegisterPublicClientAsync, once) with GetAuthorizationUrlAsync and ExchangeCodeAsync. Keep the AuthorizationRequest in your server-side session: it holds the PKCE verifier.
  • Settings come from OPAL_BASE_URL, OPAL_AUTH_ISSUER, OPAL_OAUTH_SCOPE, OPAL_OAUTH_RESOURCE, OPAL_OAUTH_CLIENT_ID, OPAL_OAUTH_REDIRECT_PORT, OPAL_OAUTH_LOGIN_TIMEOUT, OPAL_OAUTH_PROFILE and OPAL_VERIFY_SSL. The issuer defaults to https://auth.mcp.<your Opal host>.
  • The sign-in's own calls to the authorization server run on an HTTP client the SDK builds, with redirects off, so a code or refresh token is never re-posted elsewhere. It uses the system proxy; set ConfigureOAuthHandler to adjust its handler (a proxy, client certificates). Redirects stay off.

Custom credential store

Two things are stored, and each has its own pluggable store:

  • Sessions (the tokens) go in an ISessionStore, set as SessionStore.
  • The client registration (the loopback client the SDK registered with the authorization server; no secret) goes in an IClientRegistrationStore, set as ClientStore.

FileTokenStore (the default) and MemoryTokenStore implement both. Anything else (a database, a secrets manager, an OS keychain) implements the one it holds. Which store holds what:

  • Neither set: one FileTokenStore holds both, as before.
  • Only SessionStore: the registration goes in it too when it also implements IClientRegistrationStore, otherwise in the default FileTokenStore. So a MemoryTokenStore keeps everything in memory.
  • Only ClientStore: the sessions go in the default FileTokenStore.
  • Both: each kind goes only in its own store.

For example, a CLI can keep the tokens in your secret storage and the registration in the shared credentials file:

using Optimizely.Opal.AgentSdk.Auth;

// Your storage, e.g. an OS keychain or a secrets-manager client.
public interface ISecretStorage
{
    Task<string?> GetAsync(string name, CancellationToken ct);
    Task PutAsync(string name, string value, CancellationToken ct);
    Task RemoveAsync(string name, CancellationToken ct);
    Task<IAsyncDisposable> AcquireLockAsync(string name, CancellationToken ct);
}

public sealed class SecretStorageSessionStore(ISecretStorage storage) : ISessionStore
{
    public Task<string?> LoadAsync(string key, CancellationToken ct = default) => storage.GetAsync(key, ct);
    public Task SaveAsync(string key, string value, CancellationToken ct = default) => storage.PutAsync(key, value, ct);
    public Task DeleteAsync(string key, CancellationToken ct = default) => storage.RemoveAsync(key, ct);

    // Optional: serialises refreshes across processes that share the storage.
    public Task<IAsyncDisposable> LockAsync(string key, CancellationToken ct = default) =>
        storage.AcquireLockAsync(key, ct);
}

var auth = new AuthorizationCodeAuth(new AuthorizationCodeOptions
{
    SessionStore = new SecretStorageSessionStore(mySecretStorage),   // the tokens
    ClientStore = new FileTokenStore(),                              // the registration
});

The contract:

  • Values are opaque strings that the SDK writes and reads back exactly. Sessions hold tokens, so protect them at rest. Keys look like session|<issuer>|<profile> and client|<issuer>|loopback; map them to whatever names your storage accepts.
  • LockAsync is optional, and only the session store has it. The default is a no-op, which is enough for one process: refreshes in a process are single-flighted anyway. Implement it when several processes share the storage (two terminals, parallel jobs), or two of them may spend the same refresh token and one sign-in is lost. The SDK takes it around each read-refresh-write and around writing or dropping the registration, never while waiting for the browser, and never nests it, so it need not be reentrant. It may lock just the key or the whole store.
  • Without a lock the SDK still recovers. A refresh the server refuses as already spent makes it re-read the store and adopt the session the other process wrote.

Use a PAT instead for anything headless: servers, CI, daemons. A browser sign-in needs a person at a browser. It stays signed in while it is used at least once every 30 days, for up to 90 days after the sign-in.

Token exchange (product backends)

An Optimizely product backend (CMS, CMP, Experimentation, Configured Commerce, Campaign, Analytics) already holds the signed-in user's Opti ID access token. TokenExchangeAuth trades it for an Agent SDK token. The token belongs to that user and to the one Opal instance the product instances belong to.

await using var auth = TokenExchangeAuth.Builder()
    .ClientName("Optimizely CMS")   // required: your product, as the audit log names it
    // the user's current Opti ID access token, which the backend keeps refreshed
    .OptiIdTokenProvider(async ct => await userSession.GetOptiIdAccessTokenAsync(ct))
    .CmsInstanceId(cmsInstanceId)   // one or more instance ids
    .Build();
var identity = await auth.ExchangeAsync();      // identity.InstanceId is the Opal instance

await using var client = new OpalClient(auth);  // the instance comes from the token
  • ClientName(string) is required: your product's name, e.g. "Optimizely CMS". It is sent (trimmed) as the confidential client's client_name, which is how authz-server's audit log attributes each exchange to a product. Build() throws InvalidOperationException without one or with a blank one.

  • Set the Opti ID token one of two ways (exactly one; Build() throws InvalidOperationException with neither or both):

    • OptiIdTokenProvider(Func<CancellationToken, ValueTask<string>>): return the user's current Opti ID token. The SDK calls it each time it exchanges (the first time, shortly before the token expires, and after a 401), not on every request. The server never issues a token that outlives the Opti ID token it was exchanged for, so this is how one auth lasts the user's whole session. A blank result throws InvalidOperationException and nothing is sent; an exception the provider throws reaches the caller unchanged.
    • OptiIdToken(string): a fixed token. The auth works until that token expires (typically about an hour), then throws OpalOAuthException token_error / invalid_grant: build a new auth with a fresh token.
  • Set one or more ids: CmsInstanceId, CmpInstanceId, ExpInstanceId, ConfiguredCommerceInstanceId, CampaignInstanceId, AnalyticsInstanceId, OpalInstanceId. The server resolves them through the user's own Admin Center connections. Each product instance belongs to exactly one Opal instance, so name product instances from one Opal instance. OpalInstanceId names that Opal instance (one the user holds a role on) or narrows to it; it cannot bring ids from different Opal instances together. Build() throws InvalidOperationException, before any request, without a token, without an id, or with a blank one.

  • BaseUrl, Issuer, ClientStore, VerifySsl and ConfigureOAuthHandler work as for AuthorizationCodeAuth. Without ConfigureOAuthHandler, every auth in the process shares one OAuth HTTP client, so an auth per user session does not mean a connection pool per session.

  • On first use the SDK registers a confidential client. It keeps the client id and secret in the client store (by default the FileTokenStore browser sign-in uses) under client|<issuer>|token-exchange|<client name>, one client per name. When the server no longer knows the stored client, the SDK registers again once and retries. On a server, pass a ClientStore (a MemoryTokenStore shared by the process's auths, say) rather than rely on a file under the home directory. A store that cannot save or read the client (a read-only file system, an unwritable home) is tolerated: the SDK warns and keeps the client in memory for the process, shared by every auth on that store (and skips a refused stored client it could not delete).

  • The token has no refresh token and can live well under an hour. The SDK exchanges again shortly before it expires, and after a 401. It keeps the token in memory only. Once a renewal shows the server will not extend the token past the current Opti ID token, the SDK stops renewing early and uses the token until it expires. If a renewal before expiry fails (the server refuses or is down, the network, the provider), the SDK warns and keeps using the current token, which is still valid, and tries again 5 seconds later; only without a valid token does the failure reach the caller.

  • A refusal throws OpalOAuthException with Code == "token_error" and the server's OAuthError:

    • invalid_grant: the Opti ID token is invalid or expired. Return a fresh one from the provider, or build a new auth with one. Until the token changes, the SDK throws the same refusal again without asking the server.
    • access_denied: the user has no Opal instance for those product ids, or an id was set under the wrong product.
    • invalid_request: the ids belong to different Opal instances. Name product instances from one Opal instance.

    A 5xx throws OpalServerException. new OpalClient(auth) before ExchangeAsync() throws OpalLoginRequiredException.

  • Keep one auth per user and instance set, and one OpalClient per auth, with an OptiIdTokenProvider returning the user's current Opti ID token (the backend refreshes it). Do not exchange on every request.

A runnable example is in examples/TokenExchange.

Configuration

Pass an OpalConfig to override anything. OpalConfig.FromPat keeps the identity discovered from the token and lets you change the rest:

var config = OpalConfig.FromPat(pat, new OpalConfig
{
    BaseUrl = "https://opal.optimizely.com",
    Timeout = TimeSpan.FromSeconds(60),
    RetryMaxAttempts = 3,
});

await using var client = new OpalClient(new PatAuth(pat), config);
Property Default Purpose
BaseUrl OpalConfig.DefaultBaseUrl Opal API endpoint
WsUrl / WsPath derived from BaseUrl socket.io endpoint for streaming
InstanceId / CustomerId from the PAT override the token's scope
ProductInstances from the PAT product connections to act against
Timeout 30s per-request timeout
WsConnectTimeout 10s limit on each socket.io connect attempt, handshake included
RetryMaxAttempts 3 retries on transient failures
VerifySsl true set false only for local development

Specialized agents

RunAsync aggregates the whole execution into one result:

var result = await client.Agents.Specialized.RunAsync("agent-id",
    new Dictionary<string, object?> { ["query"] = "Summarise last week's tickets" });

Console.WriteLine(result.OutputText);
Console.WriteLine(result.ExecutionId);

RunAsync throws OpalServerException when the run fails. Code is the server's error_type (for example MissingRequiredConnectors) and Payload is the failure event's payload, with recoverable and, for missing connectors, the requirements.

Stream yields events as they arrive, as an IAsyncEnumerable:

await foreach (var e in client.Agents.Specialized.Stream("agent-id", parameters))
{
    switch (e.EventType)
    {
        case "response_chunk":
            Console.Write(e.ResponseChunkText());
            break;
        case "tool_call":
            Console.WriteLine($"[tool {e.PayloadString("name")}]");
            break;
        case "execution_complete" when e.PayloadString("status") is { } s && s != "COMPLETED":
            Console.WriteLine($"failed: {e.PayloadString("error")}");
            break;
    }
}

A stream yields a failed run rather than throwing: it ends with an execution_complete whose status is FAILED, BLOCKED, STOPPED or SUPERSEDED, with error and error_type. A tool_call carries name and input_params; its tool_result carries only content and the same execution_id (the tool call's id, not the run's), which is how you pair them.

A run that pauses on a wait tool, is cancelled with CancelAsync, or stays silent for five minutes ends its stream without an execution_complete. Every call then throws OpalStreamEndedException, after yielding whatever did arrive; follow the run with client.Executions.GetAsync.

Cancellation stops your side of the stream. It does not stop the agent — use CancelAsync for that.

Multi-turn chat

A chat is with a multi-turn agent (conversation_mode). Turn 1 is the agent's own parameters, as in the Opal UI; every message you send after that is a user message. ChatStream returns a session that carries the conversation memory and its etag between turns, so you do not manage memory_id yourself:

var chat = client.Agents.Specialized.ChatStream("agent-id",
    parameters: new Dictionary<string, object?> { ["report"] = "Q3" });

var first = await chat.StartAsync();             // turn 1, from the parameters
Console.WriteLine(first.OutputText);

await foreach (var e in chat.Send("And who approved it?"))
{
    if (e.EventType == "response_chunk") Console.Write(e.ResponseChunkText());
}
  • A seeded agent (one with a prompt template) answers turn 1 from its parameters, so call StartAsync() first; Send() before it throws OpalUnsupportedException.
  • An open chat (no prompt template) only opens the conversation on turn 1, with no reply. Send() opens it for you, so you can send the first message straight away.
  • A single-turn agent cannot chat (OpalUnsupportedException): use RunAsync().
  • If StartAsync() fails after the server created the conversation, the session keeps it and a second StartAsync() is refused ("chat already started"). To start over, call Reset(), then StartAsync().
  • Chat(...) is the same, with each turn returned whole as an OpalChatTurn.
  • To resume, pass memoryId (and etag, if you kept it) and just Send(); parameters apply to a new chat only.

Workflow agents

var execution = await client.Agents.Workflow.TriggerAsync("workflow-agent-id", "Run the weekly digest");

// Poll for progress: workflow runs publish no live events.
var current = await client.Agents.Workflow.GetExecutionAsync(execution.ExecutionId);
Console.WriteLine(current?.Status);

Executions.SubscribeAsync does not work for a workflow run: the server publishes live events only for specialized-agent runs.

Live events for a specialized run

Executions.SubscribeAsync attaches a second observer, over socket.io, to a specialized run someone else is streaming:

await foreach (var e in client.Executions.SubscribeAsync(
                   executionId, timeout: TimeSpan.FromMinutes(10)))
{
    Console.WriteLine(e.EventType);
}

It ends on execution_complete or error. Pass timeout so it cannot wait forever on a run that publishes no terminal event: it is one overall deadline, covering connecting, the subscribe ack and every wait for an event, and OpalTimeoutException reports that it passed. Inside it, each connect attempt is limited by OpalConfig.WsConnectTimeout: a first connect that overruns is OpalConnectionException, and a reconnect attempt that does is tried again. OpalSubscribeException means the server refused the subscription, and OpalAuthException that it refused the handshake. A handshake refused after a drop ends the subscription even when the cause was passing (ws-gateway failing to introspect the token, say); subscribe again with sinceSeq to continue.

Execution history

History is per agent. Type is "specialized" or "workflow" and picks the route; it is passed again to GetAsync.

using Optimizely.Opal.AgentSdk.Executions;

var options = new ListExecutionsOptions { AgentId = "agent-id", Type = "specialized" };

var page = await client.Executions.ListAsync(options);          // one page, newest first

await foreach (var summary in client.Executions.IterAsync(options))  // every page
{
    Console.WriteLine($"{summary.ExecutionId} {summary.Status}");
}

var log = await client.Executions.GetAsync(page.Items[0].ExecutionId, "specialized");

Agent Directory

client.Agents is this instance's own catalog. client.Directory is the Agent Directory — the published, cross-region catalog you browse and install from.

var tags = await client.Directory.TagsAsync(new ListDirectoryTagsOptions { Limit = 10 });

var page = await client.Directory.BrowseAsync(new BrowseDirectoryOptions
{
    Tags = ["reporting"],
    Search = "digest",
    PageSize = 20,
});

foreach (var agent in page.Items)
{
    // Browse fills in install state for THIS instance, so one listing answers
    // both "what exists" and "what do I already have".
    Console.WriteLine($"{agent.AgentId} installed={agent.IsInstalled} v{agent.InstalledVersion}");
}

var detail = await client.Directory.GetAsync("weekly-digest");
var result = await client.Directory.InstallAsync("weekly-digest");

foreach (var update in await client.Directory.CheckUpdatesAsync())
{
    await client.Directory.InstallAsync(update.AgentId);  // applying one is just an install
}

Install does not overwrite by default. Installing over an existing installation throws on HTTP 409 unless you pass ForceUpdate = true. With it, local edits to an installed agent are overwritten with no undo.

Set UserId when you have it. Without it the server records the installer as null and creates the imported agent as the literal user "system".

The target instance is never a parameter — it comes from OpalConfig, and the server rejects a mismatch.

Other namespaces

Namespace Purpose
client.Skills create, search and update skills
client.Canvas read canvas artifacts produced by an execution
client.Memories inspect and manage conversation memories
client.Pats manage Personal Access Tokens

Error handling

Every failure surfaces as an OpalException subclass carrying Status, Code and RequestId:

try
{
    var result = await client.Agents.Specialized.RunAsync("agent-id", parameters);
}
catch (OpalLoginRequiredException) { /* browser sign-in: no usable session, call LoginAsync */ }
catch (OpalAuthException)          { /* 401/403 — token invalid, expired or revoked */ }
catch (OpalNotFoundException)      { /* 404 — no such agent or execution */ }
catch (OpalRateLimitException ex)  { /* 429 — honour ex.RetryAfter */ }
catch (OpalConcurrencyException)   { /* 409 — ETag conflict on a concurrent update */ }
catch (OpalServerException ex)     { /* 5xx, or the run failed: ex.Code is the error_type */ }
catch (OpalStreamEndedException)   { /* the stream ended before the run finished */ }
catch (OpalConnectionException)    { /* network or socket.io transport failure */ }

Subscriptions add OpalSubscribeException (the server refused the subscribe) and OpalTimeoutException (the subscription's timeout passed).

License

MIT

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  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

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.9.1 58 10/20/2026
0.9.0 87 10/8/2026
0.8.0 99 10/2/2026
0.7.1 108 9/24/2026
0.7.0 117 9/15/2026
0.6.0 112 9/11/2026