@dench.com/cli 2.7.8 → 2.7.10-staging.100

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/tools.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `dench tool` and `dench apps` — direct Dench Cloud Gateway access for
2
+ * `dench tool` and `dench integrations` — direct Dench Cloud Gateway access for
3
3
  * Composio's get-apps / get-tools / execute-tool surface.
4
4
  *
5
5
  * Mirrors `denchclaw`'s pattern: this CLI is a thin wrapper over five
@@ -7,10 +7,15 @@
7
7
  * paid-workspace gateway key resolved from the active agent session by
8
8
  * the top-level dispatcher.
9
9
  *
10
+ * The one exception is the connection LISTING, which is not a pure gateway
11
+ * passthrough: Gmail, Outlook, Calendar and LinkedIn are brokered by Dench
12
+ * through Unipile, not Composio, so `status` also reads them from Convex and
13
+ * merges the two into one list. See `fetchUnipileConnections`.
14
+ *
10
15
  * Subcommands:
11
16
  *
12
- * dench apps [--json] — list connected apps
13
- * dench tool status [toolkit?] [--json] — list connections (optionally narrowed)
17
+ * dench integrations [--json] — every connection, both brokers
18
+ * dench tool status [toolkit?] [--json] — same list (optionally narrowed)
14
19
  * dench tool search "<query>" [--toolkit <slug>] [--limit N] [--compact] [--json]
15
20
  * dench tool run <slug> [--args <json>] [--account <id>] [--json]
16
21
  * dench tool connect <toolkit> [--callback-url <url>] [--json] — print OAuth redirect URL
@@ -32,6 +37,8 @@
32
37
  * working.
33
38
  */
34
39
 
40
+ import type { ConvexHttpClient } from "convex/browser";
41
+ import { makeFunctionReference } from "convex/server";
35
42
  import { PRODUCTION_API_URL, PRODUCTION_GATEWAY_URL } from "./host";
36
43
  import {
37
44
  CliArgError,
@@ -51,6 +58,13 @@ export type ToolCliContext = {
51
58
  bearerToken?: string;
52
59
  gatewayBaseUrl?: string;
53
60
  gatewayHeaders?: Record<string, string>;
61
+ /**
62
+ * Convex client + bearer for the Unipile half of the list. Absent on the
63
+ * help path and whenever the caller could not be authenticated, in which
64
+ * case the command still runs and reports Composio only.
65
+ */
66
+ convex?: ConvexHttpClient;
67
+ sessionToken?: string;
54
68
  };
55
69
 
56
70
  type GatewayAuthContext = Pick<
@@ -58,6 +72,8 @@ type GatewayAuthContext = Pick<
58
72
  "bearerToken" | "gatewayBaseUrl" | "gatewayHeaders"
59
73
  >;
60
74
 
75
+ type UnipileAuthContext = Pick<ToolCliContext, "convex" | "sessionToken">;
76
+
61
77
  export async function runToolCommand(ctx: ToolCliContext): Promise<void> {
62
78
  const sub = ctx.args[0];
63
79
  if (!sub || sub === "help" || sub === "--help" || sub === "-h") {
@@ -90,48 +106,147 @@ export async function runToolCommand(ctx: ToolCliContext): Promise<void> {
90
106
  throw new ToolCliError(`Unknown dench tool subcommand: ${sub}`);
91
107
  }
92
108
 
109
+ const listUnipileConnectionsRef = makeFunctionReference<"query">(
110
+ "functions/unipileConnections:listConnections",
111
+ );
112
+ const listUnipileToolsRef = makeFunctionReference<"query">(
113
+ "functions/unipileTools:listTools",
114
+ );
115
+ const getUnipileToolRef = makeFunctionReference<"query">(
116
+ "functions/unipileTools:getTool",
117
+ );
118
+ const runUnipileToolRef = makeFunctionReference<"action">(
119
+ "functions/unipileToolsNode:runTool",
120
+ );
121
+
122
+ /**
123
+ * Gmail, Outlook, Calendar and LinkedIn are brokered by Dench, and Composio
124
+ * refuses to connect them — so its catalogue entries for those toolkits can
125
+ * never execute, and showing them next to the ones that work just teaches an
126
+ * agent to pick a tool that always answers `composio_not_connected`.
127
+ *
128
+ * The set comes from the server with the tools themselves rather than being
129
+ * hardcoded here, so a fifth platform needs no CLI change.
130
+ */
131
+
132
+ /**
133
+ * Composio spells some toolkits without a separator (`googlecalendar`), while
134
+ * our registry uses the hyphenated form. Normalize through this map before the
135
+ * membership test, or the dead Composio calendar entries survive the filter
136
+ * and sit next to the working ones under two names for one product.
137
+ */
138
+ const COMPOSIO_TOOLKIT_ALIASES: Record<string, string> = {
139
+ googlecalendar: "google-calendar",
140
+ outlookcalendar: "outlook-calendar",
141
+ };
142
+
143
+ function withoutShadowedComposioTools(
144
+ items: JsonRecord[],
145
+ ownedToolkits: Set<string>,
146
+ ): JsonRecord[] {
147
+ if (ownedToolkits.size === 0) return items;
148
+ return items.filter((item) => {
149
+ const slug = (
150
+ stringField(item, "toolkit_slug") ??
151
+ stringField(asRecord(item.toolkit), "slug", "name") ??
152
+ ""
153
+ )
154
+ .toLowerCase()
155
+ .replaceAll("_", "-");
156
+ const canonical = COMPOSIO_TOOLKIT_ALIASES[slug] ?? slug;
157
+ return !canonical || !ownedToolkits.has(canonical);
158
+ });
159
+ }
160
+
161
+ /**
162
+ * Gmail, Outlook, Calendar and LinkedIn — the connections Dench brokers itself
163
+ * through Unipile rather than Composio.
164
+ *
165
+ * They come from Convex, not the gateway, so this is a second fetch rather than
166
+ * a richer parse of the first. It NEVER throws: a CLI ships independently of
167
+ * the deployment it talks to (npm publish + a re-baked Daytona snapshot), so a
168
+ * new CLI will meet backends that do not export this query yet. Failing the
169
+ * whole command over that would take `dench integrations` from "missing the
170
+ * calendar" to "broken", which is strictly worse.
171
+ */
172
+ async function fetchUnipileConnections(ctx: UnipileAuthContext): Promise<{
173
+ available: boolean;
174
+ connections: CompactConnection[];
175
+ error?: string;
176
+ }> {
177
+ if (!ctx.convex) {
178
+ return {
179
+ available: false,
180
+ connections: [],
181
+ error: "no Dench session — sign in with `dench signin`",
182
+ };
183
+ }
184
+ try {
185
+ const rows = await ctx.convex.query(listUnipileConnectionsRef, {
186
+ ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}),
187
+ } as never);
188
+ if (!Array.isArray(rows)) return { available: true, connections: [] };
189
+ return {
190
+ available: true,
191
+ connections: rows.map((row) =>
192
+ compactUnipileConnection(asRecord(row) ?? {}),
193
+ ),
194
+ };
195
+ } catch (error) {
196
+ return { available: false, connections: [], error: errorMessage(error) };
197
+ }
198
+ }
199
+
93
200
  /**
94
201
  * Summarise the user's connected apps for `dench context` and friends.
95
202
  * Speaks straight to the gateway with either DENCH_API_KEY or a gateway
96
- * key resolved from the active agent session.
203
+ * key resolved from the active agent session, and merges in the Unipile
204
+ * connections Composio never reports.
97
205
  * Returns the same legacy shape the previous Convex-routed path did so
98
206
  * existing parsers don't need to change.
99
207
  */
100
208
  export async function fetchConnectedAppsSummary(
101
- ctx: GatewayAuthContext = {},
209
+ ctx: GatewayAuthContext & UnipileAuthContext = {},
102
210
  ): Promise<{
103
211
  available: boolean;
104
212
  count: number;
105
- connections: Array<{
106
- toolkit: string;
107
- status: string;
108
- connectedAccountId: string | null;
109
- accountLabel: string | null;
110
- }>;
213
+ connections: CompactConnection[];
214
+ unipileAvailable: boolean;
215
+ unipileError?: string;
111
216
  error?: string;
112
217
  reason?: string;
113
218
  }> {
219
+ const unipile = await fetchUnipileConnections(ctx);
220
+ const unipileFields = {
221
+ unipileAvailable: unipile.available,
222
+ ...(unipile.error ? { unipileError: unipile.error } : {}),
223
+ };
114
224
  let payload: unknown;
115
225
  try {
116
226
  payload = await callGateway(ctx, "GET", "/v1/composio/connections");
117
227
  } catch (error) {
118
- if (error instanceof MissingApiKeyError) {
119
- return {
120
- available: false,
121
- reason: error.message,
122
- count: 0,
123
- connections: [],
124
- };
125
- }
126
- return {
228
+ // Composio being unreachable must not hide a connected calendar, so the
229
+ // Unipile rows are still reported alongside the failure.
230
+ const failure = {
127
231
  available: false,
128
- error: errorMessage(error),
129
- count: 0,
130
- connections: [],
232
+ count: unipile.connections.length,
233
+ connections: unipile.connections,
234
+ ...unipileFields,
131
235
  };
236
+ return error instanceof MissingApiKeyError
237
+ ? { ...failure, reason: error.message }
238
+ : { ...failure, error: errorMessage(error) };
132
239
  }
133
- const connections = extractConnections(payload).map(compactConnection);
134
- return { available: true, count: connections.length, connections };
240
+ const connections = [
241
+ ...extractConnections(payload).map(compactConnection),
242
+ ...unipile.connections,
243
+ ];
244
+ return {
245
+ available: true,
246
+ count: connections.length,
247
+ connections,
248
+ ...unipileFields,
249
+ };
135
250
  }
136
251
 
137
252
  // ---------------------------------------------------------------------------
@@ -142,11 +257,10 @@ async function runStatusSubcommand(ctx: ToolCliContext): Promise<void> {
142
257
  const filterToolkit = ctx.args[0]?.startsWith("--") ? undefined : ctx.args[0];
143
258
  if (filterToolkit) ctx.args.shift();
144
259
 
145
- const connectionsPayload = await callGateway(
146
- ctx,
147
- "GET",
148
- "/v1/composio/connections",
149
- );
260
+ const [connectionsPayload, unipile] = await Promise.all([
261
+ callGateway(ctx, "GET", "/v1/composio/connections"),
262
+ fetchUnipileConnections(ctx),
263
+ ]);
150
264
  let toolkitsPayload: unknown = null;
151
265
  if (filterToolkit) {
152
266
  const search = encodeURIComponent(filterToolkit);
@@ -157,12 +271,16 @@ async function runStatusSubcommand(ctx: ToolCliContext): Promise<void> {
157
271
  );
158
272
  }
159
273
 
160
- const allConnections = extractConnections(connectionsPayload);
274
+ const allConnections = [
275
+ ...extractConnections(connectionsPayload).map(compactConnection),
276
+ ...unipile.connections,
277
+ ];
278
+ // Filtering after the merge is what makes `dench tool status gmail` answer
279
+ // with the Unipile mailbox instead of nothing.
161
280
  const filteredConnections = filterToolkit
162
- ? allConnections.filter((conn) => {
163
- const slug = stringField(conn, "toolkit_slug", "toolkit") ?? "";
164
- return slug.toLowerCase() === filterToolkit.toLowerCase();
165
- })
281
+ ? allConnections.filter(
282
+ (conn) => conn.toolkit.toLowerCase() === filterToolkit.toLowerCase(),
283
+ )
166
284
  : allConnections;
167
285
 
168
286
  const summary = {
@@ -170,7 +288,9 @@ async function runStatusSubcommand(ctx: ToolCliContext): Promise<void> {
170
288
  available: true,
171
289
  toolkit: filterToolkit ?? null,
172
290
  count: filteredConnections.length,
173
- connections: filteredConnections.map(compactConnection),
291
+ connections: filteredConnections,
292
+ unipileAvailable: unipile.available,
293
+ ...(unipile.error ? { unipileError: unipile.error } : {}),
174
294
  ...(toolkitsPayload
175
295
  ? { toolkit_info: extractToolkits(toolkitsPayload) }
176
296
  : {}),
@@ -210,17 +330,141 @@ async function runSearchSubcommand(ctx: ToolCliContext): Promise<void> {
210
330
  if (toolkit) body.toolkit_slug = toolkit;
211
331
  if (limit) body.limit = limit;
212
332
 
213
- const payload = await callGateway(
214
- ctx,
215
- "POST",
216
- "/v1/composio/tools/search",
217
- body,
333
+ const [payload, unipile] = await Promise.all([
334
+ callGateway(ctx, "POST", "/v1/composio/tools/search", body),
335
+ fetchUnipileTools(ctx, { query, ...(toolkit ? { toolkit } : {}) }),
336
+ ]);
337
+
338
+ const record = asRecord(payload) ?? {};
339
+ const ownedToolkits = new Set(
340
+ unipile.tools
341
+ .map((tool) => stringField(asRecord(tool.toolkit), "slug") ?? "")
342
+ .filter(Boolean),
218
343
  );
344
+ const merged = {
345
+ ...record,
346
+ items: [
347
+ ...withoutShadowedComposioTools(
348
+ extractSearchItems(record),
349
+ ownedToolkits,
350
+ ),
351
+ ...unipile.tools,
352
+ ],
353
+ unipileAvailable: unipile.available,
354
+ ...(unipile.error ? { unipileError: unipile.error } : {}),
355
+ };
356
+
219
357
  if (ctx.jsonOutput) {
220
- console.log(JSON.stringify(payload, null, 2));
358
+ console.log(JSON.stringify(merged, null, 2));
221
359
  return;
222
360
  }
223
- console.log(formatSearchResult(payload, compact));
361
+ console.log(formatSearchResult(merged, compact));
362
+ }
363
+
364
+ /**
365
+ * Whether this slug is ours. A lookup failure answers "not ours", so an old
366
+ * deployment routes everything to Composio exactly as it did before.
367
+ */
368
+ async function resolveUnipileTool(
369
+ ctx: UnipileAuthContext,
370
+ slug: string,
371
+ ): Promise<JsonRecord | null> {
372
+ if (!ctx.convex) return null;
373
+ try {
374
+ return (
375
+ asRecord(await ctx.convex.query(getUnipileToolRef, { slug } as never)) ??
376
+ null
377
+ );
378
+ } catch {
379
+ return null;
380
+ }
381
+ }
382
+
383
+ async function runUnipileTool(
384
+ ctx: ToolCliContext,
385
+ slug: string,
386
+ toolArgs: Record<string, unknown>,
387
+ approvalId: string | undefined,
388
+ ): Promise<void> {
389
+ if (!ctx.convex) throw new MissingApiKeyError();
390
+ const result = await ctx.convex.action(runUnipileToolRef, {
391
+ ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}),
392
+ slug,
393
+ arguments: toolArgs,
394
+ ...(approvalId ? { approvalId } : {}),
395
+ } as never);
396
+
397
+ const record = asRecord(result) ?? {};
398
+ if (ctx.jsonOutput) {
399
+ console.log(JSON.stringify(record, null, 2));
400
+ } else {
401
+ console.log(formatUnipileToolResult(record));
402
+ }
403
+ // Same convention as the Composio failures below: a refusal the agent is
404
+ // expected to act on exits non-zero without throwing, so the payload above
405
+ // is still the thing it reads.
406
+ if (record.ok === false) process.exitCode = 1;
407
+ }
408
+
409
+ function formatUnipileToolResult(record: JsonRecord): string {
410
+ if (record.ok === false) {
411
+ const lines = [
412
+ `Failed: ${stringField(record, "error") ?? "unknown error"}`,
413
+ ];
414
+ const approvalId = stringField(record, "approvalId");
415
+ if (approvalId) {
416
+ lines.push(
417
+ `Approval required. Once a human says yes:`,
418
+ ` dench approval approve ${approvalId} --evidence "…"`,
419
+ ` dench tool run ${stringField(record, "toolSlug") ?? "<slug>"} --args '…' --approval ${approvalId}`,
420
+ );
421
+ }
422
+ return lines.join("\n");
423
+ }
424
+ if (record.queued === true) {
425
+ const data = asRecord(record.data) ?? {};
426
+ return [
427
+ `Queued: ${stringField(data, "action_id") ?? "unknown"}`,
428
+ "LinkedIn actions are paced to protect the account.",
429
+ ` dench tool run LINKEDIN_GET_ACTION_STATUS --args '{"action_id":"${stringField(data, "action_id") ?? ""}"}'`,
430
+ ].join("\n");
431
+ }
432
+ return JSON.stringify(redactForDisplay(record), null, 2);
433
+ }
434
+
435
+ /**
436
+ * The Unipile half of tool discovery. Never throws, for the same reason the
437
+ * connection listing does not: the CLI ships on its own cadence, so a new CLI
438
+ * will meet deployments that do not export these functions yet. That must cost
439
+ * the Unipile tools, not the whole search.
440
+ */
441
+ async function fetchUnipileTools(
442
+ ctx: UnipileAuthContext,
443
+ options: { query?: string; toolkit?: string } = {},
444
+ ): Promise<{ available: boolean; tools: JsonRecord[]; error?: string }> {
445
+ if (!ctx.convex) {
446
+ return {
447
+ available: false,
448
+ tools: [],
449
+ error: "no Dench session — sign in with `dench signin`",
450
+ };
451
+ }
452
+ try {
453
+ const rows = await ctx.convex.query(listUnipileToolsRef, {
454
+ ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}),
455
+ ...(options.query ? { query: options.query } : {}),
456
+ ...(options.toolkit ? { toolkit: options.toolkit } : {}),
457
+ } as never);
458
+ if (!Array.isArray(rows)) return { available: true, tools: [] };
459
+ return {
460
+ available: true,
461
+ tools: rows
462
+ .map((row) => asRecord(row))
463
+ .filter((row): row is JsonRecord => row !== undefined),
464
+ };
465
+ } catch (error) {
466
+ return { available: false, tools: [], error: errorMessage(error) };
467
+ }
224
468
  }
225
469
 
226
470
  async function runRunSubcommand(ctx: ToolCliContext): Promise<void> {
@@ -233,6 +477,7 @@ async function runRunSubcommand(ctx: ToolCliContext): Promise<void> {
233
477
  ctx.args.shift();
234
478
  const argsJson = getFlag(ctx.args, "--args");
235
479
  const account = getFlag(ctx.args, "--account");
480
+ const approval = getFlag(ctx.args, "--approval");
236
481
  const parsedArgs = (parseJson(argsJson) ?? {}) as Record<string, unknown>;
237
482
  if (
238
483
  parsedArgs &&
@@ -244,6 +489,16 @@ async function runRunSubcommand(ctx: ToolCliContext): Promise<void> {
244
489
  throw new ToolCliError("--args must be a JSON object");
245
490
  }
246
491
 
492
+ // Ask the server which broker owns this slug rather than keeping a copy of
493
+ // the vocabulary here. Two hand-maintained lists is the drift CLAUDE.md
494
+ // records for gateway usage providers, and here it would mean a real tool
495
+ // routed to Composio, which 404s.
496
+ const unipileTool = await resolveUnipileTool(ctx, toolSlug);
497
+ if (unipileTool) {
498
+ await runUnipileTool(ctx, toolSlug, parsedArgs, approval);
499
+ return;
500
+ }
501
+
247
502
  const body: Record<string, unknown> = {
248
503
  tool_slug: toolSlug,
249
504
  arguments: parsedArgs,
@@ -653,8 +908,30 @@ function extractToolkits(payload: unknown): JsonRecord[] {
653
908
  );
654
909
  }
655
910
 
656
- function compactConnection(connection: JsonRecord) {
911
+ /**
912
+ * The merged row shape. `source` is the discriminator: `composio` rows carry a
913
+ * routable `connectedAccountId` (usable as `dench tool run --account`), while
914
+ * `unipile` rows never do — their account id is an internal credential.
915
+ */
916
+ export type CompactConnection = {
917
+ source: "composio" | "unipile";
918
+ toolkit: string;
919
+ status: string;
920
+ connectedAccountId: string | null;
921
+ accountLabel: string | null;
922
+ /** Unipile only: whether the connection belongs to this agent's member. */
923
+ isOwner?: boolean;
924
+ ownerName?: string;
925
+ /**
926
+ * Unipile only. A teammate's `private` connection is never listed at all, so
927
+ * `private` here always means the caller's own.
928
+ */
929
+ visibility?: "private" | "shared";
930
+ };
931
+
932
+ function compactConnection(connection: JsonRecord): CompactConnection {
657
933
  return {
934
+ source: "composio",
658
935
  toolkit:
659
936
  stringField(connection, "toolkit_slug", "toolkit") ??
660
937
  stringField(asRecord(connection.toolkit), "slug", "name") ??
@@ -672,10 +949,47 @@ function compactConnection(connection: JsonRecord) {
672
949
  };
673
950
  }
674
951
 
952
+ function compactUnipileConnection(connection: JsonRecord): CompactConnection {
953
+ return {
954
+ source: "unipile",
955
+ toolkit: stringField(connection, "toolkit") ?? "unknown",
956
+ status: stringField(connection, "status") ?? "unknown",
957
+ connectedAccountId: null,
958
+ accountLabel: stringField(connection, "accountLabel") ?? null,
959
+ isOwner: connection.isOwner === true,
960
+ ownerName: stringField(connection, "ownerName") ?? undefined,
961
+ visibility: connection.visibility === "private" ? "private" : "shared",
962
+ };
963
+ }
964
+
965
+ function formatConnectionLine(connection: CompactConnection): string {
966
+ const label = connection.accountLabel ? ` (${connection.accountLabel})` : "";
967
+ const id = connection.connectedAccountId
968
+ ? ` id=${connection.connectedAccountId}`
969
+ : "";
970
+ // "via Dench" both names the broker and explains why the row has no
971
+ // connected-account id to pass to `dench tool run`.
972
+ const via = connection.source === "unipile" ? " via Dench" : "";
973
+ // Whose mailbox/LinkedIn it is only matters when it is not this agent's —
974
+ // `dench email --mailbox` picks between them.
975
+ const owner =
976
+ connection.source === "unipile" &&
977
+ connection.isOwner === false &&
978
+ connection.ownerName
979
+ ? ` · ${connection.ownerName}`
980
+ : "";
981
+ // Only ever the caller's own — a teammate's private connection is filtered
982
+ // out server-side rather than shown as unusable.
983
+ const privacy = connection.visibility === "private" ? " · private" : "";
984
+ return ` - ${connection.toolkit} [${connection.status}]${label}${id}${via}${owner}${privacy}`;
985
+ }
986
+
675
987
  function formatStatusSummary(summary: {
676
988
  toolkit: string | null;
677
989
  count: number;
678
- connections: ReturnType<typeof compactConnection>[];
990
+ connections: CompactConnection[];
991
+ unipileAvailable?: boolean;
992
+ unipileError?: string;
679
993
  }): string {
680
994
  const lines: string[] = [];
681
995
  if (summary.toolkit) {
@@ -686,18 +1000,16 @@ function formatStatusSummary(summary: {
686
1000
  if (summary.connections.length === 0) {
687
1001
  lines.push(" none");
688
1002
  } else {
689
- for (const connection of summary.connections.slice(0, 24)) {
690
- const label = connection.accountLabel
691
- ? ` (${connection.accountLabel})`
692
- : "";
693
- lines.push(
694
- ` - ${connection.toolkit} [${connection.status}]${label}${
695
- connection.connectedAccountId
696
- ? ` id=${connection.connectedAccountId}`
697
- : ""
698
- }`,
699
- );
700
- }
1003
+ lines.push(...summary.connections.slice(0, 24).map(formatConnectionLine));
1004
+ }
1005
+ // Say so rather than silently listing Composio only — otherwise a connected
1006
+ // calendar reads as absent, which is the failure this merge exists to fix.
1007
+ if (summary.unipileAvailable === false) {
1008
+ lines.push(
1009
+ ` (could not check Gmail/Outlook/Calendar/LinkedIn: ${
1010
+ summary.unipileError ?? "unknown error"
1011
+ })`,
1012
+ );
701
1013
  }
702
1014
  return lines.join("\n");
703
1015
  }
@@ -849,16 +1161,27 @@ function toolHelp() {
849
1161
  console.log(`Dench tool commands
850
1162
 
851
1163
  Usage:
852
- dench apps [--json]
1164
+ dench integrations [--json]
853
1165
  dench tool status [toolkit] [--json]
854
1166
  dench tool connect <toolkit> [--callback-url <url>] [--json]
855
1167
  dench tool search "create github issue" [--toolkit github] [--limit 20] [--compact] [--json]
856
- dench tool run <composio_tool_slug> --args '{"key":"value"}' [--account <connectedAccountId>] [--json]
1168
+ dench tool run <tool_slug> --args '{"key":"value"}' [--account <connectedAccountId>] [--approval <approvalId>] [--json]
857
1169
  dench tool disconnect <connectionId> [--json]
858
1170
 
859
1171
  Notes:
860
1172
  All commands authenticate with DENCH_API_KEY or the active \`dench signin\`
861
1173
  agent session. Agent sessions require a paid workspace.
1174
+ Results carry a "source". "composio" rows are brokered by Composio; "unipile"
1175
+ rows (gmail, outlook, calendar, linkedin) are connected by Dench itself. Both
1176
+ run through \`dench tool run\` with the same slug + --args contract; only
1177
+ \`dench tool connect\` is Composio-only — the human connects the other four
1178
+ from the workspace Marketplace.
1179
+ A write tool returns approval_required with an approvalId. Once a human
1180
+ approves it, re-run with --approval <approvalId>; an approval is bound to that
1181
+ exact tool and argument set.
1182
+ LinkedIn tools are QUEUED, not run inline, so account-safety pacing (daily
1183
+ caps, working hours, spacing) applies. They return an action_id — poll it with
1184
+ LINKEDIN_GET_ACTION_STATUS.
862
1185
  dench tool connect prints the OAuth redirect URL for the human to open.
863
1186
  dench tool run output is redacted for display; pass --json for raw output.
864
1187
  Override the gateway base with DENCH_GATEWAY_URL or GATEWAY_URL.