guidinghand 0.1.0

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,262 @@
1
+ // GuidingHand SDK for JavaScript and TypeScript: the /v1 API (https://guidinghand.ai/openapi.json).
2
+ // No dependencies: it uses the platform's fetch and WebCrypto (Node 18+, Deno, Bun, Cloudflare Workers).
3
+ //
4
+ // import GuidingHand from 'guidinghand';
5
+ // const gh = new GuidingHand(); // reads GUIDINGHAND_API_KEY
6
+ // const session = await gh.sessions.create({ agent_id: 'billing' });
7
+ // // send session.invite_url to the person at the computer, then:
8
+ // await gh.sessions.waitForConnection(session.session_id);
9
+ // const task = await gh.tasks.run(session.session_id, { prompt: 'Turn on Dark Mode', onQuestion: async (q) => 'Work', onApproval: async () => true });
10
+ export const VERSION = '0.1.0';
11
+ // ---------- errors ----------
12
+ export class GuidingHandError extends Error {
13
+ status;
14
+ type;
15
+ body;
16
+ extra;
17
+ constructor(message, status = 0, type = 'error', body = null) {
18
+ super(message);
19
+ this.name = new.target.name;
20
+ this.status = status;
21
+ this.type = type;
22
+ this.body = body;
23
+ const e = body?.error;
24
+ this.extra = e ? Object.fromEntries(Object.entries(e).filter(([k]) => k !== 'type' && k !== 'message')) : {};
25
+ }
26
+ }
27
+ export class InvalidRequestError extends GuidingHandError {
28
+ }
29
+ export class AuthenticationError extends GuidingHandError {
30
+ }
31
+ export class PaymentRequiredError extends GuidingHandError {
32
+ }
33
+ export class PermissionDeniedError extends GuidingHandError {
34
+ }
35
+ export class NotFoundError extends GuidingHandError {
36
+ }
37
+ export class ConflictError extends GuidingHandError {
38
+ }
39
+ export class RateLimitError extends GuidingHandError {
40
+ }
41
+ export class APIError extends GuidingHandError {
42
+ }
43
+ export class APIConnectionError extends GuidingHandError {
44
+ }
45
+ export class TimeoutError extends GuidingHandError {
46
+ }
47
+ export class SessionExpiredError extends GuidingHandError {
48
+ }
49
+ /** A task waits on a question or approval and `run()` got no handler for it. */
50
+ export class NeedsInputError extends GuidingHandError {
51
+ task;
52
+ constructor(task) { super(`The task is waiting for ${task.pending?.type === 'approval' ? 'an approval' : 'an answer'}; pass ${task.pending?.type === 'approval' ? 'onApproval' : 'onQuestion'} to run().`, 0, 'needs_input'); this.task = task; }
53
+ }
54
+ export class WebhookVerificationError extends GuidingHandError {
55
+ }
56
+ const BY_STATUS = { 400: InvalidRequestError, 401: AuthenticationError, 402: PaymentRequiredError, 403: PermissionDeniedError, 404: NotFoundError, 409: ConflictError, 413: InvalidRequestError, 429: RateLimitError };
57
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
58
+ const env = (k) => (typeof process !== 'undefined' ? process.env?.[k] : undefined);
59
+ export class GuidingHand {
60
+ baseUrl;
61
+ agents;
62
+ sessions;
63
+ tasks;
64
+ webhook;
65
+ apiKey;
66
+ timeout;
67
+ maxRetries;
68
+ fetchImpl;
69
+ constructor(opts = {}) {
70
+ const key = opts.apiKey ?? env('GUIDINGHAND_API_KEY');
71
+ if (!key)
72
+ throw new AuthenticationError('No API key: pass { apiKey } or set GUIDINGHAND_API_KEY. Make one in the console under Settings → API keys.', 401, 'authentication');
73
+ this.apiKey = key;
74
+ this.baseUrl = (opts.baseUrl ?? env('GUIDINGHAND_BASE_URL') ?? 'https://guidinghand.ai').replace(/\/+$/, '');
75
+ this.timeout = opts.timeout ?? 60_000;
76
+ this.maxRetries = opts.maxRetries ?? 2;
77
+ this.fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
78
+ this.agents = new Agents(this);
79
+ this.sessions = new Sessions(this);
80
+ this.tasks = new Tasks(this);
81
+ this.webhook = new WebhookEndpoint(this);
82
+ }
83
+ /** @internal */
84
+ async request(method, path, { query, body, retry, wait = 0, raw } = {}) {
85
+ const qs = query ? Object.entries(query).filter(([, v]) => v !== undefined && v !== null && v !== '').map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`).join('&') : '';
86
+ const url = `${this.baseUrl}/v1${path}${qs ? `?${qs}` : ''}`;
87
+ const canRetry = retry ?? (method === 'GET' || method === 'DELETE');
88
+ for (let attempt = 0;; attempt++) {
89
+ let res;
90
+ try {
91
+ res = await this.fetchImpl(url, {
92
+ method,
93
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'User-Agent': `guidinghand-js/${VERSION}`, ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
94
+ body: body !== undefined ? JSON.stringify(body) : undefined,
95
+ signal: AbortSignal.timeout(this.timeout + wait),
96
+ });
97
+ }
98
+ catch (e) {
99
+ if (canRetry && attempt < this.maxRetries) {
100
+ await sleep(500 * 2 ** attempt);
101
+ continue;
102
+ }
103
+ const timedOut = e?.name === 'TimeoutError';
104
+ throw timedOut ? new TimeoutError(`No answer from ${this.baseUrl} in ${Math.round((this.timeout + wait) / 1000)} s.`, 0, 'timeout') : new APIConnectionError(`Couldn’t reach ${this.baseUrl}: ${e?.message ?? e}`, 0, 'connection');
105
+ }
106
+ if (res.ok)
107
+ return (raw ? new Uint8Array(await res.arrayBuffer()) : await res.json());
108
+ if (canRetry && attempt < this.maxRetries && (res.status === 429 || res.status >= 500)) {
109
+ const after = Number(res.headers.get('retry-after'));
110
+ await sleep(Number.isFinite(after) && after > 0 ? after * 1000 : 500 * 2 ** attempt);
111
+ continue;
112
+ }
113
+ const j = await res.json().catch(() => null);
114
+ const Err = BY_STATUS[res.status] ?? (res.status >= 500 ? APIError : GuidingHandError);
115
+ throw new Err(j?.error?.message ?? `The server answered ${res.status}.`, res.status, j?.error?.type ?? 'error', j);
116
+ }
117
+ }
118
+ /** @internal Every item of a paged list. */
119
+ async *pages(path, query) {
120
+ let cursor;
121
+ do {
122
+ const p = await this.request('GET', path, { query: { ...query, limit: 100, cursor } });
123
+ yield* p.data;
124
+ cursor = p.has_more ? p.next_cursor : null;
125
+ } while (cursor);
126
+ }
127
+ }
128
+ export default GuidingHand;
129
+ class Resource {
130
+ client;
131
+ constructor(client) {
132
+ this.client = client;
133
+ }
134
+ }
135
+ const enc = encodeURIComponent;
136
+ class Agents extends Resource {
137
+ list() { return this.client.request('GET', '/agents'); }
138
+ create(params) { return this.client.request('POST', '/agents', { body: params }); }
139
+ retrieve(agentId) { return this.client.request('GET', `/agents/${enc(agentId)}`); }
140
+ update(agentId, params) { return this.client.request('PATCH', `/agents/${enc(agentId)}`, { body: params }); }
141
+ delete(agentId) { return this.client.request('DELETE', `/agents/${enc(agentId)}`); }
142
+ }
143
+ class Sessions extends Resource {
144
+ /** Makes a code and its invite link. Send `invite_url` to the person at the computer. */
145
+ create(params = {}) { return this.client.request('POST', '/sessions', { body: params }); }
146
+ list(params = {}) { return this.client.request('GET', '/sessions', { query: params }); }
147
+ /** Every session, newest first, across pages. */
148
+ listAll(params = {}) { return this.client.pages('/sessions', params); }
149
+ retrieve(sessionId) { return this.client.request('GET', `/sessions/${enc(sessionId)}`); }
150
+ /** Disconnects the computer and deletes the session's tasks and recordings. */
151
+ delete(sessionId) { return this.client.request('DELETE', `/sessions/${enc(sessionId)}`); }
152
+ /** Resolves once the person's computer is connected. */
153
+ async waitForConnection(sessionId, { timeout = 600_000, pollInterval = 3_000 } = {}) {
154
+ const until = Date.now() + timeout;
155
+ for (;;) {
156
+ const s = await this.retrieve(sessionId);
157
+ if (s.status === 'connected')
158
+ return s;
159
+ if (s.status === 'expired')
160
+ throw new SessionExpiredError(`Session ${sessionId} expired before a computer connected. Make a new one.`, 0, 'session_expired');
161
+ if (Date.now() + pollInterval > until)
162
+ throw new TimeoutError(`No computer connected to ${sessionId} within ${Math.round(timeout / 1000)} s.`, 0, 'timeout');
163
+ await sleep(pollInterval);
164
+ }
165
+ }
166
+ }
167
+ class Tasks extends Resource {
168
+ /** Starts a task on the session's computer (it must be connected). */
169
+ create(sessionId, params) { return this.client.request('POST', `/sessions/${enc(sessionId)}/tasks`, { body: params, retry: !!params.request_id }); }
170
+ list(params = {}) { return this.client.request('GET', '/tasks', { query: params }); }
171
+ listAll(params = {}) { return this.client.pages('/tasks', params); }
172
+ retrieve(taskId, { include } = {}) { return this.client.request('GET', `/tasks/${enc(taskId)}`, { query: { include: include?.join(',') } }); }
173
+ /** Events after `after`; with `wait_ms`, waits (long poll) for the next one. */
174
+ events(taskId, { after = 0, wait_ms = 0 } = {}) {
175
+ return this.client.request('GET', `/tasks/${enc(taskId)}/events`, { query: { after, wait_ms }, wait: wait_ms });
176
+ }
177
+ respond(taskId, params) { return this.client.request('POST', `/tasks/${enc(taskId)}/respond`, { body: params }); }
178
+ stop(taskId) { return this.client.request('POST', `/tasks/${enc(taskId)}/stop`, { body: {} }); }
179
+ recording(taskId) { return this.client.request('GET', `/tasks/${enc(taskId)}/recording`); }
180
+ /** One recorded screen, as PNG bytes. */
181
+ recordingFrame(taskId, seq) { return this.client.request('GET', `/tasks/${enc(taskId)}/recording/${seq}`, { raw: true }); }
182
+ /** Every event of a task as it happens, until it's done. `for await (const e of gh.tasks.stream(id))` */
183
+ async *stream(taskId, { after = 0 } = {}) {
184
+ for (;;) {
185
+ const r = await this.events(taskId, { after, wait_ms: 25_000 });
186
+ for (const e of r.data)
187
+ yield e;
188
+ after = r.cursor;
189
+ if (r.task.done)
190
+ return r.task;
191
+ }
192
+ }
193
+ /** Starts a task and sees it through: events to onEvent, questions to onQuestion, approvals to onApproval. Resolves with the finished task. */
194
+ async run(sessionId, { onEvent, onQuestion, onApproval, timeout, ...params }) {
195
+ let task = await this.create(sessionId, params);
196
+ const until = timeout ? Date.now() + timeout : Infinity;
197
+ let after = 0, answered = '';
198
+ while (!task.done) {
199
+ if (Date.now() > until) {
200
+ await this.stop(task.task_id).catch(() => { });
201
+ throw new TimeoutError(`Task ${task.task_id} took longer than ${Math.round(timeout / 1000)} s; stopped it.`, 0, 'timeout');
202
+ }
203
+ const r = await this.events(task.task_id, { after, wait_ms: Math.min(25_000, Math.max(0, until - Date.now())) });
204
+ task = r.task;
205
+ after = r.cursor;
206
+ for (const e of r.data)
207
+ await onEvent?.(e, task);
208
+ const p = task.pending;
209
+ if (!p || task.done)
210
+ continue;
211
+ const id = p.type === 'question' ? p.question_id : p.approval_id;
212
+ if (id === answered)
213
+ continue; // answered already; the task is picking it up
214
+ if (p.type === 'question') {
215
+ if (!onQuestion)
216
+ throw new NeedsInputError(task);
217
+ await this.respond(task.task_id, { question_id: p.question_id, answer: String(await onQuestion(p, task)) });
218
+ }
219
+ else {
220
+ if (!onApproval)
221
+ throw new NeedsInputError(task);
222
+ const d = await onApproval(p, task);
223
+ const decision = typeof d === 'object' ? d : { decision: d === true || d === 'approve' ? 'approve' : 'deny' };
224
+ await this.respond(task.task_id, { approval_id: p.approval_id, ...decision });
225
+ }
226
+ answered = id;
227
+ }
228
+ return task;
229
+ }
230
+ }
231
+ // ---------- webhooks ----------
232
+ class WebhookEndpoint extends Resource {
233
+ retrieve() { return this.client.request('GET', '/webhook'); }
234
+ /** Sets the endpoint. The signing secret is in the response when it's new (or with rotate_secret), never again. */
235
+ update(params) { return this.client.request('PUT', '/webhook', { body: params }); }
236
+ delete() { return this.client.request('PUT', '/webhook', { body: { url: null } }); }
237
+ }
238
+ /**
239
+ * Checks a webhook's GuidingHand-Signature header against the raw request body and your signing secret, and
240
+ * returns the parsed event. Throws WebhookVerificationError if it doesn't match or is older than `tolerance` s.
241
+ */
242
+ export async function verifyWebhook(payload, signatureHeader, secret, { tolerance = 300 } = {}) {
243
+ const fail = (m) => new WebhookVerificationError(m, 400, 'webhook_verification');
244
+ const parts = Object.fromEntries(String(signatureHeader ?? '').split(',').map((kv) => kv.trim().split('=')));
245
+ const t = Number(parts.t), v1 = parts.v1;
246
+ if (!t || !v1)
247
+ throw fail('Missing or malformed GuidingHand-Signature header.');
248
+ if (Math.abs(Date.now() / 1000 - t) > tolerance)
249
+ throw fail('The webhook’s timestamp is too old.');
250
+ const body = typeof payload === 'string' ? payload : new TextDecoder().decode(payload);
251
+ const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
252
+ const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${t}.${body}`)));
253
+ const want = [...mac].map((b) => b.toString(16).padStart(2, '0')).join('');
254
+ if (want.length !== v1.length)
255
+ throw fail('The webhook’s signature doesn’t match.');
256
+ let diff = 0;
257
+ for (let i = 0; i < want.length; i++)
258
+ diff |= want.charCodeAt(i) ^ v1.charCodeAt(i);
259
+ if (diff)
260
+ throw fail('The webhook’s signature doesn’t match.');
261
+ return JSON.parse(body);
262
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "guidinghand",
3
+ "version": "0.1.0",
4
+ "description": "GuidingHand SDK: put an AI agent on your customer's computer with one link. Sessions, tasks, agents, recordings and webhooks.",
5
+ "license": "MIT",
6
+ "homepage": "https://guidinghand.ai",
7
+ "keywords": ["guidinghand", "ai", "agent", "computer-use", "support", "remote-assistance"],
8
+ "type": "module",
9
+ "main": "./dist/cjs/index.js",
10
+ "module": "./dist/esm/index.js",
11
+ "types": "./dist/esm/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "import": { "types": "./dist/esm/index.d.ts", "default": "./dist/esm/index.js" },
15
+ "require": { "types": "./dist/cjs/index.d.ts", "default": "./dist/cjs/index.js" }
16
+ }
17
+ },
18
+ "files": ["dist", "README.md", "LICENSE"],
19
+ "engines": { "node": ">=18" },
20
+ "sideEffects": false,
21
+ "scripts": {
22
+ "build": "rm -rf dist && tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node -e \"require('fs').writeFileSync('dist/cjs/package.json', JSON.stringify({ type: 'commonjs' }))\"",
23
+ "test": "npm run build && node test/e2e.mjs",
24
+ "prepublishOnly": "npm test"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^22.0.0",
28
+ "typescript": "^5.6.0"
29
+ }
30
+ }