@lovable.dev/sdk 0.1.9 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts ADDED
@@ -0,0 +1,1597 @@
1
+ import type { Client, Middleware } from "openapi-fetch";
2
+
3
+ import createClient from "openapi-fetch";
4
+
5
+ import type { components, paths } from "./generated/paths.js";
6
+
7
+ type Schemas = components["schemas"];
8
+ type CreateProjectPostBody = paths["/v1/projects"]["post"]["requestBody"]["content"]["application/json"];
9
+
10
+ // POST /v1/database/query is served but sits outside the public OpenAPI
11
+ // boundary, so the generated paths omit it; this mirrors the openapi-typescript
12
+ // operation shape for queryDatabase.
13
+ type DatabaseQueryCompatPaths = {
14
+ "/v1/database/query": {
15
+ parameters: { query?: never; header?: never; path?: never; cookie?: never };
16
+ get?: never;
17
+ put?: never;
18
+ post: {
19
+ parameters: { query?: never; header?: never; path?: never; cookie?: never };
20
+ requestBody: {
21
+ content: { "application/json": { project_id: string; sql: string } };
22
+ };
23
+ responses: {
24
+ 200: {
25
+ headers: { [name: string]: unknown };
26
+ content: { "application/json": DatabaseQueryResult };
27
+ };
28
+ default: {
29
+ headers: { [name: string]: unknown };
30
+ content: { "application/problem+json": Record<string, unknown> };
31
+ };
32
+ };
33
+ };
34
+ delete?: never;
35
+ options?: never;
36
+ head?: never;
37
+ patch?: never;
38
+ trace?: never;
39
+ };
40
+ };
41
+ import type {
42
+ LovableClientOptions,
43
+ CreateProjectOptions,
44
+ ChatMessageOptions,
45
+ CreateVariantOptions,
46
+ WaitOptions,
47
+ ProjectResponse,
48
+ CreateProjectResponse,
49
+ CreateProjectBody,
50
+ UpdateProjectOptions,
51
+ EmbedURLResponse,
52
+ ProjectPatchVisibility,
53
+ DeploymentResponse,
54
+ UnscopedFile,
55
+ FileInput,
56
+ RemixProjectOptions,
57
+ RemixCreateProjectBody,
58
+ RemixJobStatus,
59
+ RemixResult,
60
+ RemixWaitOptions,
61
+ MeResponse,
62
+ MeWorkspace,
63
+ GetWorkspacesResponse,
64
+ WorkspaceWithMembership,
65
+ DatabaseStatus,
66
+ EnableDatabaseResult,
67
+ DatabaseQueryResult,
68
+ SendMessageResponse,
69
+ GetMessageResponse,
70
+ ListMessagesResponse,
71
+ ListMessagesOptions,
72
+ ChatResponse,
73
+ ChatResponseOptions,
74
+ CreateVariantResponse,
75
+ MessageCompletionResult,
76
+ MessageCompletionOptions,
77
+ GetMessageOptions,
78
+ KnowledgeResponse,
79
+ FileUploadUrlResponse,
80
+ GitDiffResponse,
81
+ GitFilesResponse,
82
+ EditsResponse,
83
+ ListProjectsResponse,
84
+ ListProjectsOptions,
85
+ ListConnectorsOptions,
86
+ ListCursorPaginationOptions,
87
+ ListHybridPaginationOptions,
88
+ ListOffsetPaginationOptions,
89
+ CursorPaginationOptions,
90
+ MoveProjectsToFolderResponse,
91
+ ConnectorResponse,
92
+ AddConnectorBody,
93
+ ListConnectorsResponse,
94
+ ListAvailableConnectorsResponse,
95
+ ListStandardConnectorsResponse,
96
+ ListSeamlessConnectorsResponse,
97
+ ListMCPConnectorsResponse,
98
+ ListConnectionsResponse,
99
+ ProjectAnalyticsResponse,
100
+ ProjectAnalyticsTrendResponse,
101
+ ListLibraryProjectsResponse,
102
+ ListTemplateProjectsResponse,
103
+ ListWorkspaceSkillsResponse,
104
+ WorkspaceSkillResponse,
105
+ WorkspaceSkillWriteResponse,
106
+ WorkspaceSkillDeleteResponse,
107
+ ListProjectSkillsResponse,
108
+ ProjectSkillResponse,
109
+ RateLimitInfo,
110
+ } from "./types.js";
111
+
112
+ import { makeRetryFetch } from "./retryFetch.js";
113
+ import { ApiError } from "./types.js";
114
+
115
+ const DEFAULT_BASE_URL = "https://api.lovable.dev";
116
+ const PUBLIC_API_CURSOR_VERSION = 1;
117
+
118
+ function normalizeBaseUrl(url: string | undefined): string {
119
+ if (!url) return DEFAULT_BASE_URL;
120
+
121
+ const normalized = url.replace(/\/$/, "");
122
+
123
+ if (!normalized.startsWith("http://") && !normalized.startsWith("https://")) {
124
+ throw new Error(`baseUrl must include a protocol (http:// or https://). Got: "${url}"`);
125
+ }
126
+
127
+ return normalized;
128
+ }
129
+
130
+ function encodePublicCursor(id: string): string {
131
+ const raw = JSON.stringify({ v: PUBLIC_API_CURSOR_VERSION, id });
132
+ const bytes = new TextEncoder().encode(raw);
133
+ let binary = "";
134
+ for (const byte of bytes) {
135
+ binary += String.fromCharCode(byte);
136
+ }
137
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
138
+ }
139
+
140
+ function normalizeWorkspaceList<T>(body: { data?: T[] | null; workspaces?: T[] | null }): T[] {
141
+ return body.data ?? body.workspaces ?? [];
142
+ }
143
+
144
+ function cursorHasMore(body: { pagination?: { has_more?: boolean } | null; has_more?: boolean }): boolean {
145
+ return body.pagination?.has_more ?? body.has_more ?? false;
146
+ }
147
+
148
+ function requireCreateProjectId<T extends { id?: string }>(project: T): T & { id: string } {
149
+ if (!project.id) throw new Error("Create project response missing project ID");
150
+ return project as T & { id: string };
151
+ }
152
+
153
+ /**
154
+ * openapi-fetch middleware that converts non-2xx Response objects into the
155
+ * SDK's ApiError, preserving the existing error contract (status/type/detail/
156
+ * props) used by every other method in this client.
157
+ */
158
+ const errorMiddleware: Middleware = {
159
+ async onResponse({ response }) {
160
+ if (response.ok) return;
161
+ let errorBody:
162
+ | {
163
+ type?: string;
164
+ title?: string;
165
+ message?: string;
166
+ detail?: string;
167
+ details?: string;
168
+ props?: Record<string, unknown>;
169
+ }
170
+ | undefined;
171
+ try {
172
+ errorBody = (await response.clone().json()) as typeof errorBody;
173
+ } catch {
174
+ // Ignore JSON parse errors
175
+ }
176
+ const message = buildErrorMessage(errorBody, response.status, response.statusText);
177
+ const type = errorBody?.type ?? errorBody?.title;
178
+ const detail = errorBody?.detail ?? errorBody?.details;
179
+ throw new ApiError(response.status, message, type, detail, errorBody?.props, parseRateLimitInfo(response.headers));
180
+ },
181
+ };
182
+
183
+ function parseRateLimitInfo(headers: Headers): RateLimitInfo | undefined {
184
+ const limit = parsePositiveInt(headers.get("x-ratelimit-limit"));
185
+ const remaining = parsePositiveInt(headers.get("x-ratelimit-remaining"));
186
+ const retryAfterMs = parseRetryAfterMs(headers.get("retry-after"));
187
+ if (limit == null && remaining == null && retryAfterMs == null) return undefined;
188
+ return { limit, remaining, retryAfterMs };
189
+ }
190
+
191
+ function parsePositiveInt(raw: string | null): number | undefined {
192
+ if (raw == null) return undefined;
193
+ const n = Number(raw);
194
+ return Number.isFinite(n) && n >= 0 ? n : undefined;
195
+ }
196
+
197
+ function parseRetryAfterMs(raw: string | null): number | undefined {
198
+ if (!raw) return undefined;
199
+ const seconds = Number(raw);
200
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
201
+ const dateMs = Date.parse(raw);
202
+ if (!Number.isNaN(dateMs)) {
203
+ const delta = dateMs - Date.now();
204
+ return delta > 0 ? delta : 0;
205
+ }
206
+ return undefined;
207
+ }
208
+
209
+ // HTTP/2 leaves response.statusText empty, and some Go API endpoints return
210
+ // `{message: ""}` / `{title: ""}` bodies on errors. `??` would preserve the
211
+ // empty string and produce an ApiError with no usable message; consumers
212
+ // downstream (MCP, logs, UI) then render an unlabeled failure.
213
+ function buildErrorMessage(
214
+ body: { message?: string; title?: string } | undefined,
215
+ status: number,
216
+ statusText: string,
217
+ ): string {
218
+ return body?.message || body?.title || (statusText ? `HTTP ${status}: ${statusText}` : `HTTP ${status}`);
219
+ }
220
+
221
+ export class LovableClient {
222
+ private readonly authHeaders: Record<string, string>;
223
+ private readonly baseUrl: string;
224
+ private readonly extraHeaders: Record<string, string>;
225
+ private readonly clientSource: string;
226
+ private readonly typedClient: Client<paths>;
227
+
228
+ constructor(options: LovableClientOptions) {
229
+ const hasApiKey = !!options.apiKey;
230
+ const hasBearerToken = !!options.bearerToken;
231
+
232
+ if (!hasApiKey && !hasBearerToken) {
233
+ throw new Error("Either apiKey or bearerToken is required");
234
+ }
235
+ if (hasApiKey && hasBearerToken) {
236
+ throw new Error("Provide either apiKey or bearerToken, not both");
237
+ }
238
+
239
+ this.authHeaders = hasApiKey
240
+ ? { "Lovable-API-Key": options.apiKey! }
241
+ : { Authorization: `Bearer ${options.bearerToken}` };
242
+
243
+ this.baseUrl = normalizeBaseUrl(options.baseUrl);
244
+ this.extraHeaders = options.headers ?? {};
245
+ this.clientSource = options.clientSource ?? "sdk";
246
+
247
+ this.typedClient = createClient<paths>({
248
+ baseUrl: this.baseUrl,
249
+ headers: {
250
+ "X-Client-Source": this.clientSource,
251
+ ...this.authHeaders,
252
+ ...this.extraHeaders,
253
+ Accept: "application/json",
254
+ },
255
+ fetch: makeRetryFetch(),
256
+ });
257
+ this.typedClient.use(errorMiddleware);
258
+ }
259
+
260
+ /**
261
+ * Type-safe access to any documented API route, driven by the auto-generated
262
+ * OpenAPI schema. Path / query params and request bodies are checked at compile
263
+ * time; calling an unknown path is a type error.
264
+ *
265
+ * Non-2xx responses throw `ApiError`; on success, `data` is set on the result.
266
+ */
267
+ get typed(): Client<paths> {
268
+ return this.typedClient;
269
+ }
270
+
271
+ /**
272
+ * Get the current authenticated user and their workspaces.
273
+ * Useful for validating an API key and discovering workspace IDs.
274
+ */
275
+ async me(): Promise<MeResponse> {
276
+ const { data } = await this.typed.GET("/v1/me");
277
+ return {
278
+ ...data!,
279
+ workspaces: normalizeWorkspaceList<MeWorkspace>(
280
+ data! as { data?: MeWorkspace[] | null; workspaces?: MeWorkspace[] | null },
281
+ ),
282
+ };
283
+ }
284
+
285
+ /**
286
+ * List workspaces the authenticated user has access to.
287
+ */
288
+ async listWorkspaces(options: ListHybridPaginationOptions = {}): Promise<GetWorkspacesResponse> {
289
+ const { data } = await this.typed.GET("/v1/workspaces", {
290
+ params: { query: options },
291
+ });
292
+ return { ...data!, workspaces: normalizeWorkspaceList<WorkspaceWithMembership>(data!) };
293
+ }
294
+
295
+ /**
296
+ * Get a specific workspace by ID
297
+ */
298
+ async getWorkspace(workspaceId: string) {
299
+ const { data } = await this.typed.GET("/v1/workspaces/{workspace_id}", {
300
+ params: { path: { workspace_id: workspaceId } },
301
+ });
302
+ return data!.workspace;
303
+ }
304
+
305
+ /**
306
+ * List projects in a workspace.
307
+ * Supports full-text search, filtering by visibility/publish status/folder/creator,
308
+ * and cursor pagination.
309
+ */
310
+ async listProjects(workspaceId: string, options?: ListProjectsOptions): Promise<ListProjectsResponse> {
311
+ const { data } = await this.typed.GET("/v1/projects", {
312
+ params: {
313
+ query: {
314
+ workspace_id: workspaceId,
315
+ q: options?.query,
316
+ visibility: options?.visibility,
317
+ publish_status: options?.publish_status,
318
+ folder_id: options?.folder_id,
319
+ folder_ids: options?.folder_ids,
320
+ user_id: options?.user_id,
321
+ type: options?.type,
322
+ include_risk: options?.include_risk,
323
+ search_fields: options?.search_fields,
324
+ viewed_by_me: options?.viewed_by_me,
325
+ cursor: options?.cursor,
326
+ limit: options?.limit,
327
+ },
328
+ },
329
+ });
330
+ const projects =
331
+ (data as unknown as { projects?: ListProjectsResponse["projects"] }).projects ?? data!.data ?? null;
332
+ const total = (data as unknown as { total?: number }).total;
333
+ return {
334
+ ...data!,
335
+ projects,
336
+ ...(total === undefined ? {} : { total }),
337
+ has_more: data!.pagination?.has_more ?? (data as unknown as { has_more?: boolean }).has_more,
338
+ };
339
+ }
340
+
341
+ /**
342
+ * Create a new project in a workspace
343
+ */
344
+ async createProject(workspaceId: string, options: CreateProjectOptions): Promise<CreateProjectResponse> {
345
+ let fileRefs: UnscopedFile[] | undefined;
346
+ let ephemeralFileRefs: UnscopedFile[] | undefined;
347
+ if (options.uploadedFiles?.length) {
348
+ ({ fileRefs, ephemeralFileRefs } = this.partitionUploadedFiles(options.uploadedFiles));
349
+ } else if (options.files?.length) {
350
+ ephemeralFileRefs = await this.uploadEphemeralFiles(options.files);
351
+ }
352
+
353
+ const body: CreateProjectBody = {
354
+ description: options.description,
355
+ template_project_id: options.templateProjectId,
356
+ };
357
+ if (options.projectName) {
358
+ body.display_name = options.projectName;
359
+ }
360
+ if (options.visibility) {
361
+ body.visibility = options.visibility;
362
+ }
363
+ if (options.techStack) {
364
+ body.tech_stack = options.techStack;
365
+ }
366
+ if (options.sandboxTemplate) {
367
+ body.sandbox_template = options.sandboxTemplate;
368
+ }
369
+ if (options.selectedLibraries?.length) {
370
+ body.selected_libraries = options.selectedLibraries;
371
+ }
372
+ if (options.initialMessage) {
373
+ body.initial_message = options.initialMessage;
374
+ }
375
+ if (fileRefs?.length) {
376
+ body.files = fileRefs;
377
+ }
378
+ if (options.fileUrls?.length) {
379
+ body.file_urls = options.fileUrls;
380
+ }
381
+ if (ephemeralFileRefs?.length) {
382
+ body.ephemeral_files = ephemeralFileRefs;
383
+ }
384
+
385
+ const { data } = await this.typed.POST("/v1/projects", {
386
+ body: { ...body, workspace_id: workspaceId },
387
+ });
388
+ return requireCreateProjectId(data!);
389
+ }
390
+
391
+ /**
392
+ * Send a chat message to a project.
393
+ *
394
+ * The API accepts the message and processes it in the background.
395
+ * Returns the message ID and status. Use `waitForMessageCompletion()`
396
+ * to poll for the AI response.
397
+ */
398
+ async chat(projectId: string, options: ChatMessageOptions): Promise<SendMessageResponse> {
399
+ let fileRefs: UnscopedFile[] | undefined;
400
+ let ephemeralFileRefs: UnscopedFile[] | undefined;
401
+ if (options.uploadedFiles?.length) {
402
+ ({ fileRefs, ephemeralFileRefs } = this.partitionUploadedFiles(options.uploadedFiles));
403
+ } else if (options.files?.length) {
404
+ fileRefs = await this.uploadProjectFiles(projectId, options.files);
405
+ }
406
+
407
+ const body: Omit<Schemas["PublicV1SendMessageInputBody"], "project_id"> = {
408
+ message: options.message,
409
+ };
410
+ if (options.variantId) {
411
+ body.variant_id = options.variantId;
412
+ }
413
+ if (fileRefs) {
414
+ body.files = fileRefs;
415
+ }
416
+ if (ephemeralFileRefs) {
417
+ body.ephemeral_files = ephemeralFileRefs;
418
+ }
419
+ if (options.planMode) {
420
+ body.plan_mode = true;
421
+ }
422
+ if (options.continuation) {
423
+ body.continuation = options.continuation;
424
+ }
425
+
426
+ const { data } = await this.typed.POST("/v1/messages", {
427
+ body: { ...body, project_id: projectId },
428
+ });
429
+ return data!;
430
+ }
431
+
432
+ /**
433
+ * Create an independent variant from the project's current main branch, or from a full baseSha when provided.
434
+ */
435
+ async createVariant(projectId: string, options: CreateVariantOptions = {}): Promise<CreateVariantResponse> {
436
+ const { data } = await this.typed.POST("/v1/projects/{project_id}/variants", {
437
+ params: { path: { project_id: projectId } },
438
+ body: { label: options.label, base_sha: options.baseSha },
439
+ });
440
+ return data!;
441
+ }
442
+
443
+ /**
444
+ * Get project details by ID
445
+ */
446
+ async getProject(projectId: string): Promise<ProjectResponse> {
447
+ const { data } = await this.typed.GET("/v1/projects/{project_id}", {
448
+ params: { path: { project_id: projectId } },
449
+ });
450
+ return data!;
451
+ }
452
+
453
+ /**
454
+ * Create an anonymous, one-hour static preview URL for one exact HTTPS parent origin.
455
+ */
456
+ async createEmbedUrl(projectId: string, parentOrigin: string): Promise<EmbedURLResponse> {
457
+ const { data } = await this.typed.POST("/v1/projects/{project_id}/embed-url", {
458
+ params: { path: { project_id: projectId } },
459
+ body: { parent_origin: parentOrigin },
460
+ });
461
+ return data!;
462
+ }
463
+
464
+ /**
465
+ * Update supported project fields.
466
+ */
467
+ async updateProject(projectId: string, options: UpdateProjectOptions): Promise<ProjectResponse> {
468
+ const { data } = await this.typed.PATCH("/v1/projects/{project_id}", {
469
+ params: { path: { project_id: projectId } },
470
+ body: options,
471
+ });
472
+ return data!;
473
+ }
474
+
475
+ /**
476
+ * Soft-delete a project. Repeated deletes are treated as successful.
477
+ */
478
+ async deleteProject(projectId: string): Promise<void> {
479
+ await this.typed.DELETE("/v1/projects/{project_id}", {
480
+ params: { path: { project_id: projectId } },
481
+ });
482
+ }
483
+
484
+ /**
485
+ * Get the preview URL for a project.
486
+ *
487
+ * The preview URL is available once the project reaches "completed" status.
488
+ * This URL allows viewing the project in development mode.
489
+ *
490
+ * @param projectId - The project ID
491
+ * @returns The preview URL
492
+ */
493
+ getPreviewUrl(projectId: string): string {
494
+ return `https://id-preview--${projectId}.lovable.app`;
495
+ }
496
+
497
+ /**
498
+ * Get the published URL for a project (if published).
499
+ *
500
+ * Returns the public URL if the project has been published, or null if not.
501
+ *
502
+ * @param projectId - The project ID
503
+ * @returns The published URL or null if not published
504
+ */
505
+ async getPublishedUrl(projectId: string): Promise<string | null> {
506
+ const project = await this.getProject(projectId);
507
+ return project.is_published && project.url ? project.url : null;
508
+ }
509
+
510
+ /**
511
+ * Get the cloud database status for a project.
512
+ *
513
+ * @param projectId - The project ID
514
+ * @returns Whether the database is enabled and which stack is used
515
+ */
516
+ async getDatabaseStatus(projectId: string): Promise<DatabaseStatus> {
517
+ const { data } = await this.typed.GET("/v1/database", {
518
+ params: { query: { project_id: projectId } },
519
+ });
520
+ return data!;
521
+ }
522
+
523
+ /**
524
+ * Enable (provision) a cloud database for a project.
525
+ *
526
+ * This triggers database provisioning which takes 30-60 seconds.
527
+ * The call blocks until provisioning completes.
528
+ *
529
+ * @param projectId - The project ID
530
+ * @returns The database status after enablement
531
+ */
532
+ async enableDatabase(projectId: string): Promise<EnableDatabaseResult> {
533
+ const { data } = await this.typed.POST("/v1/database/enable", {
534
+ body: { project_id: projectId },
535
+ });
536
+ return data!;
537
+ }
538
+
539
+ /**
540
+ * Execute a SQL query against the project's cloud database.
541
+ *
542
+ * Supports SELECT, INSERT, UPDATE, DELETE, and DDL statements.
543
+ * The database must be enabled first (see enableDatabase).
544
+ *
545
+ * @param projectId - The project ID
546
+ * @param sql - SQL query to execute
547
+ * @returns Query result rows as JSON objects
548
+ */
549
+ async queryDatabase(projectId: string, sql: string): Promise<DatabaseQueryResult> {
550
+ // The route sits outside the public OpenAPI boundary, so the generated
551
+ // paths lack it; DatabaseQueryCompatPaths stands in for the generated shape.
552
+ const compat = this.typedClient as unknown as Client<DatabaseQueryCompatPaths>;
553
+ const { data } = await compat.POST("/v1/database/query", {
554
+ body: { project_id: projectId, sql },
555
+ });
556
+ return data!;
557
+ }
558
+
559
+ // ---------------------------------------------------------------------------
560
+ // Messages
561
+ // ---------------------------------------------------------------------------
562
+
563
+ /**
564
+ * Get a message by ID. Returns the message content, status, and (for user messages)
565
+ * the AI response if available.
566
+ *
567
+ * Pass `waitSeconds` to long-poll: the server holds the request until the
568
+ * message reaches a terminal state (completed / stopped / error / awaiting_input) or the
569
+ * duration elapses. This replaces client-side polling for `waitForMessageCompletion`.
570
+ */
571
+ async getMessage(projectId: string, messageId: string, options?: GetMessageOptions): Promise<GetMessageResponse> {
572
+ const waitSeconds = options?.waitSeconds;
573
+ const query = {
574
+ wait: waitSeconds && waitSeconds > 0 ? `${Math.floor(waitSeconds)}s` : undefined,
575
+ thread_id: options?.threadId,
576
+ };
577
+ const { data } = await this.typed.GET("/v1/messages/{message_id}", {
578
+ params: { path: { message_id: messageId }, query: { ...query, project_id: projectId } },
579
+ });
580
+ return data!;
581
+ }
582
+
583
+ /**
584
+ * List recent messages in a project, newest first. Use `cursor` from the
585
+ * previous page's `pagination.next_cursor` to paginate through history.
586
+ */
587
+ async listMessages(projectId: string, params?: ListMessagesOptions): Promise<ListMessagesResponse> {
588
+ const cursor = params?.cursor ?? (params?.before ? encodePublicCursor(params.before) : undefined);
589
+ const { data } = await this.typed.GET("/v1/messages", {
590
+ params: {
591
+ query: { project_id: projectId, limit: params?.limit, cursor },
592
+ },
593
+ });
594
+ return {
595
+ ...data!,
596
+ messages: data!.data,
597
+ has_more: cursorHasMore(data!),
598
+ };
599
+ }
600
+
601
+ /**
602
+ * Wait for the AI response to reach a terminal status (completed, stopped, error, awaiting_input)
603
+ * or for `timeout` to elapse.
604
+ *
605
+ * Primary path is SSE against `/v1/messages/{message_id}/stream`: one
606
+ * held connection that pushes a snapshot on every relevant change and closes
607
+ * on terminal. If SSE isn't reachable (proxy strips text/event-stream, server
608
+ * returns 404 / 415 / 501) we fall back to the long-poll JSON endpoint on the
609
+ * same URL. Both paths share the same `MessageCompletionResult` shape.
610
+ */
611
+ async waitForMessageCompletion(
612
+ projectId: string,
613
+ messageId: string,
614
+ options?: MessageCompletionOptions,
615
+ ): Promise<MessageCompletionResult> {
616
+ const timeout = options?.timeout ?? 600_000;
617
+ const deadline = Date.now() + timeout;
618
+
619
+ const sse = await this.waitForMessageCompletionViaSSE(projectId, messageId, deadline, options?.threadId);
620
+ if (sse.kind === "result") {
621
+ return sse.result;
622
+ }
623
+ if (Date.now() >= deadline) {
624
+ return timeoutResult(messageId, timeout);
625
+ }
626
+ return this.waitForMessageCompletionViaLongPoll(projectId, messageId, deadline, timeout, options);
627
+ }
628
+
629
+ /**
630
+ * @deprecated Use `chat()` or `createProject()`'s returned `message_id`,
631
+ * then call `waitForMessageCompletion(projectId, messageId)`.
632
+ *
633
+ * Throws when the turn pauses for human input (`awaiting_input`) — the
634
+ * legacy `ChatResponse` shape cannot carry resume metadata. HITL-capable
635
+ * flows need `waitForMessageCompletion` plus `respondToTool`.
636
+ */
637
+ async waitForResponse(projectId: string, options?: ChatResponseOptions): Promise<ChatResponse> {
638
+ const messages = await this.listMessages(projectId, { limit: 10 });
639
+ const latest = messages.messages?.find((message) => message.role === "user");
640
+
641
+ if (!latest?.message_id) {
642
+ throw new Error(`No messages found for project ${projectId}`);
643
+ }
644
+
645
+ const completionOptions = options?.timeout === undefined ? undefined : { timeout: options.timeout };
646
+ const result = await this.waitForMessageCompletion(projectId, latest.message_id, completionOptions);
647
+ // Preserve the pre-deprecation contract: throw on failure. waitForMessageCompletion
648
+ // returns (never throws) on timeout/error, so without this a timed-out or errored run
649
+ // looks like a successful empty response to try/catch callers.
650
+ if (result.status !== "completed" && result.status !== "stopped") {
651
+ throw new Error(result.error ?? `Message ${latest.message_id} did not complete (status: ${result.status})`);
652
+ }
653
+ return {
654
+ content: result.content,
655
+ messageId: result.message_id,
656
+ previewUrl: this.getPreviewUrl(projectId),
657
+ };
658
+ }
659
+
660
+ private async waitForMessageCompletionViaSSE(
661
+ projectId: string,
662
+ messageId: string,
663
+ deadline: number,
664
+ threadId?: string,
665
+ ): Promise<{ kind: "result"; result: MessageCompletionResult } | { kind: "fallback" } | { kind: "timeout" }> {
666
+ const remaining = deadline - Date.now();
667
+ if (remaining <= 0) return { kind: "timeout" };
668
+
669
+ const url = new URL(`${this.baseUrl}/v1/messages/${encodeURIComponent(messageId)}/stream`);
670
+ url.searchParams.set("project_id", projectId);
671
+ if (threadId) {
672
+ url.searchParams.set("thread_id", threadId);
673
+ }
674
+ const controller = new AbortController();
675
+ const timeoutId = setTimeout(() => controller.abort(), remaining);
676
+
677
+ let response: Response;
678
+ try {
679
+ response = await fetch(url, {
680
+ headers: {
681
+ ...this.authHeaders,
682
+ ...this.extraHeaders,
683
+ "X-Client-Source": this.clientSource,
684
+ Accept: "text/event-stream",
685
+ },
686
+ signal: controller.signal,
687
+ });
688
+ } catch (err) {
689
+ clearTimeout(timeoutId);
690
+ if (isAbortError(err)) return { kind: "timeout" };
691
+ return { kind: "fallback" };
692
+ }
693
+
694
+ if (!response.ok) {
695
+ clearTimeout(timeoutId);
696
+ if (response.status === 404 || response.status === 415 || response.status === 501) {
697
+ await cancelResponseBody(response);
698
+ return { kind: "fallback" };
699
+ }
700
+ if (response.status >= 500) {
701
+ await cancelResponseBody(response);
702
+ return { kind: "fallback" };
703
+ }
704
+ const detail = await safeReadText(response);
705
+ throw new ApiError(response.status, detail || `HTTP ${response.status}`);
706
+ }
707
+
708
+ if (!response.body) {
709
+ clearTimeout(timeoutId);
710
+ return { kind: "fallback" };
711
+ }
712
+
713
+ try {
714
+ for await (const frame of parseSSEFrames(response.body)) {
715
+ if (!frame.data) continue;
716
+ let snapshot: GetMessageResponse;
717
+ try {
718
+ snapshot = JSON.parse(frame.data) as GetMessageResponse;
719
+ } catch {
720
+ continue;
721
+ }
722
+
723
+ const queuedResult = queuedExitResult(snapshot, messageId);
724
+ if (queuedResult) return { kind: "result", result: queuedResult };
725
+
726
+ const terminal = terminalResultFromMessage(snapshot, messageId);
727
+ if (terminal) {
728
+ return { kind: "result", result: terminal };
729
+ }
730
+ }
731
+ return { kind: "fallback" };
732
+ } catch (err) {
733
+ if (isAbortError(err)) return { kind: "timeout" };
734
+ return { kind: "fallback" };
735
+ } finally {
736
+ clearTimeout(timeoutId);
737
+ controller.abort();
738
+ }
739
+ }
740
+
741
+ private async waitForMessageCompletionViaLongPoll(
742
+ projectId: string,
743
+ messageId: string,
744
+ deadline: number,
745
+ totalTimeoutMs: number,
746
+ options?: MessageCompletionOptions,
747
+ ): Promise<MessageCompletionResult> {
748
+ const waitSeconds = Math.max(1, Math.min(55, options?.waitSeconds ?? 30));
749
+ const transientBackoffMs = 1000;
750
+ let notFoundSince: number | null = null;
751
+ const notFoundGraceMs = 15_000;
752
+
753
+ while (Date.now() < deadline) {
754
+ const remainingMs = deadline - Date.now();
755
+ const perCallSeconds = Math.min(waitSeconds, Math.floor(remainingMs / 1000));
756
+ const waitOptions = perCallSeconds > 0 ? { waitSeconds: perCallSeconds } : undefined;
757
+
758
+ const callStartedAt = Date.now();
759
+ try {
760
+ const msg = await this.getMessage(projectId, messageId, { ...waitOptions, threadId: options?.threadId });
761
+ notFoundSince = null;
762
+
763
+ const queuedResult = queuedExitResult(msg, messageId);
764
+ if (queuedResult) return queuedResult;
765
+
766
+ const terminal = terminalResultFromMessage(msg, messageId);
767
+ if (terminal) return terminal;
768
+
769
+ // Server returned a non-terminal snapshot far faster than the wait
770
+ // window it was given. That happens when the streamer is nil or its
771
+ // subscription fails server-side, or for queued messages that have no
772
+ // stream events yet. Backoff so we don't tight-loop until the deadline.
773
+ if (Date.now() - callStartedAt < transientBackoffMs) {
774
+ await sleep(Math.min(transientBackoffMs, Math.max(0, deadline - Date.now())));
775
+ }
776
+ } catch (err) {
777
+ if (err instanceof ApiError) {
778
+ if (err.status === 404) {
779
+ if (notFoundSince === null) {
780
+ notFoundSince = Date.now();
781
+ }
782
+ if (Date.now() - notFoundSince < notFoundGraceMs) {
783
+ await sleep(Math.min(transientBackoffMs, Math.max(0, deadline - Date.now())));
784
+ continue;
785
+ }
786
+ return {
787
+ status: "error",
788
+ message_id: messageId,
789
+ content: "",
790
+ error: "Message not found. It may have been deleted from the queue.",
791
+ };
792
+ }
793
+ if (err.status < 500) {
794
+ throw err;
795
+ }
796
+ }
797
+ await sleep(Math.min(transientBackoffMs, Math.max(0, deadline - Date.now())));
798
+ }
799
+ }
800
+
801
+ return timeoutResult(messageId, totalTimeoutMs);
802
+ }
803
+
804
+ // ---------------------------------------------------------------------------
805
+ // Knowledge
806
+ // ---------------------------------------------------------------------------
807
+
808
+ /** Get workspace knowledge (custom instructions for the AI agent). */
809
+ async getWorkspaceKnowledge(workspaceId: string): Promise<KnowledgeResponse> {
810
+ const { data } = await this.typed.GET("/v1/workspaces/{workspace_id}/knowledge", {
811
+ params: { path: { workspace_id: workspaceId } },
812
+ });
813
+ return data!;
814
+ }
815
+
816
+ /** Set workspace knowledge. Max 10,000 characters. */
817
+ async setWorkspaceKnowledge(workspaceId: string, content: string): Promise<KnowledgeResponse> {
818
+ const { data } = await this.typed.PUT("/v1/workspaces/{workspace_id}/knowledge", {
819
+ params: { path: { workspace_id: workspaceId } },
820
+ body: { content },
821
+ });
822
+ return data!;
823
+ }
824
+
825
+ // ---------------------------------------------------------------------------
826
+ // Workspace skills
827
+ // ---------------------------------------------------------------------------
828
+
829
+ /** List workspace skills. */
830
+ async listWorkspaceSkills(
831
+ workspaceId: string,
832
+ options: { includeMarkdown?: boolean } & ListOffsetPaginationOptions = {},
833
+ ): Promise<ListWorkspaceSkillsResponse> {
834
+ const { data } = await this.typed.GET("/v1/workspaces/{workspace_id}/skills", {
835
+ params: {
836
+ path: { workspace_id: workspaceId },
837
+ query: { include_markdown: options.includeMarkdown, limit: options.limit, offset: options.offset },
838
+ },
839
+ });
840
+ return { ...data!, skills: data!.skills ?? [] };
841
+ }
842
+
843
+ /** Get a single workspace skill, including SKILL.md contents. */
844
+ async getWorkspaceSkill(workspaceId: string, skillName: string): Promise<WorkspaceSkillResponse> {
845
+ const { data } = await this.typed.GET("/v1/workspaces/{workspace_id}/skills/{skill_name}", {
846
+ params: { path: { workspace_id: workspaceId, skill_name: skillName } },
847
+ });
848
+ return data!;
849
+ }
850
+
851
+ /** Create a workspace skill from full SKILL.md markdown. */
852
+ async createWorkspaceSkill(
853
+ workspaceId: string,
854
+ skillName: string,
855
+ markdown: string,
856
+ ): Promise<WorkspaceSkillWriteResponse> {
857
+ const { data } = await this.typed.POST("/v1/workspaces/{workspace_id}/skills/{skill_name}", {
858
+ params: { path: { workspace_id: workspaceId, skill_name: skillName } },
859
+ body: { markdown },
860
+ });
861
+ return data!;
862
+ }
863
+
864
+ /** Update a workspace skill by replacing its SKILL.md markdown. */
865
+ async updateWorkspaceSkill(
866
+ workspaceId: string,
867
+ skillName: string,
868
+ markdown: string,
869
+ ): Promise<WorkspaceSkillWriteResponse> {
870
+ const { data } = await this.typed.PUT("/v1/workspaces/{workspace_id}/skills/{skill_name}", {
871
+ params: { path: { workspace_id: workspaceId, skill_name: skillName } },
872
+ body: { markdown },
873
+ });
874
+ return data!;
875
+ }
876
+
877
+ /** Delete a workspace skill. */
878
+ async deleteWorkspaceSkill(workspaceId: string, skillName: string): Promise<WorkspaceSkillDeleteResponse> {
879
+ const { data } = await this.typed.DELETE("/v1/workspaces/{workspace_id}/skills/{skill_name}", {
880
+ params: { path: { workspace_id: workspaceId, skill_name: skillName } },
881
+ });
882
+ return data!;
883
+ }
884
+
885
+ // ---------------------------------------------------------------------------
886
+ // Project skills
887
+ // ---------------------------------------------------------------------------
888
+
889
+ /** List project skills, including whether each skill is enabled. */
890
+ async listProjectSkills(
891
+ projectId: string,
892
+ options: ListCursorPaginationOptions = {},
893
+ ): Promise<ListProjectSkillsResponse> {
894
+ const { data } = await this.typed.GET("/v1/skills", {
895
+ params: { query: { project_id: projectId, limit: options.limit, cursor: options.cursor } },
896
+ });
897
+ const skills = (data as unknown as { skills?: ListProjectSkillsResponse["skills"] }).skills ?? data!.data ?? [];
898
+ return { ...data!, skills };
899
+ }
900
+
901
+ /** Enable or disable a project skill without removing it from the project repo. */
902
+ async setProjectSkillEnabled(projectId: string, skillName: string, enabled: boolean): Promise<ProjectSkillResponse> {
903
+ const { data } = await this.typed.PATCH("/v1/skills/{skill_name}", {
904
+ params: { path: { skill_name: skillName } },
905
+ body: { project_id: projectId, enabled },
906
+ });
907
+ return data!;
908
+ }
909
+
910
+ /** Get project knowledge (custom instructions for the AI agent). */
911
+ async getProjectKnowledge(projectId: string): Promise<KnowledgeResponse> {
912
+ const { data } = await this.typed.GET("/v1/knowledge", {
913
+ params: { query: { project_id: projectId } },
914
+ });
915
+ return data!;
916
+ }
917
+
918
+ /** Set project knowledge. Max 10,000 characters. */
919
+ async setProjectKnowledge(projectId: string, content: string): Promise<KnowledgeResponse> {
920
+ const { data } = await this.typed.PUT("/v1/knowledge", {
921
+ body: { project_id: projectId, content },
922
+ });
923
+ return data!;
924
+ }
925
+
926
+ // ---------------------------------------------------------------------------
927
+ // Git operations
928
+ // ---------------------------------------------------------------------------
929
+
930
+ /**
931
+ * Get the structured diff for a message or commit.
932
+ * Pass `messageId` to get the diff for a specific AI message,
933
+ * or `sha` for a specific commit.
934
+ */
935
+ async getDiff(
936
+ projectId: string,
937
+ params: { messageId?: string; sha?: string; baseSha?: string },
938
+ ): Promise<GitDiffResponse> {
939
+ const { data } = await this.typed.GET("/v1/git/diff", {
940
+ params: {
941
+ query: { project_id: projectId, message_id: params.messageId, sha: params.sha, base_sha: params.baseSha },
942
+ },
943
+ });
944
+ return data!;
945
+ }
946
+
947
+ /** List files in a project. Omitting ref uses the API default. */
948
+ async listFiles(projectId: string, ref?: string, options?: CursorPaginationOptions): Promise<GitFilesResponse>;
949
+ async listFiles(projectId: string, options?: CursorPaginationOptions): Promise<GitFilesResponse>;
950
+ async listFiles(
951
+ projectId: string,
952
+ refOrOptions?: string | CursorPaginationOptions,
953
+ options: CursorPaginationOptions = {},
954
+ ): Promise<GitFilesResponse> {
955
+ const ref = typeof refOrOptions === "string" ? refOrOptions : undefined;
956
+ const pagination = typeof refOrOptions === "string" ? options : (refOrOptions ?? options);
957
+ const { data } = await this.typed.GET("/v1/git/files", {
958
+ params: { query: { project_id: projectId, ref, limit: pagination.limit, cursor: pagination.cursor } },
959
+ });
960
+ const files = data!.data ?? [];
961
+ return { ...data!, data: files, files };
962
+ }
963
+
964
+ /** Read the raw content of a single file. Omitting ref uses the API default. */
965
+ async readFile(projectId: string, path: string, ref?: string): Promise<string> {
966
+ // The Go OpenAPI annotation for this route declares 200 as `content?: never`
967
+ // even though the handler returns the raw file text. `parseAs: "text"` makes
968
+ // openapi-fetch read the body as text at runtime; the cast covers the spec
969
+ // gap. Drop the cast once the Go annotation declares text/plain content.
970
+ const { data } = await this.typed.GET("/v1/git/files/{path}", {
971
+ params: { path: { path }, query: { project_id: projectId, ref } },
972
+ parseAs: "text",
973
+ });
974
+ return data as unknown as string;
975
+ }
976
+
977
+ // ---------------------------------------------------------------------------
978
+ // Edits
979
+ // ---------------------------------------------------------------------------
980
+
981
+ /** List the edit history of a project. */
982
+ async listEdits(projectId: string, params?: { limit?: number; before?: string }): Promise<EditsResponse> {
983
+ const { data } = await this.typed.GET("/v1/edits", {
984
+ params: {
985
+ query: { project_id: projectId, limit: params?.limit, before: params?.before },
986
+ },
987
+ });
988
+ return data!;
989
+ }
990
+
991
+ // ---------------------------------------------------------------------------
992
+ // File upload
993
+ // ---------------------------------------------------------------------------
994
+
995
+ /** Get an ephemeral presigned URL for uploading a file before a project exists. */
996
+ async getFileUploadUrl(params: { file_name: string; content_type?: string }): Promise<FileUploadUrlResponse> {
997
+ return this.getEphemeralFileUploadUrl({ content_type: params.content_type });
998
+ }
999
+
1000
+ /** Get a project-scoped presigned URL for uploading a file. Returns the upload URL, file ID, and required PUT headers. */
1001
+ async getProjectFileUploadUrl(projectId: string, params: { content_type?: string }): Promise<FileUploadUrlResponse> {
1002
+ const { data } = await this.typed.POST("/v1/project-files/upload-url", {
1003
+ body: { project_id: projectId, ...params },
1004
+ });
1005
+ return data!;
1006
+ }
1007
+
1008
+ /** Get an ephemeral presigned URL for uploading a file before a project exists. */
1009
+ async getEphemeralFileUploadUrl(params: { content_type?: string }): Promise<FileUploadUrlResponse> {
1010
+ const { data } = await this.typed.POST("/v1/files/ephemeral-upload-url", { body: params });
1011
+ return data!;
1012
+ }
1013
+
1014
+ // ---------------------------------------------------------------------------
1015
+ // Visibility
1016
+ // ---------------------------------------------------------------------------
1017
+
1018
+ /** Set a project's visibility (draft, private, workspace_view, or public). */
1019
+ async setProjectVisibility(projectId: string, visibility: ProjectPatchVisibility): Promise<ProjectResponse> {
1020
+ return this.updateProject(projectId, { visibility });
1021
+ }
1022
+
1023
+ /** Set a folder's visibility (personal or workspace). */
1024
+ async setFolderVisibility(workspaceId: string, folderId: string, visibility: "personal" | "workspace") {
1025
+ const { data } = await this.typed.PUT("/v1/workspaces/{workspace_id}/folders/{folder_id}/visibility", {
1026
+ params: { path: { workspace_id: workspaceId, folder_id: folderId } },
1027
+ body: { visibility },
1028
+ });
1029
+ return data!;
1030
+ }
1031
+
1032
+ /** Move projects into a folder, removing existing folder memberships first. */
1033
+ async moveProjectsToFolder(
1034
+ workspaceId: string,
1035
+ folderId: string,
1036
+ projectIds: string[],
1037
+ ): Promise<MoveProjectsToFolderResponse> {
1038
+ const { data } = await this.typed.POST("/v1/workspaces/{workspace_id}/folders/{folder_id}/projects/move", {
1039
+ params: { path: { workspace_id: workspaceId, folder_id: folderId } },
1040
+ body: { project_ids: projectIds },
1041
+ });
1042
+ return data!;
1043
+ }
1044
+
1045
+ // ---------------------------------------------------------------------------
1046
+ // Library & template projects
1047
+ // ---------------------------------------------------------------------------
1048
+
1049
+ /** List available design system library projects in a workspace. */
1050
+ async listLibraryProjects(
1051
+ workspaceId: string,
1052
+ options: ListOffsetPaginationOptions = {},
1053
+ ): Promise<ListLibraryProjectsResponse> {
1054
+ const { data } = await this.typed.GET("/v1/workspaces/{workspace_id}/available-library-projects", {
1055
+ params: { path: { workspace_id: workspaceId }, query: options },
1056
+ });
1057
+ return { ...data!, libraries: data!.libraries ?? [] };
1058
+ }
1059
+
1060
+ /** List available template projects in a workspace. */
1061
+ async listTemplateProjects(
1062
+ workspaceId: string,
1063
+ options: ListOffsetPaginationOptions = {},
1064
+ ): Promise<ListTemplateProjectsResponse> {
1065
+ const { data } = await this.typed.GET("/v1/workspaces/{workspace_id}/available-template-projects", {
1066
+ params: { path: { workspace_id: workspaceId }, query: options },
1067
+ });
1068
+ return { ...data!, templates: data!.templates ?? [] };
1069
+ }
1070
+
1071
+ // ---------------------------------------------------------------------------
1072
+ // Connectors (MCP servers)
1073
+ // ---------------------------------------------------------------------------
1074
+
1075
+ /** List all connectors in a workspace. */
1076
+ async listConnectors(workspaceId: string, options: ListConnectorsOptions = {}): Promise<ListConnectorsResponse> {
1077
+ const { data } = await this.typed.GET("/v1/connectors", {
1078
+ params: {
1079
+ query: {
1080
+ workspace_id: workspaceId,
1081
+ type: options.type,
1082
+ status: options.status,
1083
+ limit: options.limit,
1084
+ cursor: options.cursor,
1085
+ },
1086
+ },
1087
+ });
1088
+ const connectors =
1089
+ (data as unknown as { connectors?: ListConnectorsResponse["connectors"] }).connectors ?? data!.data ?? [];
1090
+ return { ...data!, data: connectors, connectors, has_more: cursorHasMore(data!) };
1091
+ }
1092
+
1093
+ /** Add a connector to a workspace. The server URL is tested before saving. */
1094
+ async addConnector(workspaceId: string, body: AddConnectorBody): Promise<ConnectorResponse> {
1095
+ const { data } = await this.typed.POST("/v1/connectors", {
1096
+ body: { ...body, workspace_id: workspaceId },
1097
+ });
1098
+ return data!;
1099
+ }
1100
+
1101
+ /** Remove a connector from a workspace. */
1102
+ async removeConnector(workspaceId: string, connectorId: string) {
1103
+ const { data } = await this.typed.DELETE("/v1/connectors/{connector_id}", {
1104
+ params: { path: { connector_id: connectorId }, query: { workspace_id: workspaceId } },
1105
+ });
1106
+ return data!;
1107
+ }
1108
+
1109
+ /** Browse available connector templates. */
1110
+ async listAvailableConnectors(
1111
+ workspaceId: string,
1112
+ options: ListCursorPaginationOptions = {},
1113
+ ): Promise<ListAvailableConnectorsResponse> {
1114
+ const { data } = await this.typed.GET("/v1/available-connectors", {
1115
+ params: { query: { workspace_id: workspaceId, limit: options.limit, cursor: options.cursor } },
1116
+ });
1117
+ const catalog =
1118
+ (data as unknown as { catalog?: ListAvailableConnectorsResponse["catalog"] }).catalog ?? data!.data ?? [];
1119
+ return { ...data!, data: catalog, catalog, has_more: cursorHasMore(data!) };
1120
+ }
1121
+
1122
+ // ---------------------------------------------------------------------------
1123
+ // Connectors
1124
+ // ---------------------------------------------------------------------------
1125
+
1126
+ /** List standard (OAuth-based) connectors in a workspace. */
1127
+ async listStandardConnectors(
1128
+ workspaceId: string,
1129
+ options: ListCursorPaginationOptions = {},
1130
+ ): Promise<ListStandardConnectorsResponse> {
1131
+ const { data } = await this.typed.GET("/v1/connectors", {
1132
+ params: { query: { workspace_id: workspaceId, type: "standard", limit: options.limit, cursor: options.cursor } },
1133
+ });
1134
+ const connectors =
1135
+ (data as unknown as { connectors?: ListStandardConnectorsResponse["connectors"] }).connectors ?? data!.data ?? [];
1136
+ return { ...data!, data: connectors, connectors, has_more: cursorHasMore(data!) };
1137
+ }
1138
+
1139
+ /** List seamless (zero-config) connectors in a workspace. */
1140
+ async listSeamlessConnectors(
1141
+ workspaceId: string,
1142
+ options: ListCursorPaginationOptions = {},
1143
+ ): Promise<ListSeamlessConnectorsResponse> {
1144
+ const { data } = await this.typed.GET("/v1/connectors", {
1145
+ params: { query: { workspace_id: workspaceId, type: "seamless", limit: options.limit, cursor: options.cursor } },
1146
+ });
1147
+ const connectors =
1148
+ (data as unknown as { connectors?: ListSeamlessConnectorsResponse["connectors"] }).connectors ?? data!.data ?? [];
1149
+ return { ...data!, data: connectors, connectors, has_more: cursorHasMore(data!) };
1150
+ }
1151
+
1152
+ /** List MCP connectors in a workspace. */
1153
+ async listMCPConnectors(
1154
+ workspaceId: string,
1155
+ options: ListCursorPaginationOptions = {},
1156
+ ): Promise<ListMCPConnectorsResponse> {
1157
+ const { data } = await this.typed.GET("/v1/connectors", {
1158
+ params: { query: { workspace_id: workspaceId, type: "mcp", limit: options.limit, cursor: options.cursor } },
1159
+ });
1160
+ const connectors =
1161
+ (data as unknown as { connectors?: ListMCPConnectorsResponse["connectors"] }).connectors ?? data!.data ?? [];
1162
+ return { ...data!, data: connectors, connectors, has_more: cursorHasMore(data!) };
1163
+ }
1164
+
1165
+ /** List authenticated connections (accounts) in a workspace. */
1166
+ async listConnections(
1167
+ workspaceId: string,
1168
+ params: { connector_id?: string; limit?: number; cursor?: string } = {},
1169
+ ): Promise<ListConnectionsResponse> {
1170
+ const { data } = await this.typed.GET("/v1/connections", {
1171
+ params: {
1172
+ query: {
1173
+ workspace_id: workspaceId,
1174
+ connector_id: params.connector_id,
1175
+ limit: params.limit,
1176
+ cursor: params.cursor,
1177
+ },
1178
+ },
1179
+ });
1180
+ const connections = data!.data ?? [];
1181
+ return { ...data!, data: connections, connections, has_more: cursorHasMore(data!) };
1182
+ }
1183
+
1184
+ // ---------------------------------------------------------------------------
1185
+ // Analytics
1186
+ // ---------------------------------------------------------------------------
1187
+
1188
+ /** Get historical analytics for a published project. */
1189
+ async getProjectAnalytics(
1190
+ projectId: string,
1191
+ params: { startDate: string; endDate: string; granularity?: string },
1192
+ ): Promise<ProjectAnalyticsResponse> {
1193
+ const { data } = await this.typed.GET("/v1/analytics", {
1194
+ params: {
1195
+ query: {
1196
+ project_id: projectId,
1197
+ startDate: params.startDate,
1198
+ endDate: params.endDate,
1199
+ granularity: params.granularity,
1200
+ },
1201
+ },
1202
+ });
1203
+ return data!;
1204
+ }
1205
+
1206
+ /** Get real-time visitor trend for a published project. */
1207
+ async getProjectAnalyticsTrend(projectId: string): Promise<ProjectAnalyticsTrendResponse> {
1208
+ const { data } = await this.typed.GET("/v1/analytics/trend", {
1209
+ params: { query: { project_id: projectId } },
1210
+ });
1211
+ return data!;
1212
+ }
1213
+
1214
+ /**
1215
+ * Publish a project.
1216
+ *
1217
+ * This triggers a deployment which makes the project publicly accessible.
1218
+ * The deployment runs asynchronously - use waitForProjectPublished() to wait for completion.
1219
+ *
1220
+ * @param projectId - The project ID to publish
1221
+ * @param options.name - Optional custom slug for the published URL
1222
+ * @returns Deployment info including deployment ID
1223
+ */
1224
+ async publish(projectId: string, options?: { name?: string }): Promise<DeploymentResponse> {
1225
+ const { data } = await this.typed.POST("/v1/deployments", {
1226
+ body: { project_id: projectId, name: options?.name },
1227
+ });
1228
+ return data!;
1229
+ }
1230
+
1231
+ /**
1232
+ * Remix (fork) an existing project, optionally at a specific message point in time.
1233
+ *
1234
+ * When `messageId` is provided, the remix captures the project state after
1235
+ * that message and its AI response by default. Set `remixMode: "before"` to
1236
+ * start before the message was processed.
1237
+ * Without `messageId`, the full current state is remixed.
1238
+ *
1239
+ * @param sourceProjectId - The project to remix from
1240
+ * @param options.workspaceId - Target workspace for the new project
1241
+ * @param options.messageId - Optional message ID to snapshot at
1242
+ * @param options.remixMode - "including" (server default): state after the message and its AI response; "before": state before the message
1243
+ * @param options.includeHistory - Whether to preserve chat history (default: false)
1244
+ * @param options.includeCustomKnowledge - Whether to copy custom instructions (default: false)
1245
+ * @param options.initialMessage - Optional initial message to send after remix
1246
+ * @param options.description - Optional custom description for the new project
1247
+ * @returns The remix job ID for polling progress
1248
+ */
1249
+ async remixProject(sourceProjectId: string, options: RemixProjectOptions): Promise<string> {
1250
+ const body: RemixCreateProjectBody = {
1251
+ workspace_id: options.workspaceId,
1252
+ source_project_id: sourceProjectId,
1253
+ include_history: options.includeHistory,
1254
+ include_custom_knowledge: options.includeCustomKnowledge,
1255
+ description: options.description,
1256
+ display_name: options.projectName,
1257
+ skip_initial_remix_message: options.skipInitialRemixMessage,
1258
+ skip_integrations: options.skipIntegrations,
1259
+ };
1260
+
1261
+ if (options.messageId) {
1262
+ body.message_id = options.messageId;
1263
+ if (options.remixMode) {
1264
+ body.remix_mode = options.remixMode;
1265
+ }
1266
+ }
1267
+
1268
+ if (options.initialMessage) {
1269
+ body.initial_message = options.initialMessage;
1270
+ }
1271
+
1272
+ const { data } = await this.typed.POST("/v1/projects", {
1273
+ body: body as CreateProjectPostBody,
1274
+ });
1275
+ const remix = data;
1276
+ if (!remix?.job_id) {
1277
+ throw new Error("Failed to get job ID from remix create");
1278
+ }
1279
+ return remix.job_id;
1280
+ }
1281
+
1282
+ /**
1283
+ * Wait for a remix operation to complete.
1284
+ *
1285
+ * Polls the remix progress endpoint until the job reaches "completed" or "error" status.
1286
+ *
1287
+ * @param sourceProjectId - The source project ID (used for the progress endpoint)
1288
+ * @param jobId - The job ID returned by `remixProject()`
1289
+ * @param options.pollInterval - Time between polls in ms (default: 2000)
1290
+ * @param options.timeout - Maximum time to wait in ms (default: 300000 = 5 minutes)
1291
+ * @param options.onProgress - Optional callback for status/step updates
1292
+ * @returns The new project ID
1293
+ * @throws Error if the remix fails or timeout is reached
1294
+ */
1295
+ async waitForRemix(sourceProjectId: string, jobId: string, options?: RemixWaitOptions): Promise<RemixResult> {
1296
+ const pollInterval = options?.pollInterval ?? 2000;
1297
+ const timeout = options?.timeout ?? 300000;
1298
+ const startTime = Date.now();
1299
+
1300
+ while (true) {
1301
+ const { data } = await this.typed.GET("/v1/projects/{project_id}/remix/progress", {
1302
+ params: {
1303
+ path: { project_id: sourceProjectId },
1304
+ query: { job_id: jobId },
1305
+ },
1306
+ });
1307
+ const progress = data!;
1308
+ const status = progress.status as RemixJobStatus;
1309
+ options?.onProgress?.(status, progress.step);
1310
+
1311
+ if (status === "completed" && progress.result) {
1312
+ return { projectId: progress.result.project_id };
1313
+ }
1314
+
1315
+ if (status === "error") {
1316
+ throw new Error(progress.error_message ?? "Remix failed");
1317
+ }
1318
+
1319
+ if (Date.now() - startTime > timeout) {
1320
+ throw new Error(`Timeout waiting for remix of project ${sourceProjectId}`);
1321
+ }
1322
+
1323
+ await sleep(pollInterval);
1324
+ }
1325
+ }
1326
+
1327
+ /**
1328
+ * Wait for a project to reach "completed" status.
1329
+ *
1330
+ * Projects start in "in_progress" status while being created/built.
1331
+ * This method polls until the status becomes "completed" or "failed".
1332
+ * A successful completion means the project's preview is ready to view.
1333
+ *
1334
+ * @param projectId - The project ID to wait for
1335
+ * @param options.pollInterval - Time between polls in ms (default: 2000)
1336
+ * @param options.timeout - Maximum time to wait in ms (default: 300000 = 5 minutes)
1337
+ * @param options.onProgress - Optional callback for status updates
1338
+ * @returns The completed project
1339
+ * @throws Error if project fails or timeout is reached
1340
+ */
1341
+ async waitForProjectReady(projectId: string, options?: WaitOptions): Promise<ProjectResponse> {
1342
+ const pollInterval = options?.pollInterval ?? 2000;
1343
+ const timeout = options?.timeout ?? 300000;
1344
+ const startTime = Date.now();
1345
+
1346
+ while (true) {
1347
+ const project = await this.getProject(projectId);
1348
+ options?.onProgress?.(project);
1349
+
1350
+ if (project.status === "completed") {
1351
+ return project;
1352
+ }
1353
+
1354
+ if (project.status === "failed") {
1355
+ throw new Error(`Project ${projectId} failed to build`);
1356
+ }
1357
+
1358
+ if (Date.now() - startTime > timeout) {
1359
+ throw new Error(`Timeout waiting for project ${projectId} to be ready`);
1360
+ }
1361
+
1362
+ await sleep(pollInterval);
1363
+ }
1364
+ }
1365
+
1366
+ private isFileInput(file: File | FileInput): file is FileInput {
1367
+ return "data" in file;
1368
+ }
1369
+
1370
+ private async uploadFile(
1371
+ file: File | FileInput,
1372
+ getUploadUrl: (fileName: string, mimeType: string) => Promise<FileUploadUrlResponse>,
1373
+ ): Promise<UnscopedFile> {
1374
+ const fileName = this.isFileInput(file) ? file.name : file.name;
1375
+ const mimeType = this.isFileInput(file) ? file.type : file.type;
1376
+ const body = this.isFileInput(file) ? file.data : file;
1377
+
1378
+ const uploadUrl = await getUploadUrl(fileName, mimeType);
1379
+ const { url, file_id: objectPath, headers } = uploadUrl;
1380
+
1381
+ const uploadResponse = await fetch(url, {
1382
+ method: "PUT",
1383
+ body,
1384
+ headers: { "Content-Type": mimeType, ...headers },
1385
+ });
1386
+ if (!uploadResponse.ok) {
1387
+ throw new Error(`File upload failed for "${fileName}": HTTP ${uploadResponse.status}`);
1388
+ }
1389
+
1390
+ return { file_id: objectPath, type: "user_upload", file_name: fileName, mime_type: mimeType };
1391
+ }
1392
+
1393
+ private async uploadProjectFiles(projectId: string, files: (File | FileInput)[]): Promise<UnscopedFile[]> {
1394
+ return Promise.all(
1395
+ files.map((file) =>
1396
+ this.uploadFile(file, (_fileName, mimeType) =>
1397
+ this.getProjectFileUploadUrl(projectId, { content_type: mimeType }),
1398
+ ),
1399
+ ),
1400
+ );
1401
+ }
1402
+
1403
+ private async uploadEphemeralFiles(files: (File | FileInput)[]): Promise<UnscopedFile[]> {
1404
+ return Promise.all(
1405
+ files.map((file) =>
1406
+ this.uploadFile(file, (_fileName, mimeType) => this.getEphemeralFileUploadUrl({ content_type: mimeType })),
1407
+ ),
1408
+ );
1409
+ }
1410
+
1411
+ private partitionUploadedFiles(files: UnscopedFile[]): {
1412
+ fileRefs: UnscopedFile[] | undefined;
1413
+ ephemeralFileRefs: UnscopedFile[] | undefined;
1414
+ } {
1415
+ const fileRefs: UnscopedFile[] = [];
1416
+ const ephemeralFileRefs: UnscopedFile[] = [];
1417
+ for (const file of files) {
1418
+ if (file.file_id.startsWith("ephemeral/")) {
1419
+ ephemeralFileRefs.push(file);
1420
+ } else {
1421
+ fileRefs.push(file);
1422
+ }
1423
+ }
1424
+ return {
1425
+ fileRefs: fileRefs.length > 0 ? fileRefs : undefined,
1426
+ ephemeralFileRefs: ephemeralFileRefs.length > 0 ? ephemeralFileRefs : undefined,
1427
+ };
1428
+ }
1429
+ /**
1430
+ * Wait for a project to be published (deployed).
1431
+ *
1432
+ * This method polls until the project has `is_published: true` and a `url`.
1433
+ *
1434
+ * @param projectId - The project ID to wait for
1435
+ * @param options.pollInterval - Time between polls in ms (default: 3000)
1436
+ * @param options.timeout - Maximum time to wait in ms (default: 600000 = 10 minutes)
1437
+ * @param options.onProgress - Optional callback for status updates
1438
+ * @returns The published project with URL
1439
+ * @throws Error if timeout is reached
1440
+ */
1441
+ async waitForProjectPublished(projectId: string, options?: WaitOptions): Promise<ProjectResponse> {
1442
+ const pollInterval = options?.pollInterval ?? 3000;
1443
+ const timeout = options?.timeout ?? 600000;
1444
+ const startTime = Date.now();
1445
+
1446
+ while (true) {
1447
+ const project = await this.getProject(projectId);
1448
+ options?.onProgress?.(project);
1449
+
1450
+ if (project.is_published && project.url) {
1451
+ return project;
1452
+ }
1453
+
1454
+ if (project.status === "failed") {
1455
+ throw new Error(`Project ${projectId} failed to build`);
1456
+ }
1457
+
1458
+ if (Date.now() - startTime > timeout) {
1459
+ throw new Error(`Timeout waiting for project ${projectId} to be published`);
1460
+ }
1461
+
1462
+ await sleep(pollInterval);
1463
+ }
1464
+ }
1465
+ }
1466
+
1467
+ function sleep(ms: number): Promise<void> {
1468
+ return new Promise((resolve) => setTimeout(resolve, ms));
1469
+ }
1470
+
1471
+ function isAbortError(err: unknown): boolean {
1472
+ return typeof err === "object" && err !== null && "name" in err && err.name === "AbortError";
1473
+ }
1474
+
1475
+ async function safeReadText(response: Response): Promise<string> {
1476
+ try {
1477
+ return await response.text();
1478
+ } catch {
1479
+ return "";
1480
+ }
1481
+ }
1482
+
1483
+ async function cancelResponseBody(response: Response): Promise<void> {
1484
+ try {
1485
+ await response.body?.cancel();
1486
+ } catch {}
1487
+ }
1488
+
1489
+ async function* parseSSEFrames(
1490
+ body: ReadableStream<Uint8Array>,
1491
+ ): AsyncGenerator<{ event: string; data: string }, void, void> {
1492
+ const reader = body.getReader();
1493
+ const decoder = new TextDecoder();
1494
+ let buffer = "";
1495
+
1496
+ try {
1497
+ while (true) {
1498
+ const { done, value } = await reader.read();
1499
+ if (done) break;
1500
+ buffer += decoder.decode(value, { stream: true });
1501
+
1502
+ let sep = buffer.indexOf("\n\n");
1503
+ while (sep !== -1) {
1504
+ const raw = buffer.slice(0, sep);
1505
+ buffer = buffer.slice(sep + 2);
1506
+ sep = buffer.indexOf("\n\n");
1507
+
1508
+ let event = "message";
1509
+ const dataLines: string[] = [];
1510
+ for (const line of raw.split("\n")) {
1511
+ if (!line || line.startsWith(":")) continue;
1512
+ if (line.startsWith("event:")) {
1513
+ event = line.slice(6).trimStart();
1514
+ } else if (line.startsWith("data:")) {
1515
+ dataLines.push(line.slice(5).trimStart());
1516
+ }
1517
+ }
1518
+ if (dataLines.length === 0) continue;
1519
+ yield { event, data: dataLines.join("\n") };
1520
+ }
1521
+ }
1522
+ } finally {
1523
+ try {
1524
+ reader.releaseLock();
1525
+ } catch {
1526
+ // ignore
1527
+ }
1528
+ try {
1529
+ await body.cancel();
1530
+ } catch {
1531
+ // ignore
1532
+ }
1533
+ }
1534
+ }
1535
+
1536
+ function terminalResultFromMessage(msg: GetMessageResponse, fallbackMessageId: string): MessageCompletionResult | null {
1537
+ const ai = msg.response;
1538
+ if (ai && isTerminalAIStatus(ai.status)) {
1539
+ return {
1540
+ status: messageCompletionStatus(ai.status),
1541
+ message_id: ai.message_id || fallbackMessageId,
1542
+ content: ai.content,
1543
+ edit_id: ai.edit_id,
1544
+ commit_sha: ai.commit_sha,
1545
+ summary: ai.summary,
1546
+ cost_credits: ai.cost_credits,
1547
+ awaiting_input: ai.awaiting_input,
1548
+ ...(ai.status === "error" ? { error: "Agent reported an error." } : {}),
1549
+ };
1550
+ }
1551
+ if (msg.role === "assistant" && isTerminalAIStatus(msg.status)) {
1552
+ return {
1553
+ status: messageCompletionStatus(msg.status),
1554
+ message_id: msg.message_id || fallbackMessageId,
1555
+ content: msg.content,
1556
+ edit_id: msg.edit_id,
1557
+ commit_sha: msg.commit_sha,
1558
+ summary: msg.summary,
1559
+ cost_credits: msg.cost_credits,
1560
+ awaiting_input: msg.awaiting_input,
1561
+ ...(msg.status === "error" ? { error: "Agent reported an error." } : {}),
1562
+ };
1563
+ }
1564
+ return null;
1565
+ }
1566
+
1567
+ function messageCompletionStatus(status: string): MessageCompletionResult["status"] {
1568
+ return status === "completed" || status === "stopped" || status === "awaiting_input" ? status : "error";
1569
+ }
1570
+
1571
+ function isTerminalAIStatus(status: string | undefined): boolean {
1572
+ return status === "completed" || status === "stopped" || status === "error" || status === "awaiting_input";
1573
+ }
1574
+
1575
+ function queuedExitResult(msg: GetMessageResponse, fallbackMessageId: string): MessageCompletionResult | null {
1576
+ if (msg.status !== "queued") return null;
1577
+ if (!msg.queue_paused) return null;
1578
+ if (msg.queue_pause_reason === "hitl_tool") return null;
1579
+ return {
1580
+ status: "error",
1581
+ message_id: fallbackMessageId,
1582
+ content: "",
1583
+ error:
1584
+ `Message is queued (position ${msg.queue_position ?? "unknown"}) but the queue is paused` +
1585
+ (msg.queue_pause_reason ? ` (reason: ${msg.queue_pause_reason})` : "") +
1586
+ `. Unpause the queue in the Lovable editor, or use wait=false to return immediately.`,
1587
+ };
1588
+ }
1589
+
1590
+ function timeoutResult(messageId: string, timeoutMs: number): MessageCompletionResult {
1591
+ return {
1592
+ status: "timeout",
1593
+ message_id: messageId,
1594
+ content: "",
1595
+ error: `Agent did not finish within ${Math.max(0, Math.round(timeoutMs / 1000))}s`,
1596
+ };
1597
+ }