guidinghand 0.1.1 → 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
@@ -26,8 +26,8 @@ await gh.sessions.waitForConnection(session.session_id);
26
26
  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
- onQuestion: async (q) => 'Work', // q.question, q.options
30
- onApproval: async (a) => a.risk !== 'high', // true/'approve' or false/'deny'
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' (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."
@@ -36,11 +36,12 @@ console.log('Replay:', task.replay_url);
36
36
 
37
37
  ## Agents
38
38
 
39
- An agent is your configuration: instructions (added under GuidingHand's own rules, which always win), effort and a greeting for the invite page. Every org has a `default` agent.
39
+ An agent is your configuration: instructions (added under GuidingHand's own rules, which always win), effort, a greeting for the invite page, and what the customer sees and may do on their screen. Every org has a `default` agent.
40
40
 
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, customer_approvals: false }); // on their screen
44
45
  const { data } = await gh.agents.list();
45
46
  await gh.agents.delete('billing');
46
47
  ```
@@ -72,6 +73,30 @@ const page = await gh.tasks.list({ status: 'completed', limit: 50 }); // { da
72
73
  for await (const t of gh.tasks.listAll({ agent_id: 'billing' })) { /* every page */ }
73
74
  ```
74
75
 
76
+ ## Questions and approvals the customer answers
77
+
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
+
80
+ - Without `onQuestion`, `run()` leaves those questions to the customer and keeps following the task (pass `timeout` to give up eventually). It throws `NeedsInputError` only for a question they can't answer.
81
+ - With `onQuestion`, return `null` to leave one to them (say, when `q.customer_can_answer` is true). If they answer while your handler works, your answer is refused with a 409 and `run()` carries on.
82
+ - `respond()` after they answered throws `ConflictError` with `extra.code === 'already_answered'` and `extra.answered_by === 'customer'`.
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
+
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
+
91
+ ```ts
92
+ const task = await gh.tasks.run(sessionId, {
93
+ prompt: 'Set up the office printer',
94
+ onEvent: (e) => { if (e.type === 'answer') console.log(`${e.data?.answered_by} answered: ${e.message}`); },
95
+ onQuestion: async (q) => (q.customer_can_answer ? null : askMyTeam(q.question, q.options)),
96
+ onApproval: async (a) => (a.customer_can_approve && a.risk !== 'high' ? null : askMyTeamToApprove(a.action)),
97
+ });
98
+ ```
99
+
75
100
  ## Recordings
76
101
 
77
102
  ```ts
@@ -84,7 +109,7 @@ The console replays them with the agent's cursor at `task.replay_url`.
84
109
  ## Webhooks
85
110
 
86
111
  ```ts
87
- const { secret } = await gh.webhook.update({ url: 'https://example.com/guidinghand', events: ['task.completed', 'task.waiting_for_user'] });
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'] });
88
113
  ```
89
114
 
90
115
  Verify each delivery with the raw request body:
@@ -95,6 +120,8 @@ import { verifyWebhook } from 'guidinghand';
95
120
  app.post('/guidinghand', express.raw({ type: 'application/json' }), async (req, res) => {
96
121
  const event = await verifyWebhook(req.body, req.header('GuidingHand-Signature'), process.env.GUIDINGHAND_WEBHOOK_SECRET);
97
122
  if (event.type === 'task.completed') console.log(event.data.task.result);
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);
98
125
  res.sendStatus(200);
99
126
  });
100
127
  ```
@@ -110,11 +137,11 @@ Every error is a `GuidingHandError` with `status`, `type`, `message` and `extra`
110
137
  | `PaymentRequiredError` | 402 | the org's plan doesn't allow it (free minutes used up) |
111
138
  | `PermissionDeniedError` | 403 | your role can't do this |
112
139
  | `NotFoundError` | 404 | not in this org |
113
- | `ConflictError` | 409 | no computer connected, a task already running (`extra.active_task_id`), nothing pending |
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`) |
114
141
  | `RateLimitError` | 429 | too many requests, or the plan's tasks at once |
115
142
  | `APIError` | 5xx | our side |
116
143
  | `APIConnectionError`, `TimeoutError` | | network |
117
- | `NeedsInputError` | | `run()` got a question or 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`) |
118
145
  | `SessionExpiredError` | | `waitForConnection()` on a code that expired |
119
146
 
120
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.1.1";
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 = {
@@ -8,6 +8,14 @@ export type Agent = {
8
8
  instructions: string;
9
9
  effort: Effort;
10
10
  greeting: string;
11
+ /** What the app calls the agent on the person's screen; '' is "GuidingHand". */
12
+ display_name: string;
13
+ /** The app shows the agent's thoughts and steps on the person's screen as it works. */
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). */
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;
11
19
  is_default: boolean;
12
20
  invite_url_template: string;
13
21
  created_at: string | null;
@@ -38,19 +46,29 @@ export type Session = {
38
46
  latest_task_id?: string | null;
39
47
  };
40
48
  export type TaskStatus = 'queued' | 'running' | 'waiting_for_user' | 'waiting_for_approval' | 'completed' | 'failed' | 'stopped';
49
+ /** customer_can_answer: the person at the computer can answer it on their screen too (the first answer is used). */
41
50
  export type Question = {
42
51
  type: 'question';
43
52
  question_id: string;
44
53
  question: string;
45
54
  options: string[];
55
+ customer_can_answer: boolean;
46
56
  };
57
+ /** customer_can_approve: the person at the computer can approve or deny it on their screen too (the first decision is used). */
47
58
  export type Approval = {
48
59
  type: 'approval';
49
60
  approval_id: string;
50
61
  action: string;
51
62
  risk: 'low' | 'medium' | 'high';
63
+ customer_can_approve: boolean;
52
64
  };
53
65
  export type EventType = 'started' | 'progress' | 'thinking' | 'action' | 'message' | 'question' | 'answer' | 'approval_required' | 'approved' | 'denied' | 'completed' | 'error' | 'stopped';
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). */
67
+ export type AnsweredBy = 'customer' | 'operator';
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
+ */
54
72
  export type TaskEvent = {
55
73
  cursor: number;
56
74
  type: EventType;
@@ -96,7 +114,7 @@ export type Recording = {
96
114
  task_id: string;
97
115
  frames: Frame[];
98
116
  };
99
- export type WebhookEventType = 'session.connected' | 'session.disconnected' | 'task.started' | 'task.waiting_for_user' | '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';
100
118
  export type Webhook = {
101
119
  object: 'webhook';
102
120
  url: string | null;
@@ -105,6 +123,18 @@ export type Webhook = {
105
123
  secret?: string;
106
124
  event_types: WebhookEventType[];
107
125
  };
126
+ export type QuestionAnswer = {
127
+ question_id: string;
128
+ answer: string;
129
+ answered_by: AnsweredBy;
130
+ };
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. */
108
138
  export type WebhookEvent = {
109
139
  id: string;
110
140
  type: WebhookEventType;
@@ -113,6 +143,8 @@ export type WebhookEvent = {
113
143
  data: {
114
144
  task?: Task;
115
145
  session?: Session;
146
+ answer?: QuestionAnswer;
147
+ decision?: ApprovalDecision;
116
148
  };
117
149
  };
118
150
  export type Page<T> = {
@@ -141,6 +173,7 @@ export declare class PermissionDeniedError extends GuidingHandError {
141
173
  }
142
174
  export declare class NotFoundError extends GuidingHandError {
143
175
  }
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'. */
144
177
  export declare class ConflictError extends GuidingHandError {
145
178
  }
146
179
  export declare class RateLimitError extends GuidingHandError {
@@ -153,7 +186,7 @@ export declare class TimeoutError extends GuidingHandError {
153
186
  }
154
187
  export declare class SessionExpiredError extends GuidingHandError {
155
188
  }
156
- /** A task waits on a question or approval and `run()` got no handler for it. */
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). */
157
190
  export declare class NeedsInputError extends GuidingHandError {
158
191
  task: Task;
159
192
  constructor(task: Task);
@@ -205,6 +238,10 @@ export type AgentCreate = {
205
238
  instructions?: string;
206
239
  effort?: Effort;
207
240
  greeting?: string;
241
+ display_name?: string;
242
+ narration?: boolean;
243
+ customer_answers?: boolean;
244
+ customer_approvals?: boolean;
208
245
  };
209
246
  export type AgentUpdate = Partial<Omit<AgentCreate, 'agent_id'>>;
210
247
  declare class Agents extends Resource {
@@ -261,19 +298,27 @@ export type Respond = {
261
298
  decision: 'approve' | 'deny';
262
299
  note?: string;
263
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;
264
306
  export type RunOptions = TaskCreate & {
265
307
  /** Every event, as it happens. */
266
308
  onEvent?: (event: TaskEvent, task: Task) => void | Promise<void>;
267
- /** The agent asks something: return the answer. */
268
- onQuestion?: (question: Question, task: Task) => string | Promise<string>;
269
- /** The agent wants to do something consequential: return true (or 'approve') to let it, false (or 'deny', or { decision, note }) not to. */
270
- onApproval?: (approval: Approval, task: Task) => boolean | 'approve' | 'deny' | {
271
- decision: 'approve' | 'deny';
272
- note?: string;
273
- } | Promise<boolean | 'approve' | 'deny' | {
274
- decision: 'approve' | 'deny';
275
- note?: string;
276
- }>;
309
+ /**
310
+ * The agent asks something: return the answer. When the person at the computer can answer it on their screen
311
+ * (question.customer_can_answer), return null to leave it to them; without onQuestion, run() does that for
312
+ * every such question and throws NeedsInputError only for the others. If they answer first, yours is dropped.
313
+ */
314
+ onQuestion?: (question: Question, task: Task) => string | null | undefined | Promise<string | null | undefined>;
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>;
277
322
  /** Give up (and stop the task) after this long, in ms. */
278
323
  timeout?: number;
279
324
  };
@@ -305,7 +350,7 @@ declare class Tasks extends Resource {
305
350
  stream(taskId: string, { after }?: {
306
351
  after?: number;
307
352
  }): AsyncGenerator<TaskEvent, Task>;
308
- /** Starts a task and sees it through: events to onEvent, questions to onQuestion, 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. */
309
354
  run(sessionId: string, { onEvent, onQuestion, onApproval, timeout, ...params }: RunOptions): Promise<Task>;
310
355
  }
311
356
  declare class WebhookEndpoint extends Resource {
package/dist/cjs/index.js CHANGED
@@ -8,10 +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 or onApproval, what the person at the computer can answer or decide on their screen is left to them)
11
12
  Object.defineProperty(exports, "__esModule", { value: true });
12
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;
13
14
  exports.verifyWebhook = verifyWebhook;
14
- exports.VERSION = '0.1.1';
15
+ exports.VERSION = '0.3.0';
15
16
  // ---------- errors ----------
16
17
  class GuidingHandError extends Error {
17
18
  status;
@@ -44,6 +45,7 @@ exports.PermissionDeniedError = PermissionDeniedError;
44
45
  class NotFoundError extends GuidingHandError {
45
46
  }
46
47
  exports.NotFoundError = NotFoundError;
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'. */
47
49
  class ConflictError extends GuidingHandError {
48
50
  }
49
51
  exports.ConflictError = ConflictError;
@@ -62,7 +64,7 @@ exports.TimeoutError = TimeoutError;
62
64
  class SessionExpiredError extends GuidingHandError {
63
65
  }
64
66
  exports.SessionExpiredError = SessionExpiredError;
65
- /** A task waits on a question or approval and `run()` got no handler for it. */
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). */
66
68
  class NeedsInputError extends GuidingHandError {
67
69
  task;
68
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; }
@@ -212,7 +214,7 @@ class Tasks extends Resource {
212
214
  return r.task;
213
215
  }
214
216
  }
215
- /** Starts a task and sees it through: events to onEvent, questions to onQuestion, 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. */
216
218
  async run(sessionId, { onEvent, onQuestion, onApproval, timeout, ...params }) {
217
219
  let task = await this.create(sessionId, params);
218
220
  const until = timeout ? Date.now() + timeout : Infinity;
@@ -234,23 +236,35 @@ class Tasks extends Resource {
234
236
  continue;
235
237
  const id = p.type === 'question' ? p.question_id : p.approval_id;
236
238
  if (id === answered)
237
- continue; // answered already; 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
238
240
  try {
239
241
  if (p.type === 'question') {
240
- if (!onQuestion)
242
+ // Without onQuestion, or when it returns nothing, a question the person at the computer can answer on
243
+ // their screen is left to them: keep following.
244
+ if (!onQuestion && !p.customer_can_answer)
241
245
  throw new NeedsInputError(task);
242
- await this.respond(task.task_id, { question_id: p.question_id, answer: String(await onQuestion(p, task)) });
246
+ const reply = onQuestion ? await onQuestion(p, task) : null;
247
+ if (reply !== null && reply !== undefined)
248
+ await this.respond(task.task_id, { question_id: p.question_id, answer: String(reply) });
249
+ else if (!p.customer_can_answer)
250
+ throw new TypeError('onQuestion returned no answer, and the person at the computer can’t answer this question (customer_can_answer is false).');
243
251
  }
244
252
  else {
245
- 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)
246
255
  throw new NeedsInputError(task);
247
- const d = await onApproval(p, task);
248
- const decision = d && typeof d === 'object' ? d : { decision: d === true || d === 'approve' ? 'approve' : 'deny' };
249
- 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
+ }
250
263
  }
251
264
  }
252
265
  catch (e) {
253
- // Answered from somewhere else (the console, another process) or the task ended meanwhile: keep following.
266
+ // Answered or decided somewhere else first (the person at the computer, the console, another process: extra.code
267
+ // 'already_answered') or the task ended meanwhile: keep following.
254
268
  if (!(e instanceof ConflictError))
255
269
  throw e;
256
270
  }
@@ -1,4 +1,4 @@
1
- export declare const VERSION = "0.1.1";
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 = {
@@ -8,6 +8,14 @@ export type Agent = {
8
8
  instructions: string;
9
9
  effort: Effort;
10
10
  greeting: string;
11
+ /** What the app calls the agent on the person's screen; '' is "GuidingHand". */
12
+ display_name: string;
13
+ /** The app shows the agent's thoughts and steps on the person's screen as it works. */
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). */
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;
11
19
  is_default: boolean;
12
20
  invite_url_template: string;
13
21
  created_at: string | null;
@@ -38,19 +46,29 @@ export type Session = {
38
46
  latest_task_id?: string | null;
39
47
  };
40
48
  export type TaskStatus = 'queued' | 'running' | 'waiting_for_user' | 'waiting_for_approval' | 'completed' | 'failed' | 'stopped';
49
+ /** customer_can_answer: the person at the computer can answer it on their screen too (the first answer is used). */
41
50
  export type Question = {
42
51
  type: 'question';
43
52
  question_id: string;
44
53
  question: string;
45
54
  options: string[];
55
+ customer_can_answer: boolean;
46
56
  };
57
+ /** customer_can_approve: the person at the computer can approve or deny it on their screen too (the first decision is used). */
47
58
  export type Approval = {
48
59
  type: 'approval';
49
60
  approval_id: string;
50
61
  action: string;
51
62
  risk: 'low' | 'medium' | 'high';
63
+ customer_can_approve: boolean;
52
64
  };
53
65
  export type EventType = 'started' | 'progress' | 'thinking' | 'action' | 'message' | 'question' | 'answer' | 'approval_required' | 'approved' | 'denied' | 'completed' | 'error' | 'stopped';
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). */
67
+ export type AnsweredBy = 'customer' | 'operator';
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
+ */
54
72
  export type TaskEvent = {
55
73
  cursor: number;
56
74
  type: EventType;
@@ -96,7 +114,7 @@ export type Recording = {
96
114
  task_id: string;
97
115
  frames: Frame[];
98
116
  };
99
- export type WebhookEventType = 'session.connected' | 'session.disconnected' | 'task.started' | 'task.waiting_for_user' | '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';
100
118
  export type Webhook = {
101
119
  object: 'webhook';
102
120
  url: string | null;
@@ -105,6 +123,18 @@ export type Webhook = {
105
123
  secret?: string;
106
124
  event_types: WebhookEventType[];
107
125
  };
126
+ export type QuestionAnswer = {
127
+ question_id: string;
128
+ answer: string;
129
+ answered_by: AnsweredBy;
130
+ };
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. */
108
138
  export type WebhookEvent = {
109
139
  id: string;
110
140
  type: WebhookEventType;
@@ -113,6 +143,8 @@ export type WebhookEvent = {
113
143
  data: {
114
144
  task?: Task;
115
145
  session?: Session;
146
+ answer?: QuestionAnswer;
147
+ decision?: ApprovalDecision;
116
148
  };
117
149
  };
118
150
  export type Page<T> = {
@@ -141,6 +173,7 @@ export declare class PermissionDeniedError extends GuidingHandError {
141
173
  }
142
174
  export declare class NotFoundError extends GuidingHandError {
143
175
  }
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'. */
144
177
  export declare class ConflictError extends GuidingHandError {
145
178
  }
146
179
  export declare class RateLimitError extends GuidingHandError {
@@ -153,7 +186,7 @@ export declare class TimeoutError extends GuidingHandError {
153
186
  }
154
187
  export declare class SessionExpiredError extends GuidingHandError {
155
188
  }
156
- /** A task waits on a question or approval and `run()` got no handler for it. */
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). */
157
190
  export declare class NeedsInputError extends GuidingHandError {
158
191
  task: Task;
159
192
  constructor(task: Task);
@@ -205,6 +238,10 @@ export type AgentCreate = {
205
238
  instructions?: string;
206
239
  effort?: Effort;
207
240
  greeting?: string;
241
+ display_name?: string;
242
+ narration?: boolean;
243
+ customer_answers?: boolean;
244
+ customer_approvals?: boolean;
208
245
  };
209
246
  export type AgentUpdate = Partial<Omit<AgentCreate, 'agent_id'>>;
210
247
  declare class Agents extends Resource {
@@ -261,19 +298,27 @@ export type Respond = {
261
298
  decision: 'approve' | 'deny';
262
299
  note?: string;
263
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;
264
306
  export type RunOptions = TaskCreate & {
265
307
  /** Every event, as it happens. */
266
308
  onEvent?: (event: TaskEvent, task: Task) => void | Promise<void>;
267
- /** The agent asks something: return the answer. */
268
- onQuestion?: (question: Question, task: Task) => string | Promise<string>;
269
- /** The agent wants to do something consequential: return true (or 'approve') to let it, false (or 'deny', or { decision, note }) not to. */
270
- onApproval?: (approval: Approval, task: Task) => boolean | 'approve' | 'deny' | {
271
- decision: 'approve' | 'deny';
272
- note?: string;
273
- } | Promise<boolean | 'approve' | 'deny' | {
274
- decision: 'approve' | 'deny';
275
- note?: string;
276
- }>;
309
+ /**
310
+ * The agent asks something: return the answer. When the person at the computer can answer it on their screen
311
+ * (question.customer_can_answer), return null to leave it to them; without onQuestion, run() does that for
312
+ * every such question and throws NeedsInputError only for the others. If they answer first, yours is dropped.
313
+ */
314
+ onQuestion?: (question: Question, task: Task) => string | null | undefined | Promise<string | null | undefined>;
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>;
277
322
  /** Give up (and stop the task) after this long, in ms. */
278
323
  timeout?: number;
279
324
  };
@@ -305,7 +350,7 @@ declare class Tasks extends Resource {
305
350
  stream(taskId: string, { after }?: {
306
351
  after?: number;
307
352
  }): AsyncGenerator<TaskEvent, Task>;
308
- /** Starts a task and sees it through: events to onEvent, questions to onQuestion, 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. */
309
354
  run(sessionId: string, { onEvent, onQuestion, onApproval, timeout, ...params }: RunOptions): Promise<Task>;
310
355
  }
311
356
  declare class WebhookEndpoint extends Resource {
package/dist/esm/index.js CHANGED
@@ -7,7 +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
- export const VERSION = '0.1.1';
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';
11
12
  // ---------- errors ----------
12
13
  export class GuidingHandError extends Error {
13
14
  status;
@@ -34,6 +35,7 @@ export class PermissionDeniedError extends GuidingHandError {
34
35
  }
35
36
  export class NotFoundError extends GuidingHandError {
36
37
  }
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'. */
37
39
  export class ConflictError extends GuidingHandError {
38
40
  }
39
41
  export class RateLimitError extends GuidingHandError {
@@ -46,7 +48,7 @@ export class TimeoutError extends GuidingHandError {
46
48
  }
47
49
  export class SessionExpiredError extends GuidingHandError {
48
50
  }
49
- /** A task waits on a question or approval and `run()` got no handler for it. */
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). */
50
52
  export class NeedsInputError extends GuidingHandError {
51
53
  task;
52
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; }
@@ -193,7 +195,7 @@ class Tasks extends Resource {
193
195
  return r.task;
194
196
  }
195
197
  }
196
- /** Starts a task and sees it through: events to onEvent, questions to onQuestion, 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. */
197
199
  async run(sessionId, { onEvent, onQuestion, onApproval, timeout, ...params }) {
198
200
  let task = await this.create(sessionId, params);
199
201
  const until = timeout ? Date.now() + timeout : Infinity;
@@ -215,23 +217,35 @@ class Tasks extends Resource {
215
217
  continue;
216
218
  const id = p.type === 'question' ? p.question_id : p.approval_id;
217
219
  if (id === answered)
218
- continue; // answered already; 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
219
221
  try {
220
222
  if (p.type === 'question') {
221
- if (!onQuestion)
223
+ // Without onQuestion, or when it returns nothing, a question the person at the computer can answer on
224
+ // their screen is left to them: keep following.
225
+ if (!onQuestion && !p.customer_can_answer)
222
226
  throw new NeedsInputError(task);
223
- await this.respond(task.task_id, { question_id: p.question_id, answer: String(await onQuestion(p, task)) });
227
+ const reply = onQuestion ? await onQuestion(p, task) : null;
228
+ if (reply !== null && reply !== undefined)
229
+ await this.respond(task.task_id, { question_id: p.question_id, answer: String(reply) });
230
+ else if (!p.customer_can_answer)
231
+ throw new TypeError('onQuestion returned no answer, and the person at the computer can’t answer this question (customer_can_answer is false).');
224
232
  }
225
233
  else {
226
- 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)
227
236
  throw new NeedsInputError(task);
228
- const d = await onApproval(p, task);
229
- const decision = d && typeof d === 'object' ? d : { decision: d === true || d === 'approve' ? 'approve' : 'deny' };
230
- 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
+ }
231
244
  }
232
245
  }
233
246
  catch (e) {
234
- // Answered from somewhere else (the console, another process) or the task ended meanwhile: keep following.
247
+ // Answered or decided somewhere else first (the person at the computer, the console, another process: extra.code
248
+ // 'already_answered') or the task ended meanwhile: keep following.
235
249
  if (!(e instanceof ConflictError))
236
250
  throw e;
237
251
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "guidinghand",
3
- "version": "0.1.1",
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",