guidinghand 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- guidinghand-0.1.0/.gitignore +10 -0
- guidinghand-0.1.0/LICENSE +21 -0
- guidinghand-0.1.0/PKG-INFO +352 -0
- guidinghand-0.1.0/README.md +326 -0
- guidinghand-0.1.0/pyproject.toml +42 -0
- guidinghand-0.1.0/src/guidinghand/__init__.py +117 -0
- guidinghand-0.1.0/src/guidinghand/_async_client.py +428 -0
- guidinghand-0.1.0/src/guidinghand/_base.py +207 -0
- guidinghand-0.1.0/src/guidinghand/_client.py +501 -0
- guidinghand-0.1.0/src/guidinghand/_errors.py +213 -0
- guidinghand-0.1.0/src/guidinghand/_types.py +288 -0
- guidinghand-0.1.0/src/guidinghand/_version.py +1 -0
- guidinghand-0.1.0/src/guidinghand/_webhooks.py +79 -0
- guidinghand-0.1.0/src/guidinghand/py.typed +0 -0
- guidinghand-0.1.0/tests/test_e2e.py +1168 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GuidingHand
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: guidinghand
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The official Python SDK for the GuidingHand API: run tasks on a person's computer with an AI hand.
|
|
5
|
+
Project-URL: Homepage, https://guidinghand.ai
|
|
6
|
+
Project-URL: API reference, https://guidinghand.ai/openapi.json
|
|
7
|
+
Project-URL: Developers, https://guidinghand.ai/#api
|
|
8
|
+
Author-email: GuidingHand <dev@guidinghand.ai>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agents,api,computer-use,guidinghand,sdk
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Requires-Dist: httpx>=0.25
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# GuidingHand Python SDK
|
|
28
|
+
|
|
29
|
+
The official Python client for the [GuidingHand](https://guidinghand.ai) API. You create a session and send its invite link to the person at the computer. Once their computer connects, you run tasks on it, answer the agent's questions and approvals, and read back the result and the recording.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install guidinghand
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Python 3.9 or later. The only dependency is [httpx](https://www.python-httpx.org/).
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
Create an org API key in the console under **Settings → API keys**. It starts with `gh_live_` and is shown once.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
export GUIDINGHAND_API_KEY="gh_live_..."
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from guidinghand import GuidingHand
|
|
47
|
+
|
|
48
|
+
client = GuidingHand() # reads GUIDINGHAND_API_KEY
|
|
49
|
+
|
|
50
|
+
# 1. A session, and the invite link for the customer
|
|
51
|
+
session = client.sessions.create(metadata={"ticket_id": "T-1042"})
|
|
52
|
+
print("Send this link to the customer:", session["invite_url"])
|
|
53
|
+
|
|
54
|
+
# 2. Wait for their computer to connect (polls every 3 s, up to 10 minutes by default)
|
|
55
|
+
session = client.sessions.wait_for_connection(session["session_id"])
|
|
56
|
+
print("Connected:", session["device"]["name"] or session["device"]["os"])
|
|
57
|
+
|
|
58
|
+
# 3. Run a task, answering its questions and approvals as they come up
|
|
59
|
+
task = client.tasks.run(
|
|
60
|
+
session["session_id"],
|
|
61
|
+
"Turn on Dark Mode",
|
|
62
|
+
on_event=lambda e: print(f"[{e['type']}] {e['message']}"),
|
|
63
|
+
on_question=lambda q: input(f"{q['question']} {q['options']} > "),
|
|
64
|
+
on_approval=lambda a: input(f"Approve {a['action']!r} (risk: {a['risk']})? [y/N] > ").strip().lower() == "y",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# 4. The result and the replay
|
|
68
|
+
print(f"{task['status']}: {task['result'] or task['error'] or ''}")
|
|
69
|
+
print("Replay:", task["replay_url"])
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Every method returns the API's JSON as plain dicts. They are typed with `TypedDict`s (`Agent`, `Session`, `Task`, `Event`, `Recording`, `Webhook`, `Page`, ...), so editors and type checkers know the keys. Field names are the API's own (`session_id`, `task_id`, `agent_id`, ...), and so are the parameter names.
|
|
73
|
+
|
|
74
|
+
## The client
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from guidinghand import GuidingHand
|
|
78
|
+
|
|
79
|
+
client = GuidingHand(
|
|
80
|
+
api_key=None, # default: the GUIDINGHAND_API_KEY environment variable
|
|
81
|
+
base_url="https://guidinghand.ai", # or "https://dev.guidinghand.ai" (Stripe test mode)
|
|
82
|
+
timeout=60.0, # seconds per request; long polls get their wait on top
|
|
83
|
+
max_retries=2,
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Without an API key, the constructor raises `AuthenticationError`. The client holds a connection pool: use it as a context manager or call `client.close()` when you are done.
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
with GuidingHand() as client:
|
|
91
|
+
...
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`AsyncGuidingHand` has the same resources and methods, and you `await` them. See [Async](#async).
|
|
95
|
+
|
|
96
|
+
## Agents
|
|
97
|
+
|
|
98
|
+
An agent holds your instructions, reasoning effort and the greeting shown on the invite page. Every org has a `default` agent that works without setup.
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
client.agents.list() # {"data": [Agent, ...], "has_more": False, "next_cursor": None}
|
|
102
|
+
agent = client.agents.create(
|
|
103
|
+
name="Billing help",
|
|
104
|
+
agent_id="billing", # optional: made from the name when left out
|
|
105
|
+
instructions="Our billing app is Acme Billing. Open it from the Dock first.",
|
|
106
|
+
effort="medium", # "low" | "medium" | "high"
|
|
107
|
+
greeting="Hi, this is Acme support.",
|
|
108
|
+
)
|
|
109
|
+
client.agents.retrieve("billing")
|
|
110
|
+
client.agents.update("billing", effort="high") # only the fields you pass; effort=None resets it
|
|
111
|
+
client.agents.delete("billing") # its sessions switch to default; deleting "default" resets it
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`agent["invite_url_template"]` is the agent's invite link with a `{code}` placeholder. Creating an `agent_id` that already exists raises `ConflictError`.
|
|
115
|
+
|
|
116
|
+
## Sessions
|
|
117
|
+
|
|
118
|
+
A session is one pairing code (`session_id`, e.g. `K7QM-24XP`) for one computer. Codes expire after 72 hours without use.
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
session = client.sessions.create(agent_id="billing", metadata={"ticket_id": "T-1042"})
|
|
122
|
+
session["invite_url"] # send this to the person at the computer
|
|
123
|
+
session["status"] # "waiting" | "connected" | "disconnected" | "expired"
|
|
124
|
+
|
|
125
|
+
client.sessions.retrieve(session["session_id"])
|
|
126
|
+
client.sessions.list(agent_id="billing", limit=20) # one page, newest first
|
|
127
|
+
for s in client.sessions.list_all(agent_id="billing"): # every page
|
|
128
|
+
print(s["session_id"], s["status"], s.get("task_count"))
|
|
129
|
+
client.sessions.delete(session["session_id"]) # disconnects, stops a running task, deletes its history
|
|
130
|
+
|
|
131
|
+
session = client.sessions.wait_for_connection(session["session_id"], timeout=600, poll_interval=3)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`wait_for_connection` returns the session once `status` is `connected` and `device` is set. It raises `SessionExpired` if the code expires first, and `GuidingHandTimeout` after `timeout` seconds (`timeout=None` waits indefinitely). As an alternative to polling, a [webhook](#webhooks) sends `session.connected`.
|
|
135
|
+
|
|
136
|
+
## Tasks
|
|
137
|
+
|
|
138
|
+
A task is a prompt carried out on the session's computer. Each computer runs one task at a time.
|
|
139
|
+
|
|
140
|
+
### Run a task to the end
|
|
141
|
+
|
|
142
|
+
`tasks.run` starts a task and follows it until it is done, then returns the finished task.
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
task = client.tasks.run(
|
|
146
|
+
session_id,
|
|
147
|
+
"Update the billing email to ops@acme.com",
|
|
148
|
+
agent_id=None, # run a different agent than the session's
|
|
149
|
+
metadata={"ticket_id": "T-1042"},
|
|
150
|
+
request_id="T-1042-email", # idempotency key: the same request_id returns the same task
|
|
151
|
+
on_event=print, # every event, as it happens
|
|
152
|
+
on_question=lambda q: "Work", # q = {"type": "question", "question_id", "question", "options"}
|
|
153
|
+
on_approval=lambda a: True, # a = {"type": "approval", "approval_id", "action", "risk"}
|
|
154
|
+
timeout=None, # seconds; then the task is stopped and GuidingHandTimeout raised (unless it just finished)
|
|
155
|
+
)
|
|
156
|
+
task["status"] # "completed" | "failed" | "stopped"
|
|
157
|
+
task["result"] # the agent's summary when it completed
|
|
158
|
+
task["error"] # why it failed
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`on_question` returns the answer as a string. `on_approval` returns `True` or `False`, `"approve"` or `"deny"`, or `{"decision": "deny", "note": "Not on a Friday"}` to pass a note to the agent. With `GuidingHand` the handlers are plain functions: an `async def` handler raises `TypeError`, so use [`AsyncGuidingHand`](#async) for those. If the agent needs a handler you didn't pass, `run` raises `NeedsInput` instead of hanging. The task keeps waiting on the server, so you can answer it yourself:
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
from guidinghand import NeedsInput
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
task = client.tasks.run(session_id, "Turn on Dark Mode")
|
|
168
|
+
except NeedsInput as e:
|
|
169
|
+
print(e.pending) # the question or approval
|
|
170
|
+
client.tasks.respond(e.task["task_id"], question_id=e.pending["question_id"], answer="Work")
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Follow a task yourself
|
|
174
|
+
|
|
175
|
+
`tasks.events` long-polls `GET /v1/tasks/{id}/events`. Each page has the new events and the task as it is now, so you can answer what it is waiting on:
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
from guidinghand import ConflictError
|
|
179
|
+
|
|
180
|
+
task = client.tasks.create(session_id, "Turn on Dark Mode", request_id="T-1042-dark-mode")
|
|
181
|
+
task_id, after = task["task_id"], 0
|
|
182
|
+
|
|
183
|
+
while True:
|
|
184
|
+
page = client.tasks.events(task_id, after=after, wait_ms=25000) # waits up to wait_ms (max 55000) for news
|
|
185
|
+
for event in page["data"]:
|
|
186
|
+
print(event["cursor"], event["type"], event["message"])
|
|
187
|
+
after = page["cursor"] # pass as `after` next time
|
|
188
|
+
task = page["task"] # the task now
|
|
189
|
+
if task["done"]:
|
|
190
|
+
break
|
|
191
|
+
pending = task["pending"] # what it is waiting on now, if anything
|
|
192
|
+
try:
|
|
193
|
+
if pending and pending["type"] == "question":
|
|
194
|
+
client.tasks.respond(task_id, question_id=pending["question_id"], answer="Work")
|
|
195
|
+
elif pending and pending["type"] == "approval":
|
|
196
|
+
client.tasks.respond(task_id, approval_id=pending["approval_id"], decision="approve")
|
|
197
|
+
except ConflictError:
|
|
198
|
+
pass # already answered (by an earlier run, or someone else)
|
|
199
|
+
|
|
200
|
+
print(task["status"], task["result"])
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Answer `task["pending"]`, not the `question` and `approval_required` events: the events start from the beginning, so a re-run (the same `request_id` returns the same task) sees questions that were already answered.
|
|
204
|
+
|
|
205
|
+
To only watch, `stream(task_id, after=0)` yields each event once and ends when the task is done. To resume from a known point, pass the last `cursor` you have as `after`.
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
for event in client.tasks.stream(task_id):
|
|
209
|
+
print(event["type"], event["message"])
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Everything else
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
client.tasks.retrieve(task_id, include=["events"]) # include: "events" and/or "trace"
|
|
216
|
+
client.tasks.list(session_id=None, agent_id=None, status="completed", limit=20, cursor=None)
|
|
217
|
+
client.tasks.list_all(session_id=session_id) # generator over every page
|
|
218
|
+
client.tasks.respond(task_id, question_id="q_...", answer="Work")
|
|
219
|
+
client.tasks.respond(task_id, approval_id="appr_...", decision="deny", note="Not on a Friday")
|
|
220
|
+
client.tasks.stop(task_id) # task["interrupted"] is False if it had already finished
|
|
221
|
+
|
|
222
|
+
recording = client.tasks.recording(task_id) # {"frames": [{"seq", "t_ms", "after_event", "width", "height", "url"}]}
|
|
223
|
+
png = client.tasks.recording_frame(task_id, recording["frames"][0]["seq"]) # bytes
|
|
224
|
+
open("frame-1.png", "wb").write(png)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
`task["replay_url"]` opens the replay in the console: every screen the agent saw, with its cursor and clicks.
|
|
228
|
+
|
|
229
|
+
## Pagination
|
|
230
|
+
|
|
231
|
+
Lists return one page, newest first:
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
page = client.tasks.list(limit=50)
|
|
235
|
+
page["data"], page["has_more"], page["next_cursor"]
|
|
236
|
+
next_page = client.tasks.list(limit=50, cursor=page["next_cursor"])
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
`sessions.list_all(...)` and `tasks.list_all(...)` take the same filters and fetch pages as you iterate. They also take `limit=` for the page size.
|
|
240
|
+
|
|
241
|
+
## Webhooks
|
|
242
|
+
|
|
243
|
+
Instead of polling, GuidingHand can POST events to your endpoint: `session.connected`, `session.disconnected`, `task.started`, `task.waiting_for_user`, `task.waiting_for_approval`, `task.completed`, `task.failed` and `task.stopped`.
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
endpoint = client.webhook.update("https://example.com/guidinghand", events=["task.completed", "task.failed"])
|
|
247
|
+
secret = endpoint["secret"] # whsec_...: shown when it is first made, never again. Store it.
|
|
248
|
+
|
|
249
|
+
client.webhook.retrieve() # {"url", "events", "has_secret", "event_types"}
|
|
250
|
+
client.webhook.update(events=[]) # every event; the URL stays
|
|
251
|
+
client.webhook.update(rotate_secret=True)["secret"] # a new secret; the URL and events stay
|
|
252
|
+
client.webhook.delete() # removes the endpoint and its secret
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
`update` changes only what you pass: leave out `url` or `events` to keep them (the first call needs a `url`). `events` limits which events are sent, and `[]` means all of them (a new endpoint starts with all). An unknown event type raises `InvalidRequestError`. The URL must be a public `https://` address.
|
|
256
|
+
|
|
257
|
+
Each delivery carries a `GuidingHand-Signature: t=<unix>,v1=<hex>` header. Verify it against the **raw** request body before trusting the event:
|
|
258
|
+
|
|
259
|
+
```python
|
|
260
|
+
from guidinghand import verify_webhook, WebhookVerificationError
|
|
261
|
+
|
|
262
|
+
# Flask
|
|
263
|
+
@app.post("/guidinghand")
|
|
264
|
+
def guidinghand_webhook():
|
|
265
|
+
try:
|
|
266
|
+
event = verify_webhook(request.get_data(), request.headers.get("GuidingHand-Signature"), WEBHOOK_SECRET)
|
|
267
|
+
except WebhookVerificationError:
|
|
268
|
+
return "", 400
|
|
269
|
+
if event["type"] == "task.completed":
|
|
270
|
+
task = event["data"]["task"]
|
|
271
|
+
print(task["task_id"], task["result"])
|
|
272
|
+
return "", 200
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
`verify_webhook(payload, signature_header, secret, tolerance=300)` compares signatures in constant time. It rejects timestamps more than `tolerance` seconds from now (`tolerance=None` turns that check off) and returns the parsed event: `{"id", "type", "created_at", "org_id", "data": {"task": ...} | {"session": ...}}`. Deliveries that don't get a 2xx are retried up to 4 times over about 3 minutes, so use `event["id"]` to ignore repeats.
|
|
276
|
+
|
|
277
|
+
## Errors
|
|
278
|
+
|
|
279
|
+
Every exception is a `GuidingHandError`, with `message`, `status` (the HTTP status, or `None`), `type` (the API's error type), `body` (the parsed response) and `extra` (the error's other fields). Exceptions can be pickled, e.g. to pass them between processes.
|
|
280
|
+
|
|
281
|
+
| Exception | When |
|
|
282
|
+
|---|---|
|
|
283
|
+
| `InvalidRequestError` | 400: a missing or malformed field |
|
|
284
|
+
| `AuthenticationError` | 401: missing or invalid API key (also raised by the constructor when there is none) |
|
|
285
|
+
| `PaymentRequiredError` | 402: the org's plan doesn't allow it, e.g. its free minutes are used up |
|
|
286
|
+
| `PermissionDeniedError` | 403: the key's role can't do this |
|
|
287
|
+
| `NotFoundError` | 404: no such agent, session, task or frame in this org |
|
|
288
|
+
| `ConflictError` | 409: no computer connected, a task already running, or nothing pending to answer |
|
|
289
|
+
| `RateLimitError` | 429: too many requests |
|
|
290
|
+
| `APIError` | 5xx: a problem on GuidingHand's side |
|
|
291
|
+
| `APIConnectionError` | no response: network failure (`type` is `connection`) or request timeout (`type` is `timeout`) |
|
|
292
|
+
| `GuidingHandTimeout` | `wait_for_connection` or `tasks.run(timeout=...)` ran out of time (`run` stops the task first; if it had just finished, `run` returns it instead) |
|
|
293
|
+
| `NeedsInput` | `tasks.run` reached a question or approval it has no handler for (`e.task`, `e.pending`) |
|
|
294
|
+
| `SessionExpired` | the session's code expired while waiting for a connection (`e.session`) |
|
|
295
|
+
| `WebhookVerificationError` | a webhook's signature is missing, wrong or too old |
|
|
296
|
+
|
|
297
|
+
```python
|
|
298
|
+
from guidinghand import ConflictError
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
client.tasks.create(session_id, "Turn on Dark Mode")
|
|
302
|
+
except ConflictError as e:
|
|
303
|
+
running = e.extra.get("active_task_id") # set when another task is running
|
|
304
|
+
if running:
|
|
305
|
+
client.tasks.stop(running)
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
### Retries
|
|
309
|
+
|
|
310
|
+
Requests that are safe to repeat are retried up to `max_retries` times (default 2) on connection errors, 429 and 5xx. The waits grow 0.5 s, 1 s, 2 s, and so on, and a `Retry-After` header takes precedence. Safe requests are `GET` and `DELETE`, plus `POST`s that carry a `request_id` (starting a task with `request_id` is idempotent). Other `POST`s, `PATCH` and `PUT` are never retried. To make starting a task safe to retry, pass a `request_id`. If a `DELETE`'s answer is lost and the retry finds the session or agent already gone, the delete returns its usual `{"deleted": True}` result.
|
|
311
|
+
|
|
312
|
+
## Async
|
|
313
|
+
|
|
314
|
+
`AsyncGuidingHand` mirrors `GuidingHand`. `stream` and `list_all` are async iterators, and `tasks.run` accepts both plain functions and coroutine functions as handlers.
|
|
315
|
+
|
|
316
|
+
```python
|
|
317
|
+
import asyncio
|
|
318
|
+
from guidinghand import AsyncGuidingHand
|
|
319
|
+
|
|
320
|
+
async def main():
|
|
321
|
+
async with AsyncGuidingHand() as client:
|
|
322
|
+
session = await client.sessions.create()
|
|
323
|
+
print(session["invite_url"])
|
|
324
|
+
await client.sessions.wait_for_connection(session["session_id"])
|
|
325
|
+
|
|
326
|
+
async def on_question(q):
|
|
327
|
+
return await ask_support_agent(q["question"], q["options"])
|
|
328
|
+
|
|
329
|
+
task = await client.tasks.run(session["session_id"], "Turn on Dark Mode",
|
|
330
|
+
on_question=on_question, on_approval=lambda a: a["risk"] == "low")
|
|
331
|
+
async for event in client.tasks.stream(task["task_id"]):
|
|
332
|
+
print(event["type"])
|
|
333
|
+
|
|
334
|
+
asyncio.run(main())
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
## Development
|
|
338
|
+
|
|
339
|
+
The tests start the real server (`node server/src/index.js` with an in-memory database), a fake OpenAI, a webhook receiver and fake computers on the WebSocket bridge. They need Node 18+ and `server/node_modules`.
|
|
340
|
+
|
|
341
|
+
```bash
|
|
342
|
+
cd sdks/python
|
|
343
|
+
python3 -m venv .venv && .venv/bin/pip install httpx pytest build
|
|
344
|
+
.venv/bin/python -m pytest # or: python3 tests/test_e2e.py
|
|
345
|
+
.venv/bin/python -m build # dist/guidinghand-0.1.0-py3-none-any.whl and .tar.gz
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
Set `NODE=/path/to/node` to choose the Node binary.
|
|
349
|
+
|
|
350
|
+
## License
|
|
351
|
+
|
|
352
|
+
MIT
|