Compare commits

...

7 Commits

Author SHA1 Message Date
Peter Steinberger
66a698207f
chore: update TypeScript tooling
Some checks failed
Check / Cookbook and examples (push) Has been cancelled
2026-05-04 01:54:17 +01:00
Vincent Koc
700e92ca1d
fix(security): avoid raw CLI result logging
Some checks failed
Check / Cookbook and examples (push) Has been cancelled
2026-04-30 03:08:18 -07:00
Vincent Koc
ed63cf9d48
fix(security): redact cookbook console output 2026-04-30 03:03:50 -07:00
Peter Steinberger
184676e2cc docs: improve cookbook README 2026-04-30 01:03:02 +01:00
Peter Steinberger
8acfbd5877 style: polish cookbook web examples 2026-04-30 01:03:02 +01:00
Peter Steinberger
233792e097 feat: add standalone sdk examples 2026-04-30 01:03:02 +01:00
Peter Steinberger
5795df8048 feat: build sdk cookbook 2026-04-30 01:03:02 +01:00
59 changed files with 5209 additions and 2 deletions

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

@ -0,0 +1,39 @@
name: Check
on:
workflow_dispatch:
pull_request:
push:
branches:
- main
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
check:
name: Cookbook and examples
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"
}

29
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,29 @@
# 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.
Standalone examples live under `sdk/<name>`. Each example should have its own
`package.json`, `README.md`, `tsconfig.json`, and `check` script. Add new
examples to the root README and `scripts/check-docs.mjs`.

179
README.md
View File

@ -1,2 +1,177 @@
# cookbook
Example apps for the OpenClaw SDK
# OpenClaw Cookbook
Runnable examples for building apps, tools, and automations on the OpenClaw SDK.
This repository is the copyable companion to the public SDK. It is organized as
two layers:
- `recipes/`: small, one-concept TypeScript examples that show a single SDK
workflow.
- `sdk/`: standalone starter apps that can be copied out and turned into real
products.
Use the cookbook when you want to see the SDK in context: starting runs,
streaming events, cancelling work, reusing sessions, checking model status, and
testing app code without a live Gateway.
## Status
The SDK package is landing in `openclaw/openclaw` first. Until `@openclaw/sdk`
is published, this repo uses a private workspace shim named `@openclaw/sdk` so
CI can typecheck, test, and build every example without reaching into OpenClaw
monorepo internals.
Recipe and example source imports `@openclaw/sdk` directly. That keeps copied
code aligned with the real package shape once the SDK is published.
## Quick Start
Install dependencies and run the full cookbook gate:
```bash
pnpm install
pnpm check
```
Run the smallest local recipe:
```bash
pnpm recipe:custom-transport -- "test prompt"
```
That recipe uses an in-memory transport, so it does not require a running
Gateway.
## Connect To A Gateway
Recipes that talk to OpenClaw need a Gateway URL or local discovery. Once the
published SDK is available in your app, install it and point the example at your
Gateway:
```bash
pnpm add @openclaw/sdk
export OPENCLAW_GATEWAY=auto
export OPENCLAW_AGENT_ID=main
pnpm recipe:run-agent -- "Summarize this repository"
```
Use explicit credentials for protected Gateways:
```bash
export OPENCLAW_GATEWAY=ws://127.0.0.1:1455
export OPENCLAW_TOKEN=...
```
### 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`. |
| `OPENCLAW_CANCEL_AFTER_MS` | Delay before the cancellation recipe calls `run.cancel()`. |
| `OPENCLAW_MODEL_STATUS_PROBE` | Set to `1` to let the model-status recipe request provider probes. |
## Choose An Example
Start with the smallest thing that matches your product shape:
| Goal | Start Here | Why |
| ------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------- |
| Send one prompt and wait for a result | [`recipes/run-an-agent`](recipes/run-an-agent) | Minimal request/response flow. |
| Show live progress in a UI | [`recipes/stream-events`](recipes/stream-events) | Normalized event iteration with stable event types. |
| Add a stop button or time budget | [`recipes/cancel-a-run`](recipes/cancel-a-run) | First-class cancellation by run id. |
| Build a chat thread | [`recipes/reuse-session`](recipes/reuse-session) | Stable session keys across turns. |
| Show provider/auth readiness | [`recipes/model-status`](recipes/model-status) | Gateway model status and optional probes. |
| Test without a Gateway | [`recipes/custom-transport`](recipes/custom-transport) | In-memory transport boundary for app tests. |
| Build a terminal app | [`sdk/coding-agent-cli`](sdk/coding-agent-cli) | One-shot prompts plus interactive slash commands. |
| Build a web control surface | [`sdk/agent-workbench`](sdk/agent-workbench) | Prompt, model, session, event, cancel, and result UI. |
| Build an operations dashboard | [`sdk/run-board`](sdk/run-board) | Run grouping by status, model, session, and activity. |
## Recipes
| Recipe | Command | What It Shows |
| ---------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------ |
| [`run-an-agent`](recipes/run-an-agent) | `pnpm recipe:run-agent -- "Summarize this repository"` | Start a run and wait for a stable SDK result envelope. |
| [`stream-events`](recipes/stream-events) | `pnpm recipe:stream-events -- "Explain this branch"` | Subscribe to normalized SDK events for a run. |
| [`cancel-a-run`](recipes/cancel-a-run) | `OPENCLAW_CANCEL_AFTER_MS=1500 pnpm recipe:cancel-a-run -- "Keep working"` | Cancel active work by run id. |
| [`reuse-session`](recipes/reuse-session) | `OPENCLAW_SESSION_KEY=cookbook-demo pnpm recipe:reuse-session` | Create or reuse a session across multiple messages. |
| [`model-status`](recipes/model-status) | `pnpm recipe:model-status` | Check configured model providers and auth status. |
| [`custom-transport`](recipes/custom-transport) | `pnpm recipe:custom-transport -- "test prompt"` | Test SDK code with an in-memory transport. |
The recipe manifest lives at [`recipes/manifest.json`](recipes/manifest.json).
## SDK Examples
| Example | Type | What It Demonstrates |
| ------------------------------------------ | --------- | ------------------------------------------------------------------- |
| [`quickstart`](sdk/quickstart) | Node app | The smallest complete run, stream, wait flow. |
| [`coding-agent-cli`](sdk/coding-agent-cli) | CLI app | One-shot and interactive terminal agent workflows. |
| [`agent-workbench`](sdk/agent-workbench) | React app | A compact control room for runs, events, cancellation, and results. |
| [`run-board`](sdk/run-board) | React app | Dashboard-style operator view grouped by run status. |
Each SDK example has its own `package.json`, `README.md`, `tsconfig.json`, and
`check` script. You can copy one directory into another repository and replace
the workspace SDK dependency with the published `@openclaw/sdk` version.
## Recipe Wrapper Example
[`examples/node-cli`](examples/node-cli) is a small command-line wrapper around
the core 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 recipe logic in small library functions and keep the CLI
responsible for argument parsing, output formatting, and exit codes.
## Development
The root gate matches CI:
```bash
pnpm check
```
Individual checks:
```bash
pnpm format:check
pnpm typecheck
pnpm test
pnpm docs:check
pnpm examples:check
```
Useful targeted commands:
```bash
pnpm recipe:run-agent -- "Summarize this repository"
pnpm --filter @openclaw/cookbook-quickstart check
pnpm --filter @openclaw/cookbook-agent-workbench dev
```
## Adding Examples
Recipes should be small, runnable, and copyable:
1. Add `recipes/<id>/README.md`.
2. Add `recipes/<id>/index.ts`.
3. Register it in [`recipes/manifest.json`](recipes/manifest.json).
4. Add or update coverage in [`test/recipes.test.ts`](test/recipes.test.ts).
5. Run `pnpm check`.
Standalone SDK examples live under `sdk/<name>`. Include a local README,
`package.json`, `tsconfig.json`, and a `check` script, then add the example to
this README and [`scripts/check-docs.mjs`](scripts/check-docs.mjs).
## License
MIT. See [`LICENSE`](LICENSE).

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,50 @@
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 { redactSensitiveOutput } from "../../../recipes/_shared/run-main.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));
if (typeof result === "string") {
process.stdout.write(`${result}\n`);
} else {
console.log(JSON.stringify(redactSensitiveOutput(result), null, 2));
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}

42
package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "@openclaw/cookbook",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"check": "pnpm format:check && pnpm typecheck && pnpm lint && pnpm test && pnpm docs:check && pnpm examples:check",
"docs:check": "node scripts/check-docs.mjs",
"example:node-cli": "tsx examples/node-cli/src/index.ts",
"examples:check": "pnpm --filter @openclaw/cookbook-quickstart check && pnpm --filter @openclaw/cookbook-coding-agent-cli check && pnpm --filter @openclaw/cookbook-agent-workbench check && pnpm --filter @openclaw/cookbook-run-board check",
"format": "oxfmt --write .",
"format:check": "oxfmt --check .",
"lint": "oxlint --deny-warnings recipes examples sdk packages scripts test types",
"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": "tsgo --noEmit"
},
"dependencies": {
"@openclaw/sdk": "workspace:*"
},
"devDependencies": {
"@types/node": "^25.6.0",
"@typescript/native-preview": "7.0.0-dev.20260503.1",
"@vitejs/plugin-react": "^6.0.1",
"lucide-react": "^1.14.0",
"oxfmt": "^0.47.0",
"oxlint": "^1.62.0",
"oxlint-tsgolint": "^0.22.1",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"tsx": "^4.21.0",
"typescript": "^6.0.3",
"vite": "^8.0.10",
"vitest": "^4.1.5"
},
"packageManager": "pnpm@10.23.0"
}

View File

@ -0,0 +1,12 @@
{
"name": "@openclaw/sdk",
"version": "0.0.0-cookbook-shim",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
}
}

View File

@ -0,0 +1,257 @@
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;
sessionKey?: string;
startedAt?: RunTimestamp;
endedAt?: RunTimestamp;
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 from OpenClaw" },
};
yield {
version: 1,
id: "end",
ts: Date.now(),
type: "run.completed",
runId: this.id,
data: {},
};
}
async wait(_options?: { timeoutMs?: number }): 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 : Date.now(),
raw,
};
}
return {
runId: this.id,
status: "completed",
sessionKey: this.sessionKey,
endedAt: Date.now(),
};
}
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, `run_${Math.random().toString(36).slice(2, 8)}`, 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 (_params?: unknown) => ({
providers: [
{ id: "openai", authenticated: true, defaultModel: "gpt-5.4" },
{ id: "anthropic", authenticated: true, defaultModel: "sonnet-4.6" },
{ id: "openrouter", authenticated: false },
],
}),
};
async close(): Promise<void> {
await this.transport?.close?.();
}
}
export { ShimAgent as Agent, ShimRun as Run, ShimSession as Session };

1824
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

4
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,4 @@
packages:
- "."
- "packages/*"
- "sdk/*"

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,49 @@
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(redactSensitiveOutput(result), null, 2));
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(message);
process.exitCode = 1;
}
}
export function redactSensitiveOutput(value: unknown, seen = new WeakSet<object>()): unknown {
if (Array.isArray(value)) {
return value.map((entry) => redactSensitiveOutput(entry, seen));
}
if (value && typeof value === "object") {
if (seen.has(value)) {
return "[Circular]";
}
seen.add(value);
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [
key,
isSensitiveKey(key) ? "[REDACTED]" : redactSensitiveOutput(entry, seen),
]),
);
}
return value;
}
function isSensitiveKey(key: string): boolean {
const normalized = key.toLowerCase();
return (
normalized.includes("token") ||
normalized.includes("password") ||
normalized.includes("secret") ||
normalized.includes("authorization") ||
normalized.endsWith("key")
);
}

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());
}

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

@ -0,0 +1,56 @@
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");
}
const sdkExamples = ["quickstart", "coding-agent-cli", "agent-workbench", "run-board"];
for (const id of sdkExamples) {
const base = path.join(root, "sdk", id);
for (const file of ["README.md", "package.json", "tsconfig.json"]) {
if (!fs.existsSync(path.join(base, file))) {
failures.push(`sdk/${id}: missing ${file}`);
}
}
const packageJson = JSON.parse(fs.readFileSync(path.join(base, "package.json"), "utf8"));
if (!packageJson.scripts?.check) {
failures.push(`sdk/${id}: missing check script`);
}
if (!readme.includes(`sdk/${id}`)) {
failures.push(`README missing link to sdk/${id}`);
}
}
if (failures.length > 0) {
console.error(failures.join("\n"));
process.exitCode = 1;
} else {
console.log(`docs ok: ${manifest.length} recipes`);
}

13
sdk/README.md Normal file
View File

@ -0,0 +1,13 @@
# SDK Examples
Standalone examples follow the same ladder as the public SDK adoption path:
1. `quickstart` for the smallest complete run.
2. `coding-agent-cli` for a practical terminal workflow.
3. `agent-workbench` for a product-style chat/run surface.
4. `run-board` for an operator dashboard.
Each example has its own `package.json` and can be copied out of the cookbook.
During cookbook CI, examples depend on the local `@openclaw/sdk` shim workspace
package. After the SDK is published, replace `workspace:*` with the published
version range.

View File

@ -0,0 +1,23 @@
# Agent Workbench
A local web app for driving an OpenClaw agent run from a compact control room.
It demonstrates:
- Gateway connection settings,
- prompt and model controls,
- normalized event streaming,
- cancellation,
- final result display,
- session reuse.
## Getting Started
```bash
pnpm install
pnpm dev
```
Open the Vite URL and run the demo. The cookbook shim makes the app work during
local CI; once `@openclaw/sdk` is published, the same UI can point at a real
Gateway.

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenClaw Agent Workbench</title>
<link rel="icon" href="data:," />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,28 @@
{
"name": "@openclaw/cookbook-agent-workbench",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsgo -p tsconfig.json && vite build",
"check": "pnpm typecheck && pnpm build",
"dev": "vite",
"preview": "vite preview",
"typecheck": "tsgo -p tsconfig.json --noEmit"
},
"dependencies": {
"@openclaw/sdk": "workspace:*",
"@vitejs/plugin-react": "^6.0.1",
"lucide-react": "^1.14.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"vite": "^8.0.10"
},
"devDependencies": {
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"typescript": "^6.0.3"
},
"packageManager": "pnpm@10.23.0"
}

View File

@ -0,0 +1,290 @@
import { useMemo, useState } from "react";
import {
Activity,
Bot,
Braces,
CircleStop,
Gauge,
Play,
Radio,
RotateCcw,
Signal,
Terminal,
Workflow,
} from "lucide-react";
import { OpenClaw, type OpenClawEvent, type RunResult } from "@openclaw/sdk";
type EventRow = Pick<OpenClawEvent, "type" | "runId" | "ts"> & {
text?: string;
};
type WorkbenchState = {
gateway: string;
agentId: string;
sessionKey: string;
model: string;
prompt: string;
};
const defaults: WorkbenchState = {
gateway: "auto",
agentId: "main",
sessionKey: "workbench",
model: "",
prompt: "Inspect this repository and suggest the next useful SDK example.",
};
function terminal(type: string): boolean {
return (
type === "run.completed" ||
type === "run.failed" ||
type === "run.cancelled" ||
type === "run.timed_out"
);
}
function eventTone(type: string): "good" | "bad" | "warn" | "live" {
if (type === "run.completed") return "good";
if (type === "run.failed" || type === "run.cancelled") return "bad";
if (type === "run.timed_out") return "warn";
return "live";
}
function formatClock(timestamp: string | number | undefined): string {
if (!timestamp) return "--:--:--";
const date = typeof timestamp === "number" ? new Date(timestamp) : new Date(timestamp);
if (Number.isNaN(date.getTime())) return "--:--:--";
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
export function App() {
const [settings, setSettings] = useState(defaults);
const [events, setEvents] = useState<EventRow[]>([]);
const [result, setResult] = useState<RunResult | null>(null);
const [running, setRunning] = useState(false);
const [cancel, setCancel] = useState<(() => Promise<unknown>) | null>(null);
const assistantText = useMemo(
() =>
events
.map((event) => event.text)
.filter(Boolean)
.join(""),
[events],
);
const latestEvent = events.length ? events[events.length - 1] : undefined;
const runId = result?.runId ?? latestEvent?.runId ?? "standby";
const runState = result?.status ?? (running ? "streaming" : "idle");
async function startRun() {
setRunning(true);
setEvents([]);
setResult(null);
const oc = new OpenClaw({ gateway: settings.gateway || "auto" });
try {
const run = await oc.runs.create({
input: settings.prompt,
agentId: settings.agentId,
sessionKey: settings.sessionKey,
timeoutMs: 300_000,
...(settings.model ? { model: settings.model } : {}),
});
setCancel(() => () => run.cancel());
for await (const event of run.events()) {
const delta = (event.data as { delta?: unknown }).delta;
setEvents((current) => [
...current,
{
type: event.type,
runId: event.runId,
ts: event.ts,
text: typeof delta === "string" ? delta : undefined,
},
]);
if (terminal(event.type)) {
break;
}
}
setResult(await run.wait({ timeoutMs: 120_000 }));
} finally {
setCancel(null);
setRunning(false);
await oc.close();
}
}
return (
<main className="workbench-shell">
<section className="hero">
<div>
<p className="eyebrow">OpenClaw SDK</p>
<h1>Agent Workbench</h1>
<p className="lede">
A compact control room for running agents, watching event streams, and cancelling work.
</p>
</div>
<div className="signal-stack" aria-label="Run state">
<span className={`status-pill ${running ? "live" : "idle"}`}>
<Radio size={16} />
{running ? "streaming" : "ready"}
</span>
<span>{runId}</span>
</div>
</section>
<section className="telemetry">
<div>
<Signal size={18} />
<span>State</span>
<strong>{runState}</strong>
</div>
<div>
<Activity size={18} />
<span>Events</span>
<strong>{events.length}</strong>
</div>
<div>
<Gauge size={18} />
<span>Session</span>
<strong>{settings.sessionKey || "default"}</strong>
</div>
<div>
<Workflow size={18} />
<span>Model</span>
<strong>{settings.model || "agent default"}</strong>
</div>
</section>
<section className="grid">
<form className="panel controls" onSubmit={(event) => (event.preventDefault(), startRun())}>
<div className="panel-title">
<Braces size={18} />
Run controls
</div>
<label>
Gateway
<input
id="gateway"
name="gateway"
value={settings.gateway}
onChange={(event) => setSettings({ ...settings, gateway: event.target.value })}
/>
</label>
<div className="split">
<label>
Agent
<input
id="agentId"
name="agentId"
value={settings.agentId}
onChange={(event) => setSettings({ ...settings, agentId: event.target.value })}
/>
</label>
<label>
Session
<input
id="sessionKey"
name="sessionKey"
value={settings.sessionKey}
onChange={(event) => setSettings({ ...settings, sessionKey: event.target.value })}
/>
</label>
</div>
<label>
Model override
<input
id="model"
name="model"
placeholder="optional"
value={settings.model}
onChange={(event) => setSettings({ ...settings, model: event.target.value })}
/>
</label>
<label>
Prompt
<textarea
id="prompt"
name="prompt"
value={settings.prompt}
onChange={(event) => setSettings({ ...settings, prompt: event.target.value })}
/>
</label>
<div className="toolbar">
<button className="primary" type="submit" disabled={running}>
<Play size={16} />
Run
</button>
<button
className="ghost"
type="button"
disabled={!running || !cancel}
onClick={() => cancel?.()}
>
<CircleStop size={16} />
Cancel
</button>
<button className="icon" type="button" onClick={() => (setEvents([]), setResult(null))}>
<RotateCcw size={16} />
<span className="sr-only">Reset</span>
</button>
</div>
</form>
<section className="panel transcript">
<div className="panel-title split-title">
<span>
<Bot size={18} />
Assistant stream
</span>
<small>{assistantText.length.toLocaleString()} chars</small>
</div>
<div className="assistant-text">
<span className="line-gutter">01</span>
<span>{assistantText || "Run an agent to see streamed text."}</span>
</div>
</section>
<section className="panel event-log">
<div className="panel-title split-title">
<span>
<Activity size={18} />
Event timeline
</span>
<small>{formatClock(latestEvent?.ts)}</small>
</div>
<div className="timeline">
{events.length === 0 ? (
<div className="empty-state">No events captured.</div>
) : (
events.map((event, index) => (
<div
className={`event-row ${eventTone(event.type)}`}
key={`${event.type}-${index}`}
>
<span className="event-dot" />
<div>
<strong>{event.type}</strong>
<small>{event.runId ?? "no run id"}</small>
</div>
<time>{formatClock(event.ts)}</time>
</div>
))
)}
</div>
</section>
<section className="panel result">
<div className="panel-title split-title">
<span>
<Terminal size={18} />
Result
</span>
<small className={`result-badge ${result?.status ?? "idle"}`}>
{result?.status ?? "pending"}
</small>
</div>
<pre>{result ? JSON.stringify(result, null, 2) : "No result yet."}</pre>
</section>
</section>
</main>
);
}

View File

@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App.js";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@ -0,0 +1,456 @@
:root {
color: #18231f;
background: #f6efe2;
font-family: "Avenir Next", "Segoe UI", ui-sans-serif, system-ui, sans-serif;
font-synthesis: none;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background:
linear-gradient(90deg, rgba(24, 35, 31, 0.055) 1px, transparent 1px),
linear-gradient(rgba(24, 35, 31, 0.045) 1px, transparent 1px),
radial-gradient(circle at 78% 10%, rgba(238, 95, 56, 0.18), transparent 25%),
linear-gradient(135deg, #f9f2e7 0%, #efe6d2 58%, #d9e7d6 100%);
background-size:
34px 34px,
34px 34px,
auto,
auto;
}
body::before {
position: fixed;
inset: 0;
pointer-events: none;
content: "";
background-image:
linear-gradient(rgba(24, 35, 31, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(24, 35, 31, 0.035) 1px, transparent 1px);
background-size: 8px 8px;
mask-image: linear-gradient(to bottom, #000, transparent 75%);
}
button,
input,
textarea {
font: inherit;
}
button {
transition:
transform 140ms ease,
box-shadow 140ms ease,
background 140ms ease;
}
button:not(:disabled):hover {
transform: translate(-1px, -1px);
}
.workbench-shell {
width: min(1240px, calc(100vw - 32px));
margin: 0 auto;
padding: 34px 0 42px;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 20px;
min-height: 218px;
border-bottom: 3px solid #18231f;
padding-bottom: 22px;
}
.eyebrow {
margin: 0 0 10px;
color: #c43d2f;
font-size: 12px;
font-weight: 900;
text-transform: uppercase;
}
h1 {
margin: 0;
max-width: 780px;
font-family: Georgia, "Times New Roman", ui-serif, serif;
font-size: 88px;
line-height: 0.9;
}
.lede {
max-width: 660px;
margin: 18px 0 0;
color: #435049;
font-size: 18px;
line-height: 1.45;
}
.signal-stack {
display: grid;
gap: 8px;
justify-items: end;
margin-bottom: 8px;
color: #435049;
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
font-size: 12px;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 8px;
border: 2px solid #18231f;
padding: 10px 14px;
color: #18231f;
font-family: "Avenir Next", "Segoe UI", ui-sans-serif, system-ui, sans-serif;
font-size: 13px;
font-weight: 900;
text-transform: uppercase;
box-shadow: 4px 4px 0 #18231f;
}
.status-pill.idle {
background: #f7d864;
}
.status-pill.live {
background: #68d3bd;
}
.telemetry {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin: 16px 0;
}
.telemetry div {
display: grid;
grid-template-columns: auto 1fr;
gap: 3px 10px;
border: 2px solid rgba(24, 35, 31, 0.84);
background: rgba(255, 252, 244, 0.78);
padding: 14px;
box-shadow: 4px 4px 0 rgba(24, 35, 31, 0.2);
}
.telemetry svg {
grid-row: span 2;
color: #157562;
}
.telemetry span {
color: #66736c;
font-size: 11px;
font-weight: 900;
text-transform: uppercase;
}
.telemetry strong {
min-width: 0;
overflow: hidden;
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
text-overflow: ellipsis;
white-space: nowrap;
}
.grid {
display: grid;
grid-template-columns: 408px minmax(0, 1fr);
gap: 16px;
}
.panel {
position: relative;
border: 2px solid #18231f;
background: rgba(255, 252, 244, 0.94);
box-shadow: 7px 7px 0 #18231f;
padding: 18px;
}
.panel::before {
position: absolute;
inset: 0 0 auto;
height: 5px;
content: "";
background: linear-gradient(90deg, #c43d2f, #f7d864 34%, #68d3bd 68%, #42556b);
}
.controls {
grid-row: span 3;
}
.panel-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
font-size: 13px;
font-weight: 900;
text-transform: uppercase;
}
.panel-title span,
.split-title {
min-width: 0;
}
.split-title {
justify-content: space-between;
gap: 12px;
}
.split-title span {
display: inline-flex;
align-items: center;
gap: 8px;
}
.split-title small {
color: #66736c;
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
font-size: 11px;
text-transform: none;
}
label {
display: grid;
gap: 7px;
margin-bottom: 14px;
color: #2b3732;
font-size: 12px;
font-weight: 900;
text-transform: uppercase;
}
input,
textarea {
width: 100%;
box-sizing: border-box;
border: 2px solid #18231f;
border-radius: 0;
outline: 0;
padding: 12px;
background: #fffaf0;
color: #18231f;
}
input:focus,
textarea:focus {
box-shadow: 0 0 0 4px rgba(104, 211, 189, 0.4);
}
textarea {
min-height: 172px;
resize: vertical;
}
.split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.toolbar {
display: grid;
grid-template-columns: 1fr 1fr 48px;
gap: 10px;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 2px solid #18231f;
min-height: 46px;
padding: 11px 14px;
color: #18231f;
cursor: pointer;
font-weight: 900;
}
button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.primary {
background: #157562;
color: white;
box-shadow: 4px 4px 0 #18231f;
}
.ghost {
background: #f7d864;
}
.icon {
background: #fffaf0;
}
.assistant-text {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
min-height: 172px;
border: 1px solid rgba(24, 35, 31, 0.16);
background: linear-gradient(rgba(24, 35, 31, 0.035) 1px, transparent 1px), #19231f;
background-size: 100% 28px;
color: #d9ffe6;
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
line-height: 1.7;
overflow: auto;
white-space: pre-wrap;
}
.line-gutter {
padding: 14px 0;
border-right: 1px solid rgba(217, 255, 230, 0.16);
color: rgba(217, 255, 230, 0.48);
text-align: center;
user-select: none;
}
.line-gutter + span {
min-width: 0;
padding: 14px;
}
.event-log {
min-height: 238px;
}
.timeline {
display: grid;
gap: 10px;
}
.event-row {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
border: 1px solid rgba(24, 35, 31, 0.14);
padding: 10px;
background: rgba(246, 239, 226, 0.55);
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
}
.event-dot {
width: 10px;
height: 10px;
border: 2px solid #18231f;
border-radius: 999px;
background: #68d3bd;
}
.event-row.good .event-dot {
background: #92c755;
}
.event-row.bad .event-dot {
background: #c43d2f;
}
.event-row.warn .event-dot {
background: #f7d864;
}
.event-row strong,
.event-row small {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.event-row small,
.event-row time {
color: #66736c;
font-size: 11px;
}
.empty-state {
border: 1px dashed rgba(24, 35, 31, 0.32);
padding: 22px;
color: #66736c;
text-align: center;
}
.result-badge {
border: 1px solid rgba(24, 35, 31, 0.22);
padding: 4px 8px;
background: #fffaf0;
color: #18231f;
}
.result-badge.completed {
background: #d9f2b0;
}
.result-badge.failed,
.result-badge.cancelled {
background: #f0b5a8;
}
.result-badge.timed_out {
background: #f7d864;
}
pre {
max-height: 260px;
overflow: auto;
margin: 0;
border: 1px solid rgba(24, 35, 31, 0.14);
padding: 14px;
background: #fffaf0;
font-size: 13px;
line-height: 1.5;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
@media (max-width: 960px) {
h1 {
font-size: 58px;
}
.hero,
.grid,
.telemetry,
.split {
grid-template-columns: 1fr;
}
.signal-stack {
justify-items: start;
}
}
@media (max-width: 540px) {
.workbench-shell {
width: min(100% - 20px, 1240px);
padding-top: 18px;
}
h1 {
font-size: 44px;
}
.toolbar {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"jsx": "react-jsx",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"strict": true,
"target": "ES2022",
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"]
}

View File

@ -0,0 +1,6 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
});

View File

@ -0,0 +1,30 @@
# Coding Agent CLI
A small terminal app for running OpenClaw agents from a workspace.
One-shot prompts run immediately. If you omit the prompt, the CLI enters an
interactive shell with slash commands for model selection, session switching,
status, cancellation, and exit.
## Getting Started
```bash
pnpm install
export OPENCLAW_GATEWAY=auto
pnpm dev -- "Explain this project"
```
Start interactive mode:
```bash
pnpm dev
```
## Slash Commands
- `/help` prints commands.
- `/model <model>` sets a model override for future runs.
- `/session <key>` switches the session key.
- `/status` prints Gateway model/auth status.
- `/cancel` cancels the active run.
- `/exit` exits.

View File

@ -0,0 +1,25 @@
{
"name": "@openclaw/cookbook-coding-agent-cli",
"version": "0.1.0",
"private": true,
"bin": {
"openclaw-agent": "./dist/index.js"
},
"type": "module",
"scripts": {
"build": "tsgo -p tsconfig.json",
"check": "pnpm typecheck && pnpm build",
"dev": "tsx src/index.ts",
"start": "node dist/index.js",
"typecheck": "tsgo -p tsconfig.json --noEmit"
},
"dependencies": {
"@openclaw/sdk": "workspace:*"
},
"devDependencies": {
"@types/node": "^25.6.0",
"tsx": "^4.21.0",
"typescript": "^6.0.3"
},
"packageManager": "pnpm@10.23.0"
}

View File

@ -0,0 +1,131 @@
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { OpenClaw, type Run } from "@openclaw/sdk";
type CliState = {
agentId: string;
sessionKey: string;
model?: string;
currentRun: Run | null;
};
const oc = new OpenClaw({
gateway: process.env.OPENCLAW_GATEWAY ?? "auto",
token: process.env.OPENCLAW_TOKEN,
password: process.env.OPENCLAW_PASSWORD,
});
const state: CliState = {
agentId: process.env.OPENCLAW_AGENT_ID ?? "main",
sessionKey: process.env.OPENCLAW_SESSION_KEY ?? "cli",
model: process.env.OPENCLAW_MODEL,
currentRun: null,
};
function help(): string {
return [
"Commands:",
" /help Show commands",
" /model <model> Set model override",
" /session <key> Switch session key",
" /status Print model/auth status",
" /cancel Cancel the active run",
" /exit Exit",
].join("\n");
}
async function sendPrompt(prompt: string): Promise<void> {
const run = await oc.runs.create({
input: prompt,
agentId: state.agentId,
sessionKey: state.sessionKey,
timeoutMs: 300_000,
...(state.model ? { model: state.model } : {}),
});
state.currentRun = run;
try {
for await (const event of run.events()) {
if (event.type === "assistant.delta") {
const delta = (event.data as { delta?: unknown }).delta;
if (typeof delta === "string") {
output.write(delta);
}
}
if (event.type.startsWith("run.")) {
output.write(`\n[${event.type}]`);
}
if (
event.type === "run.completed" ||
event.type === "run.failed" ||
event.type === "run.cancelled" ||
event.type === "run.timed_out"
) {
break;
}
}
const result = await run.wait({ timeoutMs: 120_000 });
output.write(`\n${JSON.stringify(result, null, 2)}\n`);
} finally {
if (state.currentRun === run) {
state.currentRun = null;
}
}
}
async function runCommand(line: string): Promise<boolean> {
const [command, ...rest] = line.trim().split(/\s+/);
switch (command) {
case "/help":
output.write(`${help()}\n`);
return true;
case "/model":
state.model = rest.join(" ") || undefined;
output.write(`model=${state.model ?? "default"}\n`);
return true;
case "/session":
state.sessionKey = rest.join(" ") || "cli";
output.write(`session=${state.sessionKey}\n`);
return true;
case "/status":
output.write(`${JSON.stringify(await oc.models.status({ probe: false }), null, 2)}\n`);
return true;
case "/cancel":
if (!state.currentRun) {
output.write("No active run.\n");
return true;
}
output.write(`${JSON.stringify(await state.currentRun.cancel(), null, 2)}\n`);
return true;
case "/exit":
case "/quit":
return false;
default:
output.write("Unknown command. Type /help.\n");
return true;
}
}
try {
const prompt = process.argv.slice(2).join(" ");
if (prompt) {
await sendPrompt(prompt);
} else {
output.write(`${help()}\n\n`);
const rl = createInterface({ input, output });
for (;;) {
const line = await rl.question("openclaw> ");
if (!line.trim()) {
continue;
}
const keepGoing = line.startsWith("/")
? await runCommand(line)
: (await sendPrompt(line), true);
if (!keepGoing) {
break;
}
}
rl.close();
}
} finally {
await oc.close();
}

View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"declaration": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"target": "ES2022",
"types": ["node"]
},
"include": ["src/**/*.ts"]
}

26
sdk/quickstart/README.md Normal file
View File

@ -0,0 +1,26 @@
# OpenClaw SDK Quickstart
A minimal Node.js example that creates one agent run, streams normalized events
to stdout, and waits for the final result.
## Getting Started
Use Node.js 22 or newer.
```bash
pnpm install
export OPENCLAW_GATEWAY=auto
pnpm dev
```
Build and run the compiled example:
```bash
pnpm build
pnpm start
```
## Notes
Set `OPENCLAW_AGENT_ID`, `OPENCLAW_SESSION_KEY`, or `OPENCLAW_MODEL` to override
the defaults.

View File

@ -0,0 +1,25 @@
{
"name": "@openclaw/cookbook-quickstart",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsgo -p tsconfig.json",
"check": "pnpm typecheck && pnpm build",
"dev": "tsx src/index.ts",
"start": "node dist/index.js",
"typecheck": "tsgo -p tsconfig.json --noEmit"
},
"dependencies": {
"@openclaw/sdk": "workspace:*"
},
"devDependencies": {
"@types/node": "^25.6.0",
"tsx": "^4.21.0",
"typescript": "^6.0.3"
},
"engines": {
"node": ">=22"
},
"packageManager": "pnpm@10.23.0"
}

View File

@ -0,0 +1,44 @@
import { OpenClaw } from "@openclaw/sdk";
const oc = new OpenClaw({
gateway: process.env.OPENCLAW_GATEWAY ?? "auto",
token: process.env.OPENCLAW_TOKEN,
password: process.env.OPENCLAW_PASSWORD,
});
const agentId = process.env.OPENCLAW_AGENT_ID ?? "main";
const sessionKey = process.env.OPENCLAW_SESSION_KEY ?? "quickstart";
const model = process.env.OPENCLAW_MODEL;
const prompt = process.argv.slice(2).join(" ") || "Explain this project in one paragraph.";
try {
const run = await oc.runs.create({
input: prompt,
agentId,
sessionKey,
timeoutMs: 60_000,
...(model ? { model } : {}),
});
for await (const event of run.events()) {
if (event.type === "assistant.delta") {
const delta = (event.data as { delta?: unknown }).delta;
if (typeof delta === "string") {
process.stdout.write(delta);
}
}
if (
event.type === "run.completed" ||
event.type === "run.failed" ||
event.type === "run.cancelled" ||
event.type === "run.timed_out"
) {
break;
}
}
const result = await run.wait({ timeoutMs: 120_000 });
process.stdout.write(`\n\n${JSON.stringify(result, null, 2)}\n`);
} finally {
await oc.close();
}

View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"declaration": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"target": "ES2022",
"types": ["node"]
},
"include": ["src/**/*.ts"]
}

17
sdk/run-board/README.md Normal file
View File

@ -0,0 +1,17 @@
# Run Board
A dashboard-style example for tracking agent runs by state, model, session, and
recent activity.
It demonstrates how a product can turn SDK events and results into an operator
view rather than a chat transcript.
## Getting Started
```bash
pnpm install
pnpm dev
```
The cookbook shim seeds sample runs during CI. With the published SDK, replace
the sample loader with Gateway-backed run/session/task queries.

13
sdk/run-board/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenClaw Run Board</title>
<link rel="icon" href="data:," />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,28 @@
{
"name": "@openclaw/cookbook-run-board",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsgo -p tsconfig.json && vite build",
"check": "pnpm typecheck && pnpm build",
"dev": "vite",
"preview": "vite preview",
"typecheck": "tsgo -p tsconfig.json --noEmit"
},
"dependencies": {
"@openclaw/sdk": "workspace:*",
"@vitejs/plugin-react": "^6.0.1",
"lucide-react": "^1.14.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"vite": "^8.0.10"
},
"devDependencies": {
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"typescript": "^6.0.3"
},
"packageManager": "pnpm@10.23.0"
}

260
sdk/run-board/src/App.tsx Normal file
View File

@ -0,0 +1,260 @@
import { useMemo, useState } from "react";
import {
AlertTriangle,
Bot,
CheckCircle2,
CircleDashed,
Clock3,
LayoutDashboard,
Play,
Search,
SlidersHorizontal,
TimerReset,
XCircle,
} from "lucide-react";
import { OpenClaw, type RunStatus } from "@openclaw/sdk";
type BoardRun = {
id: string;
title: string;
sessionKey: string;
model: string;
status: RunStatus;
updatedAt: number;
summary: string;
};
const initialRuns: BoardRun[] = [
{
id: "run_docs_1",
title: "Draft SDK quickstart",
sessionKey: "docs",
model: "gpt-5.4",
status: "completed",
updatedAt: Date.now() - 1000 * 60 * 4,
summary: "Generated a quickstart and linked it from the cookbook index.",
},
{
id: "run_ui_2",
title: "Workbench UI review",
sessionKey: "apps",
model: "sonnet-4.6",
status: "accepted",
updatedAt: Date.now() - 1000 * 60 * 12,
summary: "Waiting for event stream.",
},
{
id: "run_cancel_3",
title: "Cancellation smoke",
sessionKey: "qa",
model: "openrouter/deepseek/deepseek-r1",
status: "cancelled",
updatedAt: Date.now() - 1000 * 60 * 25,
summary: "Cancelled by operator after timeout budget expired.",
},
];
const columns: Array<{ status: RunStatus; label: string }> = [
{ status: "accepted", label: "Queued" },
{ status: "completed", label: "Done" },
{ status: "failed", label: "Failed" },
{ status: "cancelled", label: "Stopped" },
{ status: "timed_out", label: "Timed out" },
];
const filters: Array<{ value: RunStatus | "all"; label: string }> = [
{ value: "all", label: "All" },
{ value: "accepted", label: "Active" },
{ value: "completed", label: "Done" },
{ value: "failed", label: "Failed" },
{ value: "cancelled", label: "Stopped" },
{ value: "timed_out", label: "Timed out" },
];
function iconFor(status: RunStatus) {
if (status === "completed") return <CheckCircle2 size={17} />;
if (status === "timed_out") return <TimerReset size={17} />;
if (status === "failed" || status === "cancelled") {
return <XCircle size={17} />;
}
return <CircleDashed size={17} />;
}
function relativeTime(timestamp: number): string {
const minutes = Math.max(1, Math.round((Date.now() - timestamp) / 60_000));
return `${minutes}m ago`;
}
export function App() {
const [runs, setRuns] = useState(initialRuns);
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<RunStatus | "all">("all");
const [creating, setCreating] = useState(false);
const statusCounts = useMemo(
() =>
runs.reduce(
(counts, run) => ({
...counts,
[run.status]: (counts[run.status] ?? 0) + 1,
}),
{} as Partial<Record<RunStatus, number>>,
),
[runs],
);
const visibleRuns = useMemo(() => {
const normalized = query.trim().toLowerCase();
return runs.filter((run) => {
const matchesStatus = statusFilter === "all" || run.status === statusFilter;
const matchesQuery =
!normalized ||
[run.title, run.sessionKey, run.model, run.summary].some((value) =>
value.toLowerCase().includes(normalized),
);
return matchesStatus && matchesQuery;
});
}, [query, runs, statusFilter]);
async function createRun() {
setCreating(true);
const oc = new OpenClaw({ gateway: "auto" });
try {
const run = await oc.runs.create({
input: "Summarize cookbook health and propose one follow-up.",
agentId: "main",
sessionKey: "board",
timeoutMs: 60_000,
});
const result = await run.wait({ timeoutMs: 120_000 });
setRuns((current) => [
{
id: run.id,
title: "Cookbook health check",
sessionKey: "board",
model: "default",
status: result.status,
updatedAt: Date.now(),
summary: `Finished with ${result.status}.`,
},
...current,
]);
} finally {
setCreating(false);
await oc.close();
}
}
return (
<main className="board-shell">
<header className="topbar">
<div>
<p className="eyebrow">OpenClaw SDK</p>
<h1>Run Board</h1>
<p className="lede">A live operations board for SDK-created agent runs.</p>
</div>
<button onClick={createRun} disabled={creating}>
<Play size={16} />
{creating ? "Running" : "New run"}
</button>
</header>
<section className="metrics">
<div>
<LayoutDashboard />
<strong>{runs.length}</strong>
<span>Total runs</span>
</div>
<div>
<CheckCircle2 />
<strong>{statusCounts.completed ?? 0}</strong>
<span>Completed</span>
</div>
<div>
<Clock3 />
<strong>{statusCounts.accepted ?? 0}</strong>
<span>Active queue</span>
</div>
<div>
<AlertTriangle />
<strong>
{(statusCounts.failed ?? 0) +
(statusCounts.cancelled ?? 0) +
(statusCounts.timed_out ?? 0)}
</strong>
<span>Needs review</span>
</div>
</section>
<section className="command-strip">
<label className="search">
<Search size={16} />
<input
id="run-search"
name="run-search"
placeholder="Filter by model, session, title..."
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</label>
<div className="filters" aria-label="Status filter">
<SlidersHorizontal size={16} />
{filters.map((filter) => (
<button
className={statusFilter === filter.value ? "active" : ""}
key={filter.value}
onClick={() => setStatusFilter(filter.value)}
type="button"
>
{filter.label}
</button>
))}
</div>
</section>
<section className="columns">
{columns.map((column) => {
const columnRuns = visibleRuns.filter((run) => run.status === column.status);
return (
<article className="column" key={column.status}>
<h2>
{column.label}
<span>{columnRuns.length}</span>
</h2>
{columnRuns.length === 0 ? (
<div className="empty-lane">No matching runs.</div>
) : (
columnRuns.map((run) => (
<div className={`card ${run.status}`} key={run.id}>
<div className={`state ${run.status}`}>
{iconFor(run.status)}
{run.status}
</div>
<h3>{run.title}</h3>
<p>{run.summary}</p>
<dl>
<div>
<dt>Session</dt>
<dd>{run.sessionKey}</dd>
</div>
<div>
<dt>Model</dt>
<dd>{run.model}</dd>
</div>
<div>
<dt>Updated</dt>
<dd>{relativeTime(run.updatedAt)}</dd>
</div>
</dl>
<small className="run-id">
<Bot size={13} />
{run.id}
</small>
</div>
))
)}
</article>
);
})}
</section>
</main>
);
}

View File

@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App.js";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@ -0,0 +1,365 @@
:root {
color: #f1eee3;
background: #101314;
font-family: "Avenir Next", "Segoe UI", ui-sans-serif, system-ui, sans-serif;
font-synthesis: none;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background:
linear-gradient(rgba(255, 255, 255, 0.028) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px),
radial-gradient(circle at 8% 14%, rgba(107, 213, 188, 0.2), transparent 28%),
radial-gradient(circle at 86% 4%, rgba(247, 93, 62, 0.16), transparent 30%),
linear-gradient(145deg, #101314 0%, #18211f 48%, #101314 100%);
background-size:
28px 28px,
28px 28px,
auto,
auto,
auto;
}
button,
input {
font: inherit;
}
button {
transition:
background 140ms ease,
border-color 140ms ease,
transform 140ms ease;
}
button:not(:disabled):hover {
transform: translateY(-1px);
}
.board-shell {
width: min(1480px, calc(100vw - 32px));
margin: 0 auto;
padding: 30px 0 38px;
}
.topbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 18px;
margin-bottom: 18px;
border-bottom: 1px solid rgba(241, 238, 227, 0.2);
padding-bottom: 20px;
}
.eyebrow {
margin: 0 0 8px;
color: #86e0c3;
font-size: 12px;
font-weight: 900;
text-transform: uppercase;
}
h1 {
margin: 0;
font-family: Georgia, "Times New Roman", ui-serif, serif;
font-size: 86px;
line-height: 0.92;
}
.lede {
max-width: 560px;
margin: 14px 0 0;
color: #aeb8af;
font-size: 17px;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
border: 1px solid #86e0c3;
border-radius: 0;
min-height: 46px;
padding: 12px 16px;
background: #86e0c3;
color: #101314;
cursor: pointer;
font-weight: 900;
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.metrics div {
display: grid;
grid-template-columns: auto 1fr;
gap: 5px 11px;
border: 1px solid rgba(241, 238, 227, 0.16);
background:
linear-gradient(145deg, rgba(241, 238, 227, 0.08), rgba(241, 238, 227, 0.025)),
rgba(16, 19, 20, 0.76);
padding: 16px;
}
.metrics svg {
grid-row: span 2;
color: #86e0c3;
}
.metrics strong {
font-size: 29px;
line-height: 1;
}
.metrics span {
color: #aeb8af;
font-size: 13px;
}
.command-strip {
display: grid;
grid-template-columns: minmax(280px, 1fr) auto;
gap: 12px;
align-items: stretch;
}
.search,
.filters {
display: flex;
align-items: center;
gap: 10px;
border: 1px solid rgba(241, 238, 227, 0.18);
background: rgba(16, 19, 20, 0.76);
padding: 11px;
}
.search input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: #f1eee3;
}
.search input::placeholder {
color: #78847d;
}
.filters {
flex-wrap: wrap;
}
.filters > svg {
color: #86e0c3;
}
.filters button {
min-height: 32px;
border-color: rgba(241, 238, 227, 0.16);
padding: 6px 10px;
background: rgba(241, 238, 227, 0.06);
color: #f1eee3;
font-size: 12px;
}
.filters button.active {
border-color: #f2d46b;
background: #f2d46b;
color: #101314;
}
.columns {
display: grid;
grid-template-columns: repeat(5, minmax(226px, 1fr));
gap: 12px;
margin-top: 14px;
overflow-x: auto;
padding-bottom: 8px;
}
.column {
min-height: 536px;
border: 1px solid rgba(241, 238, 227, 0.15);
background:
linear-gradient(180deg, rgba(241, 238, 227, 0.07), transparent 140px),
rgba(241, 238, 227, 0.035);
padding: 12px;
}
.column h2 {
display: flex;
justify-content: space-between;
gap: 12px;
margin: 0 0 12px;
color: #f1eee3;
font-size: 13px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.column h2 span {
color: #86e0c3;
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
}
.card {
position: relative;
margin-bottom: 10px;
border: 1px solid rgba(16, 19, 20, 0.55);
padding: 15px 14px 13px;
background: #f1eee3;
color: #101314;
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.18);
}
.card::before {
position: absolute;
inset: 0 auto 0 0;
width: 6px;
content: "";
background: #86e0c3;
}
.card.completed::before {
background: #93ce5a;
}
.card.failed::before,
.card.cancelled::before {
background: #e45c46;
}
.card.timed_out::before {
background: #f2d46b;
}
.state {
display: inline-flex;
align-items: center;
gap: 6px;
margin-bottom: 11px;
padding-left: 8px;
font-size: 12px;
font-weight: 900;
text-transform: uppercase;
}
.state.accepted {
color: #157562;
}
.state.completed {
color: #4f7d24;
}
.state.failed,
.state.cancelled {
color: #b6322d;
}
.state.timed_out {
color: #8f6f00;
}
h3 {
margin: 0;
padding-left: 8px;
font-size: 19px;
line-height: 1.15;
}
p {
margin: 10px 0 0;
padding-left: 8px;
color: #3f4943;
line-height: 1.42;
}
dl {
display: grid;
gap: 8px;
margin: 14px 0 0;
padding-left: 8px;
}
dl div {
display: flex;
justify-content: space-between;
gap: 12px;
border-top: 1px solid rgba(16, 19, 20, 0.1);
padding-top: 8px;
}
dt {
color: #68736b;
}
dd {
min-width: 0;
overflow: hidden;
margin: 0;
font-weight: 800;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.run-id {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: calc(100% - 8px);
margin: 14px 0 0 8px;
color: #68736b;
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
font-size: 11px;
}
.empty-lane {
border: 1px dashed rgba(241, 238, 227, 0.22);
padding: 22px 12px;
color: #aeb8af;
text-align: center;
}
@media (max-width: 980px) {
.topbar,
.command-strip,
.metrics {
grid-template-columns: 1fr;
}
h1 {
font-size: 58px;
}
}
@media (max-width: 540px) {
.board-shell {
width: min(100% - 20px, 1480px);
padding-top: 18px;
}
h1 {
font-size: 44px;
}
.filters {
align-items: flex-start;
}
}

View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"jsx": "react-jsx",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"strict": true,
"target": "ES2022",
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"]
}

View File

@ -0,0 +1,6 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
});

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

@ -0,0 +1,66 @@
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 { redactSensitiveOutput } from "../recipes/_shared/run-main.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"]);
});
it("redacts sensitive fields before console output", () => {
expect(
redactSensitiveOutput({
sessionKey: "cookbook-demo",
nested: { token: "abc123", ok: true },
}),
).toEqual({
sessionKey: "[REDACTED]",
nested: { token: "[REDACTED]", ok: true },
});
});
});

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"
]
}

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"],
},
});