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.
@@ -0,0 +1,10 @@
1
+ .venv/
2
+ .venv*/
3
+ __pycache__/
4
+ *.py[cod]
5
+ .pytest_cache/
6
+ .mypy_cache/
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+ .ruff_cache/
@@ -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