Language Adapters
Adapters are the language-specific layer between your code and the Saikuro runtime. Each adapter handles serialization and exposes a consistent Provider / Client API. They’re deliberately thin: no routing, no schema validation, no capability enforcement. That’s all in the runtime.
TypeScript / JavaScript
The TypeScript adapter runs natively in Node.js and browsers (plain JavaScript, no WASM). Install it:
npm install saikuro
Provider
import { Provider } from "@nisoku/saikuro";
const provider = new Provider({ namespace: "math" });
// Register a function
provider.register("add", (a: number, b: number): number => {
return a + b;
});
// Register an async function
provider.register("fetchUser", async (id: string) => {
return await db.users.findById(id);
});
// Register a stream
provider.registerStream("events", async function* (filter: string) {
for await (const event of eventSource(filter)) {
yield event;
}
});
// Register a channel handler
provider.registerChannel("chat", async (args, chan) => {
for await (const msg of chan.incoming()) {
await chan.send({ echo: msg });
}
});
await provider.serve();
Client
import { Client } from "@nisoku/saikuro";
const client = new Client();
await client.connect();
// Call
const sum = await client.call("math.add", [1, 2]);
// Cast (fire and forget)
await client.cast("log.write", [{ message: "hello" }]);
// Stream
for await (const event of client.stream("events.subscribe", ["errors"])) {
console.log(event);
}
// Channel
const chan = await client.channel("chat.open", []);
await chan.send({ text: "hello" });
for await (const msg of chan) {
console.log(msg);
}
await chan.close();
// Batch
const [a, b] = await client.batch([
{ target: "math.add", args: [1, 2] },
{ target: "math.multiply", args: [3, 4] },
]);
Dev Mode
In dev mode, the provider extracts its schema automatically using the TypeScript compiler API and announces it to the runtime:
const provider = new Provider({
namespace: "math",
dev: true, // enable dev mode
sourceFiles: ["./src/math-provider.ts"], // where to extract types from
});
When dev: true is set, serve() announces the schema before accepting calls.
Transport Configuration
// Explicit WebSocket transport (browser)
const client = new Client({
transport: "websocket",
url: "ws://localhost:7700",
});
// Explicit Unix socket
const client = new Client({
transport: "unix",
socketPath: "/tmp/saikuro.sock",
});
// In-memory (for testing)
import { InMemoryTransport } from "@nisoku/saikuro";
const [pt, ct] = InMemoryTransport.pair();
const provider = new Provider({ namespace: "math", transport: pt });
const client = new Client({ transport: ct });
Python
Python 3.11+. Install:
pip install saikuro
Provider
from saikuro import Provider
provider = Provider(namespace='math')
# Decorator-based registration
@provider.register('add')
def add(a: int, b: int) -> int:
return a + b
# Async functions work too
@provider.register('fetch_user')
async def fetch_user(user_id: str):
return await db.users.find(user_id)
# Stream
@provider.register_stream('events')
async def events(filter: str):
async for event in event_source(filter):
yield event
# Channel
@provider.register_channel('chat')
async def chat(args, chan):
async for msg in chan.incoming():
await chan.send({'echo': msg})
await provider.serve()
Client
from saikuro import Client
client = Client()
await client.connect()
# Call
result = await client.call('math.add', [1, 2])
# Cast
await client.cast('log.write', [{'message': 'hello'}])
# Stream
async for event in client.stream('events.subscribe', ['errors']):
print(event)
# Channel
async with client.channel('chat.open', []) as chan:
await chan.send({'text': 'hello'})
async for msg in chan:
print(msg)
# Batch
results = await client.batch([
{'target': 'math.add', 'args': [1, 2]},
{'target': 'math.multiply', 'args': [3, 4]},
])
Transport Configuration
# WebSocket
client = Client(transport='websocket', url='ws://localhost:7700')
# Unix socket
client = Client(transport='unix', socket_path='/tmp/saikuro.sock')
# In-memory (for testing)
from saikuro import InMemoryTransport
provider_t, client_t = InMemoryTransport.pair()
provider = Provider(namespace='math', transport=provider_t)
client = Client(transport=client_t)
C#
.NET 8+. Install:
dotnet add package Saikuro
Provider
using Saikuro;
var provider = new Provider("math");
// Register a synchronous function
provider.Register<int, int, int>("add", (a, b) => a + b);
// Register an async function
provider.Register<string, Task<User>>("fetchUser", async (id) =>
{
return await db.Users.FindByIdAsync(id);
});
// Register a stream
provider.RegisterStream<string, IAsyncEnumerable<Event>>("events", async (filter) =>
{
await foreach (var evt in EventSource(filter))
yield return evt;
});
// Register a channel handler
provider.RegisterChannel("chat", async (args, chan) =>
{
await foreach (var msg in chan.IncomingAsync())
{
await chan.SendAsync(new { echo = msg });
}
});
await provider.ServeAsync();
Client
using Saikuro;
var client = new Client();
await client.ConnectAsync();
// Call
var sum = await client.CallAsync<int>("math.add", new object[] { 1, 2 });
// Cast
await client.CastAsync("log.write", new object[] { new { message = "hello" } });
// Stream
await foreach (var evt in client.StreamAsync<Event>("events.subscribe", new[] { "errors" }))
{
Console.WriteLine(evt);
}
// Channel
var chan = await client.ChannelAsync("chat.open", Array.Empty<object>());
await chan.SendAsync(new { text = "hello" });
await foreach (var msg in chan.ReceiveAsync())
{
Console.WriteLine(msg);
}
await chan.CloseAsync();
// Batch
var results = await client.BatchAsync(new[]
{
new BatchItem("math.add", new object[] { 1, 2 }),
new BatchItem("math.multiply", new object[] { 3, 4 }),
});
Transport Configuration
// WebSocket (works in Blazor WASM too)
var client = new Client(new WebSocketTransport("ws://localhost:7700"));
// Unix socket
var client = new Client(new UnixSocketTransport("/tmp/saikuro.sock"));
// In-memory (for testing)
var (providerTransport, clientTransport) = InMemoryTransport.Pair();
var provider = new Provider("math", providerTransport);
var client = new Client(clientTransport);
In WASM (Blazor), only InMemory and WebSocket transports are available. The #if WASM build flag enables the right subset automatically when you target a WASM project.
Rust
Add the crate:
[dependencies]
saikuro = "0.1"
tokio = { version = "1", features = ["full"] }
serde_json = "1"
Provider
Handlers receive a Vec<serde_json::Value> and return Result<serde_json::Value>:
use saikuro::{Provider, Result};
#[tokio::main]
async fn main() -> Result<()> {
let mut provider = Provider::new("math");
provider.register("add", |args: Vec<serde_json::Value>| async move {
let a = args[0].as_i64().unwrap_or(0);
let b = args[1].as_i64().unwrap_or(0);
Ok(serde_json::json!(a + b))
});
provider.serve("tcp://127.0.0.1:7700").await
}
serve blocks until the runtime closes the connection. Pass any supported address scheme: tcp://, ws://, or unix://.
Client
use saikuro::{Client, Result};
#[tokio::main]
async fn main() -> Result<()> {
let client = Client::connect("tcp://127.0.0.1:7700").await?;
let result = client.call("math.add", vec![
serde_json::json!(1),
serde_json::json!(2),
]).await?;
println!("{result}"); // 3
client.close().await?;
Ok(())
}
Cast and Batch
// Fire and forget
client.cast("log.write", vec![serde_json::json!({"message": "hello"})]).await?;
// Multiple calls in one round-trip
let results = client.batch(vec![
("math.add".into(), vec![serde_json::json!(1), serde_json::json!(2)]),
("math.add".into(), vec![serde_json::json!(3), serde_json::json!(4)]),
]).await?;
Streaming
let mut stream = client.stream("events.subscribe", vec![
serde_json::json!("errors"),
]).await?;
while let Some(item) = stream.next().await {
println!("{}", item?);
}
Schema Metadata
Register with a schema to enable codegen and runtime validation:
use saikuro::{Provider, RegisterOptions, FunctionSchema, Result};
let mut provider = Provider::new("math");
provider.register_with_options(
"add",
|args: Vec<serde_json::Value>| async move {
let a = args[0].as_i64().unwrap_or(0);
let b = args[1].as_i64().unwrap_or(0);
Ok(serde_json::json!(a + b))
},
RegisterOptions {
schema: Some(FunctionSchema {
doc: Some("Add two integers.".into()),
idempotent: true,
..Default::default()
}),
},
);
Transport Configuration
By default, TCP is enabled. Enable additional transports with Cargo features:
[dependencies]
saikuro = { version = "0.1", features = ["tcp", "ws", "unix"] }
| Feature | Transport |
|---|---|
tcp (default) |
tcp://host:port |
ws |
ws://host:port or wss://host:port |
unix |
unix:///path/to/socket (Unix only) |
Error Handling
All adapters use the same error model. Errors come back as a structured object with a code, message, and optional details:
// TypeScript
try {
await client.call("admin.delete_everything", []);
} catch (err) {
if (err.code === "CapabilityDenied") {
console.error("Not authorized:", err.message);
}
}
# Python
from saikuro import SaikuroError
try:
await client.call('admin.delete_everything', [])
except SaikuroError as e:
if e.code == 'CapabilityDenied':
print(f'Not authorized: {e.message}')
// C#
try
{
await client.CallAsync<object>("admin.DeleteEverything", Array.Empty<object>());
}
catch (SaikuroException ex) when (ex.Code == "CapabilityDenied")
{
Console.Error.WriteLine($"Not authorized: {ex.Message}");
}
See the Protocol Reference for the full list of error codes.
Next Steps
- Code Generation: Generate typed client stubs from a frozen schema
- Transports: In-memory, sockets, and WebSocket in depth
- Examples: Real multi-language patterns
- Protocol Reference: The wire format every adapter speaks