feat: build sdk cookbook

This commit is contained in:
Peter Steinberger 2026-04-29 22:01:18 +01:00
parent e7f791c2ba
commit 5795df8048
33 changed files with 2622 additions and 2 deletions

34
.github/workflows/check.yml vendored Normal file
View File

@ -0,0 +1,34 @@
name: Check
on:
pull_request:
push:
branches:
- main
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.23.0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install
run: pnpm install --frozen-lockfile
- name: Check
run: pnpm check

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
node_modules/
coverage/
dist/
.turbo/
.DS_Store
*.log

1
.npmrc Normal file
View File

@ -0,0 +1 @@
auto-install-peers=false

6
.prettierrc.json Normal file
View File

@ -0,0 +1,6 @@
{
"printWidth": 100,
"semi": true,
"singleQuote": false,
"trailingComma": "all"
}

25
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,25 @@
# Contributing
Cookbook recipes should be small, runnable, and copyable.
## Recipe Rules
- Put each recipe in `recipes/<id>/`.
- Include `README.md` and `index.ts`.
- Add the recipe to `recipes/manifest.json`.
- Import the SDK as `@openclaw/sdk`, not from OpenClaw monorepo internals.
- Keep recipe code focused on one SDK concept.
- Prefer environment variables for Gateway configuration.
- Add or update tests in `test/recipes.test.ts`.
## Checks
Run the full gate before opening a PR:
```bash
pnpm check
```
Until `@openclaw/sdk` is published, tests use `test/shims/openclaw-sdk.ts`.
That shim is only a local validation aid; recipe source should still reflect the
real public SDK API.

View File

@ -1,2 +1,72 @@
# cookbook
Example apps for the OpenClaw SDK
# OpenClaw Cookbook
Runnable examples for building on the OpenClaw SDK.
This repository is the public, copyable companion to the SDK. The recipes are
small enough to paste into an app, while the examples show how to compose them
into complete developer workflows.
## Status
The SDK package is landing in `openclaw/openclaw` first. Until `@openclaw/sdk`
is published, this repo keeps a tiny test shim so CI can validate recipe shape
without depending on a live Gateway or unpublished package.
## Quick Start
```bash
pnpm install
pnpm check
```
To run a recipe against a real Gateway, install the SDK once it is published and
set the Gateway connection details:
```bash
pnpm add @openclaw/sdk
export OPENCLAW_GATEWAY=auto
export OPENCLAW_AGENT_ID=main
pnpm recipe:run-agent -- "Summarize this repository"
```
Useful environment variables:
| Name | Purpose |
| ---------------------- | ------------------------------------------------------------------- |
| `OPENCLAW_GATEWAY` | Gateway URL, or `auto` for local discovery. |
| `OPENCLAW_TOKEN` | Bearer token for protected Gateways. |
| `OPENCLAW_PASSWORD` | Password for protected Gateways. |
| `OPENCLAW_AGENT_ID` | Agent id used by recipes. Defaults to `main`. |
| `OPENCLAW_SESSION_KEY` | Session key for recipes that reuse a conversation. |
| `OPENCLAW_MODEL` | Optional model override, such as `openrouter/deepseek/deepseek-r1`. |
## Recipes
| Recipe | What it shows |
| ---------------------------------------------- | --------------------------------------------------- |
| [`run-an-agent`](recipes/run-an-agent) | Start a run and wait for a stable result envelope. |
| [`stream-events`](recipes/stream-events) | Subscribe to normalized SDK events for a run. |
| [`cancel-a-run`](recipes/cancel-a-run) | Cancel active work by run id. |
| [`reuse-session`](recipes/reuse-session) | Create or reuse a session across multiple messages. |
| [`model-status`](recipes/model-status) | Check configured model providers and auth status. |
| [`custom-transport`](recipes/custom-transport) | Test SDK code with an in-memory transport. |
## Examples
| Example | What it is |
| ------------------------------- | ----------------------------------------------------- |
| [`node-cli`](examples/node-cli) | A small command-line app that wraps the core recipes. |
## Repository Scripts
```bash
pnpm format:check
pnpm typecheck
pnpm test
pnpm docs:check
pnpm check
```
The test suite aliases `@openclaw/sdk` to `test/shims/openclaw-sdk.ts`. That
shim exists only for cookbook validation. Recipe source imports `@openclaw/sdk`
directly so copied code matches real SDK usage.

View File

@ -0,0 +1,13 @@
# Node CLI Example
A small command-line wrapper around the cookbook recipes.
```bash
pnpm example:node-cli run "Say hello"
pnpm example:node-cli stream "Explain this branch"
pnpm example:node-cli models
pnpm example:node-cli session
```
In a real app, keep the recipe functions as library code and make the CLI only
responsible for parsing arguments, output formatting, and exit codes.

View File

@ -0,0 +1,45 @@
import { cancelRunRecipe } from "../../../recipes/cancel-a-run/index.js";
import { modelStatusRecipe } from "../../../recipes/model-status/index.js";
import { reuseSessionRecipe } from "../../../recipes/reuse-session/index.js";
import { runAgentRecipe } from "../../../recipes/run-an-agent/index.js";
import { streamEventsRecipe } from "../../../recipes/stream-events/index.js";
function usage(): string {
return [
"Usage: pnpm example:node-cli <command> [prompt]",
"",
"Commands:",
" run Start a run and wait for the result",
" stream Start a run and print normalized event summaries",
" cancel Start a run and cancel it",
" session Reuse a session for two messages",
" models Print model provider/auth status",
].join("\n");
}
async function main(argv: string[]): Promise<unknown> {
const [command, ...rest] = argv;
const input = rest.join(" ") || undefined;
switch (command) {
case "run":
return await runAgentRecipe({ input });
case "stream":
return await streamEventsRecipe({ input });
case "cancel":
return await cancelRunRecipe({ input });
case "session":
return await reuseSessionRecipe({ firstInput: input });
case "models":
return await modelStatusRecipe();
default:
return usage();
}
}
try {
const result = await main(process.argv.slice(2));
console.log(typeof result === "string" ? result : JSON.stringify(result, null, 2));
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}

37
package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "@openclaw/cookbook",
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "pnpm@10.23.0",
"scripts": {
"check": "pnpm format:check && pnpm typecheck && pnpm test && pnpm docs:check",
"docs:check": "node scripts/check-docs.mjs",
"example:node-cli": "tsx examples/node-cli/src/index.ts",
"format": "prettier --write .",
"format:check": "prettier --check .",
"recipe:cancel-a-run": "tsx recipes/cancel-a-run/index.ts",
"recipe:custom-transport": "tsx recipes/custom-transport/index.ts",
"recipe:model-status": "tsx recipes/model-status/index.ts",
"recipe:reuse-session": "tsx recipes/reuse-session/index.ts",
"recipe:run-agent": "tsx recipes/run-an-agent/index.ts",
"recipe:stream-events": "tsx recipes/stream-events/index.ts",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"@openclaw/sdk": "*"
},
"peerDependenciesMeta": {
"@openclaw/sdk": {
"optional": true
}
},
"devDependencies": {
"@types/node": "^24.10.1",
"prettier": "^3.7.4",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vitest": "^4.1.5"
}
}

1412
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,2 @@
packages:
- "."

14
recipes/README.md Normal file
View File

@ -0,0 +1,14 @@
# Recipes
Recipes are intentionally small. Each one demonstrates one SDK workflow and is
safe to copy into a real app.
Current recipes are listed in [`manifest.json`](manifest.json).
## Adding a Recipe
1. Create `recipes/<id>/README.md`.
2. Create `recipes/<id>/index.ts`.
3. Add the recipe to `recipes/manifest.json`.
4. Add test coverage in `test/recipes.test.ts`.
5. Run `pnpm check`.

52
recipes/_shared/config.ts Normal file
View File

@ -0,0 +1,52 @@
import { OpenClaw, type OpenClawOptions } from "@openclaw/sdk";
export type CookbookConnectionOptions = {
gateway?: string;
token?: string;
password?: string;
};
export type RunRecipeOptions = CookbookConnectionOptions & {
agentId?: string;
input?: string;
model?: string;
sessionKey?: string;
timeoutMs?: number;
waitTimeoutMs?: number;
};
export function readConnectionOptions(options: CookbookConnectionOptions = {}): OpenClawOptions {
return {
gateway: options.gateway ?? process.env.OPENCLAW_GATEWAY ?? "auto",
token: options.token ?? process.env.OPENCLAW_TOKEN,
password: options.password ?? process.env.OPENCLAW_PASSWORD,
};
}
export function createClient(options: CookbookConnectionOptions = {}): OpenClaw {
return new OpenClaw(readConnectionOptions(options));
}
export function readAgentId(agentId?: string): string {
return agentId ?? process.env.OPENCLAW_AGENT_ID ?? "main";
}
export function readInput(input?: string): string {
return input ?? (process.argv.slice(2).join(" ") || "Say hello from the OpenClaw SDK cookbook.");
}
export function readSessionKey(sessionKey?: string): string {
return sessionKey ?? process.env.OPENCLAW_SESSION_KEY ?? "cookbook";
}
export function readTimeoutMs(value: number | undefined, fallback: number): number {
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
return Math.floor(value);
}
return fallback;
}
export function optionalModel(model?: string): { model?: string } {
const resolved = model ?? process.env.OPENCLAW_MODEL;
return resolved ? { model: resolved } : {};
}

View File

@ -0,0 +1,19 @@
import { pathToFileURL } from "node:url";
export function isDirectRun(metaUrl: string): boolean {
const entry = process.argv[1];
return entry ? metaUrl === pathToFileURL(entry).href : false;
}
export async function runMain(action: () => Promise<unknown>): Promise<void> {
try {
const result = await action();
if (result !== undefined) {
console.log(JSON.stringify(result, null, 2));
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(message);
process.exitCode = 1;
}
}

View File

@ -0,0 +1,11 @@
# Cancel a Run
Start a run, cancel it by `runId`, then wait for the Gateway result. This is the
shape to use for UI stop buttons and automation time budgets.
```bash
OPENCLAW_CANCEL_AFTER_MS=1500 pnpm recipe:cancel-a-run -- "Keep working until cancelled"
```
The SDK cancellation path does not require callers to know the session key when
the Gateway can resolve the active run by id.

View File

@ -0,0 +1,46 @@
import type { RunResult } from "@openclaw/sdk";
import {
createClient,
optionalModel,
readAgentId,
readInput,
readSessionKey,
readTimeoutMs,
type RunRecipeOptions,
} from "../_shared/config.js";
import { isDirectRun, runMain } from "../_shared/run-main.js";
export type CancelRunRecipeResult = {
runId: string;
cancelResponse: unknown;
result: RunResult;
};
export async function cancelRunRecipe(
options: RunRecipeOptions & { cancelAfterMs?: number } = {},
): Promise<CancelRunRecipeResult> {
const oc = createClient(options);
try {
const run = await oc.runs.create({
input: readInput(options.input),
agentId: readAgentId(options.agentId),
sessionKey: readSessionKey(options.sessionKey),
timeoutMs: readTimeoutMs(options.timeoutMs, 300_000),
...optionalModel(options.model),
});
const cancelAfterMs = readTimeoutMs(
options.cancelAfterMs ?? Number(process.env.OPENCLAW_CANCEL_AFTER_MS),
1_000,
);
await new Promise((resolve) => setTimeout(resolve, cancelAfterMs));
const cancelResponse = await run.cancel();
const result = await run.wait({ timeoutMs: readTimeoutMs(options.waitTimeoutMs, 30_000) });
return { runId: run.id, cancelResponse, result };
} finally {
await oc.close();
}
}
if (isDirectRun(import.meta.url)) {
await runMain(() => cancelRunRecipe());
}

View File

@ -0,0 +1,11 @@
# Custom Transport
Use an in-memory SDK transport to test app code without a real Gateway.
```bash
pnpm recipe:custom-transport -- "test prompt"
```
This pattern is useful for app tests: implement the SDK transport interface,
return canned RPC responses, and assert your app behavior around the SDK
boundary.

View File

@ -0,0 +1,55 @@
import type { GatewayEvent, GatewayRequestOptions, OpenClawTransport } from "@openclaw/sdk";
import { OpenClaw } from "@openclaw/sdk";
import { readInput } from "../_shared/config.js";
import { isDirectRun, runMain } from "../_shared/run-main.js";
type RequestCall = {
method: string;
params?: unknown;
options?: GatewayRequestOptions;
};
class CookbookTransport implements OpenClawTransport {
readonly calls: RequestCall[] = [];
async request<T = unknown>(
method: string,
params?: unknown,
options?: GatewayRequestOptions,
): Promise<T> {
this.calls.push({ method, params, options });
if (method === "agent") {
return { status: "accepted", runId: "cookbook-run" } as T;
}
if (method === "agent.wait") {
return { status: "ok", runId: "cookbook-run", endedAt: Date.now() } as T;
}
throw new Error(`unexpected method: ${method}`);
}
async *events(): AsyncIterable<GatewayEvent> {
yield {
event: "agent",
payload: {
runId: "cookbook-run",
stream: "lifecycle",
data: { phase: "end" },
},
};
}
}
export async function customTransportRecipe(input = readInput()): Promise<{
runId: string;
calls: RequestCall[];
}> {
const transport = new CookbookTransport();
const oc = new OpenClaw({ transport });
const run = await oc.runs.create({ input, idempotencyKey: "cookbook-custom-transport" });
await run.wait({ timeoutMs: 1_000 });
return { runId: run.id, calls: transport.calls };
}
if (isDirectRun(import.meta.url)) {
await runMain(() => customTransportRecipe());
}

38
recipes/manifest.json Normal file
View File

@ -0,0 +1,38 @@
[
{
"id": "run-an-agent",
"title": "Run an agent",
"entry": "recipes/run-an-agent/index.ts",
"readme": "recipes/run-an-agent/README.md"
},
{
"id": "stream-events",
"title": "Stream events",
"entry": "recipes/stream-events/index.ts",
"readme": "recipes/stream-events/README.md"
},
{
"id": "cancel-a-run",
"title": "Cancel a run",
"entry": "recipes/cancel-a-run/index.ts",
"readme": "recipes/cancel-a-run/README.md"
},
{
"id": "reuse-session",
"title": "Reuse a session",
"entry": "recipes/reuse-session/index.ts",
"readme": "recipes/reuse-session/README.md"
},
{
"id": "model-status",
"title": "Model status",
"entry": "recipes/model-status/index.ts",
"readme": "recipes/model-status/README.md"
},
{
"id": "custom-transport",
"title": "Custom transport",
"entry": "recipes/custom-transport/index.ts",
"readme": "recipes/custom-transport/README.md"
}
]

View File

@ -0,0 +1,10 @@
# Model Status
Ask the Gateway for model provider/auth status.
```bash
pnpm recipe:model-status
```
Pass `OPENCLAW_MODEL_STATUS_PROBE=1` to let the Gateway run provider probes when
that is appropriate for your environment.

View File

@ -0,0 +1,18 @@
import { createClient, type CookbookConnectionOptions } from "../_shared/config.js";
import { isDirectRun, runMain } from "../_shared/run-main.js";
export async function modelStatusRecipe(
options: CookbookConnectionOptions & { probe?: boolean } = {},
): Promise<unknown> {
const oc = createClient(options);
try {
const probe = options.probe ?? process.env.OPENCLAW_MODEL_STATUS_PROBE === "1";
return await oc.models.status({ probe });
} finally {
await oc.close();
}
}
if (isDirectRun(import.meta.url)) {
await runMain(() => modelStatusRecipe());
}

View File

@ -0,0 +1,10 @@
# Reuse a Session
Create or reuse a session key, send two messages, and wait for both run results.
```bash
OPENCLAW_SESSION_KEY=cookbook-demo pnpm recipe:reuse-session
```
Use this pattern for chat UIs, background workflows, and any app that wants a
stable thread rather than one-off runs.

View File

@ -0,0 +1,48 @@
import type { RunResult } from "@openclaw/sdk";
import {
createClient,
optionalModel,
readAgentId,
readSessionKey,
readTimeoutMs,
type RunRecipeOptions,
} from "../_shared/config.js";
import { isDirectRun, runMain } from "../_shared/run-main.js";
export type ReuseSessionRecipeResult = {
sessionKey: string;
results: RunResult[];
};
export async function reuseSessionRecipe(
options: RunRecipeOptions & { firstInput?: string; secondInput?: string } = {},
): Promise<ReuseSessionRecipeResult> {
const oc = createClient(options);
try {
const sessionKey = readSessionKey(options.sessionKey);
const session = await oc.sessions.create({
key: sessionKey,
agentId: readAgentId(options.agentId),
...optionalModel(options.model),
});
const first = await session.send({
message: options.firstInput ?? "Remember that the cookbook session is working.",
timeoutMs: readTimeoutMs(options.timeoutMs, 60_000),
});
const second = await session.send({
message: options.secondInput ?? "What did I ask you to remember?",
timeoutMs: readTimeoutMs(options.timeoutMs, 60_000),
});
const waitOptions = { timeoutMs: readTimeoutMs(options.waitTimeoutMs, 120_000) };
return {
sessionKey,
results: [await first.wait(waitOptions), await second.wait(waitOptions)],
};
} finally {
await oc.close();
}
}
if (isDirectRun(import.meta.url)) {
await runMain(() => reuseSessionRecipe());
}

View File

@ -0,0 +1,11 @@
# Run an Agent
Start a run, wait for the Gateway to return a terminal result, and print the
stable SDK result envelope.
```bash
OPENCLAW_AGENT_ID=main pnpm recipe:run-agent -- "Summarize this repository"
```
Use this when you want the simplest request/response shape. For live progress,
use [`stream-events`](../stream-events).

View File

@ -0,0 +1,31 @@
import type { RunResult } from "@openclaw/sdk";
import {
createClient,
optionalModel,
readAgentId,
readInput,
readSessionKey,
readTimeoutMs,
type RunRecipeOptions,
} from "../_shared/config.js";
import { isDirectRun, runMain } from "../_shared/run-main.js";
export async function runAgentRecipe(options: RunRecipeOptions = {}): Promise<RunResult> {
const oc = createClient(options);
try {
const agent = await oc.agents.get(readAgentId(options.agentId));
const run = await agent.run({
input: readInput(options.input),
sessionKey: readSessionKey(options.sessionKey),
timeoutMs: readTimeoutMs(options.timeoutMs, 60_000),
...optionalModel(options.model),
});
return await run.wait({ timeoutMs: readTimeoutMs(options.waitTimeoutMs, 120_000) });
} finally {
await oc.close();
}
}
if (isDirectRun(import.meta.url)) {
await runMain(() => runAgentRecipe());
}

View File

@ -0,0 +1,12 @@
# Stream Events
Start a run and iterate normalized SDK events until the run reaches a terminal
state.
```bash
pnpm recipe:stream-events -- "Refactor the current branch and explain the diff"
```
The SDK keeps provider-native payloads in `event.raw`, while `event.type` gives
apps a stable UI contract such as `run.started`, `assistant.delta`, or
`run.completed`.

View File

@ -0,0 +1,48 @@
import type { OpenClawEvent, OpenClawEventType } from "@openclaw/sdk";
import {
createClient,
optionalModel,
readAgentId,
readInput,
readSessionKey,
readTimeoutMs,
type RunRecipeOptions,
} from "../_shared/config.js";
import { isDirectRun, runMain } from "../_shared/run-main.js";
const terminalEvents = new Set<OpenClawEventType>([
"run.completed",
"run.failed",
"run.cancelled",
"run.timed_out",
]);
export async function streamEventsRecipe(
options: RunRecipeOptions = {},
): Promise<Array<Pick<OpenClawEvent, "type" | "runId">>> {
const oc = createClient(options);
try {
const run = await oc.runs.create({
input: readInput(options.input),
agentId: readAgentId(options.agentId),
sessionKey: readSessionKey(options.sessionKey),
timeoutMs: readTimeoutMs(options.timeoutMs, 60_000),
...optionalModel(options.model),
});
const seen: Array<Pick<OpenClawEvent, "type" | "runId">> = [];
for await (const event of run.events()) {
seen.push({ type: event.type, runId: event.runId });
if (terminalEvents.has(event.type)) {
break;
}
}
return seen;
} finally {
await oc.close();
}
}
if (isDirectRun(import.meta.url)) {
await runMain(() => streamEventsRecipe());
}

39
scripts/check-docs.mjs Normal file
View File

@ -0,0 +1,39 @@
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const manifestPath = path.join(root, "recipes", "manifest.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const failures = [];
for (const recipe of manifest) {
for (const key of ["entry", "readme"]) {
const value = recipe[key];
if (typeof value !== "string" || !fs.existsSync(path.join(root, value))) {
failures.push(`${recipe.id}: missing ${key} ${value}`);
}
}
const readme = fs.readFileSync(path.join(root, recipe.readme), "utf8");
if (!readme.includes("```bash")) {
failures.push(`${recipe.id}: README should include a bash command`);
}
}
const readme = fs.readFileSync(path.join(root, "README.md"), "utf8");
for (const recipe of manifest) {
const link = `recipes/${recipe.id}`;
if (!readme.includes(link)) {
failures.push(`README missing link to ${link}`);
}
}
if (!fs.existsSync(path.join(root, "examples", "node-cli", "src", "index.ts"))) {
failures.push("missing node-cli example entry");
}
if (failures.length > 0) {
console.error(failures.join("\n"));
process.exitCode = 1;
} else {
console.log(`docs ok: ${manifest.length} recipes`);
}

53
test/recipes.test.ts Normal file
View File

@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { cancelRunRecipe } from "../recipes/cancel-a-run/index.js";
import { customTransportRecipe } from "../recipes/custom-transport/index.js";
import { modelStatusRecipe } from "../recipes/model-status/index.js";
import { reuseSessionRecipe } from "../recipes/reuse-session/index.js";
import { runAgentRecipe } from "../recipes/run-an-agent/index.js";
import { streamEventsRecipe } from "../recipes/stream-events/index.js";
describe("cookbook recipes", () => {
it("runs an agent and returns a completed result", async () => {
await expect(runAgentRecipe({ input: "hello" })).resolves.toMatchObject({
runId: "cookbook-run",
status: "completed",
});
});
it("streams normalized events through a terminal event", async () => {
await expect(streamEventsRecipe({ input: "stream" })).resolves.toEqual([
{ type: "run.started", runId: "cookbook-run" },
{ type: "assistant.delta", runId: "cookbook-run" },
{ type: "run.completed", runId: "cookbook-run" },
]);
});
it("cancels a run", async () => {
await expect(cancelRunRecipe({ input: "cancel", cancelAfterMs: 0 })).resolves.toMatchObject({
runId: "cookbook-run",
cancelResponse: { ok: true, status: "aborted" },
result: { status: "completed" },
});
});
it("reuses a session for multiple messages", async () => {
const result = await reuseSessionRecipe({ sessionKey: "recipe-test" });
expect(result.sessionKey).toBe("recipe-test");
expect(result.results).toHaveLength(2);
expect(result.results.every((entry) => entry.status === "completed")).toBe(true);
});
it("reads model status", async () => {
await expect(modelStatusRecipe()).resolves.toMatchObject({
providers: [{ id: "openai", authenticated: true }],
});
});
it("runs against a custom transport", async () => {
const result = await customTransportRecipe("transport");
expect(result.runId).toBe("cookbook-run");
expect(result.calls.map((call) => call.method)).toEqual(["agent", "agent.wait"]);
});
});

244
test/shims/openclaw-sdk.ts Normal file
View File

@ -0,0 +1,244 @@
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 OpenClawOptions = {
gateway?: "auto" | (string & {});
url?: string;
token?: string;
password?: string;
requestTimeoutMs?: number;
transport?: OpenClawTransport;
};
export type RunStatus = "accepted" | "completed" | "failed" | "cancelled" | "timed_out";
export type RunResult = {
runId: string;
status: RunStatus;
sessionKey?: string;
startedAt?: string | number;
endedAt?: string | number;
raw?: unknown;
};
export type OpenClawEventType =
| "run.started"
| "run.completed"
| "run.failed"
| "run.cancelled"
| "run.timed_out"
| "assistant.delta"
| "raw";
export type OpenClawEvent<TData = unknown> = {
version: 1;
id: string;
ts: number;
type: OpenClawEventType;
runId?: string;
data: TData;
raw?: GatewayEvent;
};
export type AgentRunParams = {
input: string;
agentId?: string;
model?: string;
sessionKey?: string;
timeoutMs?: number;
idempotencyKey?: string;
};
export type SessionCreateParams = {
key?: string;
agentId?: string;
model?: string;
};
export type SessionSendParams = {
key?: string;
message: string;
timeoutMs?: number;
};
function normalizeEvent(event: GatewayEvent): OpenClawEvent {
const payload =
typeof event.payload === "object" && event.payload !== null
? (event.payload as Record<string, unknown>)
: {};
const data =
typeof payload.data === "object" && payload.data !== null
? (payload.data as Record<string, unknown>)
: {};
const phase = typeof data.phase === "string" ? data.phase : undefined;
const stream = typeof payload.stream === "string" ? payload.stream : undefined;
const runId = typeof payload.runId === "string" ? payload.runId : undefined;
const type =
stream === "assistant"
? "assistant.delta"
: phase === "start"
? "run.started"
: phase === "end"
? "run.completed"
: "raw";
return {
version: 1,
id: `${event.seq ?? "test"}:${event.event}`,
ts: Date.now(),
type,
runId,
data,
raw: event,
};
}
class ShimRun {
constructor(
private readonly client: OpenClaw,
readonly id: string,
private readonly sessionKey?: string,
) {}
async *events(): AsyncIterable<OpenClawEvent> {
if (this.client.transport) {
for await (const event of this.client.transport.events()) {
yield normalizeEvent(event);
}
return;
}
yield {
version: 1,
id: "start",
ts: Date.now(),
type: "run.started",
runId: this.id,
data: {},
};
yield {
version: 1,
id: "message",
ts: Date.now(),
type: "assistant.delta",
runId: this.id,
data: { delta: "hello" },
};
yield {
version: 1,
id: "end",
ts: Date.now(),
type: "run.completed",
runId: this.id,
data: {},
};
}
async wait(): Promise<RunResult> {
if (this.client.transport) {
const raw = await this.client.transport.request<Record<string, unknown>>(
"agent.wait",
{ runId: this.id },
{ timeoutMs: null },
);
return {
runId: this.id,
status: raw.status === "ok" ? "completed" : "failed",
endedAt: typeof raw.endedAt === "number" ? raw.endedAt : undefined,
raw,
};
}
return { runId: this.id, status: "completed", sessionKey: this.sessionKey, endedAt: 456 };
}
async cancel(): Promise<unknown> {
return { ok: true, status: "aborted", abortedRunId: this.id };
}
}
class ShimAgent {
constructor(
private readonly client: OpenClaw,
readonly id: string,
) {}
async run(input: string | Omit<AgentRunParams, "agentId">): Promise<ShimRun> {
const params =
typeof input === "string" ? { input, agentId: this.id } : { ...input, agentId: this.id };
return await this.client.runs.create(params);
}
}
class ShimSession {
constructor(
private readonly client: OpenClaw,
readonly key: string,
) {}
async send(input: string | Omit<SessionSendParams, "key">): Promise<ShimRun> {
const message = typeof input === "string" ? input : input.message;
return await this.client.runs.create({ input: message, sessionKey: this.key });
}
async abort(runId?: string): Promise<unknown> {
return { ok: true, status: runId ? "aborted" : "no-active-run" };
}
}
export class OpenClaw {
readonly transport?: OpenClawTransport;
constructor(options: OpenClawOptions = {}) {
this.transport = options.transport;
}
readonly agents = {
get: async (id: string) => new ShimAgent(this, id),
};
readonly runs = {
create: async (params: AgentRunParams) => {
if (this.transport) {
const raw = await this.transport.request<Record<string, unknown>>("agent", params, {
expectFinal: false,
});
const runId = typeof raw.runId === "string" ? raw.runId : "transport-run";
return new ShimRun(this, runId, params.sessionKey);
}
return new ShimRun(this, "cookbook-run", params.sessionKey);
},
wait: async (runId: string) => new ShimRun(this, runId).wait(),
cancel: async (runId: string) => ({ ok: true, abortedRunId: runId, status: "aborted" }),
};
readonly sessions = {
create: async (params: SessionCreateParams = {}) =>
new ShimSession(this, params.key ?? "cookbook"),
};
readonly models = {
status: async () => ({ providers: [{ id: "openai", authenticated: true }] }),
};
async close(): Promise<void> {
await this.transport?.close?.();
}
}
export { ShimAgent as Agent, ShimRun as Run, ShimSession as Session };

26
tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"declaration": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "ES2022",
"types": ["node", "vitest/globals"]
},
"include": [
"examples/**/*.ts",
"recipes/**/*.ts",
"scripts/**/*.mjs",
"test/**/*.ts",
"types/**/*.d.ts",
"vitest.config.ts"
]
}

159
types/openclaw-sdk.d.ts vendored Normal file
View File

@ -0,0 +1,159 @@
declare module "@openclaw/sdk" {
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 OpenClawOptions = {
gateway?: "auto" | (string & {});
url?: string;
token?: string;
password?: string;
requestTimeoutMs?: number;
transport?: OpenClawTransport;
};
export type RunStatus = "accepted" | "completed" | "failed" | "cancelled" | "timed_out";
export type RunTimestamp = string | number;
export type RunResult = {
runId: string;
status: RunStatus;
sessionId?: string;
sessionKey?: string;
taskId?: string;
startedAt?: RunTimestamp;
endedAt?: RunTimestamp;
error?: { code?: string; message: string; details?: unknown };
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;
sessionId?: string;
sessionKey?: string;
deliver?: boolean;
timeoutMs?: number;
label?: string;
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 class Run {
readonly id: string;
events(filter?: (event: OpenClawEvent) => boolean): AsyncIterable<OpenClawEvent>;
wait(options?: { timeoutMs?: number }): Promise<RunResult>;
cancel(): Promise<unknown>;
}
export class Agent {
readonly id: string;
run(input: string | Omit<AgentRunParams, "agentId">): Promise<Run>;
}
export class Session {
readonly key: string;
send(input: string | Omit<SessionSendParams, "key">): Promise<Run>;
abort(runId?: string): Promise<unknown>;
}
export class OpenClaw {
readonly agents: {
get(id: string): Promise<Agent>;
};
readonly runs: {
create(params: AgentRunParams): Promise<Run>;
wait(runId: string, options?: { timeoutMs?: number }): Promise<RunResult>;
cancel(runId: string, sessionKey?: string): Promise<unknown>;
};
readonly sessions: {
create(params?: SessionCreateParams): Promise<Session>;
};
readonly models: {
status(params?: unknown): Promise<unknown>;
};
constructor(options?: OpenClawOptions);
close(): Promise<void>;
}
}

13
vitest.config.ts Normal file
View File

@ -0,0 +1,13 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
"@openclaw/sdk": fileURLToPath(new URL("./test/shims/openclaw-sdk.ts", import.meta.url)),
},
},
test: {
include: ["test/**/*.test.ts"],
},
});