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
<PackageReference Include="Optimizely.Opal.AgentSdk" Version="0.9.1" />
<PackageVersion Include="Optimizely.Opal.AgentSdk" Version="0.9.1" />
<PackageReference Include="Optimizely.Opal.AgentSdk" />
paket add Optimizely.Opal.AgentSdk --version 0.9.1
#r "nuget: Optimizely.Opal.AgentSdk, 0.9.1"
#:package Optimizely.Opal.AgentSdk@0.9.1
#addin nuget:?package=Optimizely.Opal.AgentSdk&version=0.9.1
#tool nuget:?package=Optimizely.Opal.AgentSdk&version=0.9.1
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
TokenExchangeAuthinstead 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 throwsOpalLoginRequiredExceptioninstead, for scripts that must not pop one mid-job.LogoutAsync()revokes the session and deletes it locally.- Sessions live in
credentials.json(FileTokenStore,0600in a0700directory), shared with the Python, Java and TypeScript SDKs: a sign-in from any of them serves the others. PassSessionStore(andClientStore) to keep them elsewhere, orProfileto hold sessions for several instances. - An
AuthorizationCodeAuthstays 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 throwsOpalSessionIdentityException(anOpalAuthException) on that and every later call rather than switching under a liveOpalClient, and leaves the stored session alone; create a new auth and client to use it. ALoginAsync(force: true)on a bound auth that picks another instance saves that session and throws the same way.LogoutAsyncends the binding. To hold several identities at once, use one auth perProfile. - A web app that owns its redirect uses a pre-registered
ClientId(OAuthClients.RegisterPublicClientAsync, once) withGetAuthorizationUrlAsyncandExchangeCodeAsync. Keep theAuthorizationRequestin 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_PROFILEandOPAL_VERIFY_SSL. The issuer defaults tohttps://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
ConfigureOAuthHandlerto 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 asSessionStore. - The client registration (the loopback client the SDK registered with
the authorization server; no secret) goes in an
IClientRegistrationStore, set asClientStore.
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
FileTokenStoreholds both, as before. - Only
SessionStore: the registration goes in it too when it also implementsIClientRegistrationStore, otherwise in the defaultFileTokenStore. So aMemoryTokenStorekeeps everything in memory. - Only
ClientStore: the sessions go in the defaultFileTokenStore. - 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>andclient|<issuer>|loopback; map them to whatever names your storage accepts. LockAsyncis 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'sclient_name, which is how authz-server's audit log attributes each exchange to a product.Build()throwsInvalidOperationExceptionwithout one or with a blank one.Set the Opti ID token one of two ways (exactly one;
Build()throwsInvalidOperationExceptionwith 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 throwsInvalidOperationExceptionand 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 throwsOpalOAuthExceptiontoken_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.OpalInstanceIdnames that Opal instance (one the user holds a role on) or narrows to it; it cannot bring ids from different Opal instances together.Build()throwsInvalidOperationException, before any request, without a token, without an id, or with a blank one.BaseUrl,Issuer,ClientStore,VerifySslandConfigureOAuthHandlerwork as forAuthorizationCodeAuth. WithoutConfigureOAuthHandler, 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
FileTokenStorebrowser sign-in uses) underclient|<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 aClientStore(aMemoryTokenStoreshared 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
OpalOAuthExceptionwithCode == "token_error"and the server'sOAuthError: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)beforeExchangeAsync()throwsOpalLoginRequiredException.Keep one auth per user and instance set, and one
OpalClientper auth, with anOptiIdTokenProviderreturning 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 throwsOpalUnsupportedException. - 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): useRunAsync(). - If
StartAsync()fails after the server created the conversation, the session keeps it and a secondStartAsync()is refused ("chat already started"). To start over, callReset(), thenStartAsync(). Chat(...)is the same, with each turn returned whole as anOpalChatTurn.- To resume, pass
memoryId(andetag, if you kept it) and justSend();parametersapply 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).
Links
License
MIT
| Product | Versions 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. |
-
net8.0
- SocketIOClient (>= 4.0.5)
- YamlDotNet (>= 16.2.1)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.