Skip to content

Commit be06a81

Browse files
stainluclaude
andcommitted
feat(sdk+examples): Python SDK auth support + research-assistant example app
### SDK (sdk/python/ → v0.2.0) OpenClawClient gains an `api_token` keyword parameter. When set, the client attaches `Authorization: Bearer <token>` as a default header on every request via the underlying httpx.Client — which means both JSON calls and the SSE streaming (connect_sse inherits the client's headers) are gated. Unset/None = no auth header (matches the orchestrator's own "disabled when OPENCLAW_API_TOKEN is unset" default). Version bump 0.1.0 → 0.2.0 for the new optional kwarg. Backwards- compatible; existing callers keep working unchanged. Updated README.md example snippet to thread api_token through. ### Example app (examples/research-assistant/) ~200-line Python script that builds on top of the Python SDK. Shows what a real client looks like: - Connects with a preflight /healthz probe and a clear error if the orchestrator isn't reachable. - Respects OPENCLAW_ORCHESTRATOR_URL, OPENCLAW_API_TOKEN, OPENCLAW_MODEL env vars. - Creates an agent with a research-flavored system prompt, opens a session, loops on stdin. - Streams events in real time with a compact human-readable format: - thinking blocks dimmed - tool_use lines MAGENTA with arg preview - tool_result lines CYAN (or RED on error) with one-line content - final agent.message GREEN, plus per-turn usage summary - tool_confirmation_request YELLOW so the hook is discoverable - session.* metadata dimmed - Terminal colors auto-disabled when stdout isn't a TTY (pipe-friendly). - /usage builtin prints cumulative tokens + cost for the session. - /quit exits; leaves the session alive for post-hoc inspection via GET /v1/sessions/<id>/events. Plus examples/research-assistant/README.md walking through prereqs, run instructions, what the output looks like, configuration env vars, and exactly which lines to modify for a different system prompt / different event-rendering policy / different provider. Makes it plain the script is a copy-paste starting point, not a framework. Root README.md adds a Python SDK snippet above the existing OpenAI- compat one and points at the example directory. ### Why an example matters Every developer-facing project I've seen succeed has a concrete copy-and-run demo. Until this, README had a curl snippet and pointed at the SDK resource list; no hand-off artifact. Now a prospective user goes: clone → docker compose up → pip install -r requirements.txt → python research_assistant.py → streaming research Q&A with cost in ~5 minutes, not 30. Also doubles as launch-day marketing material and surfaces SDK bugs that would otherwise wait for the first external user. ### Removed examples/research-agent/ (empty scaffold leftover from the original project skeleton, same category as the src/storage/ empty dir removed earlier). examples/research-assistant/ replaces it with actual content. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c364b41 commit be06a81

7 files changed

Lines changed: 332 additions & 3 deletions

File tree

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,23 @@ while [ "$(curl -s http://localhost:8080/v1/sessions/$SESSION | jq -r .status)"
6060
curl -s "http://localhost:8080/v1/sessions/$SESSION" | jq .output
6161
```
6262

63+
Or use the Python SDK:
64+
65+
```python
66+
from openclaw_managed_agents import OpenClawClient
67+
68+
client = OpenClawClient(base_url="http://localhost:8080")
69+
agent = client.agents.create(model="moonshot/kimi-k2.5", instructions="You are helpful.")
70+
session = client.sessions.create(agent_id=agent.agent_id)
71+
client.sessions.send(session.session_id, content="What is 2+2?")
72+
for event in client.sessions.stream(session.session_id):
73+
if event.type == "agent.message":
74+
print(event.content)
75+
break
76+
```
77+
78+
See [`examples/research-assistant/`](./examples/research-assistant/) for a ~200-line copy-paste starting point that streams events in real time.
79+
6380
Or use the OpenAI SDK — just change `base_url`:
6481

6582
```python
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Research Assistant — example app
2+
3+
A ~200-line Python script showing what it looks like to build with OpenClaw Managed Agents: create an agent, open a session, send questions, stream events in real time with a compact human-readable format. Multi-turn. Colored terminal output for different event types (thinking, tool calls, tool results, final message).
4+
5+
This is a starting point you fork, not a library you import.
6+
7+
## What you'll see
8+
9+
```
10+
$ python research_assistant.py
11+
connected to http://localhost:8080
12+
model: moonshot/kimi-k2.5
13+
auth: disabled
14+
agent: agt_9k2nvxbb5p1q
15+
session: ses_a3rpxq82fm9w
16+
type /quit to exit, /usage for cumulative token + cost
17+
18+
> In 3 bullets, what are the main architectural differences between Pi agent and Claude Code?
19+
you: In 3 bullets, what are the main architectural differences between Pi agent and Claude Code?
20+
… thinking: Let me compare Pi and Claude Code along three axes: process model, extension surface, and session model…
21+
→ tool web_fetch(url='https://mariozechner.at/posts/…')
22+
← tool_out Pi is built around a single long-running AgentSession… [118 more lines]
23+
24+
assistant:
25+
- **Process model.** Pi runs a single in-process AgentSession with an
26+
explicit event bus; Claude Code spawns a fresh session per task and
27+
hides subagent transcripts behind opaque tool results.
28+
- **Extension surface.** Pi ships four tools (read, write, edit, bash)
29+
and expects users to write their own extensions; Claude Code ships
30+
a curated 8-tool set plus a proprietary toolset spec.
31+
- **Session model.** Pi's SessionManager is append-only JSONL with a
32+
tree of branches; Claude Code sessions are opaque cloud objects with
33+
a versioned event log.
34+
[turn usage: 1247 in / 312 out, $0.0008]
35+
36+
>
37+
```
38+
39+
## Prerequisites
40+
41+
1. **Running orchestrator.** From the repo root:
42+
```bash
43+
export MOONSHOT_API_KEY=sk-... # or any provider key
44+
docker compose up -d
45+
```
46+
2. **Python 3.9+.** This example uses only stdlib + the SDK.
47+
48+
## Run it
49+
50+
```bash
51+
cd examples/research-assistant
52+
pip install -r requirements.txt # installs openclaw-managed-agents>=0.2.0
53+
python research_assistant.py
54+
```
55+
56+
Type questions. `/usage` prints cumulative tokens + cost. `/quit` exits.
57+
58+
## Configuration
59+
60+
Environment variables (all optional):
61+
62+
| Variable | Default | Purpose |
63+
|---|---|---|
64+
| `OPENCLAW_ORCHESTRATOR_URL` | `http://localhost:8080` | Where the orchestrator is |
65+
| `OPENCLAW_API_TOKEN` | *(unset)* | Bearer token — set this to match the orchestrator's `OPENCLAW_API_TOKEN` when auth is enabled |
66+
| `OPENCLAW_MODEL` | `moonshot/kimi-k2.5` | Any `<provider>/<model-id>` the runtime supports |
67+
68+
## What to modify
69+
70+
The script is deliberately one file and < 250 lines. Places to look if you're adapting it:
71+
72+
- **`RESEARCH_INSTRUCTIONS`** — the agent's system prompt. Swap to whatever your use case is.
73+
- **`print_event`** — how each streamed event type renders. The full event catalog is documented in [`docs/architecture.md`](../../docs/architecture.md#live-event-streaming-get-v1sessionsidevents-streamtrue).
74+
- **`run_turn`** — the streaming loop. Currently breaks as soon as the first new `agent.message` lands, which is the right call for chat-style UX; for longer analyses you might want to let the stream drain fully. Or plug in a different policy around tool calls (wait for them to complete, retry on error, etc.).
75+
76+
## Swap to a different provider
77+
78+
```bash
79+
export ANTHROPIC_API_KEY=sk-...
80+
export OPENCLAW_MODEL=anthropic/claude-sonnet-4-6
81+
python research_assistant.py
82+
```
83+
84+
No code change needed — the runtime forwards whichever provider API key it sees.
85+
86+
## What this example is NOT
87+
88+
- **Not production-ready.** No retries, no structured error handling, no conversation history export. Real products wrap `OpenClawClient` behind their own interface.
89+
- **Not the full SDK surface.** Doesn't exercise `environments`, `cancel`, `confirm_tool`, agent versioning, or delegated subagents. Look at [`sdk/python/README.md`](../../sdk/python/README.md) for the full resource list.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
openclaw-managed-agents>=0.2.0
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
"""Research assistant — a copy-paste starting point for building with
2+
OpenClaw Managed Agents from Python.
3+
4+
What it does:
5+
1. Connects to a running orchestrator (default http://localhost:8080).
6+
2. Creates an agent template tuned for research-flavored answers.
7+
3. Opens a session.
8+
4. Accepts questions at the terminal. For each question, posts a
9+
user message and streams events in real time — thinking blocks,
10+
tool calls, tool results, and the final agent message — with a
11+
compact human-readable format. Polls the session state at end of
12+
turn to report rolling token + cost usage.
13+
5. Repeats until the user types `/quit`.
14+
15+
Run:
16+
pip install openclaw-managed-agents>=0.2.0
17+
export MOONSHOT_API_KEY=sk-... # or any provider
18+
docker compose up -d # in the repo root
19+
python research_assistant.py
20+
21+
Optional env vars:
22+
OPENCLAW_ORCHESTRATOR_URL default http://localhost:8080
23+
OPENCLAW_API_TOKEN bearer token (match the orchestrator's)
24+
OPENCLAW_MODEL default moonshot/kimi-k2.5
25+
"""
26+
from __future__ import annotations
27+
28+
import os
29+
import sys
30+
from typing import Optional
31+
32+
from openclaw_managed_agents import OpenClawClient
33+
34+
# ANSI colors. Plain ASCII if stdout isn't a TTY (piping to a file etc.).
35+
_ANSI = sys.stdout.isatty()
36+
37+
38+
def _color(code: str, s: str) -> str:
39+
return f"\x1b[{code}m{s}\x1b[0m" if _ANSI else s
40+
41+
42+
DIM = lambda s: _color("2", s) # noqa: E731
43+
CYAN = lambda s: _color("36", s) # noqa: E731
44+
GREEN = lambda s: _color("32", s) # noqa: E731
45+
YELLOW = lambda s: _color("33", s) # noqa: E731
46+
MAGENTA = lambda s: _color("35", s) # noqa: E731
47+
RED = lambda s: _color("31", s) # noqa: E731
48+
49+
50+
RESEARCH_INSTRUCTIONS = (
51+
"You are a research assistant. When answering, cite sources "
52+
"whenever possible, label any uncertainty explicitly, and prefer "
53+
"structured summaries over long prose. If a question needs "
54+
"verification, use your tools to look it up; never fabricate."
55+
)
56+
57+
58+
def print_event(event) -> None:
59+
"""Format a single streamed event to stdout."""
60+
t = event.type
61+
if t == "user.message":
62+
# Echo user messages we've posted — skipped here because we
63+
# already print them when the user types.
64+
return
65+
if t == "agent.thinking":
66+
print(DIM(f" … thinking: {event.content}"))
67+
return
68+
if t == "agent.tool_use":
69+
args = event.tool_arguments or {}
70+
# Keep it compact — one line per tool call.
71+
args_preview = ", ".join(f"{k}={v!r}" for k, v in list(args.items())[:3])
72+
if len(args) > 3:
73+
args_preview += ", …"
74+
print(MAGENTA(f" → tool {event.tool_name}({args_preview})"))
75+
return
76+
if t == "agent.tool_result":
77+
prefix = RED(" ← tool_err ") if event.is_error else CYAN(" ← tool_out ")
78+
snippet = event.content.strip().splitlines()[0] if event.content else "(empty)"
79+
if len(snippet) > 120:
80+
snippet = snippet[:117] + "…"
81+
print(f"{prefix}{snippet}")
82+
return
83+
if t == "agent.message":
84+
print()
85+
print(GREEN("assistant:"))
86+
print(event.content)
87+
# Per-turn usage surfaces here when the model reports it.
88+
if event.tokens or event.cost_usd is not None:
89+
usage_bits = []
90+
if event.tokens:
91+
usage_bits.append(
92+
f"{event.tokens.get('input', 0)} in / "
93+
f"{event.tokens.get('output', 0)} out"
94+
)
95+
if event.cost_usd is not None:
96+
usage_bits.append(f"${event.cost_usd:.4f}")
97+
print(DIM(f" [turn usage: {', '.join(usage_bits)}]"))
98+
return
99+
if t == "agent.tool_confirmation_request":
100+
# always_ask policy — not used in this example's agent template,
101+
# but surface it if it shows up so the reader knows the hook exists.
102+
print(YELLOW(f" ⚠ tool confirmation requested: {event.tool_name} "
103+
f"(respond via client.sessions.confirm_tool(...))"))
104+
return
105+
if t == "agent.error":
106+
print(RED(f" ⚠ error: {event.content}"))
107+
return
108+
# session.model_change / session.thinking_level_change / session.compaction
109+
if t.startswith("session."):
110+
print(DIM(f" [{t}: {event.content}]"))
111+
112+
113+
def run_turn(client: OpenClawClient, session_id: str, prompt: str) -> None:
114+
print(CYAN("you: ") + prompt)
115+
client.sessions.send(session_id, content=prompt)
116+
# stream() returns an iterator over events; it blocks on the SSE
117+
# connection and ends when the session goes idle for ~30 s after
118+
# the last event. We break out as soon as the first agent.message
119+
# lands for this turn — the demo-grade UX we want.
120+
seen_agent_msg_ids: set[str] = set()
121+
# Pre-populate with any agent.messages from earlier turns so we
122+
# don't stop on a stale one during catch-up.
123+
for prior in client.sessions.events(session_id):
124+
if prior.type == "agent.message":
125+
seen_agent_msg_ids.add(prior.event_id)
126+
for event in client.sessions.stream(session_id):
127+
print_event(event)
128+
if event.type == "agent.message" and event.event_id not in seen_agent_msg_ids:
129+
break
130+
131+
132+
def main() -> int:
133+
base_url = os.environ.get("OPENCLAW_ORCHESTRATOR_URL", "http://localhost:8080")
134+
api_token = os.environ.get("OPENCLAW_API_TOKEN") or None
135+
model = os.environ.get("OPENCLAW_MODEL", "moonshot/kimi-k2.5")
136+
137+
client = OpenClawClient(base_url=base_url, api_token=api_token)
138+
139+
# Verify the orchestrator is reachable before creating anything.
140+
# Matches the deploy scripts' /healthz probe pattern.
141+
import httpx
142+
try:
143+
resp = httpx.get(f"{base_url}/healthz", timeout=5.0)
144+
resp.raise_for_status()
145+
except httpx.HTTPError as err:
146+
print(RED(f"cannot reach {base_url}/healthz: {err}"), file=sys.stderr)
147+
print(
148+
" — is the orchestrator running? Try: docker compose up -d",
149+
file=sys.stderr,
150+
)
151+
return 1
152+
153+
print(DIM(f"connected to {base_url}"))
154+
print(DIM(f"model: {model}"))
155+
if api_token:
156+
print(DIM("auth: bearer-token (OPENCLAW_API_TOKEN set)"))
157+
else:
158+
print(DIM("auth: disabled"))
159+
160+
agent = client.agents.create(
161+
model=model,
162+
instructions=RESEARCH_INSTRUCTIONS,
163+
# tools defaults to [] — the agent uses whatever skills the
164+
# runtime image has pre-installed. Point this at specific skill
165+
# ids once the runtime exposes them explicitly.
166+
)
167+
print(DIM(f"agent: {agent.agent_id}"))
168+
169+
session = client.sessions.create(agent_id=agent.agent_id)
170+
print(DIM(f"session: {session.session_id}"))
171+
print(DIM("type /quit to exit, /usage for cumulative token + cost"))
172+
print()
173+
174+
try:
175+
while True:
176+
try:
177+
prompt = input(CYAN("> "))
178+
except (EOFError, KeyboardInterrupt):
179+
print()
180+
break
181+
prompt = prompt.strip()
182+
if not prompt:
183+
continue
184+
if prompt == "/quit":
185+
break
186+
if prompt == "/usage":
187+
current = client.sessions.get(session.session_id)
188+
print(
189+
DIM(
190+
f"cumulative: "
191+
f"{current.tokens['input']} in / "
192+
f"{current.tokens['output']} out, "
193+
f"${current.cost_usd:.4f}"
194+
)
195+
)
196+
continue
197+
run_turn(client, session.session_id, prompt)
198+
print()
199+
finally:
200+
# Leave the session alive for post-hoc inspection via
201+
# GET /v1/sessions/<id>/events. Uncomment to tear down.
202+
# client.sessions.delete(session.session_id)
203+
client.close()
204+
205+
return 0
206+
207+
208+
if __name__ == "__main__":
209+
raise SystemExit(main())

sdk/python/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ pip install openclaw-managed-agents
1313
```python
1414
from openclaw_managed_agents import OpenClawClient
1515

16-
client = OpenClawClient(base_url="http://localhost:8080")
16+
# Pass api_token to match the orchestrator's OPENCLAW_API_TOKEN when
17+
# bearer-token auth is enabled. Omit for a local orchestrator without auth.
18+
client = OpenClawClient(base_url="http://localhost:8080", api_token="my-shared-secret")
1719

1820
# Create an agent
1921
agent = client.agents.create(

sdk/python/openclaw_managed_agents/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,20 +38,31 @@ class OpenClawClient:
3838
3939
Args:
4040
base_url: Orchestrator URL (e.g. ``http://localhost:8080``).
41+
api_token: Bearer token matching the orchestrator's
42+
``OPENCLAW_API_TOKEN`` env var. Attached as
43+
``Authorization: Bearer <token>`` on every request. Leave
44+
unset for a local orchestrator running without auth.
4145
timeout: Request timeout in seconds. Default 600 (matches the
4246
orchestrator's chat.completions poll cap).
4347
"""
4448

4549
def __init__(
4650
self,
4751
base_url: str = "http://localhost:8080",
52+
api_token: str | None = None,
4853
timeout: float = 600.0,
4954
) -> None:
55+
headers: dict[str, str] = {}
56+
if api_token:
57+
headers["Authorization"] = f"Bearer {api_token}"
5058
# trust_env=False bypasses system proxy settings (macOS scutil
5159
# proxy) that httpx auto-detects. The orchestrator is typically on
5260
# localhost or a private network — proxying it is never wanted.
5361
self._client = httpx.Client(
54-
base_url=base_url, timeout=timeout, trust_env=False,
62+
base_url=base_url,
63+
timeout=timeout,
64+
trust_env=False,
65+
headers=headers,
5566
)
5667
self.agents = Agents(self._client)
5768
self.environments = Environments(self._client)

sdk/python/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "openclaw-managed-agents"
7-
version = "0.1.0"
7+
version = "0.2.0"
88
description = "Python SDK for OpenClaw Managed Agents — the open alternative to Claude Managed Agents"
99
readme = "README.md"
1010
license = "MIT"

0 commit comments

Comments
 (0)