guidinghand 0.2.0 → 0.3.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.
package/README.md CHANGED
@@ -27,7 +27,7 @@ const task = await gh.tasks.run(session.session_id, {
27
27
  prompt: 'Turn on Dark Mode',
28
28
  onEvent: (e) => console.log(e.type, e.message),
29
29
  onQuestion: async (q) => 'Work', // q.question, q.options (leave it out to let the customer answer on their screen)
30
- onApproval: async (a) => a.risk !== 'high', // true/'approve' or false/'deny'
30
+ onApproval: async (a) => a.risk !== 'high', // true/'approve' or false/'deny' (leave it out to let the customer decide on their screen)
31
31
  });
32
32
 
33
33
  console.log(task.status, task.result); // completed "Dark Mode is on."
@@ -41,7 +41,7 @@ An agent is your configuration: instructions (added under GuidingHand's own rule
41
41
  ```ts
42
42
  await gh.agents.create({ agent_id: 'billing', name: 'Billing help', instructions: 'Open the Billing app from the Dock.', effort: 'medium', greeting: 'Hi, this is Acme billing.' });
43
43
  await gh.agents.update('billing', { effort: 'high' });
44
- await gh.agents.update('billing', { display_name: 'Acme Support', narration: true, customer_answers: false }); // on their screen
44
+ await gh.agents.update('billing', { display_name: 'Acme Support', narration: true, customer_answers: false, customer_approvals: false }); // on their screen
45
45
  const { data } = await gh.agents.list();
46
46
  await gh.agents.delete('billing');
47
47
  ```
@@ -73,7 +73,7 @@ const page = await gh.tasks.list({ status: 'completed', limit: 50 }); // { da
73
73
  for await (const t of gh.tasks.listAll({ agent_id: 'billing' })) { /* every page */ }
74
74
  ```
75
75
 
76
- ## Questions the customer answers
76
+ ## Questions and approvals the customer answers
77
77
 
78
78
  Unless the agent turns it off (`customer_answers: false`), the agent's questions also appear on the customer's screen, and they can answer them there. `question.customer_can_answer` says whether they can answer the open one. The first answer is used, from them or from you.
79
79
 
@@ -82,17 +82,21 @@ Unless the agent turns it off (`customer_answers: false`), the agent's questions
82
82
  - `respond()` after they answered throws `ConflictError` with `extra.code === 'already_answered'` and `extra.answered_by === 'customer'`.
83
83
  - The `answer` event has `data.answered_by` (`'customer'` or `'operator'`), and the `task.question_answered` webhook carries the answer and who gave it.
84
84
 
85
+ Approvals work the same way. Unless the agent turns it off (`customer_approvals: false`), the customer can approve or deny the agent's approval requests on their screen, and `approval.customer_can_approve` says whether they can decide the open one.
86
+
87
+ - Without `onApproval`, `run()` leaves those approvals to the customer and keeps following. With `onApproval`, return `null` to leave one to them.
88
+ - `respond()` after they decided throws `ConflictError` with `extra.code === 'already_answered'` and `extra.answered_by === 'customer'`.
89
+ - The `approved` and `denied` events have `data.answered_by`, and the `task.approval_decided` webhook carries the decision and who made it.
90
+
85
91
  ```ts
86
92
  const task = await gh.tasks.run(sessionId, {
87
93
  prompt: 'Set up the office printer',
88
94
  onEvent: (e) => { if (e.type === 'answer') console.log(`${e.data?.answered_by} answered: ${e.message}`); },
89
95
  onQuestion: async (q) => (q.customer_can_answer ? null : askMyTeam(q.question, q.options)),
90
- onApproval: async (a) => askMyTeamToApprove(a.action),
96
+ onApproval: async (a) => (a.customer_can_approve && a.risk !== 'high' ? null : askMyTeamToApprove(a.action)),
91
97
  });
92
98
  ```
93
99
 
94
- Approvals always come from your team.
95
-
96
100
  ## Recordings
97
101
 
98
102
  ```ts
@@ -105,7 +109,7 @@ The console replays them with the agent's cursor at `task.replay_url`.
105
109
  ## Webhooks
106
110
 
107
111
  ```ts
108
- const { secret } = await gh.webhook.update({ url: 'https://example.com/guidinghand', events: ['task.completed', 'task.waiting_for_user', 'task.question_answered'] });
112
+ const { secret } = await gh.webhook.update({ url: 'https://example.com/guidinghand', events: ['task.completed', 'task.waiting_for_user', 'task.question_answered', 'task.approval_decided'] });
109
113
  ```
110
114
 
111
115
  Verify each delivery with the raw request body:
@@ -117,6 +121,7 @@ app.post('/guidinghand', express.raw({ type: 'application/json' }), async (req,
117
121
  const event = await verifyWebhook(req.body, req.header('GuidingHand-Signature'), process.env.GUIDINGHAND_WEBHOOK_SECRET);
118
122
  if (event.type === 'task.completed') console.log(event.data.task.result);
119
123
  if (event.type === 'task.question_answered') console.log(event.data.answer.answered_by, event.data.answer.answer);
124
+ if (event.type === 'task.approval_decided') console.log(event.data.decision.answered_by, event.data.decision.decision);
120
125
  res.sendStatus(200);
121
126
  });
122
127
  ```
@@ -132,11 +137,11 @@ Every error is a `GuidingHandError` with `status`, `type`, `message` and `extra`
132
137
  | `PaymentRequiredError` | 402 | the org's plan doesn't allow it (free minutes used up) |
133
138
  | `PermissionDeniedError` | 403 | your role can't do this |
134
139
  | `NotFoundError` | 404 | not in this org |
135
- | `ConflictError` | 409 | no computer connected, a task already running (`extra.active_task_id`), nothing pending, or already answered (`extra.code: 'already_answered'`, `extra.answered_by`) |
140
+ | `ConflictError` | 409 | no computer connected, a task already running (`extra.active_task_id`), nothing pending, or already answered or decided (`extra.code: 'already_answered'`, `extra.answered_by`) |
136
141
  | `RateLimitError` | 429 | too many requests, or the plan's tasks at once |
137
142
  | `APIError` | 5xx | our side |
138
143
  | `APIConnectionError`, `TimeoutError` | | network |
139
- | `NeedsInputError` | | `run()` got a question (that the customer can't answer) or an approval without a handler (`error.task`) |
144
+ | `NeedsInputError` | | `run()` got a question or approval (that the customer can't answer or decide) without a handler (`error.task`) |
140
145
  | `SessionExpiredError` | | `waitForConnection()` on a code that expired |
141
146
 
142
147
  Reads, deletes and starts with a `request_id` are retried on connection errors, 429 and 5xx (`maxRetries`, default 2).
@@ -1,4 +1,4 @@
1
- export declare const VERSION = "0.2.0";
1
+ export declare const VERSION = "0.3.0";
2
2
  export type Metadata = Record<string, string>;
3
3
  export type Effort = 'low' | 'medium' | 'high' | null;
4
4
  export type Agent = {
@@ -12,8 +12,10 @@ export type Agent = {
12
12
  display_name: string;
13
13
  /** The app shows the agent's thoughts and steps on the person's screen as it works. */
14
14
  narration: boolean;
15
- /** The person at the computer can answer the agent's questions in the app (the team still can; the first answer is used). Approvals always stay with the team. */
15
+ /** The person at the computer can answer the agent's questions in the app (the team still can; the first answer is used). */
16
16
  customer_answers: boolean;
17
+ /** The person at the computer can approve or deny the agent's approval requests in the app (the team still can; the first decision is used). */
18
+ customer_approvals: boolean;
17
19
  is_default: boolean;
18
20
  invite_url_template: string;
19
21
  created_at: string | null;
@@ -52,16 +54,21 @@ export type Question = {
52
54
  options: string[];
53
55
  customer_can_answer: boolean;
54
56
  };
57
+ /** customer_can_approve: the person at the computer can approve or deny it on their screen too (the first decision is used). */
55
58
  export type Approval = {
56
59
  type: 'approval';
57
60
  approval_id: string;
58
61
  action: string;
59
62
  risk: 'low' | 'medium' | 'high';
63
+ customer_can_approve: boolean;
60
64
  };
61
65
  export type EventType = 'started' | 'progress' | 'thinking' | 'action' | 'message' | 'question' | 'answer' | 'approval_required' | 'approved' | 'denied' | 'completed' | 'error' | 'stopped';
62
- /** Who answered a question: the person at the computer ('customer', in the app) or your team ('operator': the API or the console). */
66
+ /** Who answered a question or decided an approval: the person at the computer ('customer', in the app) or your team ('operator': the API or the console). */
63
67
  export type AnsweredBy = 'customer' | 'operator';
64
- /** data: e.g. { action } for 'action', { question_id, question, options } for 'question', { question_id, answered_by } for 'answer' (whose message is the answer). */
68
+ /**
69
+ * data: e.g. { action } for 'action', { question_id, question, options } for 'question', { question_id, answered_by } for 'answer'
70
+ * (whose message is the answer), { approval_id, answered_by, note? } for 'approved' and 'denied'.
71
+ */
65
72
  export type TaskEvent = {
66
73
  cursor: number;
67
74
  type: EventType;
@@ -107,7 +114,7 @@ export type Recording = {
107
114
  task_id: string;
108
115
  frames: Frame[];
109
116
  };
110
- export type WebhookEventType = 'session.connected' | 'session.disconnected' | 'task.started' | 'task.waiting_for_user' | 'task.question_answered' | 'task.waiting_for_approval' | 'task.completed' | 'task.failed' | 'task.stopped';
117
+ export type WebhookEventType = 'session.connected' | 'session.disconnected' | 'task.started' | 'task.waiting_for_user' | 'task.question_answered' | 'task.waiting_for_approval' | 'task.approval_decided' | 'task.completed' | 'task.failed' | 'task.stopped';
111
118
  export type Webhook = {
112
119
  object: 'webhook';
113
120
  url: string | null;
@@ -121,7 +128,13 @@ export type QuestionAnswer = {
121
128
  answer: string;
122
129
  answered_by: AnsweredBy;
123
130
  };
124
- /** data.answer: for task.question_answered. */
131
+ export type ApprovalDecision = {
132
+ approval_id: string;
133
+ decision: 'approve' | 'deny';
134
+ note: string | null;
135
+ answered_by: AnsweredBy;
136
+ };
137
+ /** data.answer: for task.question_answered. data.decision: for task.approval_decided. */
125
138
  export type WebhookEvent = {
126
139
  id: string;
127
140
  type: WebhookEventType;
@@ -131,6 +144,7 @@ export type WebhookEvent = {
131
144
  task?: Task;
132
145
  session?: Session;
133
146
  answer?: QuestionAnswer;
147
+ decision?: ApprovalDecision;
134
148
  };
135
149
  };
136
150
  export type Page<T> = {
@@ -159,7 +173,7 @@ export declare class PermissionDeniedError extends GuidingHandError {
159
173
  }
160
174
  export declare class NotFoundError extends GuidingHandError {
161
175
  }
162
- /** 409. From respond(): extra.code is 'already_answered' when someone answered first (extra.answered_by: 'customer' is the person at the computer), else 'not_pending'. */
176
+ /** 409. From respond(): extra.code is 'already_answered' when someone answered or decided first (extra.answered_by: 'customer' is the person at the computer), else 'not_pending'. */
163
177
  export declare class ConflictError extends GuidingHandError {
164
178
  }
165
179
  export declare class RateLimitError extends GuidingHandError {
@@ -172,7 +186,7 @@ export declare class TimeoutError extends GuidingHandError {
172
186
  }
173
187
  export declare class SessionExpiredError extends GuidingHandError {
174
188
  }
175
- /** A task waits on a question or approval and `run()` got no handler for it (a question the person at the computer can answer is left to them instead). */
189
+ /** A task waits on a question or approval and `run()` got no handler for it (one the person at the computer can answer or decide is left to them instead). */
176
190
  export declare class NeedsInputError extends GuidingHandError {
177
191
  task: Task;
178
192
  constructor(task: Task);
@@ -227,6 +241,7 @@ export type AgentCreate = {
227
241
  display_name?: string;
228
242
  narration?: boolean;
229
243
  customer_answers?: boolean;
244
+ customer_approvals?: boolean;
230
245
  };
231
246
  export type AgentUpdate = Partial<Omit<AgentCreate, 'agent_id'>>;
232
247
  declare class Agents extends Resource {
@@ -283,6 +298,11 @@ export type Respond = {
283
298
  decision: 'approve' | 'deny';
284
299
  note?: string;
285
300
  };
301
+ /** null leaves the approval to the person at the computer when they can decide it (customer_can_approve); otherwise it denies. */
302
+ export type ApprovalReply = boolean | 'approve' | 'deny' | {
303
+ decision: 'approve' | 'deny';
304
+ note?: string;
305
+ } | null | undefined;
286
306
  export type RunOptions = TaskCreate & {
287
307
  /** Every event, as it happens. */
288
308
  onEvent?: (event: TaskEvent, task: Task) => void | Promise<void>;
@@ -292,14 +312,13 @@ export type RunOptions = TaskCreate & {
292
312
  * every such question and throws NeedsInputError only for the others. If they answer first, yours is dropped.
293
313
  */
294
314
  onQuestion?: (question: Question, task: Task) => string | null | undefined | Promise<string | null | undefined>;
295
- /** The agent wants to do something consequential: return true (or 'approve') to let it, false (or 'deny', or { decision, note }) not to. */
296
- onApproval?: (approval: Approval, task: Task) => boolean | 'approve' | 'deny' | {
297
- decision: 'approve' | 'deny';
298
- note?: string;
299
- } | Promise<boolean | 'approve' | 'deny' | {
300
- decision: 'approve' | 'deny';
301
- note?: string;
302
- }>;
315
+ /**
316
+ * The agent wants to do something consequential: return true (or 'approve') to let it, false (or 'deny', or
317
+ * { decision, note }) not to. When the person at the computer can decide it on their screen
318
+ * (approval.customer_can_approve), return null to leave it to them; without onApproval, run() does that for
319
+ * every such approval and throws NeedsInputError only for the others. If they decide first, yours is dropped.
320
+ */
321
+ onApproval?: (approval: Approval, task: Task) => ApprovalReply | Promise<ApprovalReply>;
303
322
  /** Give up (and stop the task) after this long, in ms. */
304
323
  timeout?: number;
305
324
  };
@@ -331,7 +350,7 @@ declare class Tasks extends Resource {
331
350
  stream(taskId: string, { after }?: {
332
351
  after?: number;
333
352
  }): AsyncGenerator<TaskEvent, Task>;
334
- /** Starts a task and sees it through: events to onEvent, questions to onQuestion (or the person at the computer), approvals to onApproval. Resolves with the finished task. */
353
+ /** Starts a task and sees it through: events to onEvent, questions to onQuestion and approvals to onApproval (or the person at the computer). Resolves with the finished task. */
335
354
  run(sessionId: string, { onEvent, onQuestion, onApproval, timeout, ...params }: RunOptions): Promise<Task>;
336
355
  }
337
356
  declare class WebhookEndpoint extends Resource {
package/dist/cjs/index.js CHANGED
@@ -8,11 +8,11 @@
8
8
  // // send session.invite_url to the person at the computer, then:
9
9
  // await gh.sessions.waitForConnection(session.session_id);
10
10
  // const task = await gh.tasks.run(session.session_id, { prompt: 'Turn on Dark Mode', onQuestion: async (q) => 'Work', onApproval: async () => true });
11
- // // (without onQuestion, questions the person at the computer can answer on their screen are left to them)
11
+ // // (without onQuestion or onApproval, what the person at the computer can answer or decide on their screen is left to them)
12
12
  Object.defineProperty(exports, "__esModule", { value: true });
13
13
  exports.GuidingHand = exports.WebhookVerificationError = exports.NeedsInputError = exports.SessionExpiredError = exports.TimeoutError = exports.APIConnectionError = exports.APIError = exports.RateLimitError = exports.ConflictError = exports.NotFoundError = exports.PermissionDeniedError = exports.PaymentRequiredError = exports.AuthenticationError = exports.InvalidRequestError = exports.GuidingHandError = exports.VERSION = void 0;
14
14
  exports.verifyWebhook = verifyWebhook;
15
- exports.VERSION = '0.2.0';
15
+ exports.VERSION = '0.3.0';
16
16
  // ---------- errors ----------
17
17
  class GuidingHandError extends Error {
18
18
  status;
@@ -45,7 +45,7 @@ exports.PermissionDeniedError = PermissionDeniedError;
45
45
  class NotFoundError extends GuidingHandError {
46
46
  }
47
47
  exports.NotFoundError = NotFoundError;
48
- /** 409. From respond(): extra.code is 'already_answered' when someone answered first (extra.answered_by: 'customer' is the person at the computer), else 'not_pending'. */
48
+ /** 409. From respond(): extra.code is 'already_answered' when someone answered or decided first (extra.answered_by: 'customer' is the person at the computer), else 'not_pending'. */
49
49
  class ConflictError extends GuidingHandError {
50
50
  }
51
51
  exports.ConflictError = ConflictError;
@@ -64,7 +64,7 @@ exports.TimeoutError = TimeoutError;
64
64
  class SessionExpiredError extends GuidingHandError {
65
65
  }
66
66
  exports.SessionExpiredError = SessionExpiredError;
67
- /** A task waits on a question or approval and `run()` got no handler for it (a question the person at the computer can answer is left to them instead). */
67
+ /** A task waits on a question or approval and `run()` got no handler for it (one the person at the computer can answer or decide is left to them instead). */
68
68
  class NeedsInputError extends GuidingHandError {
69
69
  task;
70
70
  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; }
@@ -214,7 +214,7 @@ class Tasks extends Resource {
214
214
  return r.task;
215
215
  }
216
216
  }
217
- /** Starts a task and sees it through: events to onEvent, questions to onQuestion (or the person at the computer), approvals to onApproval. Resolves with the finished task. */
217
+ /** Starts a task and sees it through: events to onEvent, questions to onQuestion and approvals to onApproval (or the person at the computer). Resolves with the finished task. */
218
218
  async run(sessionId, { onEvent, onQuestion, onApproval, timeout, ...params }) {
219
219
  let task = await this.create(sessionId, params);
220
220
  const until = timeout ? Date.now() + timeout : Infinity;
@@ -236,7 +236,7 @@ class Tasks extends Resource {
236
236
  continue;
237
237
  const id = p.type === 'question' ? p.question_id : p.approval_id;
238
238
  if (id === answered)
239
- continue; // answered already (or left to the person at the computer); the task is picking it up
239
+ continue; // answered or decided already (or left to the person at the computer); the task is picking it up
240
240
  try {
241
241
  if (p.type === 'question') {
242
242
  // Without onQuestion, or when it returns nothing, a question the person at the computer can answer on
@@ -250,15 +250,20 @@ class Tasks extends Resource {
250
250
  throw new TypeError('onQuestion returned no answer, and the person at the computer can’t answer this question (customer_can_answer is false).');
251
251
  }
252
252
  else {
253
- if (!onApproval)
253
+ // The same for an approval the person at the computer can decide on their screen.
254
+ if (!onApproval && !p.customer_can_approve)
254
255
  throw new NeedsInputError(task);
255
- const d = await onApproval(p, task);
256
- const decision = d && typeof d === 'object' ? d : { decision: d === true || d === 'approve' ? 'approve' : 'deny' };
257
- await this.respond(task.task_id, { approval_id: p.approval_id, ...decision });
256
+ const d = onApproval ? await onApproval(p, task) : null;
257
+ // null leaves it to the person at the computer when they can decide it; otherwise it denies, as before.
258
+ if ((d === null || d === undefined) && p.customer_can_approve) { /* theirs */ }
259
+ else {
260
+ const decision = d && typeof d === 'object' ? d : { decision: d === true || d === 'approve' ? 'approve' : 'deny' };
261
+ await this.respond(task.task_id, { approval_id: p.approval_id, ...decision });
262
+ }
258
263
  }
259
264
  }
260
265
  catch (e) {
261
- // Answered somewhere else first (the person at the computer, the console, another process: extra.code
266
+ // Answered or decided somewhere else first (the person at the computer, the console, another process: extra.code
262
267
  // 'already_answered') or the task ended meanwhile: keep following.
263
268
  if (!(e instanceof ConflictError))
264
269
  throw e;
@@ -1,4 +1,4 @@
1
- export declare const VERSION = "0.2.0";
1
+ export declare const VERSION = "0.3.0";
2
2
  export type Metadata = Record<string, string>;
3
3
  export type Effort = 'low' | 'medium' | 'high' | null;
4
4
  export type Agent = {
@@ -12,8 +12,10 @@ export type Agent = {
12
12
  display_name: string;
13
13
  /** The app shows the agent's thoughts and steps on the person's screen as it works. */
14
14
  narration: boolean;
15
- /** The person at the computer can answer the agent's questions in the app (the team still can; the first answer is used). Approvals always stay with the team. */
15
+ /** The person at the computer can answer the agent's questions in the app (the team still can; the first answer is used). */
16
16
  customer_answers: boolean;
17
+ /** The person at the computer can approve or deny the agent's approval requests in the app (the team still can; the first decision is used). */
18
+ customer_approvals: boolean;
17
19
  is_default: boolean;
18
20
  invite_url_template: string;
19
21
  created_at: string | null;
@@ -52,16 +54,21 @@ export type Question = {
52
54
  options: string[];
53
55
  customer_can_answer: boolean;
54
56
  };
57
+ /** customer_can_approve: the person at the computer can approve or deny it on their screen too (the first decision is used). */
55
58
  export type Approval = {
56
59
  type: 'approval';
57
60
  approval_id: string;
58
61
  action: string;
59
62
  risk: 'low' | 'medium' | 'high';
63
+ customer_can_approve: boolean;
60
64
  };
61
65
  export type EventType = 'started' | 'progress' | 'thinking' | 'action' | 'message' | 'question' | 'answer' | 'approval_required' | 'approved' | 'denied' | 'completed' | 'error' | 'stopped';
62
- /** Who answered a question: the person at the computer ('customer', in the app) or your team ('operator': the API or the console). */
66
+ /** Who answered a question or decided an approval: the person at the computer ('customer', in the app) or your team ('operator': the API or the console). */
63
67
  export type AnsweredBy = 'customer' | 'operator';
64
- /** data: e.g. { action } for 'action', { question_id, question, options } for 'question', { question_id, answered_by } for 'answer' (whose message is the answer). */
68
+ /**
69
+ * data: e.g. { action } for 'action', { question_id, question, options } for 'question', { question_id, answered_by } for 'answer'
70
+ * (whose message is the answer), { approval_id, answered_by, note? } for 'approved' and 'denied'.
71
+ */
65
72
  export type TaskEvent = {
66
73
  cursor: number;
67
74
  type: EventType;
@@ -107,7 +114,7 @@ export type Recording = {
107
114
  task_id: string;
108
115
  frames: Frame[];
109
116
  };
110
- export type WebhookEventType = 'session.connected' | 'session.disconnected' | 'task.started' | 'task.waiting_for_user' | 'task.question_answered' | 'task.waiting_for_approval' | 'task.completed' | 'task.failed' | 'task.stopped';
117
+ export type WebhookEventType = 'session.connected' | 'session.disconnected' | 'task.started' | 'task.waiting_for_user' | 'task.question_answered' | 'task.waiting_for_approval' | 'task.approval_decided' | 'task.completed' | 'task.failed' | 'task.stopped';
111
118
  export type Webhook = {
112
119
  object: 'webhook';
113
120
  url: string | null;
@@ -121,7 +128,13 @@ export type QuestionAnswer = {
121
128
  answer: string;
122
129
  answered_by: AnsweredBy;
123
130
  };
124
- /** data.answer: for task.question_answered. */
131
+ export type ApprovalDecision = {
132
+ approval_id: string;
133
+ decision: 'approve' | 'deny';
134
+ note: string | null;
135
+ answered_by: AnsweredBy;
136
+ };
137
+ /** data.answer: for task.question_answered. data.decision: for task.approval_decided. */
125
138
  export type WebhookEvent = {
126
139
  id: string;
127
140
  type: WebhookEventType;
@@ -131,6 +144,7 @@ export type WebhookEvent = {
131
144
  task?: Task;
132
145
  session?: Session;
133
146
  answer?: QuestionAnswer;
147
+ decision?: ApprovalDecision;
134
148
  };
135
149
  };
136
150
  export type Page<T> = {
@@ -159,7 +173,7 @@ export declare class PermissionDeniedError extends GuidingHandError {
159
173
  }
160
174
  export declare class NotFoundError extends GuidingHandError {
161
175
  }
162
- /** 409. From respond(): extra.code is 'already_answered' when someone answered first (extra.answered_by: 'customer' is the person at the computer), else 'not_pending'. */
176
+ /** 409. From respond(): extra.code is 'already_answered' when someone answered or decided first (extra.answered_by: 'customer' is the person at the computer), else 'not_pending'. */
163
177
  export declare class ConflictError extends GuidingHandError {
164
178
  }
165
179
  export declare class RateLimitError extends GuidingHandError {
@@ -172,7 +186,7 @@ export declare class TimeoutError extends GuidingHandError {
172
186
  }
173
187
  export declare class SessionExpiredError extends GuidingHandError {
174
188
  }
175
- /** A task waits on a question or approval and `run()` got no handler for it (a question the person at the computer can answer is left to them instead). */
189
+ /** A task waits on a question or approval and `run()` got no handler for it (one the person at the computer can answer or decide is left to them instead). */
176
190
  export declare class NeedsInputError extends GuidingHandError {
177
191
  task: Task;
178
192
  constructor(task: Task);
@@ -227,6 +241,7 @@ export type AgentCreate = {
227
241
  display_name?: string;
228
242
  narration?: boolean;
229
243
  customer_answers?: boolean;
244
+ customer_approvals?: boolean;
230
245
  };
231
246
  export type AgentUpdate = Partial<Omit<AgentCreate, 'agent_id'>>;
232
247
  declare class Agents extends Resource {
@@ -283,6 +298,11 @@ export type Respond = {
283
298
  decision: 'approve' | 'deny';
284
299
  note?: string;
285
300
  };
301
+ /** null leaves the approval to the person at the computer when they can decide it (customer_can_approve); otherwise it denies. */
302
+ export type ApprovalReply = boolean | 'approve' | 'deny' | {
303
+ decision: 'approve' | 'deny';
304
+ note?: string;
305
+ } | null | undefined;
286
306
  export type RunOptions = TaskCreate & {
287
307
  /** Every event, as it happens. */
288
308
  onEvent?: (event: TaskEvent, task: Task) => void | Promise<void>;
@@ -292,14 +312,13 @@ export type RunOptions = TaskCreate & {
292
312
  * every such question and throws NeedsInputError only for the others. If they answer first, yours is dropped.
293
313
  */
294
314
  onQuestion?: (question: Question, task: Task) => string | null | undefined | Promise<string | null | undefined>;
295
- /** The agent wants to do something consequential: return true (or 'approve') to let it, false (or 'deny', or { decision, note }) not to. */
296
- onApproval?: (approval: Approval, task: Task) => boolean | 'approve' | 'deny' | {
297
- decision: 'approve' | 'deny';
298
- note?: string;
299
- } | Promise<boolean | 'approve' | 'deny' | {
300
- decision: 'approve' | 'deny';
301
- note?: string;
302
- }>;
315
+ /**
316
+ * The agent wants to do something consequential: return true (or 'approve') to let it, false (or 'deny', or
317
+ * { decision, note }) not to. When the person at the computer can decide it on their screen
318
+ * (approval.customer_can_approve), return null to leave it to them; without onApproval, run() does that for
319
+ * every such approval and throws NeedsInputError only for the others. If they decide first, yours is dropped.
320
+ */
321
+ onApproval?: (approval: Approval, task: Task) => ApprovalReply | Promise<ApprovalReply>;
303
322
  /** Give up (and stop the task) after this long, in ms. */
304
323
  timeout?: number;
305
324
  };
@@ -331,7 +350,7 @@ declare class Tasks extends Resource {
331
350
  stream(taskId: string, { after }?: {
332
351
  after?: number;
333
352
  }): AsyncGenerator<TaskEvent, Task>;
334
- /** Starts a task and sees it through: events to onEvent, questions to onQuestion (or the person at the computer), approvals to onApproval. Resolves with the finished task. */
353
+ /** Starts a task and sees it through: events to onEvent, questions to onQuestion and approvals to onApproval (or the person at the computer). Resolves with the finished task. */
335
354
  run(sessionId: string, { onEvent, onQuestion, onApproval, timeout, ...params }: RunOptions): Promise<Task>;
336
355
  }
337
356
  declare class WebhookEndpoint extends Resource {
package/dist/esm/index.js CHANGED
@@ -7,8 +7,8 @@
7
7
  // // send session.invite_url to the person at the computer, then:
8
8
  // await gh.sessions.waitForConnection(session.session_id);
9
9
  // const task = await gh.tasks.run(session.session_id, { prompt: 'Turn on Dark Mode', onQuestion: async (q) => 'Work', onApproval: async () => true });
10
- // // (without onQuestion, questions the person at the computer can answer on their screen are left to them)
11
- export const VERSION = '0.2.0';
10
+ // // (without onQuestion or onApproval, what the person at the computer can answer or decide on their screen is left to them)
11
+ export const VERSION = '0.3.0';
12
12
  // ---------- errors ----------
13
13
  export class GuidingHandError extends Error {
14
14
  status;
@@ -35,7 +35,7 @@ export class PermissionDeniedError extends GuidingHandError {
35
35
  }
36
36
  export class NotFoundError extends GuidingHandError {
37
37
  }
38
- /** 409. From respond(): extra.code is 'already_answered' when someone answered first (extra.answered_by: 'customer' is the person at the computer), else 'not_pending'. */
38
+ /** 409. From respond(): extra.code is 'already_answered' when someone answered or decided first (extra.answered_by: 'customer' is the person at the computer), else 'not_pending'. */
39
39
  export class ConflictError extends GuidingHandError {
40
40
  }
41
41
  export class RateLimitError extends GuidingHandError {
@@ -48,7 +48,7 @@ export class TimeoutError extends GuidingHandError {
48
48
  }
49
49
  export class SessionExpiredError extends GuidingHandError {
50
50
  }
51
- /** A task waits on a question or approval and `run()` got no handler for it (a question the person at the computer can answer is left to them instead). */
51
+ /** A task waits on a question or approval and `run()` got no handler for it (one the person at the computer can answer or decide is left to them instead). */
52
52
  export class NeedsInputError extends GuidingHandError {
53
53
  task;
54
54
  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; }
@@ -195,7 +195,7 @@ class Tasks extends Resource {
195
195
  return r.task;
196
196
  }
197
197
  }
198
- /** Starts a task and sees it through: events to onEvent, questions to onQuestion (or the person at the computer), approvals to onApproval. Resolves with the finished task. */
198
+ /** Starts a task and sees it through: events to onEvent, questions to onQuestion and approvals to onApproval (or the person at the computer). Resolves with the finished task. */
199
199
  async run(sessionId, { onEvent, onQuestion, onApproval, timeout, ...params }) {
200
200
  let task = await this.create(sessionId, params);
201
201
  const until = timeout ? Date.now() + timeout : Infinity;
@@ -217,7 +217,7 @@ class Tasks extends Resource {
217
217
  continue;
218
218
  const id = p.type === 'question' ? p.question_id : p.approval_id;
219
219
  if (id === answered)
220
- continue; // answered already (or left to the person at the computer); the task is picking it up
220
+ continue; // answered or decided already (or left to the person at the computer); the task is picking it up
221
221
  try {
222
222
  if (p.type === 'question') {
223
223
  // Without onQuestion, or when it returns nothing, a question the person at the computer can answer on
@@ -231,15 +231,20 @@ class Tasks extends Resource {
231
231
  throw new TypeError('onQuestion returned no answer, and the person at the computer can’t answer this question (customer_can_answer is false).');
232
232
  }
233
233
  else {
234
- if (!onApproval)
234
+ // The same for an approval the person at the computer can decide on their screen.
235
+ if (!onApproval && !p.customer_can_approve)
235
236
  throw new NeedsInputError(task);
236
- const d = await onApproval(p, task);
237
- const decision = d && typeof d === 'object' ? d : { decision: d === true || d === 'approve' ? 'approve' : 'deny' };
238
- await this.respond(task.task_id, { approval_id: p.approval_id, ...decision });
237
+ const d = onApproval ? await onApproval(p, task) : null;
238
+ // null leaves it to the person at the computer when they can decide it; otherwise it denies, as before.
239
+ if ((d === null || d === undefined) && p.customer_can_approve) { /* theirs */ }
240
+ else {
241
+ const decision = d && typeof d === 'object' ? d : { decision: d === true || d === 'approve' ? 'approve' : 'deny' };
242
+ await this.respond(task.task_id, { approval_id: p.approval_id, ...decision });
243
+ }
239
244
  }
240
245
  }
241
246
  catch (e) {
242
- // Answered somewhere else first (the person at the computer, the console, another process: extra.code
247
+ // Answered or decided somewhere else first (the person at the computer, the console, another process: extra.code
243
248
  // 'already_answered') or the task ended meanwhile: keep following.
244
249
  if (!(e instanceof ConflictError))
245
250
  throw e;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "guidinghand",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "GuidingHand SDK: put an AI agent on your customer's computer with one link. Sessions, tasks, agents, recordings and webhooks.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://guidinghand.ai",