The Opal SDK is a TypeScript client library for the Opal agent runtime. It communicates over JSON-RPC 2.0 via stdio, providing fully typed operations, async-iterable event streams, and structured error handling. It lives at cli/src/sdk/.
import { createSession } from "./sdk/index.js";
const session = await createSession({ workingDir: "." });
for await (const event of session.prompt("List all files")) {
if (event.type === "messageDelta") process.stdout.write(event.delta);
}
session.close();createSession handles the full bootstrap sequence — spawning the server, establishing the RPC connection, and running session/start. The returned Session is the only object most consumers ever need.
graph LR
A[createSession] --> B[StdioTransport]
B --> C[RpcConnection]
C --> D[OpalClient]
D --> E[Session]
E -->|prompt| F[AgentStream]
E -->|models, state, auth, ...| D
F -->|for await...of| G[AgentEvent]
| Layer | Module | Concern |
|---|---|---|
| Transport | transport/stdio.ts |
Spawn server process, read/write newline-delimited JSON |
| RPC | rpc/connection.ts |
JSON-RPC 2.0 framing, request/response correlation, timeouts |
| Client | client.ts |
Typed request<M>(), event routing, server→client method handling |
| Session | session.ts |
Domain API: prompt, models, auth, config, history |
| Stream | stream.ts |
Push→pull bridge: AgentStream is an AsyncIterable<AgentEvent> |
const session = await createSession({
workingDir: "/path/to/project",
sessionId: "resume-this-id", // optional: resume existing session
model: "claude-sonnet-4-5", // string shorthand
model: { id: "gpt-4", thinkingLevel: "high" }, // full spec
systemPrompt: "You are a code reviewer",
features: { skills: true },
callbacks: {
onAskUser: async ({ question, choices }) => "user response",
onRpcMessage: (entry) => console.log(entry),
onStderr: (line) => process.stderr.write(line),
},
});// Stream events one by one
for await (const event of session.prompt("Explain this code")) {
// event is fully typed as AgentEvent
}
// Or collect the final response in one shot
const { text, usage, aborted } = await session
.prompt("Summarize")
.finalMessage();
// Steer the agent while it's running
const queued = await session.steer("Focus on the auth module instead");
// Cancel the current run
await session.abort();session.prompt() returns an AgentStream — an AsyncIterable<AgentEvent> that also exposes .finalMessage() for one-shot consumption and .toReadableStream() for Web Streams interop.
const { models } = await session.models();
await session.setModel("claude-haiku-3-5");
await session.setModel({ id: "gpt-4", thinkingLevel: "high" });
await session.setThinking("medium");const { authenticated } = await session.auth_.status();
const { userCode, verificationUri, deviceCode, interval } =
await session.auth_.login();
await session.auth_.poll(deviceCode, interval);const history = await session.history();
const state = await session.state();
await session.compact(10); // keep 10 most recent messages
await session.branch("entry-id"); // branch conversationconst settings = await session.config.getSettings();
await session.config.saveSettings({ theme: "dark" });
const runtime = await session.config.getRuntime();
await session.config.setRuntime({ features: { debug: true } });await session.ping(); // liveness check
const { version } = await session.version();
session.close(); // clean shutdown
// Or use explicit resource management
using session = await createSession({ workingDir: "." });
// session.close() called automatically at end of scopeThe SDK provides type-safe pattern matching over the 19 event types.
import { matchEvent } from "./sdk/index.js";
// Compile error if any event type is missing
const label = matchEvent(event, {
agentStart: () => "Starting...",
agentEnd: (e) => `Done (${e.usage?.totalTokens} tokens)`,
messageDelta: (e) => e.delta,
toolExecutionStart: (e) => `⚙ ${e.tool}`,
// ... all 19 types must be handled
});import { matchEventPartial } from "./sdk/index.js";
const text = matchEventPartial(event, {
messageDelta: (e) => e.delta,
thinkingDelta: (e) => `💭 ${e.delta}`,
_: () => null, // default for everything else
});import { isEventType } from "./sdk/index.js";
if (isEventType(event, "messageDelta")) {
event.delta; // narrowed to MessageDeltaEvent
}All SDK errors extend OpalError with a .code discriminant for structured handling.
import { isOpalError, isErrorCode } from "./sdk/index.js";
try {
for await (const event of session.prompt("hello")) {
/* ... */
}
} catch (e) {
if (isOpalError(e)) {
switch (e.code) {
case "CONNECTION_LOST": // server exited — e.exitCode, e.signal, e.stderr
case "TIMEOUT": // request timed out — e.method, e.timeoutMs
case "SERVER_ERROR": // JSON-RPC error — e.rpcCode, e.data
case "ABORTED": // agent run cancelled
case "CLIENT_CLOSED": // used after close()
}
}
}| Error Class | Code | When |
|---|---|---|
ConnectionError |
CONNECTION_LOST |
Server process exited or pipe broke |
TimeoutError |
TIMEOUT |
RPC request exceeded deadline |
RpcError |
SERVER_ERROR |
Server returned a JSON-RPC error |
AbortError |
ABORTED |
Agent run was cancelled via abort() |
ClientClosedError |
CLIENT_CLOSED |
Method called after session.close() |
For custom transports or non-standard setups, use OpalClient directly:
import { OpalClient, RpcConnection, StdioTransport } from "./sdk/index.js";
const transport = new StdioTransport({ serverPath: "/custom/opal-server" });
const rpc = new RpcConnection(transport);
const client = new OpalClient(rpc);
// Fully typed requests
const result = await client.request("session/start", { workingDir: "." });
// Event subscription
client.onEvent((event) => console.log(event.type));
// Server→client method handlers
client.addServerMethod("client/confirm", async (params) => {
return { action: "allow" };
});The SDK is structured as a layered pipeline where each layer adds one concern:
Transport handles raw bytes. StdioTransport spawns the opal-server process and communicates via newline-delimited JSON over stdin/stdout. It captures stderr for diagnostics. A createMemoryTransport() factory exists for testing.
RpcConnection adds JSON-RPC 2.0 framing. It assigns request IDs, correlates responses, manages timeouts, and routes incoming notifications and server→client method calls to registered handlers. An optional RpcObserver hook captures all traffic for debugging.
OpalClient adds type safety. Its request<M>() method infers params and result types from the protocol's MethodTypes map. It also routes agent/event notifications to registered event handlers, transforming wire-format snake_case event types to camelCase.
Session adds domain semantics. It's the public API — prompt(), models(), auth_, config. The factory createSession() wires all layers together and runs session/start.
AgentStream bridges the push/pull gap. Events arrive as notifications (push), but consumers iterate with for await...of (pull). The stream buffers events and resolves a pending promise when new events arrive. Terminal events (agentEnd, agentAbort, error) close the stream.
cli/src/sdk/protocol.ts is auto-generated — do not edit manually.
# Regenerate from Elixir protocol definitions
cd opal && mix opal.gen.json_schema # → priv/rpc_schema.json
cd opal && mix run ../scripts/codegen_ts.exs # → cli/src/sdk/protocol.ts
# Verify in CI
cd opal && mix run ../scripts/codegen_ts.exs --checkThe pipeline: protocol.ex → rpc_schema.json → codegen_ts.exs → protocol.ts.
| File | Purpose |
|---|---|
cli/src/sdk/session.ts |
Session class and createSession factory |
cli/src/sdk/client.ts |
Typed JSON-RPC client |
cli/src/sdk/stream.ts |
AgentStream async iterable |
cli/src/sdk/match.ts |
Event matching utilities |
cli/src/sdk/errors.ts |
Structured error hierarchy |
cli/src/sdk/rpc/connection.ts |
JSON-RPC 2.0 connection |
cli/src/sdk/transport/stdio.ts |
Stdio transport |
cli/src/sdk/index.ts |
Public API barrel export |
cli/src/sdk/protocol.ts |
Auto-generated types (do not edit) |
opal/lib/opal/rpc/protocol.ex |
Source of truth for all types |
- JSON-RPC 2.0 Specification — The wire protocol