feat: add OpenClaw SDK package

This commit is contained in:
Peter Steinberger
2026-04-29 21:43:28 +01:00
parent 01254500df
commit 43f6c8b01a
20 changed files with 2625 additions and 11 deletions

View File

@@ -0,0 +1,367 @@
---
summary: "Design proposal for a public OpenClaw app SDK for agent runs, sessions, tasks, artifacts, and managed environments"
title: "OpenClaw SDK design"
read_when:
- You are designing or implementing a public OpenClaw app SDK
- You are comparing OpenClaw agent APIs with Cursor, Claude Agent SDK, OpenAI Agents, Google ADK, OpenCode, Codex, or ACP
- You need to decide whether a feature belongs in the public app SDK, plugin SDK, Gateway protocol, ACP backend, or managed environment layer
---
This page is a design proposal for a future public **OpenClaw app SDK**. It is
separate from the existing [plugin SDK](/plugins/sdk-overview).
The plugin SDK is for code that runs inside OpenClaw and extends providers,
channels, tools, hooks, and trusted runtimes. The app SDK should be for
external applications, scripts, dashboards, CI jobs, IDE extensions, and
automation systems that want to run and observe OpenClaw agents through a stable
public API.
## Status
Draft architecture.
This document captures the design direction from a comparative review of these
agent SDK and runtime surfaces:
| Project | Useful lesson |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Cursor SDK cookbook | Best high-level product API: `Agent`, `Run`, local and cloud runtimes, streaming, cancellation, model discovery, repositories, artifacts, and cloud pull request flows. |
| Claude Agent SDK | Strong bidirectional session client, interrupt and steer support, permission modes, hooks, custom tools, session stores, and resumable transcripts. |
| OpenAI Agents SDK | Strong workflow concepts: handoffs, guardrails, human approvals, tracing, run state, streaming result objects, and resume after interruptions. |
| Google ADK | Strong internal architecture: runner, session service, memory service, artifact service, credential service, plugins, event actions, and long running tool confirmations. |
| OpenCode | Strong client/server shape: generated API client, REST plus SSE, sessions, workspaces, worktrees, permissions, questions, files, VCS, PTY, tools, agents, skills, and MCP. |
| Codex | Strong local runtime boundary: approvals, sandboxing, network policy, local and remote exec servers, structured protocol events, and thread aware app-server sessions. |
| ACP and acpx | Strong interoperability layer for external coding harnesses with named sessions, prompt queues, cooperative cancellation, and runtime adapters. |
The recommendation is to build a Cursor-simple public facade on top of an
OpenCode-style generated Gateway client, while keeping Claude, OpenAI Agents,
ADK, Codex, and ACP concepts as internal design references where they fit.
## Goals
- Give app developers a tiny high-level API for running OpenClaw agents.
- Keep local-first OpenClaw as the default runtime.
- Make cloud or managed environments an additive environment provider, not a
different agent API.
- Preserve existing OpenClaw boundaries: Gateway owns public protocol, plugin
SDK owns in-process extensions, ACP owns external harness interop.
- Support `stream`, `wait`, `cancel`, `resume`, `fork`, artifacts, approvals,
and background tasks as first-class operations.
- Expose stable normalized events while preserving runtime-native raw events for
advanced consumers.
- Make SDK permissions, secret forwarding, approvals, sandboxing, and remote
environments explicit.
- Keep the public contract small enough to document, test, version, and
generate.
## Non goals
- Do not expose `openclaw/plugin-sdk/*` as the app SDK.
- Do not make ACP the only runtime model.
- Do not require a cloud service before the SDK is useful.
- Do not clone Cursor, Claude, OpenAI, ADK, OpenCode, Codex, or ACP APIs
exactly.
- Do not expose unbounded `any` event payloads as the only public contract.
- Do not promise sandbox or network isolation for an external harness unless
the selected environment can actually enforce it.
- Do not make plugin authors depend on app SDK objects inside plugin runtime
code.
## Current OpenClaw fit
OpenClaw already has most of the substrate:
| Existing surface | What it contributes |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| [Agent loop](/concepts/agent-loop) | `agent` and `agent.wait` run lifecycle, streaming, timeout, and session serialization. |
| [Agent runtimes](/concepts/agent-runtimes) | Provider, model, runtime, and channel separation. |
| [ACP agents](/tools/acp-agents) | External harness sessions for Claude Code, Cursor, Gemini CLI, OpenCode, explicit Codex ACP, and similar tools. |
| [Background tasks](/automation/tasks) | Detached activity ledger for ACP, subagents, cron, CLI operations, and async media jobs. |
| [Sub-agents](/tools/subagents) | Isolated background agent runs, optional forked context, delivery back to requester sessions. |
| [Agent harness plugins](/plugins/sdk-agent-harness) | Trusted native runtime registration for embedded harnesses such as Codex. |
| Gateway protocol schemas | Current typed method and event definitions for agent params, sessions, subscriptions, aborts, compaction, and checkpoints. |
The gap is not agent execution. The gap is a stable, friendly public facade over
these pieces.
## Core model
The app SDK should use a small set of durable nouns.
| Noun | Meaning |
| ------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `OpenClaw` | Client entry point. Owns Gateway discovery, auth, low-level client access, and namespace factories. |
| `Agent` | Configured actor. Carries agent id, default model, default runtime, default tool policy, and app-facing helpers. |
| `Session` | Durable transcript, routing, workspace, context, and runtime binding. |
| `Run` | One submitted turn or task. Streams events, waits for result, cancels, and exposes artifacts. |
| `Task` | Detached or background activity ledger entry. Covers subagents, ACP spawns, cron jobs, CLI runs, and async jobs. |
| `Artifact` | Files, patches, diffs, media, logs, trajectories, pull requests, screenshots, and generated bundles. |
| `Environment` | Where the run executes: local Gateway, local workspace, node host, ACP harness, managed runner, or future cloud workspace. |
| `ToolSpace` | The effective tool surface: OpenClaw tools, MCP servers, channel tools, app tools, approval rules, and tool metadata. |
| `Approval` | Human or policy decision requested by a run, tool, environment, or harness. |
These nouns map cleanly to existing OpenClaw concepts but avoid leaking
implementation-specific names such as PI runner internals, plugin harness
registration, or ACP adapter details.
## Product shape
The high-level SDK should feel like this:
```typescript
import { OpenClaw } from "@openclaw/sdk";
const oc = new OpenClaw({ gateway: "auto" });
const agent = await oc.agents.get("main");
const run = await agent.run({
input: "Review this pull request and suggest the smallest safe fix.",
model: "openai/gpt-5.5",
});
for await (const event of run.events()) {
if (event.type === "assistant.delta") {
process.stdout.write(event.text);
}
}
const result = await run.wait();
console.log(result.status);
```
The same app should be able to use a durable session:
```typescript
const session = await oc.sessions.create({
agentId: "main",
label: "release-review",
});
const run = await session.send("Prepare release notes from the current diff.");
await run.wait();
```
Current implementation note: `@openclaw/sdk` starts with the Gateway-backed
surface that exists today. Provider-qualified model refs such as
`openai/gpt-5.5` are split into Gateway `provider` and `model` overrides.
Per-run `workspace`, `runtime`, `environment`, and `approvals` selections are
still design targets; the client throws when callers set them so requests do not
silently execute with defaults. Task, artifact, environment, and generic tool
invocation helpers are also scaffolded as future API shape and throw explicit
unsupported errors until Gateway RPCs exist for them.
And the same API should be able to use an external ACP harness:
```typescript
const run = await oc.runs.create({
input: "Deep review this repository and return only high-risk findings.",
workspace: { cwd: process.cwd() },
runtime: { type: "acp", harness: "claude" },
mode: "task",
});
```
Managed environments should not change the top-level API:
```typescript
const run = await agent.run({
input: "Run the full changed gate and summarize failures.",
workspace: { repo: "openclaw/openclaw", ref: "main" },
runtime: {
type: "managed",
provider: "testbox",
timeoutMinutes: 90,
},
});
```
## Runtime selection
The app SDK should expose runtime selection as a normalized union:
```typescript
type RuntimeSelection =
| "auto"
| { type: "embedded"; id: "pi" | "codex" | string }
| { type: "cli"; id: "claude-cli" | string }
| { type: "acp"; harness: "claude" | "cursor" | "gemini" | "opencode" | string }
| { type: "managed"; provider: "local" | "node" | "testbox" | "cloud" | string };
```
Rules:
- `auto` follows OpenClaw runtime selection rules.
- `embedded` targets trusted in-process harnesses registered through the plugin
SDK, such as `pi` or `codex`.
- `cli` targets OpenClaw-owned CLI backend execution where available.
- `acp` targets external harnesses through ACP/acpx.
- `managed` targets an environment provider and may still run an embedded,
CLI, or ACP runtime inside that environment.
The runtime selection object should be descriptive. It should not be the place
where secret handling, sandbox policy, or workspace provisioning hides.
## Environment model
The environment is the execution substrate. It should be explicit because local
CLI runs, external harnesses, node hosts, and cloud workspaces have different
safety and lifecycle properties.
```typescript
type EnvironmentSelection =
| { type: "local"; cwd?: string }
| { type: "gateway"; url?: string; cwd?: string }
| { type: "node"; nodeId: string; cwd?: string }
| { type: "managed"; provider: string; repo?: string; ref?: string }
| { type: "ephemeral"; provider: string; repo?: string; ref?: string };
```
The environment owns:
- checkout or workspace preparation
- process and file access
- sandbox and network enforcement
- environment variables and secret references
- logs, traces, and artifacts
- cleanup and retention
- runtime availability
This separation makes managed agents a natural extension of the SDK. A managed
agent is a normal run in a managed environment, not a special product fork.
The detailed namespace, event, result, approval, artifact, security, package,
and environment provider contracts live in
[OpenClaw SDK API design](/reference/openclaw-sdk-api-design).
## Cookbook plan
The SDK should ship with a cookbook, not just reference docs.
Recommended examples:
| Example | Shows |
| ---------------------------- | -------------------------------------------------------------------------------------------- |
| Quickstart | Create client, run an agent, stream output, wait for result. |
| Coding agent CLI | Local workspace, model picker, cancellation, approvals, JSON output. |
| Agent dashboard | Sessions, runs, background tasks, artifacts, event replay, status filters. |
| App builder | Agent edits a workspace while a preview server runs beside it. |
| Pull request reviewer | Run against a repository ref, collect diff comments and artifacts. |
| Approval console | Subscribe to approvals and answer them from a UI. |
| ACP harness runner | Run Claude Code, Cursor, Gemini CLI, or OpenCode through ACP using the same `Run` API. |
| Managed environment provider | Minimal provider that prepares a workspace, streams events, saves artifacts, and cleans up. |
| Slack or Discord bridge | External app receives events and posts progress summaries without becoming a channel plugin. |
| Multi-agent research | Spawn parallel runs, collect artifacts, and synthesize a final report. |
Cookbook examples should use the high-level API first. Low-level generated
client examples belong in an advanced section.
## Phased implementation
### Phase 0: RFC and vocabulary
- Agree on public nouns and names.
- Decide package names.
- Define the first event taxonomy.
- Mark the current plugin SDK as intentionally separate in docs.
### Phase 1: Low-level generated client
- Generate a TypeScript client from Gateway protocol schemas.
- Cover `agent`, `agent.wait`, sessions, subscriptions, aborts, and tasks first.
- Add smoke tests that generated methods match Gateway method names and schema
shapes.
- Publish as experimental or internal package.
### Phase 2: High-level run API
- Add `OpenClaw`, `Agent`, `Session`, and `Run`.
- Support `run.events()`, `run.wait()`, and `run.cancel()`.
- Support local Gateway discovery and explicit Gateway URLs.
- Support durable sessions and session send.
### Phase 3: Normalized event projection
- Add Gateway-side normalized event projection beside existing raw events.
- Preserve raw runtime events where policy allows.
- Add replay cursors and reconnect behavior.
- Map PI, Codex, ACP, and task events into the stable taxonomy.
### Phase 4: Artifacts and approvals
- Add artifact listing and download.
- Add approval subscription and response helpers.
- Add question subscription and response helpers.
- Add cookbook approval console.
### Phase 5: Environment providers
- Introduce local, node, and managed environment provider contracts.
- Start with an environment that already exists operationally.
- Add workspace preparation, logs, artifacts, timeout, cleanup, and retention.
### Phase 6: Cloud style workflows
- Add repository and branch oriented runs.
- Add pull request artifacts.
- Add run boards grouped by repo, branch, status, and assignee.
- Add long-running managed sessions and retention policy.
## Design choices to copy
Copy these ideas:
- From Cursor: `Agent` plus `Run`, local and cloud symmetry, model discovery,
artifacts, and cookbook-driven onboarding.
- From Claude Agent SDK: bidirectional clients, interrupt, permissions, hooks,
custom tools, session stores, and resume semantics.
- From OpenAI Agents: handoffs, guardrails, human approval resume, tracing, and
structured streamed result objects.
- From Google ADK: services behind runner, event actions, memory, artifacts,
credential services, and plugin interception around run lifecycle.
- From OpenCode: generated protocol client, REST plus SSE, sessions,
workspaces, questions, permissions, files, VCS, PTY, MCP, agents, and skills.
- From Codex: explicit sandbox, approval, network, local and remote exec, and
app-server thread boundaries.
- From ACP and acpx: adapter based external harness interoperability and named
prompt queues.
## Design choices to avoid
Avoid these traps:
- A public SDK that is just a thin dump of Gateway internals.
- A public SDK that imports plugin SDK subpaths.
- A public SDK where events are only `stream` plus `data`.
- A cloud-first API that makes local OpenClaw feel like a legacy mode.
- Runtime selection hidden in model id prefixes.
- Secret forwarding hidden in environment maps.
- ACP specific options at the top level of every run.
- Sandbox flags that cannot be enforced by the chosen runtime.
- One SDK object that tries to be provider plugin, channel plugin, app client,
and managed runner at once.
## Open questions
- Should the initial package live in this repo or a separate SDK repo?
- Should the generated low-level client be published publicly before the
high-level wrapper stabilizes?
- What is the first supported app auth mechanism: local token, admin token,
OAuth device flow, or signed app registration?
- How much session message history should the SDK expose by default?
- Should managed environments be configured only in Gateway config, or can SDK
callers request them directly with scoped tokens?
- What retention rules apply to artifacts generated by local runs?
- Which event payloads require redaction before app delivery?
- Should `Run` cover normal chat turns and detached tasks, or should detached
background work always return a `Task` wrapper with a nested `Run`?
## Related docs
- [Agent loop](/concepts/agent-loop)
- [Agent runtimes](/concepts/agent-runtimes)
- [Session](/concepts/session)
- [Sub-agents](/tools/subagents)
- [Background tasks](/automation/tasks)
- [ACP agents](/tools/acp-agents)
- [Agent harness plugins](/plugins/sdk-agent-harness)
- [Plugin SDK overview](/plugins/sdk-overview)

View File

@@ -1114,6 +1114,7 @@
"concepts/agent",
"concepts/agent-loop",
"concepts/agent-runtimes",
"concepts/openclaw-sdk",
"concepts/system-prompt",
"concepts/context",
"concepts/context-engine",
@@ -1651,7 +1652,11 @@
},
{
"group": "RPC and API",
"pages": ["reference/rpc", "reference/device-models"]
"pages": [
"reference/rpc",
"reference/openclaw-sdk-api-design",
"reference/device-models"
]
},
{
"group": "Templates",

View File

@@ -402,7 +402,7 @@ enumeration of `src/gateway/server-methods/*.ts`.
- `sessions.create` creates a new session entry.
- `sessions.send` sends a message into an existing session.
- `sessions.steer` is the interrupt-and-steer variant for an active session.
- `sessions.abort` aborts active work for a session.
- `sessions.abort` aborts active work for a session. A caller may pass `key` plus optional `runId`, or pass `runId` alone for active runs the Gateway can resolve to a session.
- `sessions.patch` updates session metadata/overrides and reports the resolved canonical model plus effective `agentRuntime`.
- `sessions.reset`, `sessions.delete`, and `sessions.compact` perform session maintenance.
- `sessions.get` returns the full stored session row.

View File

@@ -0,0 +1,378 @@
---
summary: "Reference design for the proposed public OpenClaw app SDK API, event taxonomy, artifacts, approvals, and package structure"
title: "OpenClaw SDK API design"
read_when:
- You are implementing the proposed public OpenClaw app SDK
- You need the draft namespace, event, result, artifact, approval, or security contract for the app SDK
- You are comparing Gateway protocol resources with the high-level OpenClaw SDK wrapper
---
This page is the detailed API reference design for the proposed public
[OpenClaw SDK](/concepts/openclaw-sdk). It is intentionally separate from the
[plugin SDK](/plugins/sdk-overview).
The public app SDK should be built in two layers:
1. A low-level generated Gateway client.
2. A high-level ergonomic wrapper with `OpenClaw`, `Agent`, `Session`, `Run`,
`Task`, `Artifact`, `Approval`, and `Environment` objects.
## Namespace design
The low-level namespaces should closely follow Gateway resources:
```typescript
oc.agents.list();
oc.agents.get("main");
oc.agents.create(...);
oc.agents.update(...);
oc.sessions.list();
oc.sessions.create(...);
oc.sessions.resolve(...);
oc.sessions.send(...);
oc.sessions.messages(...);
oc.sessions.fork(...);
oc.sessions.compact(...);
oc.sessions.abort(...);
oc.runs.create(...);
oc.runs.get(runId);
oc.runs.events(runId, { after });
oc.runs.wait(runId);
oc.runs.cancel(runId);
oc.tasks.list(); // future API: current SDK throws unsupported
oc.tasks.get(taskId); // future API: current SDK throws unsupported
oc.tasks.cancel(taskId); // future API: current SDK throws unsupported
oc.tasks.events(taskId, { after }); // future API
oc.models.list();
oc.models.status(); // Gateway models.authStatus
oc.tools.list();
oc.tools.invoke(...); // future API: current SDK throws unsupported
oc.artifacts.list({ runId }); // future API: current SDK throws unsupported
oc.artifacts.get(artifactId); // future API: current SDK throws unsupported
oc.artifacts.download(artifactId); // future API: current SDK throws unsupported
oc.approvals.list();
oc.approvals.respond(approvalId, ...);
oc.environments.list(); // future API: current SDK throws unsupported
oc.environments.create(...); // future API: current SDK throws unsupported
oc.environments.status(environmentId); // future API: current SDK throws unsupported
oc.environments.delete(environmentId); // future API: current SDK throws unsupported
```
High-level wrappers should return objects that make common flows pleasant:
```typescript
const run = await agent.run(inputOrParams);
await run.cancel();
await run.wait();
for await (const event of run.events()) {
// normalized event stream
}
const artifacts = await run.artifacts.list();
const session = await run.session();
```
## Event contract
The public SDK should expose versioned, replayable, normalized events.
```typescript
type OpenClawEvent = {
version: 1;
id: string;
ts: number;
type: OpenClawEventType;
runId?: string;
sessionId?: string;
sessionKey?: string;
taskId?: string;
agentId?: string;
data: unknown;
raw?: unknown;
};
```
`id` is a replay cursor. Consumers should be able to reconnect with
`events({ after: id })` and receive missed events when retention allows.
Recommended normalized event families:
| Event | Meaning |
| --------------------- | ----------------------------------------------------------- |
| `run.created` | Run accepted. |
| `run.queued` | Run is waiting for a session lane, runtime, or environment. |
| `run.started` | Runtime started execution. |
| `run.completed` | Run finished successfully. |
| `run.failed` | Run ended with an error. |
| `run.cancelled` | Run was cancelled. |
| `run.timed_out` | Run exceeded its timeout. |
| `assistant.delta` | Assistant text delta. |
| `assistant.message` | Complete assistant message or replacement. |
| `thinking.delta` | Reasoning or plan delta, when policy allows exposure. |
| `tool.call.started` | Tool call began. |
| `tool.call.delta` | Tool call streamed progress or partial output. |
| `tool.call.completed` | Tool call returned successfully. |
| `tool.call.failed` | Tool call failed. |
| `approval.requested` | A run or tool needs approval. |
| `approval.resolved` | Approval was granted, denied, expired, or cancelled. |
| `question.requested` | Runtime asks the user or host app for input. |
| `question.answered` | Host app supplied an answer. |
| `artifact.created` | New artifact available. |
| `artifact.updated` | Existing artifact changed. |
| `session.created` | Session created. |
| `session.updated` | Session metadata changed. |
| `session.compacted` | Session compaction happened. |
| `task.updated` | Background task state changed. |
| `git.branch` | Runtime observed or changed branch state. |
| `git.diff` | Runtime produced or changed a diff. |
| `git.pr` | Runtime opened, updated, or linked a pull request. |
Runtime-native payloads should be available through `raw`, but apps should not
have to parse `raw` for normal UI.
## Result contract
`Run.wait()` should return a stable result envelope:
```typescript
type RunResult = {
runId: string;
status: "completed" | "failed" | "cancelled" | "timed_out";
sessionId?: string;
sessionKey?: string;
taskId?: string;
startedAt?: string | number;
endedAt?: string | number;
output?: {
text?: string;
messages?: SDKMessage[];
};
usage?: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
costUsd?: number;
};
artifacts?: ArtifactSummary[];
error?: SDKError;
};
```
The result should be boring and stable. Timestamp values preserve the Gateway
shape, so current lifecycle-backed runs usually report epoch millisecond
numbers while adapters may still surface ISO strings. Rich UI, tool traces, and
runtime-native details belong in events and artifacts.
## Approvals and questions
Approvals must be first-class because coding agents constantly cross safety
boundaries.
```typescript
run.onApproval(async (request) => {
if (request.kind === "tool" && request.toolName === "exec") {
return request.approveOnce({ reason: "CI command allowed by policy" });
}
return request.askUser();
});
```
Approval events should carry:
- approval id
- run id and session id
- request kind
- requested action summary
- tool name or environment action
- risk level
- available decisions
- expiration
- whether the decision can be reused
Questions are separate from approvals. A question asks the user or host app for
information. An approval asks for permission to perform an action.
## ToolSpace model
Apps need to understand the tool surface without importing plugin internals.
```typescript
const tools = await run.toolSpace();
for (const tool of tools.list()) {
console.log(tool.name, tool.source, tool.requiresApproval);
}
```
The SDK should expose:
- normalized tool metadata
- source: OpenClaw, MCP, plugin, channel, runtime, or app
- schema summary
- approval policy
- runtime compatibility
- whether a tool is hidden, readonly, write capable, or host capable
Tool invocation through the SDK should be explicit and scoped. Most apps should
run agents, not call arbitrary tools directly.
## Artifact model
Artifacts should cover more than files.
```typescript
type ArtifactSummary = {
id: string;
runId?: string;
sessionId?: string;
type:
| "file"
| "patch"
| "diff"
| "log"
| "media"
| "screenshot"
| "trajectory"
| "pull_request"
| "workspace";
title?: string;
mimeType?: string;
sizeBytes?: number;
createdAt: string;
expiresAt?: string;
};
```
Common examples:
- file edits and generated files
- patch bundles
- VCS diffs
- screenshots and media outputs
- logs and trace bundles
- pull request links
- runtime trajectories
- managed environment workspace snapshots
Artifact access should support redaction, retention, and download URLs without
assuming every artifact is a normal local file.
## Security model
The app SDK must be explicit about authority.
Recommended token scopes:
| Scope | Allows |
| ------------------- | --------------------------------------------------- |
| `agent.read` | List and inspect agents. |
| `agent.run` | Start runs. |
| `session.read` | Read session metadata and messages. |
| `session.write` | Create, send to, fork, compact, and abort sessions. |
| `task.read` | Read background task state. |
| `task.write` | Cancel or modify task notification policy. |
| `approval.respond` | Approve or deny requests. |
| `tools.invoke` | Invoke exposed tools directly. |
| `artifacts.read` | List and download artifacts. |
| `environment.write` | Create or destroy managed environments. |
| `admin` | Administrative operations. |
Defaults:
- no secret forwarding by default
- no unrestricted environment variable pass-through
- secret references instead of secret values
- explicit sandbox and network policy
- explicit remote environment retention
- approvals for host execution unless policy proves otherwise
- raw runtime events redacted before they leave Gateway unless the caller has a
stronger diagnostic scope
## Managed environment provider
Managed agents should be implemented as environment providers.
```typescript
type EnvironmentProvider = {
id: string;
capabilities: {
checkout?: boolean;
sandbox?: boolean;
networkPolicy?: boolean;
secrets?: boolean;
artifacts?: boolean;
logs?: boolean;
pullRequests?: boolean;
longRunning?: boolean;
};
};
```
The first implementation does not need to be a hosted SaaS. It can target
existing node hosts, ephemeral workspaces, CI-style runners, or Testbox-style
environments. The important contract is:
1. prepare workspace
2. bind safe environment and secrets
3. start run
4. stream events
5. collect artifacts
6. clean up or retain by policy
Once this is stable, a hosted cloud service can implement the same provider
contract.
## Package structure
Recommended packages:
| Package | Purpose |
| ----------------------- | ------------------------------------------------------------- |
| `@openclaw/sdk` | Public high-level SDK and generated low-level Gateway client. |
| `@openclaw/sdk-react` | Optional React hooks for dashboards and app builders. |
| `@openclaw/sdk-testing` | Test helpers and fake Gateway server for app integrations. |
The repo already has `openclaw/plugin-sdk/*` for plugins. Keep that namespace
separate to avoid confusing plugin authors with app developers.
## Generated client strategy
The low-level client should be generated from versioned Gateway protocol
schemas, then wrapped by handwritten ergonomic classes.
Layering:
1. Gateway schema source of truth.
2. Generated low-level TypeScript client.
3. Runtime validators for external inputs and event payloads.
4. High-level `OpenClaw`, `Agent`, `Session`, `Run`, `Task`, and `Artifact`
wrappers.
5. Cookbook examples and integration tests.
Benefits:
- protocol drift is visible
- tests can compare generated methods with Gateway exports
- app SDK stays independent from plugin SDK internals
- low-level consumers still have full protocol access
- high-level consumers get the small product API
## Related docs
- [OpenClaw SDK design](/concepts/openclaw-sdk)
- [Gateway RPC reference](/reference/rpc)
- [Agent loop](/concepts/agent-loop)
- [Agent runtimes](/concepts/agent-runtimes)
- [Background tasks](/automation/tasks)
- [ACP agents](/tools/acp-agents)
- [Plugin SDK overview](/plugins/sdk-overview)

21
packages/sdk/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "@openclaw/sdk",
"version": "0.0.0-private",
"private": true,
"files": [
"dist"
],
"type": "module",
"main": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"default": "./dist/index.mjs"
}
},
"scripts": {
"build": "tsdown src/index.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
}
}

538
packages/sdk/src/client.ts Normal file
View File

@@ -0,0 +1,538 @@
import { randomUUID } from "node:crypto";
import { normalizeGatewayEvent } from "./normalize.js";
import { GatewayClientTransport, isConnectableTransport } from "./transport.js";
import type {
AgentRunParams,
GatewayEvent,
GatewayRequestOptions,
OpenClawEvent,
OpenClawTransport,
RunCreateParams,
RunResult,
RunTimestamp,
SessionCreateParams,
SessionSendParams,
SessionTarget,
} from "./types.js";
export type OpenClawOptions = {
gateway?: "auto" | (string & {});
url?: string;
token?: string;
password?: string;
requestTimeoutMs?: number;
transport?: OpenClawTransport;
};
function resolveGatewayUrl(options: OpenClawOptions): string | undefined {
if (options.url) {
return options.url;
}
if (options.gateway && options.gateway !== "auto") {
return options.gateway;
}
return undefined;
}
function runStatusFromWaitPayload(payload: unknown): RunResult["status"] {
const record =
typeof payload === "object" && payload !== null ? (payload as { status?: unknown }) : {};
const status = typeof record.status === "string" ? record.status : undefined;
if (status === "ok" || status === "completed" || status === "succeeded") {
return "completed";
}
if (status === "timeout" || status === "timed_out") {
return "timed_out";
}
if (status === "cancelled" || status === "canceled") {
return "cancelled";
}
if (status === "accepted") {
return "accepted";
}
return "failed";
}
function readOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function readOptionalTimestamp(value: unknown): RunTimestamp | undefined {
if (typeof value === "string" && value.length > 0) {
return value;
}
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function normalizeTimeoutMs(timeoutMs: number | undefined): number | undefined {
if (timeoutMs === undefined) {
return undefined;
}
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
throw new Error("timeoutMs must be a finite non-negative number");
}
return Math.floor(timeoutMs);
}
function timeoutSecondsFromMs(timeoutMs: number | undefined): number | undefined {
const normalized = normalizeTimeoutMs(timeoutMs);
if (normalized === undefined) {
return undefined;
}
return normalized === 0 ? 0 : Math.ceil(normalized / 1000);
}
function splitModelRef(model: string | undefined): { provider?: string; model?: string } {
if (!model) {
return {};
}
const index = model.indexOf("/");
if (index <= 0 || index === model.length - 1) {
return { model };
}
return {
provider: model.slice(0, index),
model: model.slice(index + 1),
};
}
function assertNoUnsupportedRunOptions(params: AgentRunParams): void {
const unsupported = [
params.workspace ? "workspace" : undefined,
params.runtime ? "runtime" : undefined,
params.environment ? "environment" : undefined,
params.approvals ? "approvals" : undefined,
].filter((value): value is string => Boolean(value));
if (unsupported.length === 0) {
return;
}
throw new Error(
`OpenClaw Gateway does not support per-run SDK option${
unsupported.length === 1 ? "" : "s"
} yet: ${unsupported.join(", ")}`,
);
}
function buildAgentParams(params: AgentRunParams): Record<string, unknown> {
assertNoUnsupportedRunOptions(params);
const modelRef = splitModelRef(params.model);
const timeoutSeconds = timeoutSecondsFromMs(params.timeoutMs);
return {
message: params.input,
...(params.agentId ? { agentId: params.agentId } : {}),
...(modelRef.provider ? { provider: modelRef.provider } : {}),
...(modelRef.model ? { model: modelRef.model } : {}),
...(params.sessionId ? { sessionId: params.sessionId } : {}),
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
...(params.thinking ? { thinking: params.thinking } : {}),
...(typeof params.deliver === "boolean" ? { deliver: params.deliver } : {}),
...(params.attachments ? { attachments: params.attachments } : {}),
...(timeoutSeconds !== undefined ? { timeout: timeoutSeconds } : {}),
...(params.label ? { label: params.label } : {}),
idempotencyKey: params.idempotencyKey ?? randomUUID(),
};
}
function unsupportedGatewayApi(api: string): never {
throw new Error(`${api} is not supported by the current OpenClaw Gateway yet`);
}
export class OpenClaw {
readonly agents: AgentsNamespace;
readonly sessions: SessionsNamespace;
readonly runs: RunsNamespace;
readonly tasks: TasksNamespace;
readonly models: ModelsNamespace;
readonly tools: ToolsNamespace;
readonly artifacts: ArtifactsNamespace;
readonly approvals: ApprovalsNamespace;
readonly environments: EnvironmentsNamespace;
private readonly transport: OpenClawTransport;
private connected = false;
constructor(options: OpenClawOptions = {}) {
this.transport =
options.transport ??
new GatewayClientTransport({
url: resolveGatewayUrl(options),
token: options.token,
password: options.password,
requestTimeoutMs: options.requestTimeoutMs,
});
this.agents = new AgentsNamespace(this);
this.sessions = new SessionsNamespace(this);
this.runs = new RunsNamespace(this);
this.tasks = new TasksNamespace(this);
this.models = new ModelsNamespace(this);
this.tools = new ToolsNamespace(this);
this.artifacts = new ArtifactsNamespace(this);
this.approvals = new ApprovalsNamespace(this);
this.environments = new EnvironmentsNamespace(this);
}
async connect(): Promise<void> {
if (this.connected) {
return;
}
if (isConnectableTransport(this.transport)) {
await this.transport.connect();
}
this.connected = true;
}
async close(): Promise<void> {
await this.transport.close?.();
this.connected = false;
}
async request<T = unknown>(
method: string,
params?: unknown,
options?: GatewayRequestOptions,
): Promise<T> {
await this.connect();
return await this.transport.request<T>(method, params, options);
}
events(filter?: (event: OpenClawEvent) => boolean): AsyncIterable<OpenClawEvent> {
const source = this.transport.events();
async function* iterate(): AsyncIterable<OpenClawEvent> {
for await (const event of source) {
const normalized = normalizeGatewayEvent(event);
if (!filter || filter(normalized)) {
yield normalized;
}
}
}
return iterate();
}
rawEvents(filter?: (event: GatewayEvent) => boolean): AsyncIterable<GatewayEvent> {
return this.transport.events(filter);
}
}
export class Agent {
constructor(
private readonly client: OpenClaw,
readonly id: string,
) {}
async run(input: string | Omit<AgentRunParams, "agentId">): Promise<Run> {
const params: AgentRunParams =
typeof input === "string" ? { input, agentId: this.id } : { ...input, agentId: this.id };
return await this.client.runs.create(params);
}
async identity(params?: { sessionKey?: string }): Promise<unknown> {
return await this.client.request("agent.identity.get", {
agentId: this.id,
...(params?.sessionKey ? { sessionKey: params.sessionKey } : {}),
});
}
}
export class Run {
constructor(
private readonly client: OpenClaw,
readonly id: string,
readonly sessionKey?: string,
) {}
events(filter?: (event: OpenClawEvent) => boolean): AsyncIterable<OpenClawEvent> {
return this.client.events((event) => {
if (event.runId !== this.id) {
return false;
}
return filter ? filter(event) : true;
});
}
async wait(options?: { timeoutMs?: number }): Promise<RunResult> {
const timeoutMs = normalizeTimeoutMs(options?.timeoutMs);
const raw = await this.client.request(
"agent.wait",
{
runId: this.id,
...(timeoutMs !== undefined ? { timeoutMs } : {}),
},
{ timeoutMs: null },
);
const record = typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
const status = runStatusFromWaitPayload(raw);
const error = readOptionalString(record.error)
? { message: readOptionalString(record.error) ?? "run failed" }
: undefined;
return {
runId: this.id,
status,
sessionKey: readOptionalString(record.sessionKey) ?? this.sessionKey,
sessionId: readOptionalString(record.sessionId),
startedAt: readOptionalTimestamp(record.startedAt),
endedAt: readOptionalTimestamp(record.endedAt),
...(error ? { error } : {}),
raw,
};
}
async cancel(): Promise<unknown> {
return await this.client.request("sessions.abort", {
runId: this.id,
...(this.sessionKey ? { key: this.sessionKey } : {}),
});
}
}
export class Session {
constructor(
private readonly client: OpenClaw,
readonly key: string,
readonly info?: unknown,
) {}
async send(input: string | Omit<SessionSendParams, "key">): Promise<Run> {
const params: SessionSendParams =
typeof input === "string" ? { key: this.key, message: input } : { ...input, key: this.key };
const raw = await this.client.request("sessions.send", params, { expectFinal: true });
const record = typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
const runId = readOptionalString(record.runId);
if (!runId) {
throw new Error("sessions.send did not return a runId");
}
return new Run(this.client, runId, this.key);
}
async abort(runId?: string): Promise<unknown> {
return await this.client.request("sessions.abort", {
key: this.key,
...(runId ? { runId } : {}),
});
}
async patch(params: Record<string, unknown>): Promise<unknown> {
return await this.client.request("sessions.patch", { ...params, key: this.key });
}
async compact(params?: { maxLines?: number }): Promise<unknown> {
return await this.client.request("sessions.compact", { key: this.key, ...params });
}
}
export class AgentsNamespace {
constructor(private readonly client: OpenClaw) {}
async list(params?: Record<string, unknown>): Promise<unknown> {
return await this.client.request("agents.list", params);
}
async get(id: string): Promise<Agent> {
return new Agent(this.client, id);
}
async create(params: Record<string, unknown>): Promise<unknown> {
return await this.client.request("agents.create", params);
}
async update(params: Record<string, unknown>): Promise<unknown> {
return await this.client.request("agents.update", params);
}
async delete(params: Record<string, unknown>): Promise<unknown> {
return await this.client.request("agents.delete", params);
}
}
export class SessionsNamespace {
constructor(private readonly client: OpenClaw) {}
async list(params?: Record<string, unknown>): Promise<unknown> {
return await this.client.request("sessions.list", params);
}
async create(params: SessionCreateParams = {}): Promise<Session> {
const raw = await this.client.request("sessions.create", params);
const record = typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
const key =
readOptionalString(record.key) ?? readOptionalString(record.sessionKey) ?? params.key;
if (!key) {
throw new Error("sessions.create did not return a session key");
}
return new Session(this.client, key, raw);
}
async get(target: SessionTarget | string): Promise<Session> {
const key = typeof target === "string" ? target : target.key;
return new Session(this.client, key);
}
async resolve(params: Record<string, unknown>): Promise<unknown> {
return await this.client.request("sessions.resolve", params);
}
async send(input: SessionSendParams): Promise<Run> {
return await new Session(this.client, input.key).send(input);
}
}
export class RunsNamespace {
constructor(private readonly client: OpenClaw) {}
async create(params: RunCreateParams): Promise<Run> {
const raw = await this.client.request("agent", buildAgentParams(params), {
expectFinal: false,
timeoutMs: params.timeoutMs,
});
const record = typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
const runId = readOptionalString(record.runId);
if (!runId) {
throw new Error("agent did not return a runId");
}
return new Run(this.client, runId, readOptionalString(record.sessionKey) ?? params.sessionKey);
}
async get(runId: string): Promise<Run> {
return new Run(this.client, runId);
}
events(runId: string): AsyncIterable<OpenClawEvent> {
return new Run(this.client, runId).events();
}
async wait(runId: string, options?: { timeoutMs?: number }): Promise<RunResult> {
return await new Run(this.client, runId).wait(options);
}
async cancel(runId: string, sessionKey?: string): Promise<unknown> {
return await new Run(this.client, runId, sessionKey).cancel();
}
}
class RpcNamespace {
constructor(
protected readonly client: OpenClaw,
private readonly prefix: string,
) {}
protected async call<T = unknown>(
method: string,
params?: unknown,
options?: GatewayRequestOptions,
): Promise<T> {
return await this.client.request<T>(`${this.prefix}.${method}`, params, options);
}
}
export class TasksNamespace extends RpcNamespace {
constructor(client: OpenClaw) {
super(client, "tasks");
}
async list(params?: unknown): Promise<unknown> {
void params;
return unsupportedGatewayApi("oc.tasks.list");
}
async get(taskId: string): Promise<unknown> {
void taskId;
return unsupportedGatewayApi("oc.tasks.get");
}
async cancel(taskId: string): Promise<unknown> {
void taskId;
return unsupportedGatewayApi("oc.tasks.cancel");
}
}
export class ModelsNamespace extends RpcNamespace {
constructor(client: OpenClaw) {
super(client, "models");
}
async list(params?: unknown): Promise<unknown> {
return await this.call("list", params);
}
async status(params?: unknown): Promise<unknown> {
return await this.call("authStatus", params);
}
}
export class ToolsNamespace extends RpcNamespace {
constructor(client: OpenClaw) {
super(client, "tools");
}
async list(params?: unknown): Promise<unknown> {
return await this.call("catalog", params);
}
async effective(params?: unknown): Promise<unknown> {
return await this.call("effective", params);
}
async invoke(name: string, params?: unknown): Promise<unknown> {
void name;
void params;
return unsupportedGatewayApi("oc.tools.invoke");
}
}
export class ArtifactsNamespace extends RpcNamespace {
constructor(client: OpenClaw) {
super(client, "artifacts");
}
async list(params?: unknown): Promise<unknown> {
void params;
return unsupportedGatewayApi("oc.artifacts.list");
}
async get(id: string): Promise<unknown> {
void id;
return unsupportedGatewayApi("oc.artifacts.get");
}
async download(id: string): Promise<unknown> {
void id;
return unsupportedGatewayApi("oc.artifacts.download");
}
}
export class ApprovalsNamespace {
constructor(private readonly client: OpenClaw) {}
async list(params?: unknown): Promise<unknown> {
return await this.client.request("exec.approval.list", params);
}
async respond(approvalId: string, decision: Record<string, unknown>): Promise<unknown> {
return await this.client.request("exec.approval.resolve", { approvalId, ...decision });
}
}
export class EnvironmentsNamespace extends RpcNamespace {
constructor(client: OpenClaw) {
super(client, "environments");
}
async list(params?: unknown): Promise<unknown> {
void params;
return unsupportedGatewayApi("oc.environments.list");
}
async create(params?: unknown): Promise<unknown> {
void params;
return unsupportedGatewayApi("oc.environments.create");
}
async status(environmentId: string): Promise<unknown> {
void environmentId;
return unsupportedGatewayApi("oc.environments.status");
}
async delete(environmentId: string): Promise<unknown> {
void environmentId;
return unsupportedGatewayApi("oc.environments.delete");
}
}

View File

@@ -0,0 +1,77 @@
import type { GatewayEvent } from "./types.js";
type Listener<T> = (event: T) => void;
export class EventHub<T> {
private closed = false;
private readonly listeners = new Set<Listener<T>>();
private readonly waiters = new Set<() => void>();
publish(event: T): void {
if (this.closed) {
return;
}
for (const listener of this.listeners) {
listener(event);
}
}
close(): void {
this.closed = true;
this.listeners.clear();
for (const wake of this.waiters) {
wake();
}
this.waiters.clear();
}
async *stream(filter?: (event: T) => boolean): AsyncIterable<T> {
const queue: T[] = [];
let wake: (() => void) | null = null;
const listener = (event: T) => {
if (!filter || filter(event)) {
queue.push(event);
wake?.();
wake = null;
}
};
this.listeners.add(listener);
try {
while (!this.closed) {
const next = queue.shift();
if (next) {
yield next;
continue;
}
await new Promise<void>((resolve) => {
const wakeCurrent = () => {
this.waiters.delete(wakeCurrent);
resolve();
};
wake = wakeCurrent;
this.waiters.add(wakeCurrent);
});
}
while (queue.length > 0) {
const next = queue.shift();
if (next) {
yield next;
}
}
} finally {
this.listeners.delete(listener);
if (wake) {
this.waiters.delete(wake);
}
}
}
}
export function isGatewayEvent(value: unknown): value is GatewayEvent {
return (
typeof value === "object" &&
value !== null &&
typeof (value as { event?: unknown }).event === "string"
);
}

View File

@@ -0,0 +1,278 @@
import type { AddressInfo } from "node:net";
import net from "node:net";
import { afterEach, describe, expect, it } from "vitest";
import { WebSocketServer, type RawData, type WebSocket } from "ws";
import { GatewayClientTransport, OpenClaw } from "./index.js";
type JsonObject = Record<string, unknown>;
const servers: WebSocketServer[] = [];
function sendJson(socket: WebSocket, payload: JsonObject): void {
socket.send(JSON.stringify(payload));
}
function readRawMessage(raw: RawData): string {
if (typeof raw === "string") {
return raw;
}
if (Buffer.isBuffer(raw)) {
return raw.toString("utf8");
}
if (raw instanceof ArrayBuffer) {
return Buffer.from(raw).toString("utf8");
}
return Buffer.concat(raw).toString("utf8");
}
async function reservePort(): Promise<number> {
const server = net.createServer();
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as AddressInfo;
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
return port;
}
async function createFakeGateway(port = 0): Promise<{ url: string; close: () => Promise<void> }> {
const server = new WebSocketServer({ host: "127.0.0.1", port });
servers.push(server);
await new Promise<void>((resolve) => server.once("listening", resolve));
let seq = 1;
server.on("connection", (socket) => {
sendJson(socket, {
type: "event",
event: "connect.challenge",
seq: seq++,
payload: { nonce: "sdk-e2e-nonce" },
});
socket.on("message", (raw) => {
const frame = JSON.parse(readRawMessage(raw)) as {
id: string;
method: string;
params?: unknown;
};
if (frame.method === "connect") {
sendJson(socket, {
type: "res",
id: frame.id,
ok: true,
payload: {
type: "hello-ok",
protocol: 1,
server: { version: "sdk-e2e", connId: "conn-sdk-e2e" },
features: {
methods: [
"agent",
"agent.wait",
"connect",
"sessions.abort",
"sessions.create",
"sessions.send",
],
events: ["agent", "sessions.changed"],
},
snapshot: {
presence: [],
health: {},
stateVersion: { presence: 0, health: 0 },
uptimeMs: 1,
},
auth: { role: "operator", scopes: [] },
policy: {
maxPayload: 262144,
maxBufferedBytes: 262144,
tickIntervalMs: 30000,
},
},
});
return;
}
if (frame.method === "agent") {
const params = frame.params as { sessionKey?: string } | undefined;
sendJson(socket, {
type: "res",
id: frame.id,
ok: true,
payload: { status: "accepted", runId: "run-sdk-e2e", sessionKey: params?.sessionKey },
});
setTimeout(() => {
sendJson(socket, {
type: "event",
event: "agent",
seq: seq++,
payload: {
runId: "run-sdk-e2e",
sessionKey: params?.sessionKey,
stream: "lifecycle",
ts: Date.now(),
data: { phase: "start" },
},
});
sendJson(socket, {
type: "event",
event: "agent",
seq: seq++,
payload: {
runId: "run-sdk-e2e",
sessionKey: params?.sessionKey,
stream: "assistant",
ts: Date.now(),
data: { delta: "hello from fake gateway" },
},
});
sendJson(socket, {
type: "event",
event: "agent",
seq: seq++,
payload: {
runId: "run-sdk-e2e",
sessionKey: params?.sessionKey,
stream: "lifecycle",
ts: Date.now(),
data: { phase: "end" },
},
});
}, 50);
return;
}
if (frame.method === "agent.wait") {
sendJson(socket, {
type: "res",
id: frame.id,
ok: true,
payload: {
status: "ok",
runId: "run-sdk-e2e",
sessionKey: "main",
startedAt: 123,
endedAt: 456,
},
});
}
if (frame.method === "sessions.abort") {
sendJson(socket, {
type: "res",
id: frame.id,
ok: true,
payload: {
ok: true,
abortedRunId: "run-sdk-e2e",
status: "aborted",
},
});
}
});
});
const { port: boundPort } = server.address() as AddressInfo;
return {
url: `ws://127.0.0.1:${boundPort}`,
close: () => {
const index = servers.indexOf(server);
if (index >= 0) {
servers.splice(index, 1);
}
return new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
},
};
}
describe("OpenClaw SDK websocket e2e", () => {
afterEach(async () => {
await Promise.all(
servers.splice(0).map(
(server) =>
new Promise<void>((resolve) => {
server.close(() => resolve());
}),
),
);
});
it("runs an agent and streams normalized events over a Gateway websocket", async () => {
const gateway = await createFakeGateway();
const transport = new GatewayClientTransport({
url: gateway.url,
deviceIdentity: null,
requestTimeoutMs: 2_000,
});
const oc = new OpenClaw({ transport });
try {
const agent = await oc.agents.get("main");
const run = await agent.run({
input: "say hello",
sessionKey: "main",
idempotencyKey: "sdk-e2e",
});
const seenPromise = (async () => {
const seen: string[] = [];
for await (const event of run.events()) {
seen.push(event.type);
if (event.type === "run.completed") {
break;
}
}
return seen;
})();
const timeoutPromise = new Promise<never>((_resolve, reject) => {
setTimeout(() => reject(new Error("timed out waiting for SDK run events")), 2_000);
});
const [seen, result] = await Promise.all([
Promise.race([seenPromise, timeoutPromise]),
run.wait({ timeoutMs: 2_000 }),
]);
expect(run.id).toBe("run-sdk-e2e");
expect(seen).toEqual(["run.started", "assistant.delta", "run.completed"]);
expect(result).toMatchObject({
runId: "run-sdk-e2e",
sessionKey: "main",
status: "completed",
startedAt: 123,
endedAt: 456,
});
await expect(run.cancel()).resolves.toMatchObject({
abortedRunId: "run-sdk-e2e",
status: "aborted",
});
} finally {
await oc.close();
await gateway.close();
}
});
it("retries after an initial websocket connection failure", async () => {
const port = await reservePort();
const url = `ws://127.0.0.1:${port}`;
const transport = new GatewayClientTransport({
url,
deviceIdentity: null,
connectChallengeTimeoutMs: 200,
preauthHandshakeTimeoutMs: 200,
requestTimeoutMs: 500,
});
await expect(transport.connect()).rejects.toThrow();
const gateway = await createFakeGateway(port);
try {
await expect(transport.connect()).resolves.toBeUndefined();
} finally {
await transport.close();
await gateway.close();
}
});
});

View File

@@ -0,0 +1,331 @@
import { describe, expect, it } from "vitest";
import { EventHub, OpenClaw, normalizeGatewayEvent } from "./index.js";
import type { GatewayEvent, GatewayRequestOptions, OpenClawTransport } from "./types.js";
type RequestCall = {
method: string;
params?: unknown;
options?: GatewayRequestOptions;
};
class FakeTransport implements OpenClawTransport {
readonly calls: RequestCall[] = [];
private readonly eventHub = new EventHub<GatewayEvent>();
constructor(private readonly responses: Record<string, unknown>) {}
async request<T = unknown>(
method: string,
params?: unknown,
options?: GatewayRequestOptions,
): Promise<T> {
this.calls.push({ method, params, options });
return this.responses[method] as T;
}
events(filter?: (event: GatewayEvent) => boolean): AsyncIterable<GatewayEvent> {
return this.eventHub.stream(filter);
}
emit(event: GatewayEvent): void {
this.eventHub.publish(event);
}
close(): void {
this.eventHub.close();
}
}
describe("OpenClaw SDK", () => {
it("runs an agent through the Gateway agent method", async () => {
const transport = new FakeTransport({
agent: { status: "accepted", runId: "run_123" },
"agent.wait": { status: "ok", runId: "run_123", sessionKey: "main" },
});
const oc = new OpenClaw({ transport });
const agent = await oc.agents.get("main");
const run = await agent.run({
input: "ship it",
model: "sonnet-4.6",
sessionKey: "main",
timeoutMs: 30_000,
idempotencyKey: "idempotent-test",
});
const result = await run.wait({ timeoutMs: 500 });
expect(run.id).toBe("run_123");
expect(result).toMatchObject({
runId: "run_123",
sessionKey: "main",
status: "completed",
});
expect(transport.calls).toEqual([
{
method: "agent",
options: { expectFinal: false, timeoutMs: 30_000 },
params: {
agentId: "main",
idempotencyKey: "idempotent-test",
message: "ship it",
model: "sonnet-4.6",
sessionKey: "main",
timeout: 30,
},
},
{
method: "agent.wait",
options: { timeoutMs: null },
params: { runId: "run_123", timeoutMs: 500 },
},
]);
});
it("preserves numeric wait timestamps", async () => {
const transport = new FakeTransport({
"agent.wait": { status: "ok", runId: "run_numeric", startedAt: 123, endedAt: 456 },
});
const oc = new OpenClaw({ transport });
const result = await oc.runs.wait("run_numeric");
expect(result).toMatchObject({
runId: "run_numeric",
status: "completed",
startedAt: 123,
endedAt: 456,
});
expect(transport.calls).toEqual([
{
method: "agent.wait",
params: { runId: "run_numeric" },
options: { timeoutMs: null },
},
]);
});
it("splits provider-qualified model refs and rejects unsupported run options", async () => {
const transport = new FakeTransport({
agent: { status: "accepted", runId: "run_openrouter" },
});
const oc = new OpenClaw({ transport });
await oc.runs.create({
input: "use a routed model",
model: "openrouter/deepseek/deepseek-r1",
idempotencyKey: "model-ref-test",
});
expect(transport.calls[0]).toMatchObject({
method: "agent",
params: {
message: "use a routed model",
provider: "openrouter",
model: "deepseek/deepseek-r1",
idempotencyKey: "model-ref-test",
},
});
await expect(
oc.runs.create({
input: "unsupported",
idempotencyKey: "unsupported-options-test",
workspace: { cwd: "/tmp/project" },
runtime: { type: "managed", provider: "testbox" },
environment: { type: "local" },
approvals: "ask",
}),
).rejects.toThrow(
"OpenClaw Gateway does not support per-run SDK options yet: workspace, runtime, environment, approvals",
);
});
it("ceil-converts run timeoutMs to Gateway timeout seconds", async () => {
const transport = new FakeTransport({
agent: { status: "accepted", runId: "run_timeout" },
});
const oc = new OpenClaw({ transport });
await oc.runs.create({
input: "short run",
timeoutMs: 1_500,
idempotencyKey: "timeout-test",
});
expect(transport.calls[0]).toMatchObject({
method: "agent",
options: { expectFinal: false, timeoutMs: 1_500 },
params: {
message: "short run",
timeout: 2,
idempotencyKey: "timeout-test",
},
});
await expect(
oc.runs.create({
input: "bad timeout",
timeoutMs: Number.NaN,
idempotencyKey: "bad-timeout-test",
}),
).rejects.toThrow("timeoutMs must be a finite non-negative number");
});
it("throws explicit unsupported errors for SDK namespaces without Gateway RPCs", async () => {
const transport = new FakeTransport({});
const oc = new OpenClaw({ transport });
await expect(oc.tasks.list()).rejects.toThrow(
"oc.tasks.list is not supported by the current OpenClaw Gateway yet",
);
await expect(oc.tasks.get("task_123")).rejects.toThrow(
"oc.tasks.get is not supported by the current OpenClaw Gateway yet",
);
await expect(oc.tasks.cancel("task_123")).rejects.toThrow(
"oc.tasks.cancel is not supported by the current OpenClaw Gateway yet",
);
await expect(oc.tools.invoke("demo")).rejects.toThrow(
"oc.tools.invoke is not supported by the current OpenClaw Gateway yet",
);
await expect(oc.artifacts.list()).rejects.toThrow(
"oc.artifacts.list is not supported by the current OpenClaw Gateway yet",
);
await expect(oc.environments.list()).rejects.toThrow(
"oc.environments.list is not supported by the current OpenClaw Gateway yet",
);
expect(transport.calls).toEqual([]);
});
it("cancels runs and checks model auth status through current Gateway methods", async () => {
const transport = new FakeTransport({
agent: { status: "accepted", runId: "run_without_session" },
"sessions.abort": { ok: true, status: "aborted", abortedRunId: "run_without_session" },
"models.authStatus": { providers: [] },
});
const oc = new OpenClaw({ transport });
const run = await oc.runs.create({
input: "start",
idempotencyKey: "cancel-test",
});
await run.cancel();
await oc.models.status({ probe: false });
expect(transport.calls.map((call) => call.method)).toEqual([
"agent",
"sessions.abort",
"models.authStatus",
]);
expect(transport.calls[1]?.params).toEqual({ runId: "run_without_session" });
expect(transport.calls[2]?.params).toEqual({ probe: false });
});
it("creates a session and sends a message as a run", async () => {
const transport = new FakeTransport({
"sessions.create": { key: "session-main", label: "Main" },
"sessions.send": { status: "accepted", runId: "run_session" },
});
const oc = new OpenClaw({ transport });
const session = await oc.sessions.create({ key: "session-main" });
const run = await session.send({ message: "continue", thinking: "medium" });
expect(run.id).toBe("run_session");
expect(transport.calls).toEqual([
{
method: "sessions.create",
options: undefined,
params: { key: "session-main" },
},
{
method: "sessions.send",
options: { expectFinal: true },
params: { key: "session-main", message: "continue", thinking: "medium" },
},
]);
});
it("normalizes Gateway agent stream events into SDK events", () => {
const ts = 1_777_000_000_000;
expect(
normalizeGatewayEvent({
event: "agent",
seq: 1,
payload: { runId: "run_1", stream: "lifecycle", ts, data: { phase: "start" } },
}),
).toMatchObject({
type: "run.started",
runId: "run_1",
data: { phase: "start" },
});
expect(
normalizeGatewayEvent({
event: "agent",
seq: 2,
payload: { runId: "run_1", stream: "assistant", ts, data: { delta: "hello" } },
}),
).toMatchObject({
type: "assistant.delta",
runId: "run_1",
data: { delta: "hello" },
});
expect(
normalizeGatewayEvent({
event: "agent",
seq: 3,
payload: { runId: "run_1", stream: "lifecycle", ts, data: { phase: "end" } },
}),
).toMatchObject({
type: "run.completed",
runId: "run_1",
data: { phase: "end" },
});
expect(
normalizeGatewayEvent({
event: "agent",
seq: 4,
payload: {
runId: "run_1",
stream: "lifecycle",
ts,
data: { phase: "end", aborted: true },
},
}),
).toMatchObject({
type: "run.timed_out",
runId: "run_1",
data: { phase: "end", aborted: true },
});
expect(
normalizeGatewayEvent({
event: "agent",
seq: 5,
payload: {
runId: "run_1",
stream: "lifecycle",
ts,
data: { phase: "end", aborted: true, stopReason: "rpc" },
},
}),
).toMatchObject({
type: "run.cancelled",
runId: "run_1",
data: { phase: "end", aborted: true, stopReason: "rpc" },
});
expect(
normalizeGatewayEvent({
event: "agent",
seq: 6,
payload: {
runId: "run_1",
stream: "lifecycle",
ts,
data: { phase: "end", stopReason: "timeout" },
},
}),
).toMatchObject({
type: "run.timed_out",
runId: "run_1",
data: { phase: "end", stopReason: "timeout" },
});
});
});

42
packages/sdk/src/index.ts Normal file
View File

@@ -0,0 +1,42 @@
export {
Agent,
AgentsNamespace,
ApprovalsNamespace,
ArtifactsNamespace,
EnvironmentsNamespace,
ModelsNamespace,
OpenClaw,
Run,
RunsNamespace,
Session,
SessionsNamespace,
TasksNamespace,
ToolsNamespace,
type OpenClawOptions,
} from "./client.js";
export { EventHub, isGatewayEvent } from "./event-hub.js";
export { normalizeGatewayEvent } from "./normalize.js";
export { GatewayClientTransport, isConnectableTransport } from "./transport.js";
export type {
AgentRunParams,
ApprovalMode,
ArtifactSummary,
ConnectableOpenClawTransport,
EnvironmentSelection,
GatewayEvent,
GatewayRequestOptions,
JsonObject,
OpenClawEvent,
OpenClawEventType,
OpenClawTransport,
RunCreateParams,
RunResult,
RunStatus,
RuntimeSelection,
SDKError,
SDKMessage,
SessionCreateParams,
SessionSendParams,
SessionTarget,
WorkspaceSelection,
} from "./types.js";

View File

@@ -0,0 +1,159 @@
import type { GatewayEvent, JsonObject, OpenClawEvent, OpenClawEventType } from "./types.js";
function asRecord(value: unknown): JsonObject {
return typeof value === "object" && value !== null ? (value as JsonObject) : {};
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function readNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function readLowerString(value: unknown): string | undefined {
return readString(value)?.toLowerCase();
}
function normalizeLifecycleEndEventType(data: JsonObject): OpenClawEventType {
const status = readLowerString(data.status);
const stopReason = readLowerString(data.stopReason);
if (
status === "timeout" ||
status === "timed_out" ||
stopReason === "timeout" ||
stopReason === "timed_out"
) {
return "run.timed_out";
}
if (
status === "aborted" ||
status === "cancelled" ||
status === "canceled" ||
status === "killed" ||
stopReason === "aborted" ||
stopReason === "cancelled" ||
stopReason === "canceled" ||
stopReason === "killed" ||
stopReason === "rpc" ||
stopReason === "user" ||
(data.aborted === true && stopReason === "stop")
) {
return "run.cancelled";
}
if (data.aborted === true) {
return "run.timed_out";
}
return "run.completed";
}
function normalizeAgentEventType(payload: JsonObject): OpenClawEventType {
const stream = readString(payload.stream);
const data = asRecord(payload.data);
const phase = readString(data.phase);
const status = readString(data.status);
if (stream === "assistant") {
return data.delta === true || typeof data.delta === "string"
? "assistant.delta"
: "assistant.message";
}
if (stream === "thinking" || stream === "plan") {
return "thinking.delta";
}
if (stream === "lifecycle") {
if (phase === "start") {
return "run.started";
}
if (phase === "end") {
return normalizeLifecycleEndEventType(data);
}
if (phase === "error") {
return "run.failed";
}
}
if (stream === "tool" || stream === "item" || stream === "command_output") {
if (phase === "start" || status === "running") {
return "tool.call.started";
}
if (phase === "delta" || phase === "update") {
return "tool.call.delta";
}
if (phase === "end" || status === "completed") {
return "tool.call.completed";
}
if (status === "failed" || status === "blocked") {
return "tool.call.failed";
}
return "tool.call.delta";
}
if (stream === "approval") {
return phase === "resolved" ? "approval.resolved" : "approval.requested";
}
if (stream === "patch") {
return "artifact.updated";
}
if (stream === "error") {
return "run.failed";
}
return "raw";
}
function normalizeNamedEventType(event: GatewayEvent): OpenClawEventType {
const payload = asRecord(event.payload);
switch (event.event) {
case "agent":
return normalizeAgentEventType(payload);
case "sessions.changed": {
const reason = readString(payload.reason);
if (reason === "create") {
return "session.created";
}
if (reason === "compact") {
return "session.compacted";
}
return "session.updated";
}
case "session.message":
return "assistant.message";
case "session.tool":
return "tool.call.delta";
case "exec.approval.requested":
case "plugin.approval.requested":
return "approval.requested";
case "exec.approval.resolved":
case "plugin.approval.resolved":
return "approval.resolved";
case "task.updated":
case "tasks.changed":
return "task.updated";
default:
return "raw";
}
}
export function normalizeGatewayEvent(event: GatewayEvent): OpenClawEvent {
const payload = asRecord(event.payload);
const runId = readString(payload.runId);
const sessionId = readString(payload.sessionId);
const sessionKey = readString(payload.sessionKey);
const taskId = readString(payload.taskId);
const agentId = readString(payload.agentId);
const ts = readNumber(payload.ts) ?? Date.now();
const idParts = [event.seq ?? "local", event.event, runId, sessionKey, ts].filter(Boolean);
return {
version: 1,
id: idParts.join(":"),
ts,
type: normalizeNamedEventType(event),
...(runId ? { runId } : {}),
...(sessionId ? { sessionId } : {}),
...(sessionKey ? { sessionKey } : {}),
...(taskId ? { taskId } : {}),
...(agentId ? { agentId } : {}),
data: payload.data ?? payload,
raw: event,
};
}

View File

@@ -0,0 +1,150 @@
import { GatewayClient } from "../../../src/gateway/client.js";
import { EventHub } from "./event-hub.js";
import type {
ConnectableOpenClawTransport,
GatewayEvent,
GatewayRequestOptions,
OpenClawTransport,
} from "./types.js";
type GatewayClientLike = {
request<T = unknown>(
method: string,
params?: unknown,
options?: GatewayRequestOptions,
): Promise<T>;
stopAndWait(): Promise<void>;
};
export type GatewayClientTransportOptions = {
url?: string;
connectChallengeTimeoutMs?: number;
connectDelayMs?: number;
preauthHandshakeTimeoutMs?: number;
tickWatchMinIntervalMs?: number;
requestTimeoutMs?: number;
token?: string;
bootstrapToken?: string;
deviceToken?: string;
password?: string;
instanceId?: string;
clientName?: string;
clientDisplayName?: string;
clientVersion?: string;
platform?: string;
deviceFamily?: string;
mode?: string;
role?: string;
scopes?: string[];
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
pathEnv?: string;
deviceIdentity?: unknown;
minProtocol?: number;
maxProtocol?: number;
tlsFingerprint?: string;
onEvent?: (evt: GatewayEvent) => void;
onHelloOk?: (hello: unknown) => void;
onConnectError?: (err: Error) => void;
onReconnectPaused?: (info: unknown) => void;
onClose?: (code: number, reason: string) => void;
onGap?: (info: { expected: number; received: number }) => void;
};
function toGatewayEvent(event: unknown): GatewayEvent {
const record =
typeof event === "object" && event !== null ? (event as Record<string, unknown>) : {};
const eventName = typeof record.event === "string" ? record.event : "unknown";
return {
event: eventName,
payload: record.payload,
...(typeof record.seq === "number" ? { seq: record.seq } : {}),
...(record.stateVersion ? { stateVersion: record.stateVersion } : {}),
};
}
export class GatewayClientTransport implements ConnectableOpenClawTransport {
private readonly eventsHub = new EventHub<GatewayEvent>();
private readonly options: GatewayClientTransportOptions;
private client: GatewayClientLike | null = null;
private connectPromise: Promise<void> | null = null;
private closePromise: Promise<void> | null = null;
constructor(options: GatewayClientTransportOptions = {}) {
this.options = options;
}
connect(): Promise<void> {
if (this.connectPromise) {
return this.connectPromise;
}
this.connectPromise = new Promise<void>((resolve, reject) => {
const client = new GatewayClient({
...this.options,
onEvent: (event: unknown) => {
const normalized = toGatewayEvent(event);
this.eventsHub.publish(normalized);
this.options.onEvent?.(normalized);
},
onHelloOk: (_hello: unknown) => {
this.options.onHelloOk?.(_hello);
resolve();
},
onConnectError: (error: Error) => {
this.options.onConnectError?.(error);
if (this.client === client) {
this.client = null;
}
if (this.connectPromise) {
this.connectPromise = null;
}
void client.stopAndWait().catch(() => {});
reject(error);
},
onReconnectPaused: this.options.onReconnectPaused,
onClose: this.options.onClose,
onGap: this.options.onGap,
} as never);
this.client = client;
client.start();
});
return this.connectPromise;
}
async request<T = unknown>(
method: string,
params?: unknown,
options?: GatewayRequestOptions,
): Promise<T> {
await this.connect();
if (!this.client) {
throw new Error("gateway transport is not connected");
}
return await this.client.request<T>(method, params, options);
}
events(filter?: (event: GatewayEvent) => boolean): AsyncIterable<GatewayEvent> {
return this.eventsHub.stream(filter);
}
async close(): Promise<void> {
if (this.closePromise) {
return await this.closePromise;
}
this.eventsHub.close();
const client = this.client;
this.client = null;
this.connectPromise = null;
this.closePromise = client?.stopAndWait() ?? Promise.resolve();
await this.closePromise;
this.closePromise = null;
}
}
export function isConnectableTransport(
transport: OpenClawTransport,
): transport is ConnectableOpenClawTransport {
return typeof (transport as { connect?: unknown }).connect === "function";
}

201
packages/sdk/src/types.ts Normal file
View File

@@ -0,0 +1,201 @@
export type JsonObject = Record<string, unknown>;
export type GatewayRequestOptions = {
expectFinal?: boolean;
timeoutMs?: number | null;
};
export type GatewayEvent = {
event: string;
payload?: unknown;
seq?: number;
stateVersion?: unknown;
};
export type OpenClawTransport = {
request<T = unknown>(
method: string,
params?: unknown,
options?: GatewayRequestOptions,
): Promise<T>;
events(filter?: (event: GatewayEvent) => boolean): AsyncIterable<GatewayEvent>;
close?(): Promise<void> | void;
};
export type ConnectableOpenClawTransport = OpenClawTransport & {
connect(): Promise<void>;
};
export type RuntimeSelection =
| "auto"
| { type: "embedded"; id: "pi" | "codex" | (string & {}) }
| { type: "cli"; id: "claude-cli" | (string & {}) }
| { type: "acp"; harness: "claude" | "cursor" | "gemini" | "opencode" | (string & {}) }
| { type: "managed"; provider: "local" | "node" | "testbox" | "cloud" | (string & {}) };
export type EnvironmentSelection =
| { type: "local"; cwd?: string }
| { type: "gateway"; url?: string; cwd?: string }
| { type: "node"; nodeId: string; cwd?: string }
| { type: "managed"; provider: string; repo?: string; ref?: string }
| { type: "ephemeral"; provider: string; repo?: string; ref?: string };
export type WorkspaceSelection = {
cwd?: string;
repo?: string;
ref?: string;
};
export type ApprovalMode = "ask" | "never" | "auto" | "trusted";
export type RunStatus = "accepted" | "completed" | "failed" | "cancelled" | "timed_out";
export type RunTimestamp = string | number;
export type SDKMessage = {
role: "system" | "user" | "assistant" | "tool";
content: string;
name?: string;
toolCallId?: string;
};
export type ArtifactSummary = {
id: string;
runId?: string;
sessionId?: string;
type:
| "file"
| "patch"
| "diff"
| "log"
| "media"
| "screenshot"
| "trajectory"
| "pull_request"
| "workspace"
| (string & {});
title?: string;
mimeType?: string;
sizeBytes?: number;
createdAt?: string;
expiresAt?: string;
};
export type SDKError = {
code?: string;
message: string;
details?: unknown;
};
export type RunResult = {
runId: string;
status: RunStatus;
sessionId?: string;
sessionKey?: string;
taskId?: string;
startedAt?: RunTimestamp;
endedAt?: RunTimestamp;
output?: {
text?: string;
messages?: SDKMessage[];
};
usage?: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
costUsd?: number;
};
artifacts?: ArtifactSummary[];
error?: SDKError;
raw?: unknown;
};
export type OpenClawEventType =
| "run.created"
| "run.queued"
| "run.started"
| "run.completed"
| "run.failed"
| "run.cancelled"
| "run.timed_out"
| "assistant.delta"
| "assistant.message"
| "thinking.delta"
| "tool.call.started"
| "tool.call.delta"
| "tool.call.completed"
| "tool.call.failed"
| "approval.requested"
| "approval.resolved"
| "question.requested"
| "question.answered"
| "artifact.created"
| "artifact.updated"
| "session.created"
| "session.updated"
| "session.compacted"
| "task.updated"
| "git.branch"
| "git.diff"
| "git.pr"
| "raw";
export type OpenClawEvent<TData = unknown> = {
version: 1;
id: string;
ts: number;
type: OpenClawEventType;
runId?: string;
sessionId?: string;
sessionKey?: string;
taskId?: string;
agentId?: string;
data: TData;
raw?: GatewayEvent;
};
export type AgentRunParams = {
input: string;
agentId?: string;
model?: string;
thinking?: string;
sessionId?: string;
sessionKey?: string;
deliver?: boolean;
attachments?: unknown[];
timeoutMs?: number;
label?: string;
runtime?: RuntimeSelection;
environment?: EnvironmentSelection;
workspace?: WorkspaceSelection;
approvals?: ApprovalMode;
idempotencyKey?: string;
};
export type SessionCreateParams = {
key?: string;
agentId?: string;
label?: string;
model?: string;
parentSessionKey?: string;
task?: string;
message?: string;
};
export type SessionSendParams = {
key: string;
message: string;
thinking?: string;
attachments?: unknown[];
timeoutMs?: number;
idempotencyKey?: string;
};
export type SessionTarget = {
key: string;
sessionId?: string;
agentId?: string;
label?: string;
};
export type RunCreateParams = AgentRunParams;

View File

@@ -152,20 +152,39 @@ export function upsertCanonicalModelConfigEntry(
params: { provider: string; model: string },
) {
const key = modelKey(params.provider, params.model);
const legacyKey = legacyModelKey(params.provider, params.model);
if (legacyKey && models[legacyKey]) {
const legacyKeys = [
legacyModelKey(params.provider, params.model),
`${params.provider}/${key}`,
].filter(
(legacyKey): legacyKey is string =>
typeof legacyKey === "string" && legacyKey.length > 0 && legacyKey !== key,
);
let legacyEntry: AgentModelEntryConfig | undefined;
for (const legacyKey of legacyKeys) {
const entry = models[legacyKey];
if (!entry) {
continue;
}
Object.assign((legacyEntry ??= {}), entry);
legacyEntry.params = {
...legacyEntry.params,
...entry.params,
};
}
if (legacyEntry) {
models[key] = {
...models[legacyKey],
...legacyEntry,
...models[key],
params: {
...models[legacyKey].params,
...legacyEntry.params,
...models[key]?.params,
},
};
} else if (!models[key]) {
models[key] = {};
}
if (legacyKey) {
for (const legacyKey of legacyKeys) {
delete models[legacyKey];
}
return key;

View File

@@ -123,7 +123,7 @@ export const SessionsMessagesUnsubscribeParamsSchema = Type.Object(
export const SessionsAbortParamsSchema = Type.Object(
{
key: NonEmptyString,
key: Type.Optional(NonEmptyString),
runId: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },

View File

@@ -64,6 +64,7 @@ import {
validateSessionsResolveParams,
validateSessionsSendParams,
} from "../protocol/index.js";
import { resolveSessionKeyForRun } from "../server-session-key.js";
import {
getSessionCompactionCheckpoint,
listSessionCompactionCheckpoints,
@@ -1293,7 +1294,16 @@ export const sessionsHandlers: GatewayRequestHandlers = {
return;
}
const p = params;
const key = requireSessionKey(p.key, respond);
const requestedRunId = readStringValue(p.runId);
const keyCandidate =
p.key ??
(requestedRunId ? context.chatAbortControllers.get(requestedRunId)?.sessionKey : undefined) ??
(requestedRunId ? resolveSessionKeyForRun(requestedRunId) : undefined);
if (!keyCandidate && requestedRunId) {
respond(true, { ok: true, abortedRunId: null, status: "no-active-run" });
return;
}
const key = requireSessionKey(keyCandidate, respond);
if (!key) {
return;
}
@@ -1302,14 +1312,14 @@ export const sessionsHandlers: GatewayRequestHandlers = {
context,
requestedKey: key,
canonicalKey,
runId: readStringValue(p.runId),
runId: requestedRunId,
});
let abortedRunId: string | null = null;
await chatHandlers["chat.abort"]({
req,
params: {
sessionKey: abortSessionKey,
runId: readStringValue(p.runId),
runId: requestedRunId,
},
respond: (ok, payload, error, meta) => {
if (!ok) {

View File

@@ -292,6 +292,41 @@ describe("gateway server chat", () => {
}
});
test("sessions.abort resolves active runs by runId without a caller session key", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sessions-abort-runid-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
try {
await writeSessionStore({
entries: {
"agent:main:dashboard:test-abort-runid": {
sessionId: "sess-dashboard-abort-runid",
updatedAt: Date.now(),
},
},
});
const sendRes = await rpcReq(ws, "sessions.send", {
key: "agent:main:dashboard:test-abort-runid",
message: "hello",
idempotencyKey: "idem-sessions-abort-runid-1",
timeoutMs: 30_000,
});
expect(sendRes.ok).toBe(true);
const abortRes = await rpcReq(ws, "sessions.abort", {
runId: "idem-sessions-abort-runid-1",
});
expect(abortRes.ok).toBe(true);
expect(["aborted", "no-active-run"]).toContain(abortRes.payload?.status);
if (abortRes.payload?.status === "aborted") {
expect(abortRes.payload?.abortedRunId).toBe("idem-sessions-abort-runid-1");
}
} finally {
testState.sessionStorePath = undefined;
await fs.rm(dir, { recursive: true, force: true });
}
});
test("sanitizes inbound chat.send message text and rejects null bytes", async () => {
const nullByteRes = await rpcReq(ws, "chat.send", {
sessionKey: "main",

View File

@@ -15,6 +15,7 @@ describe("e2e vitest config", () => {
expect(e2eConfig.test?.include).toEqual([
"test/**/*.e2e.test.ts",
"src/**/*.e2e.test.ts",
"packages/**/*.e2e.test.ts",
"src/gateway/gateway.test.ts",
"src/gateway/server.startup-matrix-migration.integration.test.ts",
"src/gateway/sessions-history-http.test.ts",

View File

@@ -40,6 +40,7 @@ export default defineConfig({
include: [
"test/**/*.e2e.test.ts",
"src/**/*.e2e.test.ts",
"packages/**/*.e2e.test.ts",
"src/gateway/gateway.test.ts",
"src/gateway/server.startup-matrix-migration.integration.test.ts",
"src/gateway/sessions-history-http.test.ts",

View File

@@ -22,6 +22,7 @@
"openclaw/extension-api": ["./src/extensionAPI.ts"],
"openclaw/plugin-sdk": ["./src/plugin-sdk/index.ts"],
"openclaw/plugin-sdk/*": ["./src/plugin-sdk/*.ts"],
"@openclaw/sdk": ["./packages/sdk/src/index.ts"],
"@openclaw/plugin-sdk": ["./src/plugin-sdk/index.ts"],
"@openclaw/plugin-sdk/*": ["./src/plugin-sdk/*.ts"],
"openclaw/plugin-sdk/account-id": ["./src/plugin-sdk/account-id.ts"],