Ikon.Sdk
2.3.0
dotnet add package Ikon.Sdk --version 2.3.0
NuGet\Install-Package Ikon.Sdk -Version 2.3.0
<PackageReference Include="Ikon.Sdk" Version="2.3.0" />
<PackageVersion Include="Ikon.Sdk" Version="2.3.0" />
<PackageReference Include="Ikon.Sdk" />
paket add Ikon.Sdk --version 2.3.0
#r "nuget: Ikon.Sdk, 2.3.0"
#:package Ikon.Sdk@2.3.0
#addin nuget:?package=Ikon.Sdk&version=2.3.0
#tool nuget:?package=Ikon.Sdk&version=2.3.0
IKON C# SDK
The IKON C# SDK connects any .NET application to an IKON app. It supports .NET 10 and .NET Standard 2.1 (including Unity).
Features
- Eight authentication modes: API Key, Local Development, Backend, External Connect URL, UserLogin (developer CLI sign-in, for dev tooling), UserToken (a person's session token for one space, issued elsewhere), Guest (a user who has not signed in), and ResumeAuthResponse (joining another connection's client session)
- Automatic reconnection with exponential backoff
- Audio streaming with Opus encoding/decoding
- Flexible audio streaming modes
- Function registration and remote invocation
- Low-level protocol message access
Installation
Install the NuGet package:
dotnet add package Ikon.Sdk
Quick Start
Add using Ikon.Sdk; and using Ikon.Common.Core.Protocol; (the UserType and ClientType enums live there), then:
// Create configuration with API key authentication
var config = new IkonClientConfig
{
ApiKey = new ApiKeyConfig
{
ApiKey = Environment.GetEnvironmentVariable("IKON_API_KEY")!,
SpaceId = "your-space-id",
ExternalUserId = "user-123",
BackendType = BackendType.Production // Development for an app on *.dev.ikonai.app
},
Description = "My App"
};
// Create and connect the client
await using var client = new IkonClient(config);
client.ReadyAsync += async e =>
{
Console.WriteLine("Connected!");
};
client.MessageReceivedAsync += async e =>
{
Console.WriteLine($"Received: {e.Message.Opcode}");
};
await client.ConnectAsync();
ContextType defaults to ContextType.Plugin — a headless backend component that
receives no UI and gets no per-connection ClientScope. That is correct for a bot or
service, but an interactive player client that expects to render UI or hold client state
must set ContextType = ContextType.Native (or Browser); otherwise it connects with no
error yet never receives a ClientScope.
Transports
The SDK opens one reliable connection to the server — TCP with TLS first (a local server offers it too), then plain TCP, then a WebSocket when the TCP ports do not answer — and, alongside it, an unreliable UDP side channel used for messages the app flags unreliable. A client that ends up on a WebSocket opens no side channel. The side channel is encrypted with DTLS, which the .NET Standard 2.1 build lacks, so that build runs over the reliable connection alone.
Set EnableUdpChannel = false to run over the reliable connection alone. Messages flagged unreliable
are then sent over it, though one is still dropped rather than waited on when the send queue is full,
and they can no longer overtake a queued reliable message.
Turn it off in two cases. One is when the network between the client and the server drops or blocks
UDP, usually because of a corporate egress filter. The connect does not wait for UDP, but every
connect would otherwise spend up to the 15-second DTLS handshake timeout on a background attempt that
cannot succeed. The other is when you connect many clients from one process and want each to cost as
little server memory as possible: the side channel is a second socket, a DTLS session and a second
pair of send queues per client on both ends.
var config = new IkonClientConfig
{
// ... authentication ...
EnableUdpChannel = false,
};
Authentication Modes
The SDK supports eight authentication modes. Exactly one must be configured, or the IkonClient constructor throws ArgumentException:
ApiKey, Local, Backend, ExternalConnectUrl, UserLogin, UserToken, Guest, or ResumeAuthResponse (another connection's IkonClient.LastAuthResponse, to join its client session). UserLogin authenticates as the
developer signed in on this machine (the ikon CLI's stored sign-in) and is intended for dev tooling and
headless tests; production clients use ApiKey or Backend. UserToken connects as a person with a session
token for the space that was issued elsewhere. The constructor also throws ArgumentException
when the chosen mode lacks a required field: ApiKey's ApiKey, SpaceId and ExternalUserId, Backend's
SpaceId and ExternalUserId, UserLogin's or Guest's SpaceId, UserToken's SpaceId, Token and BackendUrl, or Local's Host and a positive HttpsPort.
API Key Authentication
Use this for programmatic access to an IKON app. ikon api-key create in the app's folder makes an API key and prints it once.
var config = new IkonClientConfig
{
ApiKey = new ApiKeyConfig
{
ApiKey = "ikon-xxxxx", // From `ikon api-key create` in the app's folder
SpaceId = "...", // The app's id
ExternalUserId = "user-123", // Your user identifier
SessionIdentityHash = "...", // Optional: attach to a specific live session (connect fails if none owns this hash)
BackendType = BackendType.Production, // Development for an app on *.dev.ikonai.app
UserType = UserType.Human,
ClientType = ClientType.DesktopApp
}
};
Local Development
Connect directly to a local IKON server during development.
var config = new IkonClientConfig
{
Local = new LocalConfig
{
Host = "localhost",
HttpsPort = 8443,
UserId = "dev-user"
}
};
A local connection is a machine user by default. A client acting for a signed-in person sets UserType = UserType.Human and AuthSessionId to the authSession claim of that person's backend token, as the browser SDK's local connect does, and ClientType to the kind of client it is.
Backend Authentication
Use existing IKON backend sign-in credentials. This is for applications that have already authenticated to the backend.
var config = new IkonClientConfig
{
Backend = new BackendConfig
{
SpaceId = "...",
ExternalUserId = "user-123", // Your user identifier
SessionIdentityHash = "...", // Optional: attach to a specific live session (connect fails if none owns this hash)
UserType = UserType.Human,
ClientType = ClientType.DesktopApp
}
};
External Connect URL
Connect through a pre-minted connect URL ({serverUrl}/connect?token=...) issued by a trusted
host — for example an embedded in-process app server minting URLs for its own clients. The
authentication step is skipped entirely and the client connects straight through the URL. The
client still describes itself on the request: its InitialPath, IsSnapshot, StyleFormat,
viewport and description come from the config. The client type always comes from the token the
URL carries, and so do the initial path, its URL, the snapshot flag and the snapshot variant when
the config leaves them unset. A URL whose minter already put a client description on it keeps that
description, and none of the config's values are sent. This mode is mutually exclusive with the other seven; a config that
combines them is rejected.
var config = new IkonClientConfig
{
ExternalConnectUrl = connectUrl
};
UserLogin
Authenticate as the developer signed in on this machine (the ikon CLI's stored sign-in), connecting
through the cloud gateway like a browser client. Intended for dev tooling, spikes, and headless
tests — production clients use ApiKey or Backend. Mutually exclusive with the other seven modes.
Environment names the platform the app is on. If it names a platform other than the process's own,
the client signs in with that platform's saved sign-in. Otherwise, or when it is null, the process's
own sign-in is used.
var config = new IkonClientConfig
{
UserLogin = new UserLoginConfig
{
SpaceId = "...", // required
UserType = UserType.Human,
ClientType = ClientType.DesktopApp
}
};
UserToken
UserToken = new UserTokenConfig { SpaceId = "...", Token = "...", BackendUrl = "..." } connects as a
person with a session token for the space that was issued elsewhere, through the cloud gateway like
a browser client. It is how an app's server shows another app inside itself (view.Portal in the
Parallax guide): the backend hands the host a session in the guest app for one of its users, and the
host connects with it. The token's own lifetime limits the connection. When a reconnect's token is
refused, because it expired or the person's sign-in was revoked, the client stops reconnecting and
raises CredentialRejectedAsync (see "When the backend refuses the sign-in" below); the program that
holds the token gets a new one and passes it to client.UpdateAuthToken(token). BackendUrl is the backend that
issued the token, which /init is posted to; UserType, ClientType and JoinOnly have the same
meaning as in UserLogin.
Guest
Guest = new GuestConfig { SpaceId = "..." } connects to a deployed app as a guest, the same way the
browser SDK connects a user who has not signed in. It needs no sign-in. The platform picks the guest
user by IkonClientConfig.DeviceId: two clients with the same device id are the same guest, and a new
device id is a new guest. If the app's guest entries include "global", the device id is ignored and
all guests share one user. If the app lets no guests in, or the surface the program enters
(IkonClientConfig.Surface, else, leaving out sign-in pages, the only surface left or the one with
neither Routes nor Host, else the first declared) does not, the connect
fails with a 403. Environment (IkonBackend.EnvironmentType, in Ikon.Common.Core) names the
platform the app is on, Development or Production. Null uses the platform this process signed in to.
A standalone program has not signed in, so it must set Environment; otherwise ConnectAsync throws
ArgumentException ("Guest authentication needs the Development or Production platform, not
Unknown"). For a local server, LocalConfig.IsAnonymous = true connects as a guest in the same way,
under the UserId you give it.
A Console Program That Joins An App
A kiosk, a bot or a script that shows an app's live state or calls its functions is a console
program with this SDK. Kept with an app, it goes in the app's devices/<name>/ folder, which is
kept in the app's repository (the save before a deploy commits it) but is not built or run by the
deploy or by ikon run. Create it with dotnet new console -n <Name>, which puts the project in a <Name>/ subfolder of devices/<name>/, and add the package from inside that subfolder with
dotnet add package Ikon.Sdk --version <version>, taking the Ikon.Sdk version the app's csproj
lists in its PackageReference. The SDK is released on its own version line, apart from the app's
other Ikon.* libraries, and is on nuget.org. That published package also works against an app
that this machine builds from platform sources, locally and deployed, so the program needs no
source build of its own. When ikon update raises the versions in the app's csproj, raise the
program's Ikon.Sdk to match. OnMessage<T>, IkonClientConfig.LogFilter, the console log at
Info on stderr and LocalConfig.SpaceId need Ikon.Sdk 2.0.54 or later; ikon update raises an
app whose csproj lists an earlier one. An earlier package logs
every connect step to stdout; Log.Instance.Filter = LogFilter.Info; before creating the client
keeps the steps out.
Program.cs starts with:
using Ikon.Common.Core;
using Ikon.Common.Core.Protocol;
using Ikon.Sdk;
Which authentication mode to use depends on where the app runs:
| The app | Mode | What it needs |
|---|---|---|
A local ikon run |
Local |
HttpsPort: the port of the Server URL the run prints (the project's port from its last run while that is free, else the first free one from 8443), also in serverUrl of build/app/artifacts/bin/<project>/debug/ikon-server-info.json. SpaceId: the app id, so a connect to a server that runs another app throws instead of joining it. Without it the program joins whatever app holds the port |
| Deployed | Guest |
SpaceId: the app id ([Target] SpaceId in ikon-config.<environment>.toml). Environment: Development for an app on the development platform (*.dev.ikonai.app), otherwise Production. The surface the program enters (IkonClientConfig.Surface, else, leaving out sign-in pages, the only surface left, else the one with neither Routes nor Host, else the first declared) must list guest or global in its Methods |
| Deployed, for users of your own service | ApiKey |
A space API key, made with ikon api-key create (or in the Portal under the app's API keys); ExternalUserId names the user it connects as, and BackendType the platform as for Guest. The surface needs no guest or global entry in Methods. Any holder of the key can join as any ExternalUserId, so it stays in the program's secret store. ikon auth token create makes a service token for CI, not an API key |
// The app id: [Target] SpaceId in the app's ikon-config.<environment>.toml
const string appId = "your-app-id";
// For a local `ikon run`, use Local with the port of the Server URL that the run prints (pass it as
// `--local 8443`); with SpaceId set, a port that serves another app makes the connect throw. For the
// deployed app, join as a guest, which needs no key; Environment is required.
var localIndex = Array.IndexOf(args, "--local");
var local = localIndex >= 0;
var localPort = local && localIndex + 1 < args.Length ? int.Parse(args[localIndex + 1]) : 8443;
var config = local
? new IkonClientConfig { Local = new LocalConfig { Host = "localhost", HttpsPort = localPort, UserId = "door-kiosk", SpaceId = appId } }
: new IkonClientConfig
{
Guest = new GuestConfig { SpaceId = appId, Environment = IkonBackend.EnvironmentType.Development },
DeviceId = "door-kiosk",
};
await using var client = new IkonClient(config);
await client.ConnectAsync();
// The app looks up a reactive's stable id by the member's name in its [App] class
var stableId = await client.FunctionRegistry.CallAsync<string>("Ikon.Reactive.GetStableIdByName", args: ["_visitorCount"]);
// The callback runs once with the current value, then on every change
var reactives = new ReactiveRegistry(client);
await using var visitors = await reactives.SubscribeAsync<int>(stableId, count => Console.WriteLine($"Visitors today: {count}"));
// Call an External [Function] of the app, passing the key it checks
var answer = await client.FunctionRegistry.CallAsync<string>("RecordVisit",
args: [Environment.GetEnvironmentVariable("KIOSK_KEY"), "Front door"]);
The app's side, on its [App] class:
// On the [App] class: a shared reactive the program reads by its name
private readonly Reactive<int> _visitorCount = new(0);
// Any program can join as a guest, so a call that changes data sends a key the app checks
[Function(Name = "RecordVisit", Visibility = FunctionVisibility.External)]
[AllowAnonymous]
public string RecordVisit(string key, string door)
{
if (!app.Secrets.TryGet("KIOSK_KEY", out var expected)
|| !System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(key), Encoding.UTF8.GetBytes(expected)))
{
return "denied";
}
_visitorCount.Value++;
return $"ok, {door}";
}
- Which reactive.
Ikon.Reactive.GetStableIdByNamefinds a reactive field or auto-property of the app's[App]class by its name in the source. A reactive kept anywhere else has no name there: the app hands out itsStableIdfrom anExternalfunction instead. A stable id is made from where the reactive is declared, so it stays the same across restarts and deploys until that code moves. - Which instance. The program joins the instance its identity maps to. With the scaffold's
per-user
SessionIdentity(string? UserId)that is an instance of its own, for its own guest or local user, so it does not see what other people's instances hold. To share state with everyone, keep it in an app with one instance for everyone (public record SessionIdentity();), in aGlobalReactiveorPersistentGlobalReactivefield, which every instance shares (a program subscribed to it usually sees another instance's write within seconds), or in the database. Count in a global value withUpdate(v => v + 1), which runs again over a newer value another instance wrote. An assignment such asValue++is last-writer-wins, so two instances counting at once lose a visit. - Values.
SubscribeAsync<T>reads the value as JSON intoT:intfor aReactive<int>, aList<T>for aReactiveList<T>,JsonElementto look at any of them. AClientReactivegives the program its own copy, not a browser's. - Who can read a reactive. Any client that joins the instance, a guest included, can subscribe
to every reactive of the
[App]class by its name, private fields too, and to any other reactive whose stable id it learns. Nothing marks a reactive as server-only. AClientReactiveorUserReactivegives each caller only its own value; keep anything a guest must not see out of shared reactives, in a plain field or the database, and hand it out from a function that checks the caller. - A function that returns an app type. The result arrives as JSON with the C# property names
as they are (
Name,SinceUtc) and enums as their names. Ask forJsonElementand read it withGetProperty("Name"), or declare a record with the same property names in the program and ask for it (CallAsync<RosterEntry[]>); the names are matched with their case. Aschema/*.tptype arrives in its binary form instead, and needs the app's generated class. - A function that takes an app type. The program's argument type must have the app type's full name, namespace and nesting included, so a record nested in the app class does not match a top-level record of the same name; or let the function take primitives.
- Keys. Anyone can join an app that lets guests in, so a function that changes data checks a
key it is given against an app secret (
ikon secret set KIOSK_KEY), and the program reads the key from its environment. ContextType.Plugin, the default, is the right one here, because the program draws no UI.- Output. In a program that has not used
Log.Instancebefore it creates the client, the SDK logs atLogFilter.Infoto stderr, so stdout holds only what the program prints. SetLogFilter = LogFilter.DebuginIkonClientConfigto see each step of a connect that fails. - Typed messages. A device that sends readings as the app's
schema/*.tptypes, and waits for an ack, is thedevice-typed-messagespattern; see "Receiving Typed Messages" below.
A program that should keep running while the app restarts (ikon stop and a new ikon run, a
deploy, ikon session stop) must connect again itself: the client does not reconnect after the
server stops. It reconnects by itself after a dropped connection, but when the server stops, it goes
Offline and stays there. A loop at the end of the program connects it again:
// Connecting again joins the app's next server, and the ReactiveRegistry subscribes again by itself
while (true)
{
await Task.Delay(TimeSpan.FromSeconds(15));
if (client.State != ConnectionState.Offline)
{
continue;
}
try
{
await client.ConnectAsync();
}
catch (Exception ex)
{
Console.Error.WriteLine($"Not connected: {ex.Message}");
}
}
Against a deployed app, each connect starts the app's server if none is running.
Connection Lifecycle
Connection States
The client tracks its connection state via the State property:
| State | Description |
|---|---|
Idle |
Initial state, not connected |
Connecting |
Authentication and connection in progress |
Connected |
Fully connected and ready |
Reconnecting |
Lost connection, attempting automatic reconnect |
Offline |
Disconnected: the connection failed, the reconnect ladder was exhausted, or the server shut down. Not terminal after an exhausted ladder: with BackgroundReconnect on (the default) the client keeps retrying and can return to Connected |
Helper extension methods are available:
state.IsConnecting()- True ifConnectingorReconnectingstate.IsConnected()- True ifConnectedstate.IsDisconnected()- True ifIdleorOffline(covers the pristine initial state too, not just failures)state.IsFaulted()- True only forOffline(a genuine failure) — use this, notIsDisconnected, to detect a dropped/failed connection; it is not final while the background reconnect runs, since that can still bring the client back
Events
// Connection state changes
client.StateChangedAsync += async e =>
{
Console.WriteLine($"State: {e.State}");
};
// Raised once the SDK has told the server this client is ready, and again on every reconnect.
// The app knows the client from here on, so calls made here reach it with the client's identity.
client.ReadyAsync += async e =>
{
// e.IsReconnect is false only for the first connect; e.IsNewSession says the server
// session changed and its server-side state is gone
};
// Server is stopping (can still send messages)
client.StoppingAsync += async e =>
{
Console.WriteLine("Server stopping...");
};
// Disconnected: the client gave up and went Offline — every reconnect attempt failed, or
// the server is stopping. With BackgroundReconnect on (the default) a failed reconnect keeps
// retrying from Offline, so this is final only when the server is stopping,
// BackgroundReconnect is off, a JoinOnly client found the session ended, the sign-in was
// refused with 401 or 403, or the server requires a newer SDK or app. Not raised by a failed IkonClient.ConnectAsync (that is the
// call's exception), by IkonClient.DisconnectAsync or IkonClient.DisposeAsync, nor by a
// drop that reconnects
client.DisconnectedAsync += async e =>
{
Console.WriteLine("Disconnected");
};
// Error occurred
client.ErrorOccurredAsync += async e =>
{
Console.WriteLine($"Error: {e.Error.Message}");
};
// Protocol message received
client.MessageReceivedAsync += async e =>
{
Console.WriteLine($"Message: {e.Message.Opcode}");
};
Joining and Ready
A connect has two steps. The client joins the app's session and receives its state, then it signals
ready. Only at ready does the app hear about the client and learn its user, scopes and roles.
ConnectAsync signals ready itself on every connect and reconnect, before it returns and before
ReadyAsync is raised, so a ReadyAsync handler can call functions, subscribe to reactives and send
messages at once. SignalReadyAsync is obsolete: existing programs still build, with a compiler
warning, and once the client is ready the call does nothing.
Register the program's own functions in client.FunctionRegistry before ConnectAsync. They are
sent while joining, so the app has them when it hears the client is ready. Anything else the client
sends to the app while joining, such as a send from a MessageReceivedAsync handler, is held and sent
right after ready, in order. At most 4096 messages are held per join; later ones are dropped with one
warning. Audio and video sent then are dropped, and held messages are discarded if the connection
drops before ready.
Connecting and Disconnecting
// Connect (will throw on failure)
await client.ConnectAsync();
// Wait for a specific client to connect
bool found = await client.WaitForClientAsync(
productId: "my-product",
userId: "user-123",
timeout: TimeSpan.FromSeconds(30)
);
// Disconnect
await client.DisconnectAsync();
// Or dispose (also disconnects)
await client.DisposeAsync();
A cloud connect that a full server refuses asks /init again up to three times, naming the servers that refused it, so a sharded app places the client on another shard; when none has room, ConnectAsync throws an HttpRequestException saying the app is full.
When the platform no longer accepts the client, ConnectAsync throws, and the SDK neither retries nor reconnects, because only a new build fixes it:
| Exception | What is too old | The fix |
|---|---|---|
SdkUpgradeRequiredException |
This version of the Ikon.Sdk package |
Update the package and rebuild the program |
AppUpgradeRequiredException |
The IKON libraries the app's deployed bundle was built on | The app's developer runs ikon update and deploys again |
Both are HttpRequestExceptions carrying 426. Reason is the platform's message and Minimum the oldest version it accepts. A reconnect that meets either one stops and raises ErrorOccurredAsync with it.
Automatic Reconnection
The SDK automatically attempts to reconnect when the connection is lost unexpectedly. Configure reconnection behavior:
var config = new IkonClientConfig
{
// ... authentication config ...
Timeouts = new TimeoutConfig
{
InitialReconnectDelay = TimeSpan.FromMilliseconds(500), // Initial backoff delay
MaxReconnectAttempts = 4, // Max attempts (default)
MaxReconnectDelay = TimeSpan.FromSeconds(30), // Backoff delay cap (default)
ReconnectAttemptTimeout = TimeSpan.FromSeconds(30), // Time budget per reconnect tier (default)
BackgroundReconnect = true // Keep retrying after max attempts (default)
}
};
Reconnection uses exponential backoff starting from InitialReconnectDelay (500ms, 1s, 2s, 4s by default), capped at MaxReconnectDelay and jittered. When BackgroundReconnect is enabled (the default), the client keeps retrying with capped exponential backoff from the Offline state after the fast reconnection attempts are exhausted. A JoinOnly client whose reconnect finds the session ended does not: it stays Offline.
A function call made while the client is reconnecting waits for the reconnect and is sent once the client is ready again; see Calling Functions.
When the backend refuses the sign-in
A reconnect signs in again, and the backend can refuse it: with 401 when the token expired or the
person's sign-in was revoked (for example, their organisation's directory removed them), with 403 when the
app or surface does not admit them. The same credential would be refused on every retry, so the
client stops: it raises ErrorOccurredAsync and DisconnectedAsync, goes Offline and does not
retry in the background. A server that is down or busy (5xx, network errors) is still retried as
above. On a 401 it also raises CredentialRejectedAsync, so the program can act:
client.CredentialRejectedAsync += async e =>
{
// A UserToken client resumes with a new token; any client can call ConnectAsync again
client.UpdateAuthToken(await GetNewTokenAsync());
};
UpdateAuthToken works only for a UserToken client, and a client waiting after a refusal starts
reconnecting with the new token at once. Call it whenever the program renews the token, too; a
connected client uses it the next time it signs in. A first ConnectAsync that is refused throws
the HttpRequestException instead.
Sending Messages
Raw Protocol Messages
ClientContext is null before the first connect and after a disconnect, and while
connecting it holds a placeholder until the server's context arrives, so read it only after
ReadyAsync has fired; a null check does not prove the client is connected. The ! below is safe because a raw send happens
on a connected client:
// Send a raw protocol message (on a connected client)
var message = ProtocolMessage.Create(client.ClientContext!.SessionId, payload);
await client.SendMessageAsync(message);
Typed Payloads
Like the raw overload, this throws InvalidOperationException instead of dropping the
payload when the client is not connected, so send only after ReadyAsync has fired:
// Send a typed payload (creates ProtocolMessage automatically)
await client.SendMessageAsync(new MyCustomPayload { /* ... */ });
Receiving Typed Messages
OnMessage<T> is the client's side of the app's app.OnMessage<T>. It can be called before
ConnectAsync and lasts across reconnects until the handle it returns is disposed. The handler runs
on the client's receive loop, so the next message waits until it returns:
// The handler gets only messages of this type, decoded, with the sender's session id
var subscription = client.OnMessage<MyCustomPayload>(async (message, senderId) =>
{
Console.WriteLine($"{senderId}: {message.Text}");
});
// Later, to stop receiving them
subscription.Dispose();
In the app, the senderId that app.OnMessage<T> gets for a message from this program is the
program's client.ClientContext.SessionId, the same number as ReactiveScope.ClientId and
Context.SessionId for the program's client, and app.SendMessageAsync(reply, senderId) answers
only the program.
The types an app sends and receives come from its schema/*.tp, and the app's build writes their C#
to app/<App>/Generated/Protocol/ only. A standalone program compiles those same files, linked from
its csproj, never copied. A type's opcode comes from its schema file's place in the sorted list of
schema files, so a copy can end up with the wrong opcodes when a schema is added. The device-typed-messages pattern
(docs/Ikon.Agent.Docs/patterns/device-typed-messages.md in an app) has the csproj lines and a
program that sends a reading and waits for the app's ack.
Audio
The SDK encodes and decodes audio with Opus automatically.
Sending Audio
Send audio to the server. Like every send, it throws InvalidOperationException when the client is
not connected — call it after ReadyAsync has fired, never from setup code:
// Default encoder options for every stream that sends no encoderOptions of its own. A
// stream's encoder is created on its first SendAudioAsync and keeps the options in force
// then, so set this before the first send — not after.
client.DefaultEncoderOptions = new AudioEncoderOptions(bitrate: 48000, complexity: 8);
// Get audio samples (float PCM, range [-1.0, 1.0]) at 8, 12, 16, 24 or 48 kHz in 1 or 2 channels;
// the SDK does not resample, and a stream's first send throws ArgumentOutOfRangeException otherwise
ReadOnlyMemory<float> samples = GetAudioSamples();
// Send audio
await client.SendAudioAsync(
MediaTargets.Everyone,
samples: samples,
sampleRate: 48000,
channelCount: 1,
isFirst: true, // First chunk of this stream
isLast: false // More chunks coming
);
// Send final chunk
await client.SendAudioAsync(MediaTargets.Everyone, samples, 48000, 1, isFirst: false, isLast: true);
// Optional: specify stream ID, total duration, encoder options, and target clients.
// A stream's sample rate and channel count are fixed by its first send, and every call
// without a streamId shares one stream, so audio in another format needs its own streamId
await client.SendAudioAsync(
MediaTargets.To(123, 456), // Target specific session IDs
samples: samples,
sampleRate: 48000,
channelCount: 1,
isFirst: true,
isLast: true,
streamId: "my-audio-stream", // Unique stream identifier
totalDuration: TimeSpan.FromSeconds(5),
encoderOptions: new AudioEncoderOptions( // Custom encoder settings
bitrate: 64000,
complexity: 10
));
Receiving Audio
Subscribe to audio events to receive incoming audio streams:
client.AudioInputStreamBeginAsync += async e =>
{
Console.WriteLine($"Audio stream started: {e.StreamId}");
Console.WriteLine($" Codec: {e.Codec}");
Console.WriteLine($" Sample rate: {e.SampleRate}");
Console.WriteLine($" Channel count: {e.ChannelCount}");
// Optional: choose the decode rate (Opus accepts 8, 12, 16, 24 or 48 kHz)
// e.SampleRate = 24000;
// Optional: change streaming mode
// e.StreamingMode = AudioInputStreamingMode.DelayUntilTotalDurationKnown;
};
client.AudioInputFrameAsync += async e =>
{
// e.Samples contains decoded PCM float samples
float[] samples = e.Samples;
Console.WriteLine($"Frame: {e.StreamId}");
Console.WriteLine($" Samples: {samples.Length}");
Console.WriteLine($" IsFirst: {e.IsFirst}");
Console.WriteLine($" IsLast: {e.IsLast}");
Console.WriteLine($" Total duration: {e.TotalDuration}"); // Zero if unknown
// Process or play the audio samples...
};
client.AudioInputStreamEndAsync += async e =>
{
Console.WriteLine($"Audio stream ended: {e.StreamId}");
};
Audio Streaming Modes
Control how audio frames are delivered:
| Mode | Behavior |
|---|---|
Streaming |
Forward frames immediately (lowest latency) |
DelayUntilTotalDurationKnown |
Buffer until the total duration is known, then stream |
DelayUntilIsLast |
Buffer each segment, emit its frames when its last frame arrives |
The two Delay* modes are for clips. A live microphone sends no last frame until it closes, so a segment that buffers past e.MaxBufferedDuration (5 minutes by default) is delivered with one warning and the rest of it streams.
Set the streaming mode in the AudioInputStreamBeginAsync event handler:
client.AudioInputStreamBeginAsync += async e =>
{
// Buffer audio for UI timeline display
e.StreamingMode = AudioInputStreamingMode.DelayUntilTotalDurationKnown;
};
Functions
Each IkonClient has its own isolated FunctionRegistry, accessible via client.FunctionRegistry. A function registered in it can be called locally or offered to the app through the server (never to other clients).
Registering Functions
Attribute-Based Registration (Recommended)
Mark methods with the [Function] attribute and register the containing class. A method is registered under its
type's full name plus the method name (MyNamespace.MyFunctions.Greet) unless [Function(Name = "...")] sets one,
and calls and lookups must use that name:
Add using Ikon.Common.Core.Functions;, then:
public class MyFunctions
{
[Function(Description = "Greets a user by name")]
public string Greet(string name)
{
return $"Hello, {name}!";
}
[Function(Description = "Calculates sum", Visibility = FunctionVisibility.External)]
public async Task<int> AddAsync(int a, int b)
{
return a + b;
}
[Function(Description = "Streams numbers")]
public async IAsyncEnumerable<int> CountAsync(int max)
{
for (int i = 0; i < max; i++)
yield return i;
}
}
// Register all [Function] methods from an instance
var myFuncs = new MyFunctions();
client.FunctionRegistry.RegisterFromInstance(myFuncs);
// Or register from a type: only its static methods (and a [RegisterAll] class's constructors) become callable
client.FunctionRegistry.RegisterFromType<MyStaticFunctions>();
// Or scan entire assembly (same rule as RegisterFromType)
client.FunctionRegistry.RegisterFromAssembly(typeof(MyFunctions).Assembly);
Without an instance, RegisterFromType and RegisterFromAssembly still register every instance [Function] method,
but as metadata only (RequiresInstance is true and there is no callback): calling one locally has nothing to run,
and a remote call that names no instance is rejected. For MyFunctions, whose methods are all instance methods, the
assembly scan therefore lists Greet, AddAsync and CountAsync without making them callable; use
RegisterFromInstance for those. On a [RegisterAll] class the same holds for every public instance method, with or
without [Function].
Manual Registration (Lambda/Delegate)
Register functions directly using lambdas:
// Simple synchronous function
client.FunctionRegistry.AddFunction(
Function.Register((string name) => $"Hello, {name}!", "Greet")
);
// Async function
client.FunctionRegistry.AddFunction(
Function.Register(async (int a, int b) =>
{
await Task.Delay(10);
return a + b;
}, "AddAsync")
);
// With attributes (description, visibility, etc.)
client.FunctionRegistry.AddFunction(
Function.Register(
(string query) => SearchDatabase(query),
"Search",
new FunctionAttribute { Description = "Searches the database", Visibility = FunctionVisibility.External }
)
);
Function Visibility
Functions can be either local or external:
- Local (default): Function is not advertised. In a standalone SDK client a remote caller that names it can still call it; only an IKON app restricts it to its own process.
- External: Function is advertised over the protocol; the app can call it. The server offers a client's functions to the app only, not to other clients.
// Local - not advertised (default); only an IKON app keeps remote callers out
[Function(Visibility = FunctionVisibility.Local)]
public string LocalOnly() => "local";
// External - advertised over the protocol; the app can call it
[Function(Visibility = FunctionVisibility.External)]
public string SharedWithAll() => "shared";
Registered inside an IKON app rather than a standalone client, an External function is also
expected to declare who may call it: [RequireLogin], [RequireRole(...)] or [AllowAnonymous]
on the method, or a policy attached at registration time. The app's startup audit, run once before Main()
over the functions registered by then (the app class's own [Function] methods), logs a warning for
every External function with none of them, and throws instead when the app sets
<ExternalFunctionsRequireAuth>true</ExternalFunctionsRequireAuth>. A function registered in
Main() or later is never audited. A standalone SDK client runs no
such audit.
Visibility can also be overridden where the instance is registered:
client.FunctionRegistry.RegisterFromInstance(myFuncs, FunctionVisibility.External);
Discovering Functions
Query the registry to find available functions:
// Check if a function exists
if (client.FunctionRegistry.HasFunction("MyFunc"))
{
var func = client.FunctionRegistry.GetFunction("MyFunc");
Console.WriteLine($"Found: {func?.Name}, Params: {func?.Parameters.Length}");
}
// Get all functions grouped by name (including remote)
var allFuncs = client.FunctionRegistry.Functions;
// Find which sessions (the app's and the server's) have a specific function
var clientIds = client.FunctionRegistry.GetClientSessionsWithFunction("SharedFunc");
// Wait for a function the app registers to become available (other clients' functions never reach this registry)
bool available = await client.FunctionRegistry.WaitForFunctionAsync(
"RemoteFunc",
timeout: TimeSpan.FromSeconds(30)
);
Calling Functions
Call registered functions locally or remotely:
// Synchronous call
string result = client.FunctionRegistry.Call<string>("Greet", args: new object?[] { "World" });
// Async call
int sum = await client.FunctionRegistry.CallAsync<int>("AddAsync", args: new object?[] { 1, 2 });
// Void async call
await client.FunctionRegistry.CallAsync("LogMessage", args: new object?[] { "Hello" });
// Call a function on one session (targetId: the app's session id; another client's functions
// never reach this registry, so naming its session fails with "Function 'Calculate' not found")
int remoteSum = await client.FunctionRegistry.CallAsync<int>("Calculate", targetId: 123, args: new object?[] { 5, 10 });
// Streaming results (async enumerable). A [Function] with no Name is registered under its type's full name
await foreach (var item in client.FunctionRegistry.CallAsyncEnumerable<int>($"{typeof(MyFunctions).FullName}.CountAsync", args: new object?[] { 10 }))
{
Console.WriteLine(item);
}
A call to the app waits while the client is connecting or reconnecting (soft, or into a new session)
and is sent right after the client is ready, after the messages held until then, in the order the calls
were made. The function is looked up then, so a function the app no longer has fails with
"Function 'x' not found". The wait counts against the call's 30-minute timeout; if the client is not
ready by then, the call fails with a TimeoutException saying the client was not connected, and it
is never sent. Pass a CancellationToken to wait less. At most 4096 calls wait; later ones fail at
once.
A call fails at once with a FunctionCallException saying the client is not connected when the
client is not trying to connect: before ConnectAsync, after DisconnectAsync or DisposeAsync,
when the server is stopping, or Offline with no reconnect left (BackgroundReconnect off, or a
failure no retry can fix). A waiting call fails the same way when the client moves into one of those
states. Calls to the program's own functions run at once whatever the connection state, and a call
already sent when the connection drops fails, since the server will not answer it.
Removing Functions
// Remove a specific function by name (local functions only)
client.FunctionRegistry.RemoveFunction("MyFunc");
// Remove a function with specific visibility
client.FunctionRegistry.RemoveFunction("MyFunc", FunctionVisibility.External);
// Clear all local functions
client.FunctionRegistry.ClearLocalFunctions();
Function Events
Subscribe to function registration events:
client.FunctionRegistry.FunctionRegistered += func =>
{
Console.WriteLine($"Registered: {func.Name} ({func.Visibility})");
};
client.FunctionRegistry.FunctionUnregistered += name =>
{
Console.WriteLine($"Unregistered: {name}");
};
Advanced Configuration
Timeouts
var config = new IkonClientConfig
{
// ... authentication ...
Timeouts = new TimeoutConfig
{
InitialReconnectDelay = TimeSpan.FromMilliseconds(500), // Initial backoff delay
MaxReconnectAttempts = 4, // Max reconnect attempts (default)
MaxReconnectDelay = TimeSpan.FromSeconds(30), // Backoff delay cap (default)
ReconnectAttemptTimeout = TimeSpan.FromSeconds(30), // Time budget per reconnect tier (default)
BackgroundReconnect = true // Keep retrying after max attempts (default)
}
};
Protocol Options
var config = new IkonClientConfig
{
// ... authentication ...
// Filter which message types to receive/send (default; leaving out GROUP_APP_LOCAL drops the app's own schema messages)
OpcodeGroupsFromServer = Opcode.GROUP_ALL | Opcode.GROUP_APP_LOCAL,
OpcodeGroupsToServer = Opcode.GROUP_ALL | Opcode.GROUP_APP_LOCAL,
// Payload serialization format
PayloadType = PayloadType.Teleport, // Default
// How this connection identifies to the server.
// Default Plugin connects as a backend component (no UI).
// Native or Browser connects as a first-class player client that receives streamed UI.
ContextType = ContextType.Plugin
};
Client Identification
var config = new IkonClientConfig
{
// ... authentication ...
DeviceId = "unique-device-id",
ProductId = "my-app",
VersionId = "3", // a whole number
InstallId = "install-xyz",
Locale = "en-US",
Description = "My Application",
UserAgent = "my-app/1.0.0", // replaces the default, which names this machine's OS and architecture
Parameters = new Dictionary<string, string>
{
["custom_param"] = "value"
}
};
API Reference
Core Types
| Type | Description |
|---|---|
IkonClient |
Main client class for connecting to IKON servers |
IkonClientConfig |
Configuration for the client |
ConnectionState |
Enum: Idle, Connecting, Connected, Reconnecting, Offline |
ReactiveRegistry |
Subscribes to an app's reactive values over one client, and subscribes again after every reconnect |
Configuration Types
| Type | Description |
|---|---|
LocalConfig |
Configuration for local server development |
ApiKeyConfig |
Configuration for API key authentication |
BackendConfig |
Configuration for backend authentication |
TimeoutConfig |
Timeout settings |
BackendType |
Enum: Production, Development |
Audio Types
| Type | Description |
|---|---|
AudioInputStreamingMode |
Enum (from Ikon.Resonance.Core): Streaming, DelayUntilTotalDurationKnown, DelayUntilIsLast |
IkonClient.AudioInputStreamBeginEventArgs |
Event args for audio stream start |
IkonClient.AudioInputFrameEventArgs |
Event args for audio frame |
IkonClient.AudioInputStreamEndEventArgs |
Event args for audio stream end |
AudioEncoderOptions |
Options for configuring the Opus encoder |
Function Types
| Type | Description |
|---|---|
FunctionRegistry |
Per-client registry for function registration and invocation |
Function |
Immutable function metadata and callback |
FunctionAttribute |
Attribute for marking methods as registerable functions |
RegisterAllAttribute |
Class-level attribute to auto-register all public members as functions |
FunctionVisibility |
Enum: Local, External |
FunctionParameter |
Parameter metadata (name, type, default value) |
Event Types
| Type | Description |
|---|---|
MessageEventArgs |
Event args containing a ProtocolMessage |
IkonClient.ConnectionStateEventArgs |
Event args containing the new ConnectionState |
IkonClient.ErrorEventArgs |
Event args containing an Exception |
License
This SDK is licensed under the Ikon AI SDK License. See LICENSE for details.
Support
For issues and feature requests, contact IKON support or send a report with ikon report create.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 was computed. 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 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. |
| .NET Core | netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.1 is compatible. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.1
- Ikon.Common.Core (>= 3.21.0)
- Ikon.Resonance.Core (>= 3.21.0)
- System.Net.Http.Json (>= 10.0.14)
-
net10.0
- Ikon.Common.Core (>= 3.21.0)
- Ikon.Resonance.Core (>= 3.21.0)
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 |
|---|---|---|
| 2.3.0 | 76 | 11/11/2026 |
| 2.1.0 | 88 | 11/9/2026 |
| 2.0.74 | 147 | 11/5/2026 |
| 2.0.73 | 167 | 11/3/2026 |
| 2.0.72 | 205 | 10/31/2026 |
| 2.0.71 | 171 | 10/31/2026 |
| 2.0.70 | 147 | 10/31/2026 |
| 2.0.69 | 144 | 10/29/2026 |
| 2.0.68 | 111 | 10/28/2026 |
| 2.0.67 | 104 | 10/27/2026 |
| 2.0.66 | 108 | 10/26/2026 |
| 2.0.65 | 111 | 10/26/2026 |
| 2.0.64 | 99 | 10/26/2026 |
| 2.0.63 | 151 | 10/22/2026 |
| 2.0.62 | 153 | 10/21/2026 |
| 2.0.61 | 163 | 10/19/2026 |
| 2.0.60 | 207 | 10/16/2026 |
| 2.0.59 | 140 | 10/16/2026 |
| 2.0.58 | 107 | 10/16/2026 |
| 2.0.57 | 102 | 10/16/2026 |