Files
multica/packages/core/api/client.ts
Jiayuan Zhang 0fdc38704e MUL-5149: add agent-generated Chat quick actions (#5766)
* feat(chat): add agent-generated quick actions

Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): preserve mid-response quick-action fences

Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): drop quick actions on empty reply to keep no_response fallback

An actions-only completion — a quick-actions footer with no visible text —
wrote an empty-content assistant message (message_kind=message). Older
Desktop/mobile clients ignore the quick_actions field and render that as an
empty bubble, breaking the MUL-4351 contract that an empty turn always gives
old clients a visible no_response fallback.

Drop the quick actions when the visible body is empty so an actions-only turn
falls through to the visible no_response outcome, and revert the completion
switch to gate the message row on visible text only. Update the completion
test to pin the corrected behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(chat): generate quick actions via daemon suggestion pass

Replace the in-band runtime-brief instruction with a dedicated post-completion
provider turn: after a direct chat reply finishes, the daemon resumes the same
session with a JSON-only suggest prompt and forwards the raw output on the
complete callback. The server parses it leniently and reuses the existing
sanitize/redact/store/broadcast pipeline; the stripped in-band footer stays as
a fallback for older daemons and pre-upgrade sessions. The footer strip now
covers every chat completion, fixing the intro-turn protocol leak. Adds a
Settings → Chat toggle (client-persisted, default on) that hides the chips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(chat): deliver quick actions async with skeleton placeholders

Decouple suggestion generation from the turn: the daemon reports completion
immediately (chat:done carries quick_actions_pending as a per-turn capability
signal) and runs the suggestion pass in the background, delivering results
through a new supplement endpoint + chat:quick_actions broadcast. A new turn
on the same session cancels the stale pass. Clients render pill skeletons
under the finished reply until the supplement resolves them (entrance
animation on arrival, 30s safety timeout); older daemons never raise the flag
so no skeleton dangles. Suggest usage re-reports merged totals because
task_usage upserts replace per (task, provider, model). Prompt now asks for
exactly 3 actions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(chat): make the quick-actions toggle stop generation, not hide pills

The Settings → Chat toggle previously only hid rendered pills while the
daemon kept burning a suggestion call every turn. It now travels with each
send (quick_actions_enabled, absent = enabled for older clients), is stamped
on the chat task (migration 213), forwarded on the claim, and gates the
daemon's suggestion pass at the source — no call, no pending flag, no
skeleton. Existing suggestions stay visible; settings copy now says
'generate' instead of 'show'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(migrations): renumber quick-action migrations onto current main

Merging current origin/main brought the vcs migrations to their canonical
216-221 prefixes, which collided with the quick-action migrations that were
sitting at 219/220 (backend CI red in
TestMigrationNumericPrefixesStayUniqueAfterLegacySet). Renumber them to the
next unused prefixes:

- 219_chat_message_quick_actions        -> 222_chat_message_quick_actions
- 220_agent_task_quick_actions_disabled -> 223_agent_task_quick_actions_disabled

Contents are unchanged; sqlc regeneration produces no drift since the added
columns are independent of the vcs tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(mobile): render async chat quick actions via chat:quick_actions

The daemon generates quick actions in a background pass after the turn
finishes, delivering them on a separate chat:quick_actions event. Mobile
only handled chat:done (which invalidates + refetches an actions-less
message list) and keeps the messages query at staleTime: Infinity, so an
active mobile session never rendered async-generated quick actions until a
manual pull-to-refresh or refocus.

Add applyChatQuickActionsToCache — mirroring web's patcher — which patches
the supplement onto the targeted assistant message in the flat messages
cache, and subscribe to chat:quick_actions in use-chat-session-realtime.
Patch-only (no invalidate), matching web and mobile's cellular
patch-over-invalidate rule; an empty supplement is a terminal no-op. Covered
by chat-ws-updaters.test.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): cancel in-flight messages refetch before quick-actions patch

The chat:done invalidate can leave a messages refetch in flight that read the
assistant row before the daemon persisted the quick actions. If that refetch
resolves after the chat:quick_actions setQueryData patch, it overwrites the
freshly-patched actions with an actions-less row. Both message caches are
staleTime: Infinity, so the overwrite never self-heals and the actions vanish
permanently (MUL-5149, Howard review).

applyChatQuickActionsToCache now awaits cancelQueries for the affected caches
(web: flat messages + messagesPage, mobile: flat messages) before patching, so
a stale in-flight refetch is cancelled and cannot land after the patch. Cancel
must precede setQueryData because cancelQueries reverts to the pre-fetch state.
WS handlers call it via `void` (fire-and-forget).

Adds an active-query race regression test on both web and mobile that holds a
refetch open across the supplement and asserts the patched actions survive;
verified to fail without the cancel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(chat): quick-actions refresh/regenerate + review hardening (MUL-5149)

Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): address quick-actions re-review (MUL-5149)

- Ack alignment: refresh request carries the target message_id; server
  atomically confirms it is still the session's latest turn (409 stale
  otherwise), so the client marker always matches the resolving
  chat:quick_actions — no response reconciliation. Adds a regression test.
- Converge the pending marker on every terminal path: HandleFailedTasks
  (sweeper/orphan) now resolves it, and the daemon reports a failed supplement
  so FailTask resolves it instead of leaving a completed-but-unresolved task.
- Timeout fallback now clears the real query state (useQuickActionsPendingTimeout)
  instead of a component-local flag that only masked the UI; drop the skeleton's
  and pill row's local timers.
- frontend-test type-scale: text-xs -> text-caption. Strip EOF blank line.

Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): close quick-actions refresh races and failure feedback (MUL-5149)

Third-round review of the refresh button surfaced three issues; all three
are addressed here.

§1/§2 Session-busy race + concurrent-refresh double-spend: a newer reply
that is queued/running but whose assistant row hasn't landed leaves the old
turn as latest-persisted, so the stale check passes and the regen resumes
the newer provider state — attaching suggestions to the wrong turn. And two
concurrent refreshes each enqueue a quota-spending pass. Add
HasActiveChatTaskForSession and refuse a refresh (ErrChatQuickActionsBusy →
409) whenever the session has any task in flight, checked under the same
session lock as the enqueue so no sibling insert slips past.

§3a Timeout re-arm on surface switch: the pending marker now carries an
absolute expires_at deadline instead of a per-mount timer, so switching
between the floating window and the chat tab resumes the same deadline
rather than restarting a fresh 30s window each remount.

§3b Generation failure masked as success: runChatSuggestPass now returns ok
so an explicit refresh distinguishes a failed pass (didn't start / didn't
complete / timed out) from a completed-but-empty one. On failure the regen
task reports failure, resolveFailedRegenerateQuickActions broadcasts a
FAILED chat:quick_actions, and the client resolves the spinner AND toasts
"couldn't refresh" instead of silently stopping on unchanged pills.

Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): count deferred tasks in refresh busy check; solid refresh icon tone (MUL-5149)

Two re-review blockers on 2dc9404d.

§1 (deferred window): HasActiveChatTaskForSession only treated
queued/dispatched/running/waiting_local_directory as in-flight, so a chat
auto-retry armed with a backoff fire_at — inserted 'deferred' by
CreateRetryTask, as provider_network's ~5s final attempt is — slipped past
the busy check. In that window the failed turn has no assistant row yet, so
the old turn is still latest-persisted and refreshable; the regen would then
resume a session the retry is about to advance and pin the new turn's
suggestions onto the old one. Add 'deferred' so the set matches the
canonical in-flight status list the rest of the queries already use
(agent.sql has-active-task checks). New regression test covers a deferred
active turn.

CI (text-contrast gate): the refresh icon button used
text-muted-foreground/70 (transparency standing in for a text tone), which
the frontend-test contrast gate rejects. Switch to the solid
text-faint-foreground token — the tone the gate recommends for icons/glyphs,
already used repo-wide and clearing WCAG 1.4.11.

Co-authored-by: multica-agent <github@multica.ai>

* test(chat): assert quick-actions pending marker carries expires_at (MUL-5149)

The chat:done supplement-flow test still expected the 2-field marker from
before the absolute-deadline change; applyChatDoneToCache now stamps
expires_at, so the deep-equal failed on frontend-test. Assert the deadline is
present (expect.any(Number)) rather than a wall-clock-dependent value — its
timing semantics are covered by the pending-timeout hook.

Co-authored-by: multica-agent <github@multica.ai>

* fix(db): renumber regenerate-quick-actions migration 237 -> 240 (MUL-5149)

main merged Issue Quick Actions (MUL-5465) taking migrations 237/238/239
(quick_action, quick_action_workspace_index, comment_quick_action). This
branch independently took 237 for agent_task_queue.regenerate_quick_actions_for.
The two 237s do not textually conflict (different filenames) so the PR reads
mergeable, but the merged tree would carry two migration 237s. Renumber this
one to 240 so it applies after main's chain. The migration is a standalone
`ALTER TABLE agent_task_queue ADD COLUMN IF NOT EXISTS` — order-independent,
touches a column none of main's migrations reference.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Co-authored-by: NevilleQingNY <nevilleqing@gmail.com>
2026-07-30 21:25:03 +08:00

3245 lines
117 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
Issue,
IssuePriority,
CreateIssueRequest,
MoveIssueRequest,
UpdateIssueRequest,
GroupedIssuesResponse,
ListIssuesResponse,
SearchIssuesResponse,
SearchProjectsResponse,
UpdateMeRequest,
CreateMemberRequest,
UpdateMemberRequest,
ListIssuesParams,
ListGroupedIssuesParams,
IssueTableFacetsRequest,
IssueTableFacetsResponse,
IssueTableGroupsRequest,
IssueTableGroupsResponse,
IssueTableRowsRequest,
IssueTableRowsResponse,
Agent,
CreateAgentRequest,
AgentTemplate,
AgentTemplateSummary,
CreateAgentFromTemplateRequest,
CreateAgentFromTemplateResponse,
AgentBuilderRuntimeSwitch,
AgentBuilderSession,
UpdateAgentRequest,
AgentEnvResponse,
UpdateAgentEnvRequest,
AgentTask,
AgentActivityBucket,
AgentRunCount,
WorkspaceWorkingAgent,
WorkspaceWorkingAgentMineRelation,
WorkspaceWorkingAgentType,
AgentRuntime,
RuntimeProfile,
CreateRuntimeProfileRequest,
UpdateRuntimeProfileRequest,
InboxItem,
InboxWorkspaceUnread,
IssueSubscriber,
Comment,
CommentTriggerPreview,
IssueTriggerPreview,
IssueTriggerPreviewParams,
Reaction,
IssueReaction,
Workspace,
WorkspaceRepo,
MemberWithUser,
User,
Skill,
SkillSummary,
CreateSkillRequest,
UpdateSkillRequest,
SetAgentSkillsRequest,
SetAgentRuntimeSkillEnabledRequest,
PersonalAccessToken,
CreatePersonalAccessTokenRequest,
CreatePersonalAccessTokenResponse,
RuntimeUsage,
IssueUsageSummary,
RuntimeHourlyActivity,
RuntimeUsageByAgent,
RuntimeUsageByHour,
DashboardUsageDaily,
DashboardUsageByAgent,
DashboardAgentRunTime,
DashboardRunTimeDaily,
DashboardFailureDaily,
DashboardFailureByAgent,
RuntimeUpdate,
RuntimeModelListRequest,
RuntimeLocalSkillListRequest,
CreateRuntimeLocalSkillImportRequest,
RuntimeLocalSkillImportRequest,
TimelineEntry,
AssigneeFrequencyEntry,
TaskMessagePayload,
Attachment,
ChatSession,
ChatPinnedAgent,
ChatMessage,
ChatMessagesPage,
ChatDraftRestoresResponse,
ChatPendingTask,
PendingChatTasksResponse,
HasPendingChatTasksResponse,
SendChatMessageResponse,
CancelTaskResponse,
Project,
CreateProjectRequest,
UpdateProjectRequest,
ListProjectsResponse,
ProjectResource,
CreateProjectResourceRequest,
UpdateProjectResourceRequest,
ListProjectResourcesResponse,
Label,
IssueProperty,
IssuePropertyValue,
CreatePropertyRequest,
QuickAction,
CreateQuickActionRequest,
UpdateQuickActionRequest,
ListQuickActionsResponse,
UpdatePropertyRequest,
ListPropertiesResponse,
IssuePropertiesResponse,
CreateLabelRequest,
UpdateLabelRequest,
ListLabelsResponse,
IssueLabelsResponse,
LabelResourceType,
ResourceLabelsResponse,
PinnedItem,
CreatePinRequest,
PinnedItemType,
ReorderPinsRequest,
Invitation,
Autopilot,
AutopilotTrigger,
AutopilotRun,
CreateAutopilotRequest,
UpdateAutopilotRequest,
CreateAutopilotTriggerRequest,
UpdateAutopilotTriggerRequest,
ListAutopilotsResponse,
CronPreviewResponse,
GetAutopilotResponse,
AutopilotCollaboratorsResponse,
ListAutopilotRunsResponse,
ListWebhookDeliveriesResponse,
WebhookDelivery,
NotificationPreferenceResponse,
NotificationPreferences,
GitHubPullRequest,
ListGitHubInstallationsResponse,
ListGitHubRepositoriesResponse,
GitHubConnectResponse,
ListVCSConnectionsResponse,
ConnectVCSRequest,
ConnectVCSResponse,
ListLarkInstallationsResponse,
BeginLarkInstallResponse,
LarkInstallStatusResponse,
RedeemLarkBindingTokenResponse,
ComposioToolkit,
ComposioConnection,
ComposioConnectInitResponse,
SlackInstallation,
ListSlackInstallationsResponse,
RegisterSlackBYORequest,
RedeemSlackBindingTokenResponse,
Squad,
SquadMember,
SquadMemberStatusListResponse,
BillingBalance,
BillingTransactionsPage,
BillingBatchesPage,
BillingTopupsPage,
BillingPriceTier,
CreateBillingCheckoutSessionRequest,
CreateBillingCheckoutSessionResponse,
BillingCheckoutSessionStatus,
CreateBillingPortalSessionResponse,
} from "../types";
import type { OnboardingCompletionPath } from "../onboarding/types";
import type { CreateFeedbackResponse, FeedbackKind } from "../feedback/types";
import type {
CloudRuntimeNode,
CreateCloudRuntimeNodeRequest,
ListCloudRuntimeNodesParams,
} from "../runtimes/cloud-runtime";
import { type Logger, noopLogger } from "../logger";
import { createRequestId } from "../utils";
import { getCurrentSlug } from "../platform/workspace-storage";
import { parseWithFallback } from "./schema";
import {
AgentTaskListSchema,
AgentTemplateSchema,
AgentTemplateSummaryListSchema,
AttachmentResponseSchema,
CancelTaskResponseSchema,
ChatDraftRestoresResponseSchema,
ChatMessageListSchema,
ChatMessagesPageSchema,
ChildIssuesResponseSchema,
CommentsListSchema,
CommentTriggerPreviewSchema,
IssueTriggerPreviewSchema,
CloudRuntimeNodeListSchema,
CloudRuntimeNodeSchema,
CreateAgentFromTemplateResponseSchema,
AgentBuilderRuntimeSwitchSchema,
AgentBuilderSessionSchema,
agentBuilderRuntimeSwitchFallback,
DashboardAgentRunTimeListSchema,
DashboardRunTimeDailyListSchema,
DashboardFailureDailyListSchema,
DashboardFailureByAgentListSchema,
DashboardUsageByAgentListSchema,
DashboardUsageDailyListSchema,
EMPTY_AGENT_TEMPLATE_DETAIL,
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
EMPTY_APP_CONFIG,
EMPTY_ATTACHMENT,
EMPTY_CHAT_MESSAGE_LIST,
EMPTY_CLOUD_RUNTIME_NODE,
EMPTY_CLOUD_RUNTIME_NODE_LIST,
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
EMPTY_AGENT_BUILDER_SESSION,
EMPTY_GROUPED_ISSUES_RESPONSE,
EMPTY_ISSUE_TABLE_FACETS_RESPONSE,
EMPTY_ISSUE_TABLE_GROUPS_RESPONSE,
EMPTY_ISSUE_TABLE_ROWS_RESPONSE,
EMPTY_LIST_ISSUES_RESPONSE,
EMPTY_SEARCH_ISSUES_RESPONSE,
EMPTY_SEARCH_PROJECTS_RESPONSE,
EMPTY_SQUAD,
EMPTY_SQUAD_LIST,
EMPTY_SQUAD_MEMBER_STATUS_LIST,
EMPTY_TIMELINE_ENTRIES,
EMPTY_USER,
EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE,
EMPTY_WEBHOOK_DELIVERY,
AppConfigSchema,
type AppConfigResponse,
GroupedIssuesResponseSchema,
IssueTableFacetsResponseSchema,
IssueTableGroupsResponseSchema,
IssueTableRowsResponseSchema,
ListAutopilotsResponseSchema,
EMPTY_LIST_AUTOPILOTS_RESPONSE,
AutopilotRunSchema,
FALLBACK_AUTOPILOT_RUN,
CronPreviewResponseSchema,
UNREADABLE_CRON_PREVIEW_RESPONSE,
ListIssuesResponseSchema,
CreateIssueResponseSchema,
ListWebhookDeliveriesResponseSchema,
RuntimeHourlyActivityListSchema,
RuntimeUsageByAgentListSchema,
RuntimeUsageByHourListSchema,
RuntimeUsageListSchema,
SearchIssuesResponseSchema,
SearchProjectsResponseSchema,
SquadSchema,
SquadListSchema,
SquadMemberStatusListResponseSchema,
SubscribersListSchema,
TimelineEntriesSchema,
UserSchema,
WebhookDeliveryResponseSchema,
BillingBalanceSchema,
BillingTransactionsPageSchema,
BillingBatchesPageSchema,
BillingTopupsPageSchema,
BillingPriceTierListSchema,
CreateBillingCheckoutSessionResponseSchema,
BillingCheckoutSessionStatusSchema,
CreateBillingPortalSessionResponseSchema,
EMPTY_BILLING_BALANCE,
EMPTY_BILLING_TRANSACTIONS_PAGE,
EMPTY_BILLING_BATCHES_PAGE,
EMPTY_BILLING_TOPUPS_PAGE,
EMPTY_BILLING_PRICE_TIER_LIST,
EMPTY_CREATE_BILLING_CHECKOUT_SESSION_RESPONSE,
EMPTY_BILLING_CHECKOUT_SESSION_STATUS,
EMPTY_CREATE_BILLING_PORTAL_SESSION_RESPONSE,
EMPTY_CANCEL_TASK_RESPONSE,
EMPTY_CHAT_DRAFT_RESTORES,
CreateFeedbackResponseSchema,
EMPTY_CREATE_FEEDBACK_RESPONSE,
InboxUnreadSummarySchema,
EMPTY_INBOX_UNREAD_SUMMARY,
InboxItemListSchema,
EMPTY_INBOX_ITEMS,
NotificationPreferenceResponseSchema,
EMPTY_NOTIFICATION_PREFERENCE_RESPONSE,
LabelSchema,
ListLabelsResponseSchema,
IssuePropertySchema,
ListPropertiesResponseSchema,
IssuePropertiesResponseSchema,
QuickActionSchema,
ListQuickActionsResponseSchema,
QuickActionRenderSchema,
EMPTY_QUICK_ACTION,
EMPTY_LIST_QUICK_ACTIONS_RESPONSE,
CommentSchema,
EMPTY_COMMENT,
EMPTY_ISSUE_PROPERTY,
EMPTY_LIST_PROPERTIES_RESPONSE,
EMPTY_ISSUE_PROPERTIES_RESPONSE,
EMPTY_ISSUE_PULL_REQUESTS_RESPONSE,
IssuePullRequestsResponseSchema,
ResourceLabelsResponseSchema,
EMPTY_LABEL,
EMPTY_LIST_LABELS_RESPONSE,
EMPTY_RESOURCE_LABELS_RESPONSE,
GitHubConnectResponseSchema,
ListGitHubInstallationsResponseSchema,
ListGitHubRepositoriesResponseSchema,
EMPTY_GITHUB_CONNECT_RESPONSE,
EMPTY_LIST_GITHUB_INSTALLATIONS_RESPONSE,
EMPTY_LIST_GITHUB_REPOSITORIES_RESPONSE,
RuntimeModelListRequestSchema,
MALFORMED_RUNTIME_MODEL_LIST_REQUEST,
} from "./schemas";
/** Identifies the calling client to the server.
* Sent on every HTTP request as X-Client-Platform / X-Client-Version /
* X-Client-OS so the backend can log, gate, or split metrics by client.
* See server/internal/middleware/client.go for the receiving end. */
export interface ApiClientIdentity {
/** Logical client kind. Server expects: "web" | "desktop" | "cli" | "daemon". */
platform?: string;
/** Client/app version string (e.g. "0.1.0", git tag, commit). */
version?: string;
/** Coarse operating-system bucket (for example "macos", "windows", or "linux"). */
os?: string;
}
export interface ApiClientOptions {
logger?: Logger;
onUnauthorized?: () => void;
/** Identifies the client to the server. Sent as X-Client-* headers. */
identity?: ApiClientIdentity;
}
export interface ClientRuntimeSnapshot {
probe_result: "success" | "error";
runtime_count?: number;
provider_summary?: Record<string, number>;
online_count?: number;
offline_count?: number;
}
export interface ClientUsageRequest {
install_id: string;
runtime?: ClientRuntimeSnapshot;
}
export interface LoginResponse {
token: string;
user: User;
}
export class ApiError extends Error {
readonly status: number;
readonly statusText: string;
// Raw decoded JSON body (when the server returned one). Carries structured
// error fields like `code` so callers can branch on machine-readable
// identifiers instead of pattern-matching the human-readable message.
readonly body?: unknown;
constructor(message: string, status: number, statusText: string, body?: unknown) {
super(message);
this.name = "ApiError";
this.status = status;
this.statusText = statusText;
this.body = body;
}
}
// dispatchReasonCode extracts the stable, machine-readable admission reason
// (MUL-4525) from a blocked-trigger error's structured body, when present. UI
// callers localize a blocked/partial trigger from this code instead of pattern
// matching the human-readable message. Returns undefined for non-ApiErrors or
// bodies without a reason_code (older servers), so callers fall back to their
// generic failure toast.
export function dispatchReasonCode(err: unknown): string | undefined {
if (err instanceof ApiError && err.body && typeof err.body === "object") {
const code = (err.body as { reason_code?: unknown }).reason_code;
if (typeof code === "string" && code.length > 0) return code;
}
return undefined;
}
// Thrown by getAttachmentTextContent when the server refuses to inline a
// file because it exceeds the 2 MB cap. UI maps to a "too large, please
// download" affordance with the Download CTA still available.
export class PreviewTooLargeError extends Error {
constructor() {
super("attachment too large for inline preview");
this.name = "PreviewTooLargeError";
}
}
// Thrown by getAttachmentTextContent when the server's text whitelist
// rejects the content type. Normally the client's isPreviewable() guard
// catches this earlier, but the two whitelists can drift — surfacing the
// 415 as a typed error makes the drift visible.
export class PreviewUnsupportedError extends Error {
constructor() {
super("attachment type not supported for inline preview");
this.name = "PreviewUnsupportedError";
}
}
/**
* Advertised in X-Client-Capabilities so the server knows this client can
* recover a cancelled prompt from the durable draft-restore row (#5219).
* Must stay in sync with protocol.AppCapabilityChatDraftRestoreV1.
*/
export const CHAT_DRAFT_RESTORE_CAPABILITY = "chat-draft-restore-v1";
export class ApiClient {
private baseUrl: string;
private token: string | null = null;
private logger: Logger;
private options: ApiClientOptions;
constructor(baseUrl: string, options?: ApiClientOptions) {
this.baseUrl = baseUrl;
this.options = options ?? {};
this.logger = options?.logger ?? noopLogger;
}
getBaseUrl(): string {
return this.baseUrl;
}
setToken(token: string | null) {
this.token = token;
}
private readCsrfToken(): string | null {
if (typeof document === "undefined") return null;
const match = document.cookie
.split("; ")
.find((c) => c.startsWith("multica_csrf="));
return match ? match.split("=")[1] ?? null : null;
}
private authHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
const slug = getCurrentSlug();
if (slug) headers["X-Workspace-Slug"] = slug;
const csrf = this.readCsrfToken();
if (csrf) headers["X-CSRF-Token"] = csrf;
const id = this.options.identity;
if (id?.platform) headers["X-Client-Platform"] = id.platform;
if (id?.version) headers["X-Client-Version"] = id.version;
if (id?.os) headers["X-Client-OS"] = id.os;
return headers;
}
private handleUnauthorized() {
this.token = null;
// Workspace id is owned by the URL-driven workspace-storage singleton
// (set by [workspaceSlug]/layout.tsx). On 401, the auth flow navigates
// to /login which leaves the workspace route, and the next workspace
// entry will overwrite the id. No clear needed here.
this.options.onUnauthorized?.();
}
private async parseErrorMessage(res: Response, fallback: string): Promise<string> {
try {
const data = await res.json() as { error?: string };
if (typeof data.error === "string" && data.error) return data.error;
} catch {
// Ignore non-JSON error bodies.
}
return fallback;
}
// Reads the response body once for both human-readable error message and
// structured fields. The Response stream can only be consumed once, so
// both pieces have to come from a single read.
private async parseErrorBody(res: Response, fallback: string): Promise<{ message: string; body: unknown }> {
try {
const data = await res.json() as { error?: string };
const message = typeof data.error === "string" && data.error ? data.error : fallback;
return { message, body: data };
} catch {
return { message: fallback, body: undefined };
}
}
// Sends the request with the standard headers (auth, CSRF, request id,
// client identity) and runs the shared error path (401 → handleUnauthorized,
// structured ApiError, status-aware log level). Returns the raw Response so
// callers can decide how to decode the body — JSON for the typed `fetch<T>`
// path, plain text for the attachment-preview proxy, etc.
private async fetchRaw(
path: string,
init?: RequestInit & { extraHeaders?: Record<string, string> },
): Promise<Response> {
const rid = createRequestId();
const start = Date.now();
const method = init?.method ?? "GET";
const headers: Record<string, string> = {
"X-Request-ID": rid,
...this.authHeaders(),
...(init?.extraHeaders ?? {}),
...((init?.headers as Record<string, string>) ?? {}),
};
this.logger.info(`${method} ${path}`, { rid });
const res = await fetch(`${this.baseUrl}${path}`, {
...init,
headers,
credentials: "include",
});
if (!res.ok) {
if (res.status === 401) this.handleUnauthorized();
const { message, body } = await this.parseErrorBody(res, `API error: ${res.status} ${res.statusText}`);
const logLevel = res.status === 404 ? "warn" : "error";
this.logger[logLevel](`${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message });
throw new ApiError(message, res.status, res.statusText, body);
}
this.logger.info(`${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms` });
return res;
}
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await this.fetchRaw(path, {
...init,
extraHeaders: { "Content-Type": "application/json" },
});
// Handle 204 No Content
if (res.status === 204) {
return undefined as T;
}
return res.json() as Promise<T>;
}
// Auth
async sendCode(email: string): Promise<void> {
await this.fetch("/auth/send-code", {
method: "POST",
body: JSON.stringify({ email }),
});
}
async verifyCode(email: string, code: string): Promise<LoginResponse> {
return this.fetch("/auth/verify-code", {
method: "POST",
body: JSON.stringify({ email, code }),
});
}
async googleLogin(code: string, redirectUri: string): Promise<LoginResponse> {
return this.fetch("/auth/google", {
method: "POST",
body: JSON.stringify({ code, redirect_uri: redirectUri }),
});
}
async logout(): Promise<void> {
await this.fetch("/auth/logout", { method: "POST" });
}
async issueCliToken(): Promise<{ token: string }> {
return this.fetch("/api/cli-token", { method: "POST" });
}
async getMe(): Promise<User> {
const raw = await this.fetch<unknown>("/api/me");
return parseWithFallback(raw, UserSchema, EMPTY_USER, {
endpoint: "GET /api/me",
});
}
async markOnboardingComplete(payload?: {
completion_path?: OnboardingCompletionPath;
workspace_id?: string;
}): Promise<User> {
const raw = await this.fetch<unknown>("/api/me/onboarding/complete", {
method: "POST",
body: payload ? JSON.stringify(payload) : undefined,
});
return parseWithFallback(raw, UserSchema, EMPTY_USER, {
endpoint: "POST /api/me/onboarding/complete",
});
}
async joinCloudWaitlist(payload: {
email: string;
reason?: string;
}): Promise<User> {
const raw = await this.fetch<unknown>("/api/me/onboarding/cloud-waitlist", {
method: "POST",
body: JSON.stringify(payload),
});
return parseWithFallback(raw, UserSchema, EMPTY_USER, {
endpoint: "POST /api/me/onboarding/cloud-waitlist",
});
}
async patchOnboarding(payload: {
questionnaire?: Record<string, unknown>;
}): Promise<User> {
const raw = await this.fetch<unknown>("/api/me/onboarding", {
method: "PATCH",
body: JSON.stringify(payload),
});
return parseWithFallback(raw, UserSchema, EMPTY_USER, {
endpoint: "PATCH /api/me/onboarding",
});
}
async updateMe(data: UpdateMeRequest): Promise<User> {
const raw = await this.fetch<unknown>("/api/me", {
method: "PATCH",
body: JSON.stringify(data),
});
return parseWithFallback(raw, UserSchema, EMPTY_USER, {
endpoint: "PATCH /api/me",
});
}
// Issues
async listIssues(params?: ListIssuesParams): Promise<ListIssuesResponse> {
const search = new URLSearchParams();
if (params?.limit) search.set("limit", String(params.limit));
if (params?.offset) search.set("offset", String(params.offset));
if (params?.workspace_id) search.set("workspace_id", params.workspace_id);
if (params?.q?.trim()) search.set("q", params.q.trim());
if (params?.status) search.set("status", params.status);
if (params?.statuses?.length) search.set("statuses", params.statuses.join(","));
if (params?.priority) search.set("priority", params.priority);
if (params?.priorities?.length) search.set("priorities", params.priorities.join(","));
if (params?.assignee_id) search.set("assignee_id", params.assignee_id);
if (params?.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(","));
if (params?.assignee_types?.length) search.set("assignee_types", params.assignee_types.join(","));
if (params?.creator_id) search.set("creator_id", params.creator_id);
if (params?.project_id) search.set("project_id", params.project_id);
if (params?.assignee_filters?.length) {
search.set("assignee_filters", params.assignee_filters.map((f) => `${f.type}:${f.id}`).join(","));
}
if (params?.include_no_assignee) search.set("include_no_assignee", "true");
if (params?.creator_filters?.length) {
search.set("creator_filters", params.creator_filters.map((f) => `${f.type}:${f.id}`).join(","));
}
if (params?.project_ids?.length) search.set("project_ids", params.project_ids.join(","));
if (params?.include_no_project) search.set("include_no_project", "true");
if (params?.label_ids?.length) search.set("label_ids", params.label_ids.join(","));
if (params?.top_level_only) search.set("top_level_only", "true");
// No `.length` guard on purpose: an empty ids array must still send
// `ids=` — the server treats a PRESENT-but-empty list as an empty window
// (nothing running), while an absent param means no restriction.
if (params?.ids) search.set("ids", params.ids.join(","));
if (params?.involves_user_id) search.set("involves_user_id", params.involves_user_id);
if (params?.metadata && Object.keys(params.metadata).length > 0) {
search.set("metadata", JSON.stringify(params.metadata));
}
if (params?.properties && Object.keys(params.properties).length > 0) {
search.set("properties", JSON.stringify(params.properties));
}
if (params?.open_only) search.set("open_only", "true");
if (params?.scheduled) search.set("scheduled", "true");
if (params?.date_field) search.set("date_field", params.date_field);
if (params?.date_start) search.set("date_start", params.date_start);
if (params?.date_end) search.set("date_end", params.date_end);
if (params?.sort_by) search.set("sort", params.sort_by);
if (params?.sort_direction) search.set("direction", params.sort_direction);
// An ids facet can carry hundreds of UUIDs (agents-working filter) —
// enough to blow the ~8 KB request-line cap of common reverse proxies.
// Route those windows through the POST twin, which takes the SAME
// key/value pairs as a JSON body.
if (params?.ids) {
const raw = await this.fetch<unknown>("/api/issues/query", {
method: "POST",
body: JSON.stringify(Object.fromEntries(search)),
});
return parseWithFallback(raw, ListIssuesResponseSchema, EMPTY_LIST_ISSUES_RESPONSE, {
endpoint: "POST /api/issues/query",
});
}
const path = `/api/issues?${search}`;
const raw = await this.fetch<unknown>(path);
return parseWithFallback(raw, ListIssuesResponseSchema, EMPTY_LIST_ISSUES_RESPONSE, {
endpoint: "GET /api/issues",
});
}
async listGroupedIssues(params: ListGroupedIssuesParams): Promise<GroupedIssuesResponse> {
const search = new URLSearchParams({ group_by: params.group_by });
if (params.limit) search.set("limit", String(params.limit));
if (params.offset) search.set("offset", String(params.offset));
if (params.workspace_id) search.set("workspace_id", params.workspace_id);
if (params.statuses?.length) search.set("statuses", params.statuses.join(","));
if (params.priorities?.length) search.set("priorities", params.priorities.join(","));
if (params.assignee_types?.length) search.set("assignee_types", params.assignee_types.join(","));
if (params.assignee_id) search.set("assignee_id", params.assignee_id);
if (params.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(","));
if (params.creator_id) search.set("creator_id", params.creator_id);
if (params.project_id) search.set("project_id", params.project_id);
if (params.involves_user_id) search.set("involves_user_id", params.involves_user_id);
if (params.metadata && Object.keys(params.metadata).length > 0) {
search.set("metadata", JSON.stringify(params.metadata));
}
if (params.properties && Object.keys(params.properties).length > 0) {
search.set("properties", JSON.stringify(params.properties));
}
if (params.assignee_filters?.length) {
search.set("assignee_filters", params.assignee_filters.map((f) => `${f.type}:${f.id}`).join(","));
}
if (params.include_no_assignee) search.set("include_no_assignee", "true");
if (params.creator_filters?.length) {
search.set("creator_filters", params.creator_filters.map((f) => `${f.type}:${f.id}`).join(","));
}
if (params.project_ids?.length) search.set("project_ids", params.project_ids.join(","));
if (params.include_no_project) search.set("include_no_project", "true");
if (params.label_ids?.length) search.set("label_ids", params.label_ids.join(","));
if (params.group_assignee_type) search.set("group_assignee_type", params.group_assignee_type);
if (params.group_assignee_id) search.set("group_assignee_id", params.group_assignee_id);
if (params.date_field) search.set("date_field", params.date_field);
if (params.date_start) search.set("date_start", params.date_start);
if (params.date_end) search.set("date_end", params.date_end);
if (params.sort_by) search.set("sort", params.sort_by);
if (params.sort_direction) search.set("direction", params.sort_direction);
const raw = await this.fetch<unknown>(`/api/issues/grouped?${search}`);
return parseWithFallback(raw, GroupedIssuesResponseSchema, EMPTY_GROUPED_ISSUES_RESPONSE, {
endpoint: "GET /api/issues/grouped",
});
}
async listIssueTableGroups(params: IssueTableGroupsRequest): Promise<IssueTableGroupsResponse> {
const raw = await this.fetch<unknown>("/api/issues/table/groups", {
method: "POST",
body: JSON.stringify(params),
});
return parseWithFallback(
raw,
IssueTableGroupsResponseSchema,
EMPTY_ISSUE_TABLE_GROUPS_RESPONSE,
{ endpoint: "POST /api/issues/table/groups" },
);
}
async listIssueTableRows(params: IssueTableRowsRequest): Promise<IssueTableRowsResponse> {
const raw = await this.fetch<unknown>("/api/issues/table/rows", {
method: "POST",
body: JSON.stringify(params),
});
return parseWithFallback(
raw,
IssueTableRowsResponseSchema,
EMPTY_ISSUE_TABLE_ROWS_RESPONSE,
{ endpoint: "POST /api/issues/table/rows" },
);
}
async listIssueTableFacets(params: IssueTableFacetsRequest): Promise<IssueTableFacetsResponse> {
const raw = await this.fetch<unknown>("/api/issues/table/facets", {
method: "POST",
body: JSON.stringify(params),
});
return parseWithFallback(
raw,
IssueTableFacetsResponseSchema,
EMPTY_ISSUE_TABLE_FACETS_RESPONSE,
{ endpoint: "POST /api/issues/table/facets" },
);
}
async searchIssues(params: { q: string; limit?: number; offset?: number; include_closed?: boolean; signal?: AbortSignal }): Promise<SearchIssuesResponse> {
const search = new URLSearchParams({ q: params.q });
if (params.limit !== undefined) search.set("limit", String(params.limit));
if (params.offset !== undefined) search.set("offset", String(params.offset));
if (params.include_closed) search.set("include_closed", "true");
const raw = await this.fetch<unknown>(
`/api/issues/search?${search}`,
params.signal ? { signal: params.signal } : undefined,
);
return parseWithFallback(raw, SearchIssuesResponseSchema, EMPTY_SEARCH_ISSUES_RESPONSE, {
endpoint: "GET /api/issues/search",
});
}
async searchProjects(params: { q: string; limit?: number; offset?: number; include_closed?: boolean; signal?: AbortSignal }): Promise<SearchProjectsResponse> {
const search = new URLSearchParams({ q: params.q });
if (params.limit !== undefined) search.set("limit", String(params.limit));
if (params.offset !== undefined) search.set("offset", String(params.offset));
if (params.include_closed) search.set("include_closed", "true");
const raw = await this.fetch<unknown>(
`/api/projects/search?${search}`,
params.signal ? { signal: params.signal } : undefined,
);
return parseWithFallback(raw, SearchProjectsResponseSchema, EMPTY_SEARCH_PROJECTS_RESPONSE, {
endpoint: "GET /api/projects/search",
});
}
async getIssue(id: string): Promise<Issue> {
return this.fetch(`/api/issues/${id}`);
}
async createIssue(data: CreateIssueRequest): Promise<Issue> {
// Parse through a schema (not a raw cast): the create modal keys its
// label-attach compatibility fallback off `labels` being absent vs a
// validated Label[], so an unvalidated wrong shape must not slip through.
// Unlike list endpoints, a create that returns an unusable body is a
// FAILED mutation, not a safe-empty read: fall back to null and reject so
// the modal keeps the draft and shows a failure toast instead of a blank
// "created" card pointing at an empty issue id. parseWithFallback already
// logged the schema issues + raw payload; the empty message lets the modal
// render its localized "failed to create" toast.
const raw = await this.fetch<unknown>("/api/issues", {
method: "POST",
body: JSON.stringify(data),
});
const issue = parseWithFallback<Issue | null>(raw, CreateIssueResponseSchema, null, {
endpoint: "POST /api/issues",
});
if (!issue) {
throw new Error();
}
return issue;
}
async quickCreateIssue(data: {
agent_id?: string;
squad_id?: string;
prompt: string;
priority?: IssuePriority;
due_date?: string;
project_id?: string | null;
parent_issue_id?: string | null;
attachment_ids?: string[];
}): Promise<{ task_id: string }> {
return this.fetch("/api/issues/quick-create", {
method: "POST",
body: JSON.stringify(data),
});
}
async createFeedback(data: {
message: string;
url?: string;
workspace_id?: string;
kind?: FeedbackKind;
}): Promise<CreateFeedbackResponse> {
const raw = await this.fetch<unknown>("/api/feedback", {
method: "POST",
body: JSON.stringify(data),
});
return parseWithFallback(raw, CreateFeedbackResponseSchema, EMPTY_CREATE_FEEDBACK_RESPONSE, {
endpoint: "POST /api/feedback",
});
}
async upsertClientUsage(data: ClientUsageRequest): Promise<void> {
await this.fetch("/api/client-usage", {
method: "POST",
body: JSON.stringify(data),
});
}
async updateIssue(id: string, data: UpdateIssueRequest): Promise<Issue> {
return this.fetch(`/api/issues/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
async moveIssue(id: string, data: MoveIssueRequest): Promise<Issue> {
return this.fetch(`/api/issues/${id}/move`, {
method: "POST",
body: JSON.stringify(data),
});
}
async listChildIssues(id: string): Promise<{ issues: Issue[] }> {
const raw = await this.fetch<unknown>(`/api/issues/${id}/children`);
return parseWithFallback(raw, ChildIssuesResponseSchema, { issues: [] }, {
endpoint: "GET /api/issues/:id/children",
});
}
/** Batched variant — returns children for multiple parents in one request.
* Avoids an N-request fan-out in Swimlane (one per visible parent lane).
* parentIds must be non-empty; pass a sorted, deduplicated list so the
* React Query cache key is stable across renders. */
async listChildrenByParents(parentIds: string[]): Promise<{ issues: Issue[] }> {
const raw = await this.fetch<unknown>(
`/api/issues/children?parent_ids=${parentIds.join(",")}`,
);
return parseWithFallback(raw, ChildIssuesResponseSchema, { issues: [] }, {
endpoint: "GET /api/issues/children",
});
}
async getChildIssueProgress(): Promise<{ progress: { parent_issue_id: string; total: number; done: number }[] }> {
return this.fetch("/api/issues/child-progress");
}
async deleteIssue(id: string): Promise<void> {
await this.fetch(`/api/issues/${id}`, { method: "DELETE" });
}
async batchUpdateIssues(issueIds: string[], updates: UpdateIssueRequest): Promise<{ updated: number }> {
return this.fetch("/api/issues/batch-update", {
method: "POST",
body: JSON.stringify({ issue_ids: issueIds, updates }),
});
}
async batchDeleteIssues(issueIds: string[]): Promise<{ deleted: number }> {
return this.fetch("/api/issues/batch-delete", {
method: "POST",
body: JSON.stringify({ issue_ids: issueIds }),
});
}
// Comments
async listComments(issueId: string): Promise<Comment[]> {
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/comments`);
return parseWithFallback(raw, CommentsListSchema, [], {
endpoint: "GET /api/issues/:id/comments",
});
}
async createComment(
issueId: string,
content: string,
type?: string,
parentId?: string,
attachmentIds?: string[],
suppressAgentIds?: string[],
): Promise<Comment> {
return this.fetch(`/api/issues/${issueId}/comments`, {
method: "POST",
body: JSON.stringify({
content,
type: type ?? "comment",
...(parentId ? { parent_id: parentId } : {}),
...(attachmentIds?.length ? { attachment_ids: attachmentIds } : {}),
...(suppressAgentIds?.length ? { suppress_agent_ids: suppressAgentIds } : {}),
}),
});
}
async previewCommentTriggers(issueId: string, content: string, parentId?: string, editingCommentId?: string): Promise<CommentTriggerPreview> {
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/comments/trigger-preview`, {
method: "POST",
body: JSON.stringify({
content,
...(parentId ? { parent_id: parentId } : {}),
...(editingCommentId ? { editing_comment_id: editingCommentId } : {}),
}),
});
return parseWithFallback(raw, CommentTriggerPreviewSchema, { agents: [] }, {
endpoint: "POST /api/issues/:id/comments/trigger-preview",
});
}
/** Dry-run the unified run-enqueue predicate for a prospective issue write
* (create / single assign / single status / batch). Returns the runs that
* would start; no side effect. The four entry points consult this instead
* of re-implementing the rule (MUL-3375). */
async previewIssueTrigger(params: IssueTriggerPreviewParams): Promise<IssueTriggerPreview> {
const raw = await this.fetch<unknown>("/api/issues/preview-trigger", {
method: "POST",
body: JSON.stringify({
...(params.issueIds?.length ? { issue_ids: params.issueIds } : {}),
...(params.isCreate ? { is_create: true } : {}),
...(params.assigneeType ? { assignee_type: params.assigneeType } : {}),
...(params.assigneeId ? { assignee_id: params.assigneeId } : {}),
...(params.status ? { status: params.status } : {}),
}),
});
return parseWithFallback(raw, IssueTriggerPreviewSchema, { triggers: [], total_count: 0 }, {
endpoint: "POST /api/issues/preview-trigger",
});
}
async listTimeline(issueId: string): Promise<TimelineEntry[]> {
const raw = await this.fetch<unknown>(
`/api/issues/${issueId}/timeline`,
);
return parseWithFallback(raw, TimelineEntriesSchema, EMPTY_TIMELINE_ENTRIES, {
endpoint: "GET /api/issues/:id/timeline",
});
}
async getAssigneeFrequency(): Promise<AssigneeFrequencyEntry[]> {
return this.fetch("/api/assignee-frequency");
}
async updateComment(commentId: string, content: string, attachmentIds?: string[], suppressAgentIds?: string[]): Promise<Comment> {
return this.fetch(`/api/comments/${commentId}`, {
method: "PUT",
body: JSON.stringify({
content,
attachment_ids: attachmentIds,
...(suppressAgentIds?.length ? { suppress_agent_ids: suppressAgentIds } : {}),
}),
});
}
async deleteComment(commentId: string): Promise<void> {
await this.fetch(`/api/comments/${commentId}`, { method: "DELETE" });
}
async resolveComment(commentId: string): Promise<Comment> {
return this.fetch(`/api/comments/${commentId}/resolve`, { method: "POST" });
}
async unresolveComment(commentId: string): Promise<Comment> {
return this.fetch(`/api/comments/${commentId}/resolve`, { method: "DELETE" });
}
async addReaction(commentId: string, emoji: string): Promise<Reaction> {
return this.fetch(`/api/comments/${commentId}/reactions`, {
method: "POST",
body: JSON.stringify({ emoji }),
});
}
async removeReaction(commentId: string, emoji: string): Promise<void> {
await this.fetch(`/api/comments/${commentId}/reactions`, {
method: "DELETE",
body: JSON.stringify({ emoji }),
});
}
async addIssueReaction(issueId: string, emoji: string): Promise<IssueReaction> {
return this.fetch(`/api/issues/${issueId}/reactions`, {
method: "POST",
body: JSON.stringify({ emoji }),
});
}
async removeIssueReaction(issueId: string, emoji: string): Promise<void> {
await this.fetch(`/api/issues/${issueId}/reactions`, {
method: "DELETE",
body: JSON.stringify({ emoji }),
});
}
// Subscribers
async listIssueSubscribers(issueId: string): Promise<IssueSubscriber[]> {
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/subscribers`);
return parseWithFallback(raw, SubscribersListSchema, [], {
endpoint: "GET /api/issues/:id/subscribers",
});
}
async subscribeToIssue(issueId: string, userId?: string, userType?: string): Promise<void> {
const body: Record<string, string> = {};
if (userId) body.user_id = userId;
if (userType) body.user_type = userType;
await this.fetch(`/api/issues/${issueId}/subscribe`, {
method: "POST",
body: JSON.stringify(body),
});
}
async unsubscribeFromIssue(issueId: string, userId?: string, userType?: string): Promise<void> {
const body: Record<string, string> = {};
if (userId) body.user_id = userId;
if (userType) body.user_type = userType;
await this.fetch(`/api/issues/${issueId}/unsubscribe`, {
method: "POST",
body: JSON.stringify(body),
});
}
// Agents
async listAgents(params?: { workspace_id?: string; include_archived?: boolean }): Promise<Agent[]> {
const search = new URLSearchParams();
if (params?.workspace_id) search.set("workspace_id", params.workspace_id);
if (params?.include_archived) search.set("include_archived", "true");
return this.fetch(`/api/agents?${search}`);
}
async getAgent(id: string): Promise<Agent> {
return this.fetch(`/api/agents/${id}`);
}
async createAgent(data: CreateAgentRequest): Promise<Agent> {
return this.fetch("/api/agents", {
method: "POST",
body: JSON.stringify(data),
});
}
async createAgentBuilderSession(data: {
runtime_id: string;
model?: string;
}): Promise<AgentBuilderSession> {
const raw = await this.fetch<unknown>("/api/agent-builder/sessions", {
method: "POST",
body: JSON.stringify(data),
});
return parseWithFallback(
raw,
AgentBuilderSessionSchema,
EMPTY_AGENT_BUILDER_SESSION,
{ endpoint: "POST /api/agent-builder/sessions" },
);
}
/** Rebinds a live builder conversation to another runtime. Callers must not
* show the new runtime as selected until this resolves — the whole point is
* that the UI's runtime and the executing runtime agree.
*
* A non-2xx throws before we get here and nothing was committed. Reaching the
* parse means the server bound `data.runtime_id`, so that is the fallback for
* an unparseable body — see agentBuilderRuntimeSwitchFallback. */
async switchAgentBuilderRuntime(
sessionId: string,
data: { runtime_id: string },
): Promise<AgentBuilderRuntimeSwitch> {
const raw = await this.fetch<unknown>(
`/api/agent-builder/sessions/${sessionId}/runtime`,
{ method: "PATCH", body: JSON.stringify(data) },
);
return parseWithFallback(
raw,
AgentBuilderRuntimeSwitchSchema,
agentBuilderRuntimeSwitchFallback(data.runtime_id),
{ endpoint: "PATCH /api/agent-builder/sessions/{id}/runtime" },
);
}
async listAgentTemplates(): Promise<AgentTemplateSummary[]> {
const raw = await this.fetch<unknown>("/api/agent-templates");
return parseWithFallback(
raw,
AgentTemplateSummaryListSchema,
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
{ endpoint: "GET /api/agent-templates" },
);
}
async getAgentTemplate(slug: string): Promise<AgentTemplate> {
const raw = await this.fetch<unknown>(
`/api/agent-templates/${encodeURIComponent(slug)}`,
);
// Round-trip the requested slug into the fallback so a malformed
// detail response still produces a navigable record matching the URL
// the user clicked.
return parseWithFallback(
raw,
AgentTemplateSchema,
{ ...EMPTY_AGENT_TEMPLATE_DETAIL, slug },
{ endpoint: "GET /api/agent-templates/:slug" },
);
}
/** Creates an agent from a curated template. The server fetches every
* referenced skill URL in parallel, materializes them into the workspace
* (find-or-create by name), and writes the agent + skill bindings in a
* single transaction. On any upstream fetch failure, the entire write is
* rolled back and the API returns 422 with `failed_urls`. */
async createAgentFromTemplate(
data: CreateAgentFromTemplateRequest,
): Promise<CreateAgentFromTemplateResponse> {
const raw = await this.fetch<unknown>("/api/agents/from-template", {
method: "POST",
body: JSON.stringify(data),
});
return parseWithFallback(
raw,
CreateAgentFromTemplateResponseSchema,
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
{ endpoint: "POST /api/agents/from-template" },
);
}
async updateAgent(id: string, data: UpdateAgentRequest): Promise<Agent> {
return this.fetch(`/api/agents/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
async archiveAgent(id: string): Promise<Agent> {
return this.fetch(`/api/agents/${id}/archive`, { method: "POST" });
}
/**
* Returns the plaintext `custom_env` map for an agent. Admits the
* agent's owner or a workspace owner/admin (MUL-5438); calls from
* agent-actor sessions get a 403. Every successful call writes an
* `agent_env_revealed` activity_log row server-side. MUL-2600.
*/
async getAgentEnv(id: string): Promise<AgentEnvResponse> {
return this.fetch(`/api/agents/${id}/env`);
}
/**
* Replaces an agent's `custom_env` wholesale. Values equal to
* `"****"` are preserved server-side (the **** guard) so a partial
* UI edit doesn't overwrite real secrets with the masked
* placeholder. Admits the agent's owner or a workspace owner/admin
* (MUL-5438); agent actors get a 403. Every successful call writes an
* `agent_env_updated` activity_log row. MUL-2600.
*/
async updateAgentEnv(id: string, data: UpdateAgentEnvRequest): Promise<AgentEnvResponse> {
return this.fetch(`/api/agents/${id}/env`, {
method: "PUT",
body: JSON.stringify(data),
});
}
async restoreAgent(id: string): Promise<Agent> {
return this.fetch(`/api/agents/${id}/restore`, { method: "POST" });
}
// Bulk-cancel every active task (queued/dispatched/running) for the agent.
// Permission: agent owner or workspace admin/owner. Server returns the
// count of cancelled rows; broadcasts task:cancelled for each so other
// surfaces can clear their live cards.
async cancelAgentTasks(id: string): Promise<{ cancelled: number }> {
return this.fetch(`/api/agents/${id}/cancel-tasks`, { method: "POST" });
}
async listRuntimes(params?: { workspace_id?: string; owner?: "me" }): Promise<AgentRuntime[]> {
const search = new URLSearchParams();
if (params?.workspace_id) search.set("workspace_id", params.workspace_id);
if (params?.owner) search.set("owner", params.owner);
return this.fetch(`/api/runtimes?${search}`);
}
async listCloudRuntimeNodes(
params?: ListCloudRuntimeNodesParams,
): Promise<CloudRuntimeNode[]> {
const search = new URLSearchParams();
if (params?.limit !== undefined) search.set("limit", String(params.limit));
if (params?.offset !== undefined) search.set("offset", String(params.offset));
const query = search.toString();
const raw = await this.fetch<unknown>(
`/api/cloud-runtime/nodes${query ? `?${query}` : ""}`,
);
return parseWithFallback(
raw,
CloudRuntimeNodeListSchema,
EMPTY_CLOUD_RUNTIME_NODE_LIST,
{ endpoint: "GET /api/cloud-runtime/nodes" },
);
}
async createCloudRuntimeNode(
data: CreateCloudRuntimeNodeRequest,
): Promise<CloudRuntimeNode> {
const res = await this.fetchRaw("/api/cloud-runtime/nodes", {
method: "POST",
body: JSON.stringify(data),
extraHeaders: { "Content-Type": "application/json" },
});
const raw = await res.json() as unknown;
return parseWithFallback(
raw,
CloudRuntimeNodeSchema,
EMPTY_CLOUD_RUNTIME_NODE,
{ endpoint: "POST /api/cloud-runtime/nodes" },
);
}
async deleteCloudRuntimeNode(instanceId: string): Promise<void> {
await this.fetchRaw("/api/cloud-runtime/nodes", {
method: "DELETE",
body: JSON.stringify({ instance_id: instanceId }),
extraHeaders: { "Content-Type": "application/json" },
});
}
// ---------------------------------------------------------------------
// Cloud Billing — proxies to multica-cloud /api/v1/billing/*. The
// multica-api server stamps X-User-ID and forwards bytes; everything
// here is upstream-shaped. See packages/core/types/billing.ts for the
// response field documentation.
// ---------------------------------------------------------------------
async getCloudBillingBalance(): Promise<BillingBalance> {
const raw = await this.fetch<unknown>("/api/cloud-billing/balance");
return parseWithFallback(raw, BillingBalanceSchema, EMPTY_BILLING_BALANCE, {
endpoint: "GET /api/cloud-billing/balance",
});
}
async listCloudBillingTransactions(
params?: { page?: number; page_size?: number },
): Promise<BillingTransactionsPage> {
const search = new URLSearchParams();
if (params?.page !== undefined) search.set("page", String(params.page));
if (params?.page_size !== undefined) search.set("page_size", String(params.page_size));
const query = search.toString();
const raw = await this.fetch<unknown>(
`/api/cloud-billing/transactions${query ? `?${query}` : ""}`,
);
return parseWithFallback(
raw,
BillingTransactionsPageSchema,
EMPTY_BILLING_TRANSACTIONS_PAGE,
{ endpoint: "GET /api/cloud-billing/transactions" },
);
}
async listCloudBillingBatches(
params?: { page?: number; page_size?: number },
): Promise<BillingBatchesPage> {
const search = new URLSearchParams();
if (params?.page !== undefined) search.set("page", String(params.page));
if (params?.page_size !== undefined) search.set("page_size", String(params.page_size));
const query = search.toString();
const raw = await this.fetch<unknown>(
`/api/cloud-billing/batches${query ? `?${query}` : ""}`,
);
return parseWithFallback(
raw,
BillingBatchesPageSchema,
EMPTY_BILLING_BATCHES_PAGE,
{ endpoint: "GET /api/cloud-billing/batches" },
);
}
async listCloudBillingTopups(
params?: { page?: number; page_size?: number },
): Promise<BillingTopupsPage> {
const search = new URLSearchParams();
if (params?.page !== undefined) search.set("page", String(params.page));
if (params?.page_size !== undefined) search.set("page_size", String(params.page_size));
const query = search.toString();
const raw = await this.fetch<unknown>(
`/api/cloud-billing/topups${query ? `?${query}` : ""}`,
);
return parseWithFallback(
raw,
BillingTopupsPageSchema,
EMPTY_BILLING_TOPUPS_PAGE,
{ endpoint: "GET /api/cloud-billing/topups" },
);
}
async listCloudBillingPriceTiers(): Promise<BillingPriceTier[]> {
const raw = await this.fetch<unknown>("/api/cloud-billing/price-tiers");
return parseWithFallback(
raw,
BillingPriceTierListSchema,
EMPTY_BILLING_PRICE_TIER_LIST,
{ endpoint: "GET /api/cloud-billing/price-tiers" },
);
}
async createCloudBillingCheckoutSession(
data: CreateBillingCheckoutSessionRequest,
): Promise<CreateBillingCheckoutSessionResponse> {
const res = await this.fetchRaw("/api/cloud-billing/checkout-sessions", {
method: "POST",
body: JSON.stringify(data),
extraHeaders: { "Content-Type": "application/json" },
});
const raw = (await res.json()) as unknown;
return parseWithFallback(
raw,
CreateBillingCheckoutSessionResponseSchema,
EMPTY_CREATE_BILLING_CHECKOUT_SESSION_RESPONSE,
{ endpoint: "POST /api/cloud-billing/checkout-sessions" },
);
}
async getCloudBillingCheckoutSession(
sessionId: string,
): Promise<BillingCheckoutSessionStatus> {
// Stripe session ids are `cs_<base62>` so they're URL-safe by
// construction; encodeURIComponent is paranoia for the case where a
// future Stripe format change adds a non-alphanumeric character. The
// server has its own allow-list rejection for unsafe ids.
const raw = await this.fetch<unknown>(
`/api/cloud-billing/checkout-sessions/${encodeURIComponent(sessionId)}`,
);
return parseWithFallback(
raw,
BillingCheckoutSessionStatusSchema,
EMPTY_BILLING_CHECKOUT_SESSION_STATUS,
{ endpoint: "GET /api/cloud-billing/checkout-sessions/{sessionId}" },
);
}
async createCloudBillingPortalSession(): Promise<CreateBillingPortalSessionResponse> {
const res = await this.fetchRaw("/api/cloud-billing/portal-sessions", {
method: "POST",
// Body is intentionally absent — the upstream endpoint requires no
// payload today. fetchRaw with no body skips the Content-Type
// default; that's fine because there's nothing to declare.
});
const raw = (await res.json()) as unknown;
return parseWithFallback(
raw,
CreateBillingPortalSessionResponseSchema,
EMPTY_CREATE_BILLING_PORTAL_SESSION_RESPONSE,
{ endpoint: "POST /api/cloud-billing/portal-sessions" },
);
}
async deleteRuntime(runtimeId: string): Promise<void> {
await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" });
}
// Cascade variant of deleteRuntime. The strict DELETE refuses with
// structured 409 (`code: "runtime_has_active_agents"`, body carries the
// blocking agents) when active agents are bound; the front-end then opens
// the cascade-mode confirmation dialog and submits the user-confirmed
// active agent set here. Server compares the snapshot to the live set
// inside the transaction and refuses with `code: "runtime_delete_plan_changed"`
// (same shape, fresh `active_agents`) if they don't match — caller should
// re-render the agent list and force the user to re-confirm.
async archiveAgentsAndDeleteRuntime(
runtimeId: string,
expectedActiveAgentIds: string[],
): Promise<{ status: string; agents_archived: number; tasks_cancelled: number }> {
return this.fetch(`/api/runtimes/${runtimeId}/archive-agents-and-delete`, {
method: "POST",
body: JSON.stringify({ expected_active_agent_ids: expectedActiveAgentIds }),
});
}
async updateRuntime(
runtimeId: string,
patch: {
visibility?: "private" | "public";
/**
* Custom display name. Pass an empty string to clear it (the server
* reverts to the default name). Omit to leave it unchanged — a JSON
* `null` is treated as "unchanged", not "clear". See MUL-4217.
*/
custom_name?: string;
/** Apply custom_name to every runtime on the same machine. */
apply_to_machine?: boolean;
},
): Promise<AgentRuntime> {
return this.fetch(`/api/runtimes/${runtimeId}`, {
method: "PATCH",
body: JSON.stringify(patch),
});
}
// ---------------------------------------------------------------------
// Custom runtime profiles (MUL-3284). All workspace-scoped: the caller
// passes the workspace id the same way the runtimes list resolves it.
// ---------------------------------------------------------------------
async listRuntimeProfiles(workspaceId: string): Promise<RuntimeProfile[]> {
const res = await this.fetch<{ runtime_profiles?: RuntimeProfile[] }>(
`/api/workspaces/${workspaceId}/runtime-profiles`,
);
return res.runtime_profiles ?? [];
}
async getRuntimeProfile(
workspaceId: string,
profileId: string,
): Promise<RuntimeProfile> {
return this.fetch(
`/api/workspaces/${workspaceId}/runtime-profiles/${profileId}`,
);
}
async createRuntimeProfile(
workspaceId: string,
body: CreateRuntimeProfileRequest,
): Promise<RuntimeProfile> {
return this.fetch(`/api/workspaces/${workspaceId}/runtime-profiles`, {
method: "POST",
body: JSON.stringify(body),
});
}
async updateRuntimeProfile(
workspaceId: string,
profileId: string,
patch: UpdateRuntimeProfileRequest,
): Promise<RuntimeProfile> {
return this.fetch(
`/api/workspaces/${workspaceId}/runtime-profiles/${profileId}`,
{
method: "PATCH",
body: JSON.stringify(patch),
},
);
}
async deleteRuntimeProfile(
workspaceId: string,
profileId: string,
): Promise<void> {
await this.fetch(
`/api/workspaces/${workspaceId}/runtime-profiles/${profileId}`,
{ method: "DELETE" },
);
}
async getRuntimeUsage(
runtimeId: string,
params?: { days?: number; tz?: string },
): Promise<RuntimeUsage[]> {
const search = new URLSearchParams();
if (params?.days) search.set("days", String(params.days));
// `tz` drives the calendar-day boundary for the trend chart (Viewing
// layer). Caller-supplied; the backend falls back to user.timezone /
// UTC if omitted.
if (params?.tz) search.set("tz", params.tz);
const raw = await this.fetch<unknown>(
`/api/runtimes/${runtimeId}/usage?${search}`,
);
return parseWithFallback<RuntimeUsage[]>(raw, RuntimeUsageListSchema, [], {
endpoint: "GET /api/runtimes/:id/usage",
});
}
async getRuntimeTaskActivity(
runtimeId: string,
params?: { tz?: string },
): Promise<RuntimeHourlyActivity[]> {
// Hour-of-day heatmap follows the viewer's tz, like the other reports on
// this page. Pass the viewer's IANA zone so the server buckets correctly.
const search = new URLSearchParams();
if (params?.tz) search.set("tz", params.tz);
const raw = await this.fetch<unknown>(
`/api/runtimes/${runtimeId}/activity?${search}`,
);
return parseWithFallback<RuntimeHourlyActivity[]>(
raw,
RuntimeHourlyActivityListSchema,
[],
{ endpoint: "GET /api/runtimes/:id/activity" },
);
}
async getRuntimeUsageByAgent(
runtimeId: string,
params?: { days?: number; tz?: string },
): Promise<RuntimeUsageByAgent[]> {
const search = new URLSearchParams();
if (params?.days) search.set("days", String(params.days));
if (params?.tz) search.set("tz", params.tz);
const raw = await this.fetch<unknown>(
`/api/runtimes/${runtimeId}/usage/by-agent?${search}`,
);
return parseWithFallback<RuntimeUsageByAgent[]>(
raw,
RuntimeUsageByAgentListSchema,
[],
{ endpoint: "GET /api/runtimes/:id/usage/by-agent" },
);
}
async getRuntimeUsageByHour(
runtimeId: string,
params?: { days?: number; tz?: string },
): Promise<RuntimeUsageByHour[]> {
const search = new URLSearchParams();
if (params?.days) search.set("days", String(params.days));
if (params?.tz) search.set("tz", params.tz);
const raw = await this.fetch<unknown>(
`/api/runtimes/${runtimeId}/usage/by-hour?${search}`,
);
return parseWithFallback<RuntimeUsageByHour[]>(
raw,
RuntimeUsageByHourListSchema,
[],
{ endpoint: "GET /api/runtimes/:id/usage/by-hour" },
);
}
// ---------------------------------------------------------------------------
// Workspace dashboard — three independent rollups for `/{slug}/dashboard`.
// Each accepts an optional `project_id` to narrow the scope to one project.
// Cost is computed client-side from the model pricing table (same contract
// as the per-runtime endpoints above).
// ---------------------------------------------------------------------------
async getDashboardUsageDaily(
params: { days?: number; project_id?: string | null; tz?: string },
): Promise<DashboardUsageDaily[]> {
const search = new URLSearchParams();
if (params.days) search.set("days", String(params.days));
if (params.project_id) search.set("project_id", params.project_id);
if (params.tz) search.set("tz", params.tz);
const raw = await this.fetch<unknown>(`/api/dashboard/usage/daily?${search}`);
return parseWithFallback<DashboardUsageDaily[]>(
raw,
DashboardUsageDailyListSchema,
[],
{ endpoint: "GET /api/dashboard/usage/daily" },
);
}
async getDashboardUsageByAgent(
params: { days?: number; project_id?: string | null; tz?: string },
): Promise<DashboardUsageByAgent[]> {
const search = new URLSearchParams();
if (params.days) search.set("days", String(params.days));
if (params.project_id) search.set("project_id", params.project_id);
if (params.tz) search.set("tz", params.tz);
const raw = await this.fetch<unknown>(`/api/dashboard/usage/by-agent?${search}`);
return parseWithFallback<DashboardUsageByAgent[]>(
raw,
DashboardUsageByAgentListSchema,
[],
{ endpoint: "GET /api/dashboard/usage/by-agent" },
);
}
async getDashboardAgentRunTime(
params: { days?: number; project_id?: string | null; tz?: string },
): Promise<DashboardAgentRunTime[]> {
const search = new URLSearchParams();
if (params.days) search.set("days", String(params.days));
if (params.project_id) search.set("project_id", params.project_id);
// `tz` aligns the "last N days" cutoff with the viewer's calendar,
// matching the per-agent token card.
if (params.tz) search.set("tz", params.tz);
const raw = await this.fetch<unknown>(`/api/dashboard/agent-runtime?${search}`);
return parseWithFallback<DashboardAgentRunTime[]>(
raw,
DashboardAgentRunTimeListSchema,
[],
{ endpoint: "GET /api/dashboard/agent-runtime" },
);
}
async getDashboardRunTimeDaily(
params: { days?: number; project_id?: string | null; tz?: string },
): Promise<DashboardRunTimeDaily[]> {
const search = new URLSearchParams();
if (params.days) search.set("days", String(params.days));
if (params.project_id) search.set("project_id", params.project_id);
// `tz` cuts the day buckets in the viewer's calendar so Time / Tasks
// align with the Cost / Tokens charts.
if (params.tz) search.set("tz", params.tz);
const raw = await this.fetch<unknown>(`/api/dashboard/runtime/daily?${search}`);
return parseWithFallback<DashboardRunTimeDaily[]>(
raw,
DashboardRunTimeDailyListSchema,
[],
{ endpoint: "GET /api/dashboard/runtime/daily" },
);
}
async getDashboardFailuresDaily(
params: { days?: number; project_id?: string | null; tz?: string },
): Promise<DashboardFailureDaily[]> {
const search = new URLSearchParams();
if (params.days) search.set("days", String(params.days));
if (params.project_id) search.set("project_id", params.project_id);
// `tz` cuts the day buckets in the viewer's calendar so the Errors chart
// shares an x-axis with the other four metrics.
if (params.tz) search.set("tz", params.tz);
const raw = await this.fetch<unknown>(`/api/dashboard/failures/daily?${search}`);
return parseWithFallback<DashboardFailureDaily[]>(
raw,
DashboardFailureDailyListSchema,
[],
{ endpoint: "GET /api/dashboard/failures/daily" },
);
}
async getDashboardFailuresByAgent(
params: { days?: number; project_id?: string | null; tz?: string },
): Promise<DashboardFailureByAgent[]> {
const search = new URLSearchParams();
if (params.days) search.set("days", String(params.days));
if (params.project_id) search.set("project_id", params.project_id);
if (params.tz) search.set("tz", params.tz);
const raw = await this.fetch<unknown>(`/api/dashboard/failures/by-agent?${search}`);
return parseWithFallback<DashboardFailureByAgent[]>(
raw,
DashboardFailureByAgentListSchema,
[],
{ endpoint: "GET /api/dashboard/failures/by-agent" },
);
}
async initiateUpdate(
runtimeId: string,
targetVersion: string,
): Promise<RuntimeUpdate> {
return this.fetch(`/api/runtimes/${runtimeId}/update`, {
method: "POST",
body: JSON.stringify({ target_version: targetVersion }),
});
}
async getUpdateResult(
runtimeId: string,
updateId: string,
): Promise<RuntimeUpdate> {
return this.fetch(`/api/runtimes/${runtimeId}/update/${updateId}`);
}
// Both discovery endpoints feed a UI state machine (poll while
// pending/running, then render or fail), so the response is validated rather
// than cast: an unparseable body degrades to an explicit "failed" record that
// shows the discovery error and keeps manual model entry usable, instead of a
// fabricated empty catalog or an endless spinner (MUL-5444).
async initiateListModels(runtimeId: string): Promise<RuntimeModelListRequest> {
const raw = await this.fetch<unknown>(`/api/runtimes/${runtimeId}/models`, {
method: "POST",
});
return parseWithFallback<RuntimeModelListRequest>(
raw,
RuntimeModelListRequestSchema,
{ ...MALFORMED_RUNTIME_MODEL_LIST_REQUEST, runtime_id: runtimeId },
{ endpoint: "POST /api/runtimes/{id}/models" },
);
}
async getListModelsResult(
runtimeId: string,
requestId: string,
): Promise<RuntimeModelListRequest> {
const raw = await this.fetch<unknown>(
`/api/runtimes/${runtimeId}/models/${requestId}`,
);
return parseWithFallback<RuntimeModelListRequest>(
raw,
RuntimeModelListRequestSchema,
{
...MALFORMED_RUNTIME_MODEL_LIST_REQUEST,
id: requestId,
runtime_id: runtimeId,
},
{ endpoint: "GET /api/runtimes/{id}/models/{requestId}" },
);
}
async initiateListLocalSkills(
runtimeId: string,
): Promise<RuntimeLocalSkillListRequest> {
return this.fetch(`/api/runtimes/${runtimeId}/local-skills`, {
method: "POST",
});
}
async getListLocalSkillsResult(
runtimeId: string,
requestId: string,
): Promise<RuntimeLocalSkillListRequest> {
return this.fetch(`/api/runtimes/${runtimeId}/local-skills/${requestId}`);
}
async initiateImportLocalSkill(
runtimeId: string,
data: CreateRuntimeLocalSkillImportRequest,
): Promise<RuntimeLocalSkillImportRequest> {
return this.fetch(`/api/runtimes/${runtimeId}/local-skills/import`, {
method: "POST",
body: JSON.stringify(data),
});
}
async getImportLocalSkillResult(
runtimeId: string,
requestId: string,
): Promise<RuntimeLocalSkillImportRequest> {
return this.fetch(`/api/runtimes/${runtimeId}/local-skills/import/${requestId}`);
}
async listAgentTasks(agentId: string): Promise<AgentTask[]> {
return this.fetch(`/api/agents/${agentId}/tasks`);
}
// Workspace-scoped agent task snapshot: every active task
// (queued/dispatched/running) plus each agent's most recent terminal task.
// Powers the front-end's "active wins, else latest terminal" presence
// derivation; one fetch backs every per-agent presence read in the app.
// Workspace is resolved server-side from the X-Workspace-Slug header.
async getAgentTaskSnapshot(): Promise<AgentTask[]> {
return this.fetch(`/api/agent-task-snapshot`);
}
// Independent workspace-level projection. Unlike the task snapshot, this
// already deduplicates running agents and returns only the display fields
// consumers need. Callers may narrow the projection by task source and, for
// issue work, the authenticated member's My Issues relation.
// `parentIssueId` narrows the projection to that issue's direct children,
// which is how the sub-issue header on issue detail reads the same source
// as the Issues list header. The server rejects combining it with `scope`,
// so callers pass one or the other.
async getWorkspaceWorkingAgents(
type?: WorkspaceWorkingAgentType,
mineRelation?: WorkspaceWorkingAgentMineRelation,
parentIssueId?: string,
): Promise<WorkspaceWorkingAgent[]> {
const search = new URLSearchParams();
if (type) search.set("type", type);
if (mineRelation) {
search.set("scope", "mine");
search.set("relation", mineRelation);
} else if (parentIssueId) {
search.set("parent", parentIssueId);
}
const query = search.toString();
return this.fetch(`/api/working-agents${query ? `?${query}` : ""}`);
}
// Per-agent daily activity for the last 30 days, anchored on
// completed_at. One workspace-wide fetch backs both the Agents-list
// sparkline (uses trailing 7 buckets) and the agent detail "Last 30
// days" panel (uses all 30).
async getWorkspaceAgentActivity30d(): Promise<AgentActivityBucket[]> {
return this.fetch(`/api/agent-activity-30d`);
}
// Per-agent 30-day total run count for the Agents-list RUNS column.
async getWorkspaceAgentRunCounts(): Promise<AgentRunCount[]> {
return this.fetch(`/api/agent-run-counts`);
}
async getActiveTasksForIssue(issueId: string): Promise<{ tasks: AgentTask[] }> {
return this.fetch(`/api/issues/${issueId}/active-task`);
}
async listTaskMessages(taskId: string): Promise<TaskMessagePayload[]> {
return this.fetch(`/api/tasks/${taskId}/messages`);
}
async listTasksByIssue(issueId: string): Promise<AgentTask[]> {
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/task-runs`);
return parseWithFallback<AgentTask[]>(raw, AgentTaskListSchema, [], {
endpoint: "GET /api/issues/:id/task-runs",
});
}
async getIssueUsage(issueId: string): Promise<IssueUsageSummary> {
return this.fetch(`/api/issues/${issueId}/usage`);
}
async cancelTask(issueId: string, taskId: string): Promise<AgentTask> {
return this.fetch(`/api/issues/${issueId}/tasks/${taskId}/cancel`, {
method: "POST",
});
}
async rerunIssue(issueId: string, taskId?: string): Promise<AgentTask> {
return this.fetch(`/api/issues/${issueId}/rerun`, {
method: "POST",
body: JSON.stringify(taskId ? { task_id: taskId } : {}),
});
}
// Inbox
async listInbox(): Promise<InboxItem[]> {
return this.fetch("/api/inbox");
}
async markInboxRead(id: string): Promise<InboxItem> {
return this.fetch(`/api/inbox/${id}/read`, { method: "POST" });
}
async markInboxUnread(id: string): Promise<InboxItem> {
return this.fetch(`/api/inbox/${id}/unread`, { method: "POST" });
}
async archiveInbox(id: string): Promise<InboxItem> {
return this.fetch(`/api/inbox/${id}/archive`, { method: "POST" });
}
// Archived notifications, backing the inbox's "Archived" sub-view. Capped
// server-side (no pagination in v1). Schema-guarded so a contract drift
// renders an empty archive instead of taking the inbox down with it.
async listArchivedInbox(): Promise<InboxItem[]> {
const raw = await this.fetch<unknown>("/api/inbox/archived");
return parseWithFallback(raw, InboxItemListSchema, EMPTY_INBOX_ITEMS, {
endpoint: "GET /api/inbox/archived",
});
}
async unarchiveInbox(id: string): Promise<InboxItem> {
return this.fetch(`/api/inbox/${id}/unarchive`, { method: "POST" });
}
async getUnreadInboxCount(): Promise<{ count: number }> {
return this.fetch("/api/inbox/unread-count");
}
// Cross-workspace unread summary: one entry per workspace the user belongs
// to that has unread inbox items. Backs the workspace-switcher dot for
// OTHER workspaces. Schema-guarded so a contract drift hides the dot rather
// than crashing the sidebar.
async getInboxUnreadSummary(): Promise<InboxWorkspaceUnread[]> {
const raw = await this.fetch<unknown>("/api/inbox/unread-summary");
return parseWithFallback(raw, InboxUnreadSummarySchema, EMPTY_INBOX_UNREAD_SUMMARY, {
endpoint: "GET /api/inbox/unread-summary",
});
}
async markAllInboxRead(): Promise<{ count: number }> {
return this.fetch("/api/inbox/mark-all-read", { method: "POST" });
}
async archiveAllInbox(): Promise<{ count: number }> {
return this.fetch("/api/inbox/archive-all", { method: "POST" });
}
async archiveAllReadInbox(): Promise<{ count: number }> {
return this.fetch("/api/inbox/archive-all-read", { method: "POST" });
}
async archiveCompletedInbox(): Promise<{ count: number }> {
return this.fetch("/api/inbox/archive-completed", { method: "POST" });
}
// Notification preferences
//
// `workspaceSlug` overrides the default `X-Workspace-Slug` header (which
// follows the active workspace) so a caller can read a SPECIFIC workspace's
// preferences — e.g. honoring the mute setting of the workspace an inbox
// notification came from while the user is viewing a different one (#3766).
async getNotificationPreferences(workspaceSlug?: string): Promise<NotificationPreferenceResponse> {
const raw = await this.fetch<unknown>(
"/api/notification-preferences",
workspaceSlug ? { headers: { "X-Workspace-Slug": workspaceSlug } } : undefined,
);
return parseWithFallback(
raw,
NotificationPreferenceResponseSchema,
EMPTY_NOTIFICATION_PREFERENCE_RESPONSE,
{ endpoint: "GET /api/notification-preferences" },
);
}
async updateNotificationPreferences(
preferences: NotificationPreferences,
workspaceSlug?: string,
): Promise<NotificationPreferenceResponse> {
const raw = await this.fetch<unknown>("/api/notification-preferences", {
method: "PATCH",
headers: workspaceSlug
? { "X-Workspace-Slug": workspaceSlug }
: undefined,
body: JSON.stringify({ preferences }),
});
return parseWithFallback(
raw,
NotificationPreferenceResponseSchema,
EMPTY_NOTIFICATION_PREFERENCE_RESPONSE,
{ endpoint: "PATCH /api/notification-preferences" },
);
}
// App Config
async getConfig(): Promise<AppConfigResponse> {
const raw = await this.fetch<unknown>("/api/config");
return parseWithFallback<AppConfigResponse>(raw, AppConfigSchema, EMPTY_APP_CONFIG, {
endpoint: "GET /api/config",
});
}
// Workspaces
async listWorkspaces(): Promise<Workspace[]> {
return this.fetch("/api/workspaces");
}
async getWorkspace(id: string): Promise<Workspace> {
return this.fetch(`/api/workspaces/${id}`);
}
async createWorkspace(data: { name: string; slug: string; description?: string; context?: string }): Promise<Workspace> {
return this.fetch("/api/workspaces", {
method: "POST",
body: JSON.stringify(data),
});
}
async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record<string, unknown>; repos?: WorkspaceRepo[]; issue_prefix?: string; avatar_url?: string }): Promise<Workspace> {
return this.fetch(`/api/workspaces/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
// Members
async listMembers(workspaceId: string): Promise<MemberWithUser[]> {
return this.fetch(`/api/workspaces/${workspaceId}/members`);
}
async createMember(workspaceId: string, data: CreateMemberRequest): Promise<Invitation> {
return this.fetch(`/api/workspaces/${workspaceId}/members`, {
method: "POST",
body: JSON.stringify(data),
});
}
async updateMember(workspaceId: string, memberId: string, data: UpdateMemberRequest): Promise<MemberWithUser> {
return this.fetch(`/api/workspaces/${workspaceId}/members/${memberId}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
async deleteMember(workspaceId: string, memberId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}/members/${memberId}`, {
method: "DELETE",
});
}
async leaveWorkspace(workspaceId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}/leave`, {
method: "POST",
});
}
// Invitations
async listWorkspaceInvitations(workspaceId: string): Promise<Invitation[]> {
return this.fetch(`/api/workspaces/${workspaceId}/invitations`);
}
async revokeInvitation(workspaceId: string, invitationId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}/invitations/${invitationId}`, {
method: "DELETE",
});
}
async listMyInvitations(): Promise<Invitation[]> {
return this.fetch("/api/invitations");
}
async getInvitation(invitationId: string): Promise<Invitation> {
return this.fetch(`/api/invitations/${invitationId}`);
}
async acceptInvitation(invitationId: string): Promise<MemberWithUser> {
return this.fetch(`/api/invitations/${invitationId}/accept`, {
method: "POST",
});
}
async declineInvitation(invitationId: string): Promise<void> {
await this.fetch(`/api/invitations/${invitationId}/decline`, {
method: "POST",
});
}
async deleteWorkspace(workspaceId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}`, {
method: "DELETE",
});
}
// Skills
async listSkills(): Promise<SkillSummary[]> {
return this.fetch("/api/skills");
}
async getSkill(id: string): Promise<Skill> {
return this.fetch(`/api/skills/${id}`);
}
async createSkill(data: CreateSkillRequest): Promise<Skill> {
return this.fetch("/api/skills", {
method: "POST",
body: JSON.stringify(data),
});
}
async updateSkill(id: string, data: UpdateSkillRequest): Promise<Skill> {
return this.fetch(`/api/skills/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
async deleteSkill(id: string): Promise<void> {
await this.fetch(`/api/skills/${id}`, { method: "DELETE" });
}
async importSkill(data: { url: string }): Promise<Skill> {
return this.fetch("/api/skills/import", {
method: "POST",
body: JSON.stringify(data),
});
}
async listAgentSkills(agentId: string): Promise<SkillSummary[]> {
return this.fetch(`/api/agents/${agentId}/skills`);
}
async setAgentSkills(agentId: string, data: SetAgentSkillsRequest): Promise<void> {
await this.fetch(`/api/agents/${agentId}/skills`, {
method: "PUT",
body: JSON.stringify(data),
});
}
// Incremental attach: POST /skills/add only inserts the given ids (the
// server upserts with ON CONFLICT DO NOTHING), so callers don't need to
// read the agent's current skill set first.
async addAgentSkills(agentId: string, data: SetAgentSkillsRequest): Promise<void> {
await this.fetch(`/api/agents/${agentId}/skills/add`, {
method: "POST",
body: JSON.stringify(data),
});
}
async setAgentSkillEnabled(agentId: string, skillId: string, enabled: boolean): Promise<void> {
await this.fetch(`/api/agents/${agentId}/skills/${skillId}/enabled`, {
method: "PUT",
body: JSON.stringify({ enabled }),
});
}
async setAgentRuntimeSkillEnabled(
agentId: string,
data: SetAgentRuntimeSkillEnabledRequest,
): Promise<void> {
await this.fetch(`/api/agents/${agentId}/runtime-skills/enabled`, {
method: "PUT",
body: JSON.stringify(data),
});
}
async removeAgentSkill(agentId: string, skillId: string): Promise<void> {
await this.fetch(`/api/agents/${agentId}/skills/${skillId}`, {
method: "DELETE",
});
}
// Personal Access Tokens
async listPersonalAccessTokens(): Promise<PersonalAccessToken[]> {
return this.fetch("/api/tokens");
}
async createPersonalAccessToken(data: CreatePersonalAccessTokenRequest): Promise<CreatePersonalAccessTokenResponse> {
return this.fetch("/api/tokens", {
method: "POST",
body: JSON.stringify(data),
});
}
async revokePersonalAccessToken(id: string): Promise<void> {
await this.fetch(`/api/tokens/${id}`, { method: "DELETE" });
}
// File Upload & Attachments
async uploadFile(
file: File,
opts?: { issueId?: string; commentId?: string; chatSessionId?: string },
// Optional abort signal so a module-level upload coordinator (MUL-5181)
// can cancel an in-flight upload on logout. When aborted, `fetch` rejects
// with an AbortError, which the coordinator distinguishes from a real
// failure via `signal.aborted` / `err.name === "AbortError"`.
signal?: AbortSignal,
): Promise<Attachment> {
const formData = new FormData();
formData.append("file", file);
if (opts?.issueId) formData.append("issue_id", opts.issueId);
if (opts?.commentId) formData.append("comment_id", opts.commentId);
if (opts?.chatSessionId) formData.append("chat_session_id", opts.chatSessionId);
const rid = createRequestId();
const start = Date.now();
this.logger.info("→ POST /api/upload-file", { rid });
const res = await fetch(`${this.baseUrl}/api/upload-file`, {
method: "POST",
headers: this.authHeaders(),
body: formData,
credentials: "include",
signal,
});
if (!res.ok) {
if (res.status === 401) this.handleUnauthorized();
const message = await this.parseErrorMessage(res, `Upload failed: ${res.status}`);
this.logger.error(`${res.status} /api/upload-file`, { rid, duration: `${Date.now() - start}ms`, error: message });
throw new Error(message);
}
this.logger.info(`${res.status} /api/upload-file`, { rid, duration: `${Date.now() - start}ms` });
const raw = (await res.json()) as unknown;
return parseWithFallback(raw, AttachmentResponseSchema, EMPTY_ATTACHMENT, {
endpoint: "POST /api/upload-file",
});
}
// Chat Sessions
async listChatSessions(params?: { status?: string }): Promise<ChatSession[]> {
const query = params?.status ? `?status=${params.status}` : "";
return this.fetch(`/api/chat/sessions${query}`);
}
async getChatSession(id: string): Promise<ChatSession> {
return this.fetch(`/api/chat/sessions/${id}`);
}
async createChatSession(data: {
agent_id: string;
title?: string;
project_id?: string | null;
}): Promise<ChatSession> {
return this.fetch("/api/chat/sessions", {
method: "POST",
body: JSON.stringify(data),
});
}
async deleteChatSession(id: string): Promise<void> {
await this.fetch(`/api/chat/sessions/${id}`, { method: "DELETE" });
}
// Refresh the quick-action suggestions for a session's latest assistant turn.
// Fire-and-forget: the server enqueues a background regeneration pass and the
// refreshed pills arrive over the chat:quick_actions realtime event, so the
// caller anchors its pending placeholder on the turn it already knows rather
// than on this response.
// Refresh the quick actions for the given assistant turn. Sends the message
// id the caller is refreshing so the server can atomically confirm it is still
// the session's latest turn (409 otherwise) — that keeps the client's pending
// marker aligned with the turn chat:quick_actions will resolve, with no
// response reconciliation needed even under a WS-before-HTTP race (MUL-5149).
async regenerateChatQuickActions(
sessionId: string,
messageId: string,
): Promise<void> {
await this.fetch(`/api/chat/sessions/${sessionId}/quick-actions/regenerate`, {
method: "POST",
body: JSON.stringify({ message_id: messageId }),
});
}
async updateChatSession(
id: string,
data: { title: string } | { project_id: string | null },
): Promise<ChatSession> {
return this.fetch(`/api/chat/sessions/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
async setChatSessionPinned(id: string, pinned: boolean): Promise<ChatSession> {
return this.fetch(`/api/chat/sessions/${id}/pin`, {
method: "PATCH",
body: JSON.stringify({ pinned }),
});
}
async setChatSessionArchived(id: string, archived: boolean): Promise<ChatSession> {
return this.fetch(`/api/chat/sessions/${id}/archive`, {
method: "PATCH",
body: JSON.stringify({ archived }),
});
}
// Quick-agent bar: per-user pinned agents.
async listChatPinnedAgents(): Promise<ChatPinnedAgent[]> {
return this.fetch("/api/chat/pinned-agents");
}
async pinChatAgent(agentId: string): Promise<ChatPinnedAgent> {
return this.fetch("/api/chat/pinned-agents", {
method: "POST",
body: JSON.stringify({ agent_id: agentId }),
});
}
async unpinChatAgent(agentId: string): Promise<void> {
await this.fetch(`/api/chat/pinned-agents/${agentId}`, { method: "DELETE" });
}
async listChatMessages(sessionId: string): Promise<ChatMessage[]> {
const raw: unknown = await this.fetch(`/api/chat/sessions/${sessionId}/messages`);
return parseWithFallback(raw, ChatMessageListSchema, EMPTY_CHAT_MESSAGE_LIST, {
endpoint: "GET /api/chat/sessions/:id/messages",
});
}
async listChatMessagesPage(
sessionId: string,
params: { before?: { created_at: string; id: string } | null; limit?: number } = {},
): Promise<ChatMessagesPage> {
const limit = params.limit ?? 50;
const query = new URLSearchParams({ limit: String(limit) });
if (params.before) {
query.set("before_created_at", params.before.created_at);
query.set("before_id", params.before.id);
}
try {
const raw: unknown = await this.fetch(
`/api/chat/sessions/${sessionId}/messages/page?${query.toString()}`,
);
return parseWithFallback(
raw,
ChatMessagesPageSchema,
{ messages: [], limit, has_more: false, next_cursor: null },
{
endpoint: "GET /api/chat/sessions/:id/messages/page",
},
);
} catch (err) {
// Deployment-order compatibility: a backend deployed before this endpoint
// existed returns 404 for the unknown route. Fall back to the legacy
// full-list endpoint so chat never white-screens regardless of whether
// the server or the client deploys first. Only the initial (cursorless)
// page falls back — the legacy endpoint returns every message at once, so
// the fallback page reports has_more: false and there is no follow-up
// request to translate. A 404 on a cursor request is an unexpected state
// and propagates instead of duplicating the whole list.
if (err instanceof ApiError && err.status === 404 && !params.before) {
const messages = await this.listChatMessages(sessionId);
return { messages, limit, has_more: false, next_cursor: null };
}
throw err;
}
}
async sendChatMessage(
sessionId: string,
content: string,
attachmentIds?: string[],
options?: { quickActionsEnabled?: boolean },
): Promise<SendChatMessageResponse> {
const body: {
content: string;
attachment_ids?: string[];
quick_actions_enabled?: boolean;
} = { content };
if (attachmentIds && attachmentIds.length > 0) {
body.attachment_ids = attachmentIds;
}
// Only an explicit false is sent: absent means enabled server-side, so
// older payload shapes keep generating suggestions unchanged.
if (options?.quickActionsEnabled === false) {
body.quick_actions_enabled = false;
}
return this.fetch(`/api/chat/sessions/${sessionId}/messages`, {
method: "POST",
body: JSON.stringify(body),
});
}
async getPendingChatTask(sessionId: string): Promise<ChatPendingTask> {
return this.fetch(`/api/chat/sessions/${sessionId}/pending-task`);
}
/**
* Pending deferred-cancellation draft restores for a session (#5219).
* A 404 means the backend predates the endpoint — treat as "nothing
* pending" so older servers never error the composer.
*/
async listChatDraftRestores(sessionId: string): Promise<ChatDraftRestoresResponse> {
let raw: unknown;
try {
raw = await this.fetch<unknown>(`/api/chat/sessions/${sessionId}/draft-restores`);
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
return { restores: [] };
}
throw err;
}
return parseWithFallback(raw, ChatDraftRestoresResponseSchema, EMPTY_CHAT_DRAFT_RESTORES, {
endpoint: "GET /api/chat/sessions/{id}/draft-restores",
});
}
/** Idempotent consume — deleting an already-consumed restore is a 204 no-op. */
async consumeChatDraftRestore(sessionId: string, restoreId: string): Promise<void> {
await this.fetch(`/api/chat/sessions/${sessionId}/draft-restores/${restoreId}`, {
method: "DELETE",
});
}
async listPendingChatTasks(): Promise<PendingChatTasksResponse> {
return this.fetch(`/api/chat/pending-tasks`);
}
async hasAnyPendingChatTasks(): Promise<HasPendingChatTasksResponse> {
return this.fetch(`/api/chat/pending-tasks/has-any`);
}
async markChatSessionRead(sessionId: string): Promise<void> {
await this.fetch(`/api/chat/sessions/${sessionId}/read`, { method: "POST" });
}
// Advertises the durable draft-restore capability (#5219). The server only
// defers the empty-transcript judgment — and therefore only withholds the
// synchronous restore from the response — for clients that send this; without
// it we would be treated as a pre-#5219 client and get the legacy behaviour.
async cancelTaskById(taskId: string): Promise<CancelTaskResponse> {
const raw = await this.fetch<unknown>(`/api/tasks/${taskId}/cancel`, {
method: "POST",
headers: { "X-Client-Capabilities": CHAT_DRAFT_RESTORE_CAPABILITY },
});
return parseWithFallback(raw, CancelTaskResponseSchema, EMPTY_CANCEL_TASK_RESPONSE, {
endpoint: "POST /api/tasks/{taskId}/cancel",
});
}
async listAttachments(issueId: string): Promise<Attachment[]> {
return this.fetch(`/api/issues/${issueId}/attachments`);
}
// Fetches a fresh attachment metadata record. The server re-signs
// `download_url` on every call (30 min expiry), so the click-time
// download flow uses this endpoint to avoid handing the user a stale
// signed URL cached in TanStack Query.
async getAttachment(id: string): Promise<Attachment> {
const raw = await this.fetch<unknown>(`/api/attachments/${id}`);
return parseWithFallback(raw, AttachmentResponseSchema, EMPTY_ATTACHMENT, {
endpoint: "GET /api/attachments/{id}",
});
}
async deleteAttachment(id: string): Promise<void> {
await this.fetch(`/api/attachments/${id}`, { method: "DELETE" });
}
// Fetches the raw bytes of a text-previewable attachment.
//
// The endpoint sidesteps CloudFront CORS (not configured on the CDN) and
// bypasses Content-Disposition: attachment for the `text/*` family, both
// of which would otherwise prevent the renderer from getting the body.
// The server always replies with `text/plain; charset=utf-8` for safety;
// the original MIME ships back in the `X-Original-Content-Type` header so
// the preview dispatcher can choose between markdown / html / plain code.
//
// Routes through `fetchRaw` so it inherits the standard auth headers,
// 401 → handleUnauthorized recovery, request-id logging, and ApiError
// shape. 413 / 415 are translated to typed `Preview*Error` instances so
// the modal can render specific fallbacks instead of generic failure.
async getAttachmentTextContent(
id: string,
): Promise<{ text: string; originalContentType: string }> {
let res: Response;
try {
res = await this.fetchRaw(`/api/attachments/${id}/content`);
} catch (err) {
if (err instanceof ApiError) {
if (err.status === 413) throw new PreviewTooLargeError();
if (err.status === 415) throw new PreviewUnsupportedError();
}
throw err;
}
return {
text: await res.text(),
originalContentType: res.headers.get("X-Original-Content-Type") ?? "",
};
}
// Fetches the raw bytes of an attachment through the unified download
// endpoint.
//
// This is the last-resort inline-media path for deployments where the
// server has no natively-loadable URL to offer. `GET /api/attachments/{id}`
// only upgrades `download_url` to a signed storage URL under CloudFront
// signing or presign mode; in **proxy** mode (self-hosted MinIO or any
// storage endpoint on an internal host, which the default `auto` mode
// classifies as proxy) it returns the auth-gated API path again. Clients
// that cannot ride the session cookie on a native `<img>` resource fetch —
// Desktop's file:// renderer, the mobile webview, split-origin web — get
// the bytes here and render them from an object URL instead.
//
// Routes through `fetchRaw` so it inherits the standard auth headers,
// 401 → handleUnauthorized recovery, request-id logging and ApiError shape.
// Callers must only reach for this once the metadata refresh has shown
// there is no signed URL: in the other modes the endpoint 302s to storage,
// where CORS is not configured for a JS fetch.
async getAttachmentBlob(id: string): Promise<Blob> {
const res = await this.fetchRaw(`/api/attachments/${id}/download`);
return res.blob();
}
// Projects
async listProjects(params?: { status?: string }): Promise<ListProjectsResponse> {
const search = new URLSearchParams();
if (params?.status) search.set("status", params.status);
return this.fetch(`/api/projects?${search}`);
}
async getProject(id: string): Promise<Project> {
return this.fetch(`/api/projects/${id}`);
}
async createProject(data: CreateProjectRequest): Promise<Project> {
return this.fetch("/api/projects", {
method: "POST",
body: JSON.stringify(data),
});
}
async updateProject(id: string, data: UpdateProjectRequest): Promise<Project> {
return this.fetch(`/api/projects/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
async deleteProject(id: string): Promise<void> {
await this.fetch(`/api/projects/${id}`, { method: "DELETE" });
}
// Project resources
async listProjectResources(
projectId: string,
): Promise<ListProjectResourcesResponse> {
return this.fetch(`/api/projects/${projectId}/resources`);
}
async createProjectResource(
projectId: string,
data: CreateProjectResourceRequest,
): Promise<ProjectResource> {
return this.fetch(`/api/projects/${projectId}/resources`, {
method: "POST",
body: JSON.stringify(data),
});
}
async updateProjectResource(
projectId: string,
resourceId: string,
data: UpdateProjectResourceRequest,
): Promise<ProjectResource> {
return this.fetch(`/api/projects/${projectId}/resources/${resourceId}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
async deleteProjectResource(
projectId: string,
resourceId: string,
): Promise<void> {
await this.fetch(`/api/projects/${projectId}/resources/${resourceId}`, {
method: "DELETE",
});
}
// Labels
async listLabels(resourceType: LabelResourceType = "issue"): Promise<ListLabelsResponse> {
const raw = await this.fetch<unknown>(`/api/labels?resource_type=${resourceType}`);
return parseWithFallback(raw, ListLabelsResponseSchema, EMPTY_LIST_LABELS_RESPONSE, {
endpoint: "GET /api/labels",
});
}
async getLabel(id: string): Promise<Label> {
const raw = await this.fetch<unknown>(`/api/labels/${id}`);
return parseWithFallback(raw, LabelSchema, EMPTY_LABEL, {
endpoint: "GET /api/labels/{id}",
});
}
async createLabel(data: CreateLabelRequest): Promise<Label> {
const raw = await this.fetch<unknown>(`/api/labels`, {
method: "POST",
body: JSON.stringify(data),
});
return parseWithFallback(raw, LabelSchema, EMPTY_LABEL, {
endpoint: "POST /api/labels",
});
}
async updateLabel(id: string, data: UpdateLabelRequest): Promise<Label> {
const raw = await this.fetch<unknown>(`/api/labels/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
return parseWithFallback(raw, LabelSchema, EMPTY_LABEL, {
endpoint: "PUT /api/labels/{id}",
});
}
async deleteLabel(id: string): Promise<void> {
await this.fetch(`/api/labels/${id}`, { method: "DELETE" });
}
// Custom issue properties
async listProperties(includeArchived = false): Promise<ListPropertiesResponse> {
const suffix = includeArchived ? "?include_archived=true" : "";
let raw: unknown;
try {
raw = await this.fetch<unknown>(`/api/properties${suffix}`);
} catch (error) {
// A backend predating custom properties 404s here (e.g. after a
// server-only rollback). Treat it as an empty catalog: the property
// UI sections disappear and the active-catalog reconciliation strips
// persisted property sorts/filters, so no property params ever reach
// the old server. Other errors keep normal query-error semantics.
if (error instanceof Error && "status" in error && (error as { status?: number }).status === 404) {
return EMPTY_LIST_PROPERTIES_RESPONSE;
}
throw error;
}
return parseWithFallback(raw, ListPropertiesResponseSchema, EMPTY_LIST_PROPERTIES_RESPONSE, {
endpoint: "GET /api/properties",
});
}
/**
* Quick actions catalog — one projection for every caller.
*
* The server hides nothing beyond `private` ownership; whether the caller
* may RUN an action is answered by runQuickAction, not here. There is
* deliberately no "runnable only" mode: filtering the sidebar by permission
* made two people looking at one issue see different sidebars with no
* explanation.
*
* A backend predating quick actions 404s here; treat that as an empty
* catalog so the sidebar section and settings tab simply do not render.
*/
async listQuickActions(opts?: { includeArchived?: boolean }): Promise<ListQuickActionsResponse> {
const suffix = opts?.includeArchived === true ? "?include_archived=true" : "";
let raw: unknown;
try {
raw = await this.fetch<unknown>(`/api/quick-actions${suffix}`);
} catch (error) {
if (error instanceof Error && "status" in error && (error as { status?: number }).status === 404) {
return EMPTY_LIST_QUICK_ACTIONS_RESPONSE;
}
throw error;
}
return parseWithFallback(raw, ListQuickActionsResponseSchema, EMPTY_LIST_QUICK_ACTIONS_RESPONSE, {
endpoint: "GET /api/quick-actions",
});
}
async createQuickAction(data: CreateQuickActionRequest): Promise<QuickAction> {
const raw = await this.fetch<unknown>(`/api/quick-actions`, {
method: "POST",
body: JSON.stringify(data),
});
return parseWithFallback(raw, QuickActionSchema, EMPTY_QUICK_ACTION, {
endpoint: "POST /api/quick-actions",
});
}
async updateQuickAction(id: string, data: UpdateQuickActionRequest): Promise<QuickAction> {
const raw = await this.fetch<unknown>(`/api/quick-actions/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
});
return parseWithFallback(raw, QuickActionSchema, EMPTY_QUICK_ACTION, {
endpoint: "PATCH /api/quick-actions/{id}",
});
}
async deleteQuickAction(id: string): Promise<void> {
await this.fetch<void>(`/api/quick-actions/${id}`, { method: "DELETE" });
}
/**
* Run a quick action against one issue. The response is a Comment carrying
* `trigger_outcomes` — the same shape POST /comments returns — so callers
* reuse one result handler and inherit `queued` / `coalesced` / `deferred` /
* `blocked` instead of a parallel vocabulary that would drift.
*/
async runQuickAction(issueId: string, quickActionId: string): Promise<Comment> {
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/quick-actions/${quickActionId}/run`, {
method: "POST",
});
return parseWithFallback(raw, CommentSchema, EMPTY_COMMENT, {
endpoint: "POST /api/issues/{id}/quick-actions/{quickActionId}/run",
});
}
/**
* What a quick action WOULD post, without posting it. Backs the composer
* hand-off (⌥-click and the `/` menu) so the user can edit before sending.
* Returns "" when the response cannot be read — callers must treat an empty
* string as "insert nothing" rather than clearing the composer.
*/
async renderQuickAction(issueId: string, quickActionId: string): Promise<string> {
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/quick-actions/${quickActionId}/render`, {
method: "POST",
});
const parsed = parseWithFallback(raw, QuickActionRenderSchema, { content: "" }, {
endpoint: "POST /api/issues/{id}/quick-actions/{quickActionId}/render",
});
return parsed.content;
}
async createProperty(data: CreatePropertyRequest): Promise<IssueProperty> {
const raw = await this.fetch<unknown>(`/api/properties`, {
method: "POST",
body: JSON.stringify(data),
});
return parseWithFallback(raw, IssuePropertySchema, EMPTY_ISSUE_PROPERTY, {
endpoint: "POST /api/properties",
});
}
async updateProperty(id: string, data: UpdatePropertyRequest): Promise<IssueProperty> {
const raw = await this.fetch<unknown>(`/api/properties/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
});
return parseWithFallback(raw, IssuePropertySchema, EMPTY_ISSUE_PROPERTY, {
endpoint: "PATCH /api/properties/{id}",
});
}
async setIssueProperty(issueId: string, propertyId: string, value: IssuePropertyValue): Promise<IssuePropertiesResponse> {
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/properties/${propertyId}`, {
method: "PUT",
body: JSON.stringify({ value }),
});
return parseWithFallback(raw, IssuePropertiesResponseSchema, EMPTY_ISSUE_PROPERTIES_RESPONSE, {
endpoint: "PUT /api/issues/{id}/properties/{propertyId}",
});
}
async unsetIssueProperty(issueId: string, propertyId: string): Promise<IssuePropertiesResponse> {
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/properties/${propertyId}`, {
method: "DELETE",
});
return parseWithFallback(raw, IssuePropertiesResponseSchema, EMPTY_ISSUE_PROPERTIES_RESPONSE, {
endpoint: "DELETE /api/issues/{id}/properties/{propertyId}",
});
}
async listLabelsForIssue(issueId: string): Promise<IssueLabelsResponse> {
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/labels`);
return parseWithFallback(raw, ResourceLabelsResponseSchema, EMPTY_RESOURCE_LABELS_RESPONSE, {
endpoint: "GET /api/issues/{id}/labels",
});
}
async attachLabel(issueId: string, labelId: string): Promise<IssueLabelsResponse> {
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/labels`, {
method: "POST",
body: JSON.stringify({ label_id: labelId }),
});
return parseWithFallback(raw, ResourceLabelsResponseSchema, EMPTY_RESOURCE_LABELS_RESPONSE, {
endpoint: "POST /api/issues/{id}/labels",
});
}
async detachLabel(issueId: string, labelId: string): Promise<IssueLabelsResponse> {
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/labels/${labelId}`, {
method: "DELETE",
});
return parseWithFallback(raw, ResourceLabelsResponseSchema, EMPTY_RESOURCE_LABELS_RESPONSE, {
endpoint: "DELETE /api/issues/{id}/labels/{labelId}",
});
}
async listLabelsForResource(
resourceType: "agent" | "skill",
resourceId: string,
): Promise<ResourceLabelsResponse> {
const raw = await this.fetch<unknown>(`/api/${resourceType === "agent" ? "agents" : "skills"}/${resourceId}/labels`);
return parseWithFallback(raw, ResourceLabelsResponseSchema, EMPTY_RESOURCE_LABELS_RESPONSE, {
endpoint: `GET /api/${resourceType === "agent" ? "agents" : "skills"}/{id}/labels`,
});
}
async attachLabelToResource(
resourceType: "agent" | "skill",
resourceId: string,
labelId: string,
): Promise<ResourceLabelsResponse> {
const raw = await this.fetch<unknown>(`/api/${resourceType === "agent" ? "agents" : "skills"}/${resourceId}/labels`, {
method: "POST",
body: JSON.stringify({ label_id: labelId }),
});
return parseWithFallback(raw, ResourceLabelsResponseSchema, EMPTY_RESOURCE_LABELS_RESPONSE, {
endpoint: `POST /api/${resourceType === "agent" ? "agents" : "skills"}/{id}/labels`,
});
}
async detachLabelFromResource(
resourceType: "agent" | "skill",
resourceId: string,
labelId: string,
): Promise<ResourceLabelsResponse> {
const raw = await this.fetch<unknown>(`/api/${resourceType === "agent" ? "agents" : "skills"}/${resourceId}/labels/${labelId}`, {
method: "DELETE",
});
return parseWithFallback(raw, ResourceLabelsResponseSchema, EMPTY_RESOURCE_LABELS_RESPONSE, {
endpoint: `DELETE /api/${resourceType === "agent" ? "agents" : "skills"}/{id}/labels/{labelId}`,
});
}
// Pins
async listPins(): Promise<PinnedItem[]> {
return this.fetch("/api/pins");
}
async createPin(data: CreatePinRequest): Promise<PinnedItem> {
return this.fetch("/api/pins", {
method: "POST",
body: JSON.stringify(data),
});
}
async deletePin(itemType: PinnedItemType, itemId: string): Promise<void> {
await this.fetch(`/api/pins/${itemType}/${itemId}`, { method: "DELETE" });
}
async reorderPins(data: ReorderPinsRequest): Promise<void> {
await this.fetch("/api/pins/reorder", {
method: "PUT",
body: JSON.stringify(data),
});
}
// Squads
async listSquads(): Promise<Squad[]> {
const raw = await this.fetch<unknown>(`/api/squads`);
return parseWithFallback(raw, SquadListSchema, EMPTY_SQUAD_LIST, {
endpoint: "GET /api/squads",
}) as Squad[];
}
async getSquad(id: string): Promise<Squad> {
const raw = await this.fetch<unknown>(`/api/squads/${id}`);
return parseWithFallback(raw, SquadSchema, EMPTY_SQUAD, {
endpoint: "GET /api/squads/:id",
}) as Squad;
}
async createSquad(data: { name: string; description?: string; leader_id: string; avatar_url?: string }): Promise<Squad> {
const raw = await this.fetch<unknown>("/api/squads", { method: "POST", body: JSON.stringify(data) });
return parseWithFallback(raw, SquadSchema, EMPTY_SQUAD, {
endpoint: "POST /api/squads",
}) as Squad;
}
async updateSquad(id: string, data: { name?: string; description?: string; instructions?: string; leader_id?: string; avatar_url?: string }): Promise<Squad> {
const raw = await this.fetch<unknown>(`/api/squads/${id}`, { method: "PUT", body: JSON.stringify(data) });
return parseWithFallback(raw, SquadSchema, EMPTY_SQUAD, {
endpoint: "PUT /api/squads/:id",
}) as Squad;
}
async deleteSquad(id: string): Promise<void> {
await this.fetch(`/api/squads/${id}`, { method: "DELETE" });
}
async listSquadMembers(squadId: string): Promise<SquadMember[]> {
return this.fetch(`/api/squads/${squadId}/members`);
}
async addSquadMember(squadId: string, data: { member_type: string; member_id: string; role?: string }): Promise<SquadMember> {
return this.fetch(`/api/squads/${squadId}/members`, { method: "POST", body: JSON.stringify(data) });
}
async removeSquadMember(squadId: string, data: { member_type: string; member_id: string }): Promise<void> {
await this.fetch(`/api/squads/${squadId}/members`, { method: "DELETE", body: JSON.stringify(data) });
}
async updateSquadMemberRole(squadId: string, data: { member_type: string; member_id: string; role: string }): Promise<SquadMember> {
return this.fetch(`/api/squads/${squadId}/members/role`, { method: "PATCH", body: JSON.stringify(data) });
}
// Per-squad members status snapshot: one row per member with derived
// working/idle/offline/unstable plus the issues each agent is currently
// running. Parsed with a lenient schema so a new server-side status
// value or extra field can't white-screen the Squad page (#2143).
async getSquadMemberStatus(squadId: string): Promise<SquadMemberStatusListResponse> {
const raw = await this.fetch<unknown>(`/api/squads/${squadId}/members/status`);
return parseWithFallback(raw, SquadMemberStatusListResponseSchema, EMPTY_SQUAD_MEMBER_STATUS_LIST, {
endpoint: "GET /api/squads/:id/members/status",
}) as SquadMemberStatusListResponse;
}
// Autopilots
async listAutopilots(params?: { status?: string }): Promise<ListAutopilotsResponse> {
const search = new URLSearchParams();
if (params?.status) search.set("status", params.status);
const raw = await this.fetch<unknown>(`/api/autopilots?${search}`);
return parseWithFallback(
raw,
ListAutopilotsResponseSchema,
EMPTY_LIST_AUTOPILOTS_RESPONSE as ListAutopilotsResponse,
{ endpoint: "GET /api/autopilots" },
);
}
async getAutopilot(id: string): Promise<GetAutopilotResponse> {
return this.fetch(`/api/autopilots/${id}`);
}
async createAutopilot(data: CreateAutopilotRequest): Promise<Autopilot> {
return this.fetch("/api/autopilots", {
method: "POST",
body: JSON.stringify(data),
});
}
async updateAutopilot(id: string, data: UpdateAutopilotRequest): Promise<Autopilot> {
return this.fetch(`/api/autopilots/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
async deleteAutopilot(id: string): Promise<void> {
await this.fetch(`/api/autopilots/${id}`, { method: "DELETE" });
}
// Grant a workspace member explicit write access to the autopilot. Both
// grant and revoke return the full updated collaborator list so callers can
// refresh without a second round-trip.
async grantAutopilotAccess(id: string, userId: string): Promise<AutopilotCollaboratorsResponse> {
return this.fetch(`/api/autopilots/${id}/collaborators`, {
method: "POST",
body: JSON.stringify({ user_id: userId }),
});
}
async revokeAutopilotAccess(id: string, userId: string): Promise<AutopilotCollaboratorsResponse> {
return this.fetch(`/api/autopilots/${id}/collaborators/${userId}`, {
method: "DELETE",
});
}
async triggerAutopilot(id: string): Promise<AutopilotRun> {
// Manual "run now" returns 200 even when admission blocks the run (status
// skipped/failed). The UI branches on status/reason_code to avoid a
// false-success toast (MUL-4525), so parse defensively rather than casting.
const raw = await this.fetch<unknown>(`/api/autopilots/${id}/trigger`, { method: "POST" });
return parseWithFallback(raw, AutopilotRunSchema, FALLBACK_AUTOPILOT_RUN, {
endpoint: "POST /api/autopilots/:id/trigger",
});
}
async listAutopilotRuns(id: string, params?: { limit?: number; offset?: number }): Promise<ListAutopilotRunsResponse> {
const search = new URLSearchParams();
if (params?.limit) search.set("limit", params.limit.toString());
if (params?.offset) search.set("offset", params.offset.toString());
return this.fetch(`/api/autopilots/${id}/runs?${search}`);
}
// Returns a single run including its full trigger_payload. List responses
// omit trigger_payload to keep them small (a webhook envelope can be
// up to 256 KiB × limit rows), so the detail view fetches via this route.
async getAutopilotRun(autopilotId: string, runId: string): Promise<AutopilotRun> {
return this.fetch(`/api/autopilots/${autopilotId}/runs/${runId}`);
}
async createAutopilotTrigger(autopilotId: string, data: CreateAutopilotTriggerRequest): Promise<AutopilotTrigger> {
return this.fetch(`/api/autopilots/${autopilotId}/triggers`, {
method: "POST",
body: JSON.stringify(data),
});
}
async updateAutopilotTrigger(autopilotId: string, triggerId: string, data: UpdateAutopilotTriggerRequest): Promise<AutopilotTrigger> {
return this.fetch(`/api/autopilots/${autopilotId}/triggers/${triggerId}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
async deleteAutopilotTrigger(autopilotId: string, triggerId: string): Promise<void> {
await this.fetch(`/api/autopilots/${autopilotId}/triggers/${triggerId}`, { method: "DELETE" });
}
async cronPreview(params: { expr: string; tz: string }): Promise<CronPreviewResponse> {
const search = new URLSearchParams();
search.set("expr", params.expr);
search.set("tz", params.tz);
const raw = await this.fetch<unknown>(`/api/autopilots/cron-preview?${search}`);
return parseWithFallback(
raw,
CronPreviewResponseSchema,
UNREADABLE_CRON_PREVIEW_RESPONSE,
{ endpoint: "GET /api/autopilots/cron-preview" },
);
}
async rotateAutopilotTriggerWebhookToken(
autopilotId: string,
triggerId: string,
): Promise<AutopilotTrigger> {
return this.fetch(
`/api/autopilots/${autopilotId}/triggers/${triggerId}/rotate-webhook-token`,
{ method: "POST" },
);
}
// Webhook deliveries — list is slim (no raw_body / selected_headers /
// response_body); detail returns the full row. Both responses are parsed
// through a lenient schema so an unknown server-side `status` /
// `signature_status` value degrades to a generic row instead of dropping
// the whole list.
async listAutopilotDeliveries(
autopilotId: string,
params?: { limit?: number; offset?: number },
): Promise<ListWebhookDeliveriesResponse> {
const search = new URLSearchParams();
if (params?.limit) search.set("limit", params.limit.toString());
if (params?.offset) search.set("offset", params.offset.toString());
const raw = await this.fetch<unknown>(
`/api/autopilots/${autopilotId}/deliveries?${search}`,
);
return parseWithFallback(
raw,
ListWebhookDeliveriesResponseSchema,
EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE,
{ endpoint: "GET /api/autopilots/:id/deliveries" },
);
}
async getAutopilotDelivery(
autopilotId: string,
deliveryId: string,
): Promise<WebhookDelivery> {
const raw = await this.fetch<unknown>(
`/api/autopilots/${autopilotId}/deliveries/${deliveryId}`,
);
return parseWithFallback(
raw,
WebhookDeliveryResponseSchema,
{ ...EMPTY_WEBHOOK_DELIVERY, id: deliveryId, autopilot_id: autopilotId },
{ endpoint: "GET /api/autopilots/:id/deliveries/:deliveryId" },
);
}
// Replay creates a NEW delivery row referencing the original via
// `replayed_from_delivery_id`. Server rejects replays of
// signature-invalid / rejected deliveries with 400 — the UI keeps the
// button disabled for those rows, but the server is the source of truth.
async replayAutopilotDelivery(
autopilotId: string,
deliveryId: string,
): Promise<WebhookDelivery> {
const raw = await this.fetch<unknown>(
`/api/autopilots/${autopilotId}/deliveries/${deliveryId}/replay`,
{ method: "POST" },
);
return parseWithFallback(
raw,
WebhookDeliveryResponseSchema,
{ ...EMPTY_WEBHOOK_DELIVERY, autopilot_id: autopilotId },
{ endpoint: "POST /api/autopilots/:id/deliveries/:deliveryId/replay" },
);
}
// GitHub integration
async getGitHubConnectURL(
workspaceId: string,
returnTo?: "github" | "repositories",
): Promise<GitHubConnectResponse> {
const search = new URLSearchParams();
if (returnTo) search.set("return_to", returnTo);
const suffix = search.size > 0 ? `?${search.toString()}` : "";
const raw = await this.fetch<unknown>(
`/api/workspaces/${workspaceId}/github/connect${suffix}`,
);
return parseWithFallback(
raw,
GitHubConnectResponseSchema,
EMPTY_GITHUB_CONNECT_RESPONSE,
{ endpoint: "GET /api/workspaces/:id/github/connect" },
);
}
async listGitHubInstallations(workspaceId: string): Promise<ListGitHubInstallationsResponse> {
const raw = await this.fetch<unknown>(
`/api/workspaces/${workspaceId}/github/installations`,
);
return parseWithFallback(
raw,
ListGitHubInstallationsResponseSchema,
EMPTY_LIST_GITHUB_INSTALLATIONS_RESPONSE,
{ endpoint: "GET /api/workspaces/:id/github/installations" },
);
}
async listGitHubInstallationRepositories(
workspaceId: string,
installationId: string,
params: { page?: number; per_page?: number } = {},
): Promise<ListGitHubRepositoriesResponse> {
const search = new URLSearchParams();
if (params.page !== undefined) search.set("page", String(params.page));
if (params.per_page !== undefined) search.set("per_page", String(params.per_page));
const suffix = search.size > 0 ? `?${search.toString()}` : "";
const raw = await this.fetch<unknown>(
`/api/workspaces/${workspaceId}/github/installations/${installationId}/repositories${suffix}`,
);
return parseWithFallback(
raw,
ListGitHubRepositoriesResponseSchema,
EMPTY_LIST_GITHUB_REPOSITORIES_RESPONSE,
{ endpoint: "GET /api/workspaces/:id/github/installations/:installationId/repositories" },
);
}
async deleteGitHubInstallation(workspaceId: string, installationId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}/github/installations/${installationId}`, {
method: "DELETE",
});
}
async listIssuePullRequests(issueId: string): Promise<{ pull_requests: GitHubPullRequest[] }> {
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/pull-requests`);
return parseWithFallback(
raw,
IssuePullRequestsResponseSchema,
EMPTY_ISSUE_PULL_REQUESTS_RESPONSE,
{ endpoint: "GET /api/issues/:id/pull-requests" },
);
}
// VCS integration (Forgejo / Gitea / GitLab)
async listVCSConnections(workspaceId: string): Promise<ListVCSConnectionsResponse> {
return this.fetch(`/api/workspaces/${workspaceId}/vcs/connections`);
}
async connectVCS(
workspaceId: string,
body: ConnectVCSRequest,
): Promise<ConnectVCSResponse> {
return this.fetch(`/api/workspaces/${workspaceId}/vcs/connections`, {
method: "POST",
body: JSON.stringify(body),
});
}
async deleteVCSConnection(workspaceId: string, connectionId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}/vcs/connections/${connectionId}`, {
method: "DELETE",
});
}
async rotateVCSWebhook(
workspaceId: string,
connectionId: string,
): Promise<ConnectVCSResponse> {
return this.fetch(
`/api/workspaces/${workspaceId}/vcs/connections/${connectionId}/rotate-webhook`,
{ method: "POST" },
);
}
// Lark integration
async listLarkInstallations(workspaceId: string): Promise<ListLarkInstallationsResponse> {
return this.fetch(`/api/workspaces/${workspaceId}/lark/installations`);
}
async beginLarkInstall(
workspaceId: string,
agentId: string,
region: "feishu" | "lark",
): Promise<BeginLarkInstallResponse> {
// The user picks the cloud explicitly in the UI ("Bind to Feishu"
// vs "Bind to Lark"), and the backend POSTs the device-flow `begin`
// against the corresponding accounts host (accounts.feishu.cn vs
// accounts.larksuite.com) so the QR renders against the right
// cloud up front. Empty / omitted region still resolves to Feishu
// server-side (RegionOrDefault) — we surface region as a required
// arg here so every call site is forced to make a deliberate
// choice rather than silently defaulting to mainland.
const search = new URLSearchParams({ agent_id: agentId, region });
return this.fetch(`/api/workspaces/${workspaceId}/lark/install/begin?${search.toString()}`, {
method: "POST",
});
}
async getLarkInstallStatus(workspaceId: string, sessionId: string): Promise<LarkInstallStatusResponse> {
return this.fetch(`/api/workspaces/${workspaceId}/lark/install/${sessionId}/status`);
}
async deleteLarkInstallation(workspaceId: string, installationId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}/lark/installations/${installationId}`, {
method: "DELETE",
});
}
async redeemLarkBindingToken(token: string): Promise<RedeemLarkBindingTokenResponse> {
return this.fetch(`/api/lark/binding/redeem`, {
method: "POST",
body: JSON.stringify({ token }),
});
}
// Composio integration (MUL-3720). All routes are user-scoped (a connection
// belongs to a user, not a workspace), so none take a workspaceId.
/** The project's connectable Composio toolkits (those with an enabled auth
* config). Since MUL-4009 the backend filters out non-connectable toolkits,
* so every entry has `connectable: true`. A resolver/upstream failure is a
* 502 rather than an empty list. */
async listComposioToolkits(): Promise<ComposioToolkit[]> {
return this.fetch(`/api/integrations/composio/toolkits`);
}
/** The caller's active Composio connections. */
async listComposioConnections(): Promise<ComposioConnection[]> {
return this.fetch(`/api/integrations/composio/connections`);
}
/** Starts a hosted Composio connect flow for a toolkit and returns the
* redirect URL the browser should be sent to. */
async beginComposioConnect(toolkitSlug: string): Promise<ComposioConnectInitResponse> {
return this.fetch(`/api/integrations/composio/connect/init`, {
method: "POST",
body: JSON.stringify({ toolkit_slug: toolkitSlug }),
});
}
/** Disconnects a Composio connection the caller owns. */
async deleteComposioConnection(connectionId: string): Promise<void> {
await this.fetch(`/api/integrations/composio/connections/${connectionId}`, {
method: "DELETE",
});
}
// Slack integration (MUL-3666)
async listSlackInstallations(workspaceId: string): Promise<ListSlackInstallationsResponse> {
return this.fetch(`/api/workspaces/${workspaceId}/slack/installations`);
}
// registerSlackBYO performs a bring-your-own-app install: the admin pastes the
// bot token (xoxb-) + app-level token (xapp-) of the Slack app they created,
// and the backend validates + persists it, returning the new installation.
async registerSlackBYO(
workspaceId: string,
agentId: string,
body: RegisterSlackBYORequest,
): Promise<SlackInstallation> {
const search = new URLSearchParams({ agent_id: agentId });
return this.fetch(`/api/workspaces/${workspaceId}/slack/install/byo?${search.toString()}`, {
method: "POST",
body: JSON.stringify(body),
});
}
async deleteSlackInstallation(workspaceId: string, installationId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}/slack/installations/${installationId}`, {
method: "DELETE",
});
}
async redeemSlackBindingToken(token: string): Promise<RedeemSlackBindingTokenResponse> {
return this.fetch(`/api/slack/binding/redeem`, {
method: "POST",
body: JSON.stringify({ token }),
});
}
}