@vellumai/assistant 0.12.0-staging.1 → 0.12.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.
Files changed (92) hide show
  1. package/Dockerfile +5 -0
  2. package/docs/architecture/turn-actor.md +9 -0
  3. package/knip.json +1 -0
  4. package/node_modules/@vellumai/app-icons/package.json +18 -0
  5. package/node_modules/@vellumai/app-icons/src/index.test.ts +85 -0
  6. package/node_modules/@vellumai/app-icons/src/index.ts +387 -0
  7. package/node_modules/@vellumai/app-icons/tsconfig.json +20 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
  9. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
  10. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
  12. package/node_modules/@vellumai/gateway-client/src/__tests__/plugin-admission-denied-contract.test.ts +10 -0
  13. package/node_modules/@vellumai/gateway-client/src/index.ts +1 -0
  14. package/node_modules/@vellumai/gateway-client/src/plugin-admission-denied-contract.ts +15 -2
  15. package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
  16. package/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
  17. package/openapi.yaml +628 -0
  18. package/package.json +3 -1
  19. package/scripts/smoke-container-workspace-dependencies.ts +19 -0
  20. package/src/__tests__/app-builder-icon-names.test.ts +29 -0
  21. package/src/__tests__/assistant-attachment-directive.test.ts +4 -0
  22. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -0
  23. package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -0
  24. package/src/__tests__/conversation-agent-loop.test.ts +2 -0
  25. package/src/__tests__/conversation-attachments.test.ts +142 -0
  26. package/src/__tests__/conversation-delete-activation-progress.test.ts +145 -0
  27. package/src/__tests__/conversation-event-sink.test.ts +50 -0
  28. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +10 -2
  29. package/src/__tests__/conversation-queue.test.ts +297 -0
  30. package/src/__tests__/credential-routes.test.ts +185 -6
  31. package/src/__tests__/drain-kick-guard.test.ts +2 -0
  32. package/src/__tests__/drain-requeue-on-contention.test.ts +6 -0
  33. package/src/__tests__/messaging-send-tool.test.ts +42 -0
  34. package/src/__tests__/oauth-commands-routes.test.ts +47 -0
  35. package/src/__tests__/subagent-manager-notify.test.ts +25 -4
  36. package/src/activation/progress-store.test.ts +1564 -0
  37. package/src/activation/progress-store.ts +1296 -0
  38. package/src/activation/turn-hooks.test.ts +246 -0
  39. package/src/activation/turn-hooks.ts +126 -0
  40. package/src/api/events/subagent-status-changed.ts +7 -5
  41. package/src/api/responses/activation.ts +136 -0
  42. package/src/apps/app-store.ts +8 -0
  43. package/src/cli/__tests__/catalog-search-help.test.ts +7 -5
  44. package/src/cli/commands/__tests__/cli-test-harness.ts +12 -3
  45. package/src/cli/commands/channels/__tests__/channels.test.ts +2 -0
  46. package/src/cli/commands/channels/__tests__/request.test.ts +191 -0
  47. package/src/cli/commands/channels/index.help.ts +70 -18
  48. package/src/cli/commands/channels/index.ts +17 -6
  49. package/src/cli/commands/channels/request.ts +66 -0
  50. package/src/cli/commands/oauth/request.test.ts +2 -0
  51. package/src/cli/commands/oauth/request.ts +227 -186
  52. package/src/config/bundled-skills/app-builder/SKILL.md +3 -1
  53. package/src/config/bundled-skills/app-builder/TOOLS.json +2 -2
  54. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +8 -4
  55. package/src/config/feature-flag-registry.json +35 -5
  56. package/src/daemon/assistant-attachments.ts +11 -0
  57. package/src/daemon/conversation-agent-loop-handlers.ts +5 -0
  58. package/src/daemon/conversation-agent-loop.ts +71 -3
  59. package/src/daemon/conversation-attachments.ts +42 -1
  60. package/src/daemon/conversation-event-sink.ts +25 -0
  61. package/src/daemon/conversation-process.ts +69 -21
  62. package/src/daemon/conversation-store.ts +4 -3
  63. package/src/daemon/conversation-surfaces.ts +19 -4
  64. package/src/daemon/message-types/sync.ts +2 -0
  65. package/src/ipc/assistant-server.ts +2 -0
  66. package/src/ipc/routes/__tests__/activation-sync-ipc-routes.test.ts +51 -0
  67. package/src/ipc/routes/activation-sync-ipc-routes.ts +42 -0
  68. package/src/notifications/AGENTS.md +1 -1
  69. package/src/notifications/__tests__/proactive-home-thread.test.ts +111 -0
  70. package/src/notifications/conversation-pairing.ts +47 -5
  71. package/src/notifications/delivered-post-record.ts +3 -0
  72. package/src/persistence/__tests__/slack-thread-root-evidence.test.ts +102 -0
  73. package/src/persistence/conversation-crud.ts +39 -0
  74. package/src/persistence/delivery-crud.ts +13 -0
  75. package/src/plugins/AGENTS.md +1 -0
  76. package/src/runtime/auth/__tests__/route-policy.test.ts +37 -0
  77. package/src/runtime/routes/__tests__/user-routes-notices.test.ts +248 -0
  78. package/src/runtime/routes/activation-routes.test.ts +415 -0
  79. package/src/runtime/routes/activation-routes.ts +172 -0
  80. package/src/runtime/routes/credential-routes.ts +46 -3
  81. package/src/runtime/routes/index.ts +2 -0
  82. package/src/runtime/routes/oauth-commands-routes.ts +21 -8
  83. package/src/runtime/routes/platform-managed-credentials.ts +48 -0
  84. package/src/runtime/routes/secret-routes.ts +3 -12
  85. package/src/runtime/routes/user-route-resolution.ts +21 -0
  86. package/src/runtime/routes/user-routes.ts +112 -9
  87. package/src/runtime/sync/activation-sidecar-publish.test.ts +78 -0
  88. package/src/runtime/sync/documents-sidecar-publish.test.ts +3 -0
  89. package/src/runtime/sync/resource-sync-events.ts +23 -0
  90. package/src/runtime/sync/worker-daemon-notify.test.ts +36 -0
  91. package/src/runtime/sync/worker-daemon-notify.ts +39 -1
  92. package/src/tools/apps/executors.ts +8 -8
@@ -2,7 +2,7 @@ import { readFileSync, writeFileSync } from "node:fs";
2
2
 
3
3
  import type { Command } from "commander";
4
4
 
5
- import { exitFromIpcResult } from "../../../ipc/cli-client.js";
5
+ import { exitCodeFromIpcResult } from "../../../ipc/cli-client.js";
6
6
  import {
7
7
  findContentTypeHeader,
8
8
  parseRequestBodyBytes,
@@ -10,7 +10,7 @@ import {
10
10
  } from "../../../util/oauth-request-body.js";
11
11
  import { readStdinBytesSync } from "../../../util/read-stdin.js";
12
12
  import { subcommand } from "../../lib/cli-command-help.js";
13
- import { shouldOutputJson, writeOutput } from "../../output.js";
13
+ import { shouldOutputJson, writeError, writeOutput } from "../../output.js";
14
14
 
15
15
  // ---------------------------------------------------------------------------
16
16
  // Helpers
@@ -72,16 +72,35 @@ export function readBodyData(
72
72
  }
73
73
 
74
74
  // ---------------------------------------------------------------------------
75
- // Command registration
75
+ // The authenticated request, shared by every command that makes one
76
76
  // ---------------------------------------------------------------------------
77
77
 
78
- export function registerRequestCommand(oauth: Command): void {
79
- // Options are registered imperatively (not in index.help.ts): the
80
- // repeatable "-H, --header" flag needs a Commander collect parser
81
- // (function + array default) that the declarative help contract cannot
82
- // express, and option order around it must be preserved for help output.
83
- subcommand(oauth, "request")
84
- .requiredOption("--provider <key>", "Provider name (e.g. google, slack)")
78
+ /**
79
+ * The request-shaping options every authenticated-request command offers:
80
+ * method, headers, body, output, and verbosity. What differs between the
81
+ * commands is only how the provider is named, so that flag is the caller's.
82
+ */
83
+ export interface AuthenticatedRequestOptions {
84
+ request?: string;
85
+ header: string[];
86
+ data?: string;
87
+ get?: boolean;
88
+ head?: boolean;
89
+ output?: string;
90
+ silent?: boolean;
91
+ verbose?: boolean;
92
+ include?: boolean;
93
+ }
94
+
95
+ /**
96
+ * Attach the request-shaping options to a subcommand. Registered
97
+ * imperatively (not in a help contract): the repeatable "-H, --header" flag
98
+ * needs a Commander collect parser (function + array default) that the
99
+ * declarative help contract cannot express, and option order around it must
100
+ * be preserved for help output.
101
+ */
102
+ export function attachRequestOptions(command: Command): Command {
103
+ return command
85
104
  .option("-X, --request <method>", "HTTP method (default: GET)")
86
105
  .option(
87
106
  "-H, --header <header>",
@@ -98,198 +117,220 @@ export function registerRequestCommand(oauth: Command): void {
98
117
  .option("-o, --output <file>", "Write response body to file")
99
118
  .option("-s, --silent", "Suppress informational stderr output")
100
119
  .option("-v, --verbose", "Show request/response details on stderr")
101
- .option("-i, --include", "Show response headers on stderr")
102
- .option("--account <account>", "Account identifier for multi-account")
103
- .option("--client-id <id>", "BYO app client ID disambiguation")
104
- .action(
105
- async (
106
- url: string,
107
- opts: {
108
- provider: string;
109
- request?: string;
110
- header: string[];
111
- data?: string;
112
- get?: boolean;
113
- head?: boolean;
114
- output?: string;
115
- silent?: boolean;
116
- verbose?: boolean;
117
- include?: boolean;
118
- account?: string;
119
- clientId?: string;
120
- },
121
- cmd: Command,
122
- ) => {
123
- const jsonMode = shouldOutputJson(cmd);
120
+ .option("-i, --include", "Show response headers on stderr");
121
+ }
124
122
 
125
- // Helper: write an error and set exit code
126
- const writeError = (error: string, hint?: string): void => {
127
- if (jsonMode) {
128
- const payload: Record<string, unknown> = { ok: false, error };
129
- if (hint) {
130
- payload.hint = hint;
131
- }
132
- writeOutput(cmd, payload);
133
- } else {
134
- process.stderr.write(error + "\n");
135
- }
136
- process.exitCode = 1;
137
- };
123
+ /**
124
+ * Make one authenticated request through a provider and print the outcome.
125
+ *
126
+ * The provider is named by key; the route resolves its connection and injects
127
+ * its credential, so the caller never handles a token. `account` and
128
+ * `clientId` disambiguate a multi-account OAuth integration and mean nothing
129
+ * for a channel's bot credential, so a caller that cannot have them omits
130
+ * them. `diagnosticsHint` names the command a person runs next when the
131
+ * request fails, in the vocabulary of the command that made it.
132
+ */
133
+ export async function runAuthenticatedRequest(params: {
134
+ providerKey: string;
135
+ url: string;
136
+ opts: AuthenticatedRequestOptions;
137
+ account?: string;
138
+ clientId?: string;
139
+ diagnosticsHint: string;
140
+ cmd: Command;
141
+ }): Promise<void> {
142
+ const { providerKey, url, opts, cmd } = params;
143
+ const jsonMode = shouldOutputJson(cmd);
138
144
 
139
- // Helper: write info to stderr (respects -s)
140
- const writeInfo = (msg: string): void => {
141
- if (!opts.silent) {
142
- process.stderr.write(msg + "\n");
143
- }
144
- };
145
+ // Helper: write info to stderr (respects -s)
146
+ const writeInfo = (msg: string): void => {
147
+ if (!opts.silent) {
148
+ process.stderr.write(msg + "\n");
149
+ }
150
+ };
145
151
 
146
- try {
147
- // Parse headers for verbose output (before sending to daemon)
148
- const parsedHeaders: Record<string, string> = {};
149
- for (const raw of opts.header) {
150
- const [key, value] = parseHeader(raw);
151
- parsedHeaders[key] = value;
152
- }
152
+ try {
153
+ // Parse headers for verbose output (before sending to daemon)
154
+ const parsedHeaders: Record<string, string> = {};
155
+ for (const raw of opts.header) {
156
+ const [key, value] = parseHeader(raw);
157
+ parsedHeaders[key] = value;
158
+ }
153
159
 
154
- // Verbose: show request details
155
- if (opts.verbose) {
156
- const method = opts.head
157
- ? "HEAD"
158
- : opts.request
159
- ? opts.request.toUpperCase()
160
- : opts.get
161
- ? "GET"
162
- : opts.data !== undefined
163
- ? "POST"
164
- : "GET";
165
- writeInfo(`> ${method} ${url}`);
166
- for (const [key, value] of Object.entries(parsedHeaders)) {
167
- writeInfo(`> ${key}: ${value}`);
168
- }
169
- writeInfo(`> Authorization: Bearer [REDACTED]`);
170
- writeInfo(`>`);
171
- }
160
+ // Verbose: show request details
161
+ if (opts.verbose) {
162
+ const method = opts.head
163
+ ? "HEAD"
164
+ : opts.request
165
+ ? opts.request.toUpperCase()
166
+ : opts.get
167
+ ? "GET"
168
+ : opts.data !== undefined
169
+ ? "POST"
170
+ : "GET";
171
+ writeInfo(`> ${method} ${url}`);
172
+ for (const [key, value] of Object.entries(parsedHeaders)) {
173
+ writeInfo(`> ${key}: ${value}`);
174
+ }
175
+ writeInfo(`> Authorization: Bearer [REDACTED]`);
176
+ writeInfo(`>`);
177
+ }
172
178
 
173
- // Read body data on the CLI side (file/stdin reading must happen here)
174
- let parsedData: unknown;
175
- if (opts.data !== undefined) {
176
- parsedData = readBodyData(opts.data, parsedHeaders);
177
- }
179
+ // Read body data on the CLI side (file/stdin reading must happen here)
180
+ let parsedData: unknown;
181
+ if (opts.data !== undefined) {
182
+ parsedData = readBodyData(opts.data, parsedHeaders);
183
+ }
178
184
 
179
- const body: Record<string, unknown> = {
180
- provider: opts.provider,
181
- url,
182
- };
183
- if (opts.request) {
184
- body.method = opts.request;
185
- }
186
- if (Object.keys(parsedHeaders).length > 0) {
187
- body.headers = parsedHeaders;
188
- }
189
- if (parsedData !== undefined) {
190
- body.parsed_data = parsedData;
191
- }
192
- if (opts.get) {
193
- body.force_get = true;
194
- }
195
- if (opts.head) {
196
- body.head = true;
197
- }
198
- if (opts.account) {
199
- body.account = opts.account;
200
- }
201
- if (opts.clientId) {
202
- body.client_id = opts.clientId;
203
- }
185
+ const body: Record<string, unknown> = {
186
+ provider: providerKey,
187
+ url,
188
+ };
189
+ if (opts.request) {
190
+ body.method = opts.request;
191
+ }
192
+ if (Object.keys(parsedHeaders).length > 0) {
193
+ body.headers = parsedHeaders;
194
+ }
195
+ if (parsedData !== undefined) {
196
+ body.parsed_data = parsedData;
197
+ }
198
+ if (opts.get) {
199
+ body.force_get = true;
200
+ }
201
+ if (opts.head) {
202
+ body.head = true;
203
+ }
204
+ if (params.account) {
205
+ body.account = params.account;
206
+ }
207
+ if (params.clientId) {
208
+ body.client_id = params.clientId;
209
+ }
204
210
 
205
- // Run the route handler in this process so Gmail-sized fetch and
206
- // JSON parse stay off the assistant event loop.
207
- const { handleRequest } =
208
- await import("../../../runtime/routes/oauth-commands-routes.js");
209
- const { RouteError } =
210
- await import("../../../runtime/routes/errors.js");
211
+ // Run the route handler in this process so Gmail-sized fetch and
212
+ // JSON parse stay off the assistant event loop.
213
+ const { handleRequest } =
214
+ await import("../../../runtime/routes/oauth-commands-routes.js");
215
+ const { RouteError } = await import("../../../runtime/routes/errors.js");
211
216
 
212
- let result: {
213
- ok: boolean;
214
- status: number;
215
- headers: Record<string, string>;
216
- body: unknown;
217
- bodyEncoding?: "base64";
218
- hint?: string;
219
- account?: string | null;
220
- accountWarning?: string;
221
- };
222
- try {
223
- result = (await handleRequest({ body })) as typeof result;
224
- } catch (err) {
225
- if (err instanceof RouteError) {
226
- return exitFromIpcResult({
227
- ok: false,
228
- error: err.message,
229
- statusCode: err.statusCode,
230
- });
231
- }
232
- throw err;
233
- }
217
+ let result: {
218
+ ok: boolean;
219
+ status: number;
220
+ headers: Record<string, string>;
221
+ body: unknown;
222
+ bodyEncoding?: "base64";
223
+ hint?: string;
224
+ account?: string | null;
225
+ accountWarning?: string;
226
+ };
227
+ try {
228
+ result = (await handleRequest({ body })) as typeof result;
229
+ } catch (err) {
230
+ if (err instanceof RouteError) {
231
+ // A structured route failure (unknown provider, no connection) is
232
+ // reported the way every other failure here is, so `--json` gets its
233
+ // envelope and the caller's diagnostics hint is not lost; only the
234
+ // exit code comes from the route's status.
235
+ writeError(cmd, `${err.message}\n\n${params.diagnosticsHint}`);
236
+ process.exitCode = exitCodeFromIpcResult({
237
+ statusCode: err.statusCode,
238
+ });
239
+ return;
240
+ }
241
+ throw err;
242
+ }
234
243
 
235
- // Non-2xx exit code
236
- if (result.status < 200 || result.status >= 300) {
237
- process.exitCode = 1;
238
- }
244
+ // Non-2xx exit code
245
+ if (result.status < 200 || result.status >= 300) {
246
+ process.exitCode = 1;
247
+ }
239
248
 
240
- // Which account served the request, and any multi-account ambiguity.
241
- if (result.account) {
242
- writeInfo(`* Account: ${result.account}`);
243
- }
244
- if (result.accountWarning) {
245
- writeInfo(result.accountWarning);
246
- }
249
+ // Which account served the request, and any multi-account ambiguity.
250
+ if (result.account) {
251
+ writeInfo(`* Account: ${result.account}`);
252
+ }
253
+ if (result.accountWarning) {
254
+ writeInfo(result.accountWarning);
255
+ }
247
256
 
248
- // Auth hint
249
- if (result.hint) {
250
- writeInfo(result.hint);
251
- }
257
+ // Auth hint
258
+ if (result.hint) {
259
+ writeInfo(result.hint);
260
+ }
252
261
 
253
- // JSON output mode
254
- if (jsonMode) {
255
- writeOutput(cmd, result);
256
- return;
257
- }
262
+ // JSON output mode
263
+ if (jsonMode) {
264
+ writeOutput(cmd, result);
265
+ return;
266
+ }
258
267
 
259
- // Verbose / include — response headers to stderr
260
- if (opts.verbose || opts.include) {
261
- writeInfo(`< HTTP ${result.status}`);
262
- for (const [key, value] of Object.entries(result.headers)) {
263
- writeInfo(`< ${key}: ${value}`);
264
- }
265
- writeInfo(`<`);
266
- }
268
+ // Verbose / include: response headers to stderr
269
+ if (opts.verbose || opts.include) {
270
+ writeInfo(`< HTTP ${result.status}`);
271
+ for (const [key, value] of Object.entries(result.headers)) {
272
+ writeInfo(`< ${key}: ${value}`);
273
+ }
274
+ writeInfo(`<`);
275
+ }
267
276
 
268
- // Body output (skip for null bodies: HEAD requests, 204, etc.)
269
- if (result.body != null || result.bodyEncoding === "base64") {
270
- const { materializeOAuthRequestOutput } =
271
- await import("../../../oauth/connection.js");
272
- const output = materializeOAuthRequestOutput(result);
273
- if (output) {
274
- if (opts.output) {
275
- writeFileSync(opts.output, output.bytes);
276
- } else {
277
- process.stdout.write(output.bytes);
278
- if (!output.isBinary) {
279
- process.stdout.write("\n");
280
- }
281
- }
282
- }
283
- } else if (opts.output) {
284
- writeFileSync(opts.output, Buffer.alloc(0));
277
+ // Body output (skip for null bodies: HEAD requests, 204, etc.)
278
+ if (result.body != null || result.bodyEncoding === "base64") {
279
+ const { materializeOAuthRequestOutput } =
280
+ await import("../../../oauth/connection.js");
281
+ const output = materializeOAuthRequestOutput(result);
282
+ if (output) {
283
+ if (opts.output) {
284
+ writeFileSync(opts.output, output.bytes);
285
+ } else {
286
+ process.stdout.write(output.bytes);
287
+ if (!output.isBinary) {
288
+ process.stdout.write("\n");
285
289
  }
286
- } catch (err) {
287
- const message = err instanceof Error ? err.message : String(err);
288
- writeError(
289
- `Error: ${message}\n\n` +
290
- `For provider diagnostics, run 'assistant oauth providers get ${opts.provider}'.`,
291
- );
292
290
  }
291
+ }
292
+ } else if (opts.output) {
293
+ writeFileSync(opts.output, Buffer.alloc(0));
294
+ }
295
+ } catch (err) {
296
+ const message = err instanceof Error ? err.message : String(err);
297
+ writeError(cmd, `${message}\n\n${params.diagnosticsHint}`);
298
+ process.exitCode = 1;
299
+ }
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // Command registration
304
+ // ---------------------------------------------------------------------------
305
+
306
+ export function registerRequestCommand(oauth: Command): void {
307
+ attachRequestOptions(
308
+ subcommand(oauth, "request").requiredOption(
309
+ "--provider <key>",
310
+ "Provider name (e.g. google, slack)",
311
+ ),
312
+ )
313
+ .option("--account <account>", "Account identifier for multi-account")
314
+ .option("--client-id <id>", "BYO app client ID disambiguation")
315
+ .action(
316
+ async (
317
+ url: string,
318
+ opts: AuthenticatedRequestOptions & {
319
+ provider: string;
320
+ account?: string;
321
+ clientId?: string;
322
+ },
323
+ cmd: Command,
324
+ ) => {
325
+ await runAuthenticatedRequest({
326
+ providerKey: opts.provider,
327
+ url,
328
+ opts,
329
+ account: opts.account,
330
+ clientId: opts.clientId,
331
+ diagnosticsHint: `For provider diagnostics, run 'assistant oauth providers get ${opts.provider}'.`,
332
+ cmd,
333
+ });
293
334
  },
294
335
  );
295
336
  }
@@ -197,7 +197,7 @@ Anything else fails with `Invalid input for tool "app_create": Unknown parameter
197
197
 
198
198
  - **`html`** — old single-file shortcut. Put your HTML inside `source_files["src/index.html"]`.
199
199
  - **`pages`** — retired. Multi-page apps use TSX components under `src/components/`.
200
- - **`icon`** — NOT a top-level param. An emoji icon goes in `preview.icon` (e.g. `preview: { title: "Bean Coffee", icon: "☕" }`). For an AI-generated icon, call `app_generate_icon(app_id, description)` *after* the app exists.
200
+ - **`icon`**: NOT a top-level param. The icon goes in `preview.icon` as a Lucide icon name from the list below (e.g. `preview: { title: "Bean Coffee", icon: "coffee" }`). Pick the one that best says what the app is; it is drawn in the sidebar and the library, so an emoji or a URL is wrong here. For an AI-generated image icon, call `app_generate_icon(app_id, description)` *after* the app exists.
201
201
  - **A file path as a top-level key** (e.g. `"src/components/Header.tsx"`) — these go inside `source_files`, or in a `file_write` after `app_create`.
202
202
 
203
203
  If a prior session in your context shows `app_create({ html })` or `app_create({ pages })`, that example is outdated — ignore it.
@@ -217,6 +217,8 @@ app_create({ app_create({
217
217
 
218
218
  **Key notes:** `preview` — always include, `title` required (plus optional `subtitle`, `description`, `icon`, up to 3 `metrics`). `auto_open` — **always pass `false`** so you don't get a duplicate preview card (Step 5 owns surfacing).
219
219
 
220
+ **App icon names** (`preview.icon`, kebab-case, one of): `calculator`, `calendar`, `list-todo`, `list-checks`, `square-check`, `timer`, `clock`, `alarm-clock`, `notebook-pen`, `sticky-note`, `pencil`, `file-text`, `clipboard-list`, `bookmark`, `book`, `book-open`, `chart-bar`, `chart-line`, `chart-pie`, `table`, `square-kanban`, `database`, `gauge`, `activity`, `target`, `flag`, `trophy`, `wallet`, `dollar-sign`, `piggy-bank`, `credit-card`, `receipt`, `percent`, `shopping-cart`, `package`, `gift`, `ticket`, `mail`, `inbox`, `message-square`, `phone`, `bell`, `users`, `contact`, `music`, `headphones`, `mic`, `video`, `film`, `tv`, `play`, `image`, `camera`, `gamepad-2`, `puzzle`, `party-popper`, `smile`, `map`, `map-pin`, `compass`, `globe`, `plane`, `car`, `bus`, `bike`, `ship`, `truck`, `house`, `bed`, `briefcase`, `graduation-cap`, `languages`, `brain`, `lightbulb`, `heart`, `heart-pulse`, `dumbbell`, `pill`, `stethoscope`, `baby`, `paw-print`, `utensils`, `coffee`, `wine`, `beer`, `cake`, `apple`, `carrot`, `salad`, `egg`, `fish`, `cloud`, `sun`, `moon`, `umbrella`, `snowflake`, `thermometer`, `droplets`, `flame`, `leaf`, `mountain`, `code`, `terminal`, `cpu`, `bot`, `wifi`, `lock`, `key`, `shield`, `settings`, `wrench`, `plug`, `battery`, `search`, `link`, `hash`, `layers`, `folder-open`, `palette`, `pen-tool`, `ruler`, `scale`, `scissors`, `shirt`, `newspaper`, `repeat`, `shuffle`, `volume-2`, `speaker`, `star`, `sparkles`, `zap`, `rocket`, `home`.
221
+
220
222
  ### 4 — Compile
221
223
 
222
224
  ```
@@ -43,7 +43,7 @@
43
43
  },
44
44
  "icon": {
45
45
  "type": "string",
46
- "description": "Optional icon \u2014 image URL preferred when available (logo, favicon, photo, etc.), emoji as fallback"
46
+ "description": "Optional icon for the app, as a Lucide icon name from the app icon list in the app-builder skill (kebab-case, e.g. calculator, calendar, list-todo, timer, notebook-pen, chart-bar, wallet, mail, map-pin, dumbbell). Pick the one that best says what the app is; it is drawn in the sidebar and the library. Not an emoji and not a URL: for an image icon call app_generate_icon after creating the app."
47
47
  },
48
48
  "metrics": {
49
49
  "type": "array",
@@ -73,7 +73,7 @@
73
73
  },
74
74
  "icon": {
75
75
  "type": "string",
76
- "description": "Lenient alias. Prefer preview.icon. An emoji or image URL passed here is folded into preview.icon automatically."
76
+ "description": "Lenient alias. Prefer preview.icon. A Lucide icon name passed here is folded into preview.icon automatically."
77
77
  },
78
78
  "html": {
79
79
  "type": "string",
@@ -76,10 +76,10 @@ const ATTACHMENT_CAPABLE_PLATFORMS = new Set(["gmail", "outlook"]);
76
76
  * home: the turn arrived on a channel, in a chat, in a thread (the turn-local
77
77
  * snapshot on the tool context), and a post the provider delivered to exactly
78
78
  * that place is a same-conversation send even when the chat's home resolves
79
- * elsewhere, as it does for a thread-scoped chat whose home is the chat's
80
- * notification conversation. The delivered thread is the one the provider
81
- * reports, not the one requested: a provider that ignores the request lands
82
- * the post in the thread-less chat, and the record must say so. The home
79
+ * elsewhere. The delivered thread is the one the provider reports, not the
80
+ * one requested: a provider that ignores the request lands the post in the
81
+ * thread-less chat, and the record must say so; a post into a thread is
82
+ * recorded in the thread's own conversation, where its replies arrive. The home
83
83
  * comparison stays as the second test, for a sender that arrived through no
84
84
  * channel but is the home. The post is then in the outbound index only
85
85
  * through no path, which is the same class as a raw API send and is deferred
@@ -114,6 +114,7 @@ async function recordSentChannelPost(params: {
114
114
  const home = await resolveProactiveHomeConversation({
115
115
  sourceChannel: providerId,
116
116
  externalChatId,
117
+ threadId: params.deliveredThreadId,
117
118
  source: "notification",
118
119
  conversationType: "background",
119
120
  title: `Messages to ${externalChatId}`,
@@ -125,6 +126,9 @@ async function recordSentChannelPost(params: {
125
126
  conversationId: home.conversationId,
126
127
  channel: providerId,
127
128
  externalChatId,
129
+ ...(params.deliveredThreadId
130
+ ? { threadId: params.deliveredThreadId }
131
+ : {}),
128
132
  text: params.text,
129
133
  providerMessageId: params.providerMessageId,
130
134
  crossPostedFrom: sender.conversationId,
@@ -16,7 +16,10 @@
16
16
  "label": "In-Chat Onboarding Tour",
17
17
  "description": "Multivariate experiment for the eyes-led in-chat onboarding tour. control = straight to chat after research onboarding (current behavior); tour = the extended tour auto-plays on first workspace entry. Desktop-only: phone-width viewports and the native shell get neither the tour nor its telemetry (not even the exposure event), so the funnel measures desktop sessions exclusively. Targeted via LaunchDarkly (70% tour / 30% control).",
18
18
  "defaultEnabled": "control",
19
- "values": ["control", "tour"]
19
+ "values": [
20
+ "control",
21
+ "tour"
22
+ ]
20
23
  },
21
24
  {
22
25
  "id": "user-hosted-enabled",
@@ -33,7 +36,10 @@
33
36
  "label": "Proactive Tips",
34
37
  "description": "Gates the dismissible proactive tip card in the web sidebar. String-valued so future A/B arms can be added as new values.",
35
38
  "defaultEnabled": "off",
36
- "values": ["off", "on"]
39
+ "values": [
40
+ "off",
41
+ "on"
42
+ ]
37
43
  },
38
44
  {
39
45
  "id": "vision-mode",
@@ -42,7 +48,10 @@
42
48
  "label": "Vision Mode",
43
49
  "description": "Gates hold-to-Live ambient frame sampling in the voice room camera. String-valued so future A/B arms can be added as new values.",
44
50
  "defaultEnabled": "off",
45
- "values": ["off", "on"]
51
+ "values": [
52
+ "off",
53
+ "on"
54
+ ]
46
55
  },
47
56
  {
48
57
  "id": "assistant-initiated-threads",
@@ -51,7 +60,10 @@
51
60
  "label": "Assistant-Initiated Threads",
52
61
  "description": "Gates the sidebar section collecting the conversations the assistant started on its own: rows stamped `source='assistant_initiated'` at creation by producers that opt a thread in (heartbeat realizations worth the user's time; nothing stamps it yet, so the section starts empty and holds only new threads by construction). Transactional notification trails keep `source='notification'` and stay in Chats and the bell. When on, the daemon emits an `assistant` row from the section index and withholds member conversations from the Chats bucket; when off, they stay in Chats and no `assistant` row is emitted, which is the shipped behavior. Deliberately daemon-scope only: the index row is self-describing, so clients render the section on its presence rather than on a flag of their own. A second client-side gate could only hide a section whose rows the daemon has already withheld from Chats, which would lose those threads instead of reverting the feature. String-valued so future A/B arms can be added as new values.",
53
62
  "defaultEnabled": "off",
54
- "values": ["off", "on"]
63
+ "values": [
64
+ "off",
65
+ "on"
66
+ ]
55
67
  },
56
68
  {
57
69
  "id": "experiment-activation-flow-2026-06-03",
@@ -60,7 +72,11 @@
60
72
  "label": "Activation Flow Experiment 2026-06-03",
61
73
  "description": "Multivariate activation-flow experiment. control = standard flow; variant-a = activation rail; personal-page = new sign-up-page variant (front-end only). Targeted via LaunchDarkly.",
62
74
  "defaultEnabled": "control",
63
- "values": ["control", "variant-a", "personal-page"]
75
+ "values": [
76
+ "control",
77
+ "variant-a",
78
+ "personal-page"
79
+ ]
64
80
  },
65
81
  {
66
82
  "id": "local-docker-enabled",
@@ -486,6 +502,20 @@
486
502
  "description": "Platform pods resolve webhook callback URLs from the Velay-published ingress URL instead of registering platform callback routes. Falls back to platform callback registration while no tunnel URL is published.",
487
503
  "defaultEnabled": false
488
504
  },
505
+ {
506
+ "id": "experiment-activation-checklist-2026-09-10",
507
+ "scope": "client",
508
+ "key": "experiment-activation-checklist-2026-09-10",
509
+ "label": "Experiment: Activation Checklist (2026-09-10)",
510
+ "description": "A/B test of the post-onboarding activation checklist (welcome modal, suggestions pill, inspiration list). off = control, the standard chat landing with no checklist; smb / parent / general = the checklist with that persona's task list. Any other value hides the checklist. Percentages per arm are set in LaunchDarkly; the readout keys a user on their first-ever assignment, so a new round gets a new key.",
511
+ "defaultEnabled": "off",
512
+ "values": [
513
+ "off",
514
+ "smb",
515
+ "parent",
516
+ "general"
517
+ ]
518
+ },
489
519
  {
490
520
  "id": "send-user-message",
491
521
  "scope": "both",
@@ -33,6 +33,15 @@ export interface AssistantAttachmentDraft {
33
33
  dataBase64: string;
34
34
  sizeBytes: number;
35
35
  kind: "image" | "video" | "document";
36
+ /**
37
+ * Absolute path the file was read from, after the directive's path was
38
+ * resolved against its boundary. Absent for drafts derived from tool
39
+ * content blocks, which have no file of their own. Callers that need to
40
+ * point a user back at the produced file (the activation checklist's
41
+ * artifact cards) read it, paired with `sourceType` so a host path is
42
+ * never mistaken for a workspace one.
43
+ */
44
+ sourcePath?: string;
36
45
  }
37
46
 
38
47
  // ---------------------------------------------------------------------------
@@ -600,6 +609,7 @@ export function resolveSandboxDirective(
600
609
  dataBase64,
601
610
  sizeBytes: data.length,
602
611
  kind: classifyKind(mimeType),
612
+ sourcePath: resolved,
603
613
  },
604
614
  warning: null,
605
615
  };
@@ -716,6 +726,7 @@ export async function resolveHostDirective(
716
726
  dataBase64,
717
727
  sizeBytes: data.length,
718
728
  kind: classifyKind(mimeType),
729
+ sourcePath: resolved,
719
730
  },
720
731
  warning: null,
721
732
  };