Files
multica/packages/core/api/client.ts

5618 lines
210 KiB
TypeScript
Raw Permalink 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 {
AdminAuditPageSchema,
AdminMeSchema,
AdminOverviewSchema,
AdminUserDetailSchema,
AdminUserPageSchema,
AdminWorkspaceDetailSchema,
AdminWorkspacePageSchema,
AdminMarketPageSchema,
EMPTY_ADMIN_ME,
EMPTY_ADMIN_OVERVIEW,
EMPTY_ADMIN_USER_DETAIL,
EMPTY_ADMIN_WORKSPACE_DETAIL,
EMPTY_PLATFORM_ADMIN,
PlatformAdminListSchema,
PlatformAdminSchema,
emptyAdminPage,
type AdminActionRequest,
type AdminCreateWorkspaceRequest,
type AdminAuditEntry,
type AdminAuditListParams,
type AdminMe,
type AdminOverview,
type AdminPage,
type AdminUser,
type AdminUserDetail,
type AdminUserListParams,
type AdminWorkspace,
type AdminWorkspaceDetail,
type AdminWorkspaceListParams,
type AdminMarketListParams,
type AdminMarketListing,
type PlatformAdmin,
type SetPlatformAdminRequest,
} from "../admin/schemas";
import type { ZodType } from "zod";
import type { IssueWakeup, IssueWakeupInput, IssueWakeupSummaryRow, PausedWakeup, SystemWakeup, WakeupRun, WorkspaceSystemWakeup } from "../types/issue-wakeup";
import type { WorkspaceWakeupPage, WorkspaceWakeupFilters } from "../types/issue-wakeup";
import { WorkspaceWakeupPageSchema, IssueWakeupSchema, IssueWakeupSummaryRowSchema, PausedWakeupSchema, SystemWakeupSchema, WakeupRunSchema, WorkspaceSystemWakeupSchema } from "./schemas";
import type { MarketBrowseQuery, MarketListing, MarketListingPage, MarketOperationResult, MarketPreflight, MarketPreflightInput } from "../types/market";
import { EMPTY_MARKET_LISTING_PAGE, MarketListingPageSchema, MarketListingSchema, MarketOperationSchema, MarketPreflightSchema, UNREADABLE_MARKET_OPERATION, UNREADABLE_PREFLIGHT } from "./schemas";
import type { InboxFilters } from "../inbox/filter-store";
import type { ArchivedInboxPage, ArchivedInboxFacets } from "../types/inbox";
import { configStore } from "../config";
import type {
Issue,
IssuePriority,
CreateIssueRequest,
MoveIssueRequest,
UpdateIssueRequest,
IssueDuplicates,
GroupedIssuesResponse,
ListIssuesResponse,
SearchIssuesResponse,
SearchProjectsResponse,
SearchIndexManifest,
SearchIndexSnapshotPage,
SearchIndexChanges,
UpdateMeRequest,
CreateMemberRequest,
UpdateMemberRequest,
ListIssuesParams,
ListGroupedIssuesParams,
IssueTableFacetsRequest,
IssueTableFacetsResponse,
IssueTableGroupsRequest,
IssueTableGroupsResponse,
IssueTableRowsRequest,
IssueTableRowsResponse,
Agent,
MikaBootstrapResponse,
CreateAgentRequest,
AgentBuilderRuntimeSwitch,
AgentBuilderSession,
AgentBuilderSessionSummary,
StoredAgentDraft,
UpdateAgentRequest,
AgentEnvResponse,
UpdateAgentEnvRequest,
AgentTask,
AgentActivityBucket,
AgentRunCount,
WorkspaceWorkingAgent,
WorkspaceWorkingAgentMineRelation,
WorkspaceWorkingAgentType,
AgentRuntime,
RuntimeProfile,
CreateRuntimeProfileRequest,
UpdateRuntimeProfileRequest,
InboxItem,
InboxWorkspaceUnread,
IssueSubscriber,
Comment,
CommentTriggerPreview,
IssueTriggerPreview,
IssueTriggerPreviewParams,
Reaction,
IssueReaction,
Workspace,
WorkspaceRepo,
WorkspaceMcpServer,
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,
PrioritizeQueuedChatTaskResponse,
PendingChatTasksResponse,
HasPendingChatTasksResponse,
SendChatMessageResponse,
StartMikaOnboardingResponse,
CancelTaskResponse,
Project,
CreateProjectRequest,
UpdateProjectRequest,
ListProjectsResponse,
ProjectResource,
CreateProjectResourceRequest,
UpdateProjectResourceRequest,
ListProjectResourcesResponse,
Label,
IssueProperty,
IssuePropertyValue,
CreatePropertyRequest,
QuickAction,
CreateQuickActionRequest,
UpdateQuickActionRequest,
ListQuickActionsResponse,
UpdatePropertyRequest,
ListPropertiesResponse,
IssuePropertiesResponse,
CreateLabelRequest,
UpdateLabelRequest,
ListLabelsResponse,
ListIssueStatusesResponse,
IssueStatusCategory,
IssueStatusEntry,
CreateIssueStatusRequest,
UpdateIssueStatusRequest,
IssueWorkflow,
IssueWorkflowWriteRequest,
IssueWorkflowDryRunResponse,
ListIssueWorkflowsResponse,
SetProjectWorkflowRequest,
SetWorkflowHandoffRequest,
WorkflowHandoffPreview,
IssueLabelsResponse,
LabelResourceType,
ResourceLabelsResponse,
PinnedItem,
CreatePinRequest,
PinnedItemType,
ReorderPinsRequest,
Invitation,
ShareLink,
ShareLinkInfo,
Autopilot,
AutopilotTrigger,
AutopilotRun,
AutopilotQuotaUsage,
CreateAutopilotRequest,
UpdateAutopilotRequest,
CreateAutopilotTriggerRequest,
UpdateAutopilotTriggerRequest,
ListAutopilotsResponse,
CronPreviewResponse,
GetAutopilotResponse,
AutopilotCollaboratorsResponse,
ListAutopilotRunsResponse,
ListWebhookDeliveriesResponse,
WebhookDelivery,
NotificationPreferenceResponse,
NotificationPreferences,
PluginHookResult,
PluginInstallation,
PluginInstallationListResponse,
PluginPackage,
PluginPackageListResponse,
PluginSurfaceLaunch,
PluginInvocation,
PluginMCPTool,
PluginPreview,
PluginTokenIssue,
PluginPreviewRequest,
PluginInstallRequest,
PluginConfigRequest,
IssuePullRequestsResponse,
ListGitHubInstallationsResponse,
ListGitHubRepositoriesResponse,
GitHubConnectResponse,
ListVCSConnectionsResponse,
ConnectVCSRequest,
ConnectVCSResponse,
ListLarkInstallationsResponse,
BeginLarkInstallResponse,
LarkInstallStatusResponse,
RedeemLarkBindingTokenResponse,
ComposioToolkit,
ComposioConnection,
ComposioConnectInitResponse,
SlackInstallation,
ListSlackInstallationsResponse,
RegisterSlackBYORequest,
RedeemSlackBindingTokenResponse,
DingTalkInstallation,
ListDingTalkInstallationsResponse,
ListDingTalkGroupsResponse,
ListDingTalkGroupsParams,
RegisterDingTalkBYORequest,
RedeemDingTalkBindingTokenResponse,
WecomInstallation,
ListWecomInstallationsResponse,
RegisterWecomBYORequest,
RedeemWecomBindingTokenResponse,
TelegramInstallation,
ListTelegramInstallationsResponse,
RegisterTelegramRequest,
RedeemTelegramBindingTokenResponse,
Squad,
SquadMember,
SquadMemberStatusListResponse,
BillingBalance,
BillingTransactionsPage,
BillingBatchesPage,
BillingTopupsPage,
BillingPriceTier,
CreateBillingCheckoutSessionRequest,
CreateBillingCheckoutSessionResponse,
BillingCheckoutSessionStatus,
CreateBillingPortalSessionResponse,
WorkspaceSubscriptionSummary,
IssueLimitUsage,
WorkspaceSubscriptionPrices,
CreateWorkspaceSubscriptionCheckoutRequest,
CreateWorkspaceSubscriptionCheckoutResponse,
WorkspaceSubscriptionSeatReconcileResult,
PreviewWorkspaceSeatPurchaseRequest,
WorkspaceSeatPurchasePreview,
PurchaseWorkspaceSeatsRequest,
PurchaseWorkspaceSeatsResponse,
CreateWorkspaceSubscriptionPortalResponse,
SourceContextPreview,
CreateCommentSubIssueManualRequest,
CreateCommentSubIssueAgentRequest,
CreateCommentSubIssueRequest,
} from "../types";
import type { OnboardingCompletionPath } from "../onboarding/types";
import type {
CreateFeedbackResponse,
FeedbackContext,
FeedbackKind,
} from "../feedback/types";
import type {
CloudRuntimeNode,
CreateCloudRuntimeNodeRequest,
ListCloudRuntimeNodesParams,
} from "../runtimes/cloud-runtime";
import { type Logger, noopLogger } from "../logger";
import { createRequestId, createSafeId } from "../utils";
import { getCurrentSlug } from "../platform/workspace-storage";
import { parseWithFallback } from "./schema";
import {
RuntimeProfileSchema,
RuntimeProfileListSchema,
AgentTaskListSchema,
AgentTaskPageSchema,
AgentActivityBucketListSchema,
AttachmentResponseSchema,
CancelTaskResponseSchema,
ChatDraftRestoresResponseSchema,
ChatMessageListSchema,
ChatMessagesPageSchema,
ChatPendingTaskSchema,
ChatSessionListSchema,
ChatSessionSchema,
PrioritizeQueuedChatTaskResponseSchema,
SendChatMessageResponseSchema,
StartMikaOnboardingResponseSchema,
ChildIssuesResponseSchema,
IssueDuplicatesResponseSchema,
ChildIssueProgressResponseSchema,
CommentsListSchema,
CommentTriggerPreviewSchema,
IssueTriggerPreviewSchema,
CloudRuntimeNodeListSchema,
CloudRuntimeNodeSchema,
AgentBuilderRuntimeSwitchSchema,
AgentBuilderSessionSchema,
AgentBuilderSessionListSchema,
EMPTY_AGENT_BUILDER_SESSION_LIST,
agentBuilderRuntimeSwitchFallback,
DashboardAgentRunTimeListSchema,
DashboardRunTimeDailyListSchema,
DashboardFailureDailyListSchema,
DashboardFailureByAgentListSchema,
DashboardUsageByAgentListSchema,
DashboardUsageDailyListSchema,
EMPTY_APP_CONFIG,
EMPTY_ATTACHMENT,
EMPTY_CHAT_MESSAGE_LIST,
EMPTY_CHAT_PENDING_TASK,
EMPTY_CHAT_SESSION,
EMPTY_CHAT_SESSION_LIST,
EMPTY_PRIORITIZE_QUEUED_CHAT_TASK_RESPONSE,
EMPTY_CLOUD_RUNTIME_NODE,
EMPTY_CLOUD_RUNTIME_NODE_LIST,
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,
type RefreshSessionResponse,
RefreshSessionResponseSchema,
EMPTY_REFRESH_SESSION_RESPONSE,
GroupedIssuesResponseSchema,
IssueTableFacetsResponseSchema,
IssueTableGroupsResponseSchema,
IssueTableRowsResponseSchema,
ListAutopilotsResponseSchema,
EMPTY_LIST_AUTOPILOTS_RESPONSE,
AutopilotRunSchema,
AutopilotQuotaUsageSchema,
FALLBACK_AUTOPILOT_RUN,
CronPreviewResponseSchema,
UNREADABLE_CRON_PREVIEW_RESPONSE,
ListIssuesResponseSchema,
CreateIssueResponseSchema,
IssueSchema,
AgentTaskSchema,
SourceContextPreviewSchema,
CommentSubIssueTaskResponseSchema,
ListWebhookDeliveriesResponseSchema,
RuntimeHourlyActivityListSchema,
RuntimeUsageByAgentListSchema,
RuntimeUsageByHourListSchema,
RuntimeUsageListSchema,
SearchIssuesResponseSchema,
SearchProjectsResponseSchema,
SearchIndexManifestSchema,
SearchIndexSnapshotPageSchema,
SearchIndexChangesSchema,
SquadSchema,
SquadListSchema,
SquadMemberStatusListResponseSchema,
SubscribersListSchema,
TaskMessageListSchema,
TimelineEntriesSchema,
UserSchema,
WebhookDeliveryResponseSchema,
BillingBalanceSchema,
BillingTransactionsPageSchema,
BillingBatchesPageSchema,
BillingTopupsPageSchema,
BillingPriceTierListSchema,
CreateBillingCheckoutSessionResponseSchema,
BillingCheckoutSessionStatusSchema,
CreateBillingPortalSessionResponseSchema,
WorkspaceSubscriptionSummarySchema,
IssueLimitUsageSchema,
WorkspaceSubscriptionPricesSchema,
CreateWorkspaceSubscriptionCheckoutResponseSchema,
WorkspaceSubscriptionSeatReconcileResultSchema,
WorkspaceSeatPurchasePreviewSchema,
PurchaseWorkspaceSeatsResponseSchema,
CreateWorkspaceSubscriptionPortalResponseSchema,
DingTalkInstallationSchema,
ListDingTalkInstallationsResponseSchema,
ListDingTalkGroupsResponseSchema,
RedeemDingTalkBindingTokenResponseSchema,
EMPTY_DINGTALK_INSTALLATION,
EMPTY_LIST_DINGTALK_INSTALLATIONS_RESPONSE,
EMPTY_LIST_DINGTALK_GROUPS_RESPONSE,
EMPTY_REDEEM_DINGTALK_BINDING_TOKEN_RESPONSE,
WecomInstallationSchema,
ListWecomInstallationsResponseSchema,
RedeemWecomBindingTokenResponseSchema,
EMPTY_WECOM_INSTALLATION,
EMPTY_LIST_WECOM_INSTALLATIONS_RESPONSE,
EMPTY_REDEEM_WECOM_BINDING_TOKEN_RESPONSE,
TelegramInstallationSchema,
ListTelegramInstallationsResponseSchema,
RedeemTelegramBindingTokenResponseSchema,
EMPTY_TELEGRAM_INSTALLATION,
EMPTY_LIST_TELEGRAM_INSTALLATIONS_RESPONSE,
EMPTY_REDEEM_TELEGRAM_BINDING_TOKEN_RESPONSE,
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,
ArchivedInboxPageSchema,
ArchivedInboxFacetsSchema,
EMPTY_INBOX_ITEMS,
NotificationPreferenceResponseSchema,
EMPTY_NOTIFICATION_PREFERENCE_RESPONSE,
LabelSchema,
ListLabelsResponseSchema,
ListIssueStatusesResponseSchema,
IssueStatusEntrySchema,
ListIssueWorkflowsResponseSchema,
IssueWorkflowSchema,
IssueWorkflowDryRunResponseSchema,
EMPTY_LIST_ISSUE_WORKFLOWS_RESPONSE,
EMPTY_ISSUE_WORKFLOW,
EMPTY_ISSUE_WORKFLOW_DRY_RUN,
WorkflowHandoffPreviewSchema,
EMPTY_WORKFLOW_HANDOFF_PREVIEW,
IssueWorkflowBriefPreviewSchema,
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_LIST_ISSUE_STATUSES_RESPONSE,
EMPTY_ISSUE_STATUS_ENTRY,
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,
SkillSchema,
EMPTY_SKILL,
SkillSummaryListSchema,
EMPTY_SKILL_SUMMARY_LIST,
SkillImportResultSchema,
EMPTY_SKILL_IMPORT_RESULT,
IssueViewSchema,
IssueViewListSchema,
IssueViewPreferenceSchema,
EMPTY_ISSUE_VIEW_PREFERENCE,
EMPTY_PLUGIN_INSTALLATION,
EMPTY_WORKSPACE_MCP_SERVER,
EMPTY_PLUGIN_INSTALLATION_LIST,
EMPTY_PLUGIN_PREVIEW,
EMPTY_PLUGIN_PACKAGE,
EMPTY_PLUGIN_PACKAGE_LIST,
EMPTY_PLUGIN_SURFACE_LAUNCH,
PluginHookResultSchema,
PluginInstallationListResponseSchema,
PluginPackageListResponseSchema,
PluginPackageSchema,
PluginSurfaceLaunchSchema,
PluginInvocationListSchema,
PluginMCPToolListSchema,
PluginTokenIssueSchema,
PluginInstallationSchema,
PluginPreviewSchema,
WorkspaceMcpServerListSchema,
WorkspaceMcpServerSchema,
ShareLinkSchema,
ShareLinkListResponseSchema,
ShareLinkInfoSchema,
JoinShareLinkResponseSchema,
EMPTY_SHARE_LINK,
EMPTY_SHARE_LINK_INFO,
EMPTY_JOIN_SHARE_LINK_RESPONSE,
type IssueView,
type IssueViewPreference,
type CreateIssueViewRequest,
} 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;
/**
* Reads the bearer token from shared storage at request time (token mode
* only; omit in cookie mode).
*
* Without it the token is per-instance, and Desktop runs one ApiClient per
* window over one shared localStorage: a session renewed in window A would
* leave every other window still sending the token it happened to be
* holding, until that one expired and took the whole session down with it
* (MUL-7436). Reading through means there is one current credential, not one
* per window.
*/
getToken?: () => string | null;
}
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;
}
function parseSearchIndexResponse<T>(raw: unknown, schema: ZodType, endpoint: string): T {
const parsed = parseWithFallback<T | null>(raw, schema, null, { endpoint });
if (parsed === null) throw new Error(`Malformed response from ${endpoint}`);
return parsed;
}
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;
}
}
function assertAgentConversationStartersWriteSupported(data: {
conversation_starters?: unknown;
}): void {
if (
Object.prototype.hasOwnProperty.call(data, "conversation_starters") &&
!configStore.getState().agentConversationStartersSupported
) {
throw new Error(
"This server version does not support agent conversation starters. Update the server before saving them.",
);
}
}
function requestedIssueCreateProperties(
data: CreateIssueRequest,
): NonNullable<CreateIssueRequest["properties"]> | undefined {
const properties = data.properties;
return properties && Object.keys(properties).length > 0 ? properties : undefined;
}
function assertIssueCreatePropertiesSnapshot(
requested: NonNullable<CreateIssueRequest["properties"]> | undefined,
issue: Issue,
): void {
if (!requested) return;
for (const propertyId of Object.keys(requested)) {
// The server may canonicalize a valid request (trim a URL, order and
// de-duplicate a multi-select, normalize an actor UUID). Presence is the
// integrity signal here; IssueSchema has already validated the value type.
if (!Object.prototype.hasOwnProperty.call(issue.properties, propertyId)) {
throw new Error(
`Issue ${issue.identifier || issue.id} was created, but the server did not confirm its custom properties. Review the issue before retrying.`,
);
}
}
}
// errorCode extracts the stable `code` a handler attaches to a failure
// (writeErrorCode), so a caller can render its own localized sentence instead
// of toasting the server's English one. Returns undefined for a non-ApiError,
// or a server that did not send one — the caller then falls back to
// err.message, which is what every endpoint that has not adopted this yet
// produces.
export function errorCode(err: unknown): string | undefined {
if (err instanceof ApiError && err.body && typeof err.body === "object") {
const code = (err.body as { code?: unknown }).code;
if (typeof code === "string" && code.length > 0) return code;
}
return undefined;
}
// 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;
}
// clientErrorMessage returns the server's message only when it is a CLIENT
// error (4xx). Handlers write those for the user — "autopilot is not active",
// "Idempotency-Key is too long" — so they are worth rendering. A 5xx message is
// internal detail (Go error chains, pgx table/constraint names, internal ids)
// that must never reach a toast (MUL-6472), and a non-ApiError is a transport
// failure whose message ("Failed to fetch") says nothing a user can act on.
// Both return undefined so the caller falls back to its own localized sentence.
export function clientErrorMessage(err: unknown): string | undefined {
if (err instanceof ApiError && err.status >= 400 && err.status < 500) {
return err.message || undefined;
}
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";
}
}
function remapSkillImportError(err: unknown): unknown {
if (!(err instanceof ApiError) || !err.body || typeof err.body !== "object") {
return err;
}
const body = err.body as { reason?: unknown; error?: unknown };
const reason = typeof body.reason === "string" && body.reason ? body.reason : "";
const error = typeof body.error === "string" && body.error ? body.error : "";
const message = reason || error;
if (!message || message === err.message) return err;
return new ApiError(message, err.status, err.statusText, err.body);
}
function skillFromImportResult(raw: unknown, endpoint: string): Skill {
const result = parseWithFallback(
raw,
SkillImportResultSchema,
EMPTY_SKILL_IMPORT_RESULT,
{ endpoint },
);
if (
(result.status === "created" || result.status === "updated") &&
result.skill
) {
const skill = parseWithFallback(result.skill, SkillSchema, EMPTY_SKILL, {
endpoint,
});
if (skill.id) return skill;
}
throw new Error(result.reason || "Import failed");
}
/**
* 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";
/**
* Body shared by both unsubscribe endpoints: an omitted target means "the
* caller", which the server resolves from the request actor.
*/
function subscriberTarget(
userId?: string,
userType?: string,
): Record<string, string> {
const body: Record<string, string> = {};
if (userId) body.user_id = userId;
if (userType) body.user_type = userType;
return body;
}
/**
* Per-call override for the workspace a request targets.
*
* `authHeaders()` normally stamps `X-Workspace-Slug` from the global
* current-workspace singleton, and the server resolves the workspace from that
* header BEFORE any `workspace_id` query param. Anything that acts on a
* workspace the user is not currently "in" — notably the create-workspace flow,
* which provisions a workspace it has not navigated to yet — must say so
* explicitly, or a concurrent writer of that singleton silently redirects the
* write to the wrong workspace.
*/
function workspaceHeader(
slug?: string,
): Record<string, string> | undefined {
return slug ? { "X-Workspace-Slug": slug } : undefined;
}
function dingTalkGroupSearch(params: ListDingTalkGroupsParams): string {
const search = new URLSearchParams();
if (params.activity) search.set("activity", params.activity);
if (params.installationId) search.set("installation_id", params.installationId);
if (params.offset !== undefined) search.set("offset", String(params.offset));
if (params.limit !== undefined) search.set("limit", String(params.limit));
const encoded = search.toString();
return encoded ? `?${encoded}` : "";
}
// The server's exact wording for a rejected CSRF token
// (server/internal/middleware/auth.go). Matched rather than inferred from the
// status, because 403 also covers real authorization failures that must not
// be retried.
const CSRF_REJECTED_ERROR = "CSRF validation failed";
// One header, two possible values — see ApiClient.readCsrfValue.
const CSRF_HEADER = "X-CSRF-Token";
const CSRF_COOKIE = "multica_csrf";
const SESSION_CSRF_COOKIE = "multica_csrf_session";
/**
* Whether a request body can be sent a second time. Strings and multipart
* forms can; a stream cannot, and silently replaying a half-consumed one
* would send a truncated request.
*/
function isReplayableBody(body: BodyInit | null | undefined): boolean {
if (body === undefined || body === null) return true;
if (typeof body === "string") return true;
if (typeof FormData !== "undefined" && body instanceof FormData) return true;
if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams)
return true;
return false;
}
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;
}
/**
* The bearer token this client is currently using, or null in cookie mode.
*
* Prefers the shared-storage reader when one is configured, so every window
* agrees on the current credential rather than each trusting its own copy.
*/
getToken(): string | null {
const shared = this.options.getToken?.();
if (shared !== undefined) return shared;
return this.token;
}
/**
* Which of the two CSRF cookies to echo (MUL-7436).
*
* The session-bound value is preferred: it survives a sliding renewal, so a
* request another tab built before a renewal still validates after it. The
* token-bound value is the fallback, and it is what a server running the
* PREVIOUS release can verify — after a rollback the session-bound value is
* meaningless to it.
*
* Both travel in the same header. A second header name would have to be in
* that server's CORS allowlist, and a rolled-back server allowlists only the
* names it shipped with: the preflight would fail and no retry could help.
*
* `csrfSessionValueRejected` records the exact session-bound value a server
* refused, so the fallback is used for as long as that value is current and
* no longer — a renewal or a new login replaces the cookie and the preferred
* binding is tried again. Keying on the value rather than a boolean is what
* stops this from oscillating once per renewal against a current server.
*/
private csrfSessionValueRejected: string | null = null;
private readCsrfValue(): string | null {
const sessionBound = this.readCookie(SESSION_CSRF_COOKIE);
if (sessionBound && sessionBound !== this.csrfSessionValueRejected) {
return sessionBound;
}
return this.readCookie(CSRF_COOKIE) ?? sessionBound;
}
private readCookie(name: string): string | null {
if (typeof document === "undefined") return null;
const prefix = `${name}=`;
const match = document.cookie.split("; ").find((c) => c.startsWith(prefix));
return match ? match.slice(prefix.length) || null : null;
}
private authHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
const token = this.getToken();
if (token) headers["Authorization"] = `Bearer ${token}`;
const slug = getCurrentSlug();
if (slug) headers["X-Workspace-Slug"] = slug;
const csrf = this.readCsrfValue();
if (csrf) headers[CSRF_HEADER] = 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;
}
/**
* A 401 ends the session — unless the credential it answered has already
* been replaced.
*
* A request that went out just before a renewal (or from another window
* still finishing one) carries the previous token, and its 401 arrives
* AFTER the new one is in storage. Treating that as expiry would tear down
* a session that is demonstrably alive, clearing drafts and tabs with it
* (MUL-7028). Comparing against the credential in use now is what tells the
* two apart: a genuinely expired session still has the same token stored,
* so the real case is unaffected.
*/
private handleUnauthorized(credentialUsed: string | null) {
if (credentialUsed !== null && this.getToken() !== credentialUsed) {
this.logger.info("ignoring 401 for a credential that has since been replaced");
return;
}
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";
// Rebuilt per attempt rather than captured once: authHeaders() reads the
// CSRF cookie at call time, and the retry below exists precisely because
// that cookie may have just been replaced.
const buildHeaders = (): Record<string, string> => ({
"X-Request-ID": rid,
...this.authHeaders(),
...(init?.extraHeaders ?? {}),
...((init?.headers as Record<string, string>) ?? {}),
});
// Captured before the request so a late 401 can be matched against the
// credential it actually used, not whatever is current when it lands.
const credentialUsed = this.getToken();
this.logger.info(`→ ${method} ${path}`, { rid });
const send = () =>
fetch(`${this.baseUrl}${path}`, {
...init,
headers: buildHeaders(),
credentials: "include",
});
let res = await send();
// Two things can make a CSRF value the server refuses, and one retry
// covers both (MUL-7436):
//
// - It went stale. The first renewal of a session that predates the
// session-bound binding rotates the cookie the old value was keyed to,
// and a request another tab had already built carries the previous
// one. Rebuilding the headers re-reads the cookie.
// - The server does not understand it. After a rollback, the previous
// release can only verify the token-bound binding. Recording the
// rejected value switches this client to that binding until the cookie
// changes again.
//
// Neither is an attack, and neither should surface as a failed write.
if (res.status === 403 && isReplayableBody(init?.body)) {
const { message } = await this.parseErrorBody(res.clone(), "");
if (message === CSRF_REJECTED_ERROR) {
this.csrfSessionValueRejected = this.readCookie(SESSION_CSRF_COOKIE);
this.logger.info(`↻ ${method} ${path} (CSRF token rejected, retrying once)`, { rid });
res = await send();
}
}
if (!res.ok) {
if (res.status === 401) this.handleUnauthorized(credentialUsed);
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" });
}
/**
* Ask the server to extend this session if it has entered its renewal
* window. The server owns that decision — no client reads `exp` or
* compares it against a local clock, which is what keeps clock skew out of
* the picture entirely.
*
* Cookie-mode callers never need this: middleware.Auth re-issues their
* cookie inline on any authenticated safe request.
*/
async refreshSession(): Promise<RefreshSessionResponse> {
const raw = await this.fetch<unknown>("/api/auth/refresh", {
method: "POST",
});
return parseWithFallback<RefreshSessionResponse>(
raw,
RefreshSessionResponseSchema,
EMPTY_REFRESH_SESSION_RESPONSE,
{ endpoint: "POST /api/auth/refresh" },
);
}
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",
});
}
// Local search index sync (MUL-7754). Each call names its workspace so a
// request issued for one workspace cannot be answered for whichever one the
// tab has switched to since. A body that fails its schema rejects rather
// than degrading: an empty page would be applied to the local copy as truth.
async getSearchIndexManifest(params: { workspaceSlug: string; signal?: AbortSignal }): Promise<SearchIndexManifest> {
const raw = await this.fetch<unknown>("/api/search-index/manifest", {
headers: { "X-Workspace-Slug": params.workspaceSlug },
signal: params.signal,
});
return parseSearchIndexResponse<SearchIndexManifest>(raw, SearchIndexManifestSchema, "GET /api/search-index/manifest");
}
async getSearchIndexSnapshot(params: {
workspaceSlug: string;
afterNumber: number;
limit?: number;
signal?: AbortSignal;
}): Promise<SearchIndexSnapshotPage> {
const search = new URLSearchParams({ after_number: String(params.afterNumber) });
if (params.limit !== undefined) search.set("limit", String(params.limit));
const raw = await this.fetch<unknown>(`/api/search-index/snapshot?${search}`, {
headers: { "X-Workspace-Slug": params.workspaceSlug },
signal: params.signal,
});
return parseSearchIndexResponse<SearchIndexSnapshotPage>(raw, SearchIndexSnapshotPageSchema, "GET /api/search-index/snapshot");
}
async getSearchIndexChanges(params: {
workspaceSlug: string;
cursor: string;
limit?: number;
signal?: AbortSignal;
}): Promise<SearchIndexChanges> {
const raw = await this.fetch<unknown>("/api/search-index/changes", {
method: "POST",
headers: { "X-Workspace-Slug": params.workspaceSlug },
body: JSON.stringify({ cursor: params.cursor, limit: params.limit }),
signal: params.signal,
});
return parseSearchIndexResponse<SearchIndexChanges>(raw, SearchIndexChangesSchema, "POST /api/search-index/changes");
}
async listMarketListings(query: MarketBrowseQuery): Promise<MarketListingPage> {
const search = new URLSearchParams();
if (query.q) search.set("q", query.q);
if (query.page !== undefined) search.set("page", String(query.page));
const qs = search.toString();
const raw = await this.fetch<unknown>(`/api/market/listings${qs ? `?${qs}` : ""}`);
return parseWithFallback(raw, MarketListingPageSchema, EMPTY_MARKET_LISTING_PAGE, {
endpoint: "GET /api/market/listings",
});
}
async getMarketListing(listingId: string, version?: string): Promise<MarketListing | null> {
const qs = version ? `?${new URLSearchParams({ version })}` : "";
const raw = await this.fetch<unknown>(`/api/market/listings/${encodeURIComponent(listingId)}${qs}`);
return parseWithFallback<MarketListing | null>(raw, MarketListingSchema, null, {
endpoint: "GET /api/market/listings/{id}",
});
}
async preflightMarketInstall(input: MarketPreflightInput): Promise<MarketPreflight> {
const body: { version: string; runtime_id?: string; model?: string } = { version: input.version };
if (input.runtimeId) body.runtime_id = input.runtimeId;
if (input.model) body.model = input.model;
const raw = await this.fetch<unknown>(
`/api/market/listings/${encodeURIComponent(input.listingId)}/preflight`,
{ method: "POST", body: JSON.stringify(body) },
);
return parseWithFallback<MarketPreflight>(raw, MarketPreflightSchema, UNREADABLE_PREFLIGHT, {
endpoint: "POST /api/market/listings/{id}/preflight",
});
}
async commitMarketOperation(body: unknown): Promise<MarketOperationResult> {
const raw = await this.fetch<unknown>("/api/market/operations", { method: "POST", body: JSON.stringify(body) });
return parseWithFallback(raw, MarketOperationSchema, UNREADABLE_MARKET_OPERATION, {
endpoint: "POST /api/market/operations",
});
}
async getMarketOperation(operationId: string): Promise<MarketOperationResult> {
const raw = await this.fetch<unknown>(`/api/market/operations/${encodeURIComponent(operationId)}`);
return parseWithFallback(raw, MarketOperationSchema, UNREADABLE_MARKET_OPERATION, {
endpoint: "GET /api/market/operations/{id}",
});
}
async reconcileMarketOperation(operationId: string): Promise<MarketOperationResult> {
const raw = await this.fetch<unknown>(`/api/market/operations/${encodeURIComponent(operationId)}/reconcile`, {
method: "POST",
});
return parseWithFallback(raw, MarketOperationSchema, UNREADABLE_MARKET_OPERATION, {
endpoint: "POST /api/market/operations/{id}/reconcile",
});
}
/**
* Fetch one issue by UUID **or** by bare identifier ("MUL-123"): the server
* resolves `PREFIX-NUMBER` against the workspace's own prefix through the
* unique `(workspace_id, number)` index, and 404s on a wrong prefix or a
* missing number.
*
* `signal` remains optional for callers that need to abort an in-flight
* lookup; identifier autolink resolution intentionally lets it complete.
*
* The 2xx body is validated, not cast. A single issue is not a list: there
* is no safe-empty shape to degrade to, and the identifier-autolink caller
* caches this result for 5 minutes, so a field-missing or type-drifted 200
* must not become a truthy issue with an `undefined` id. Like createIssue,
* an unusable body fails the call — and it fails with a plain Error, never
* an ApiError 404, so `issueIdentifierOptions` propagates it instead of
* caching it as "no such issue".
*/
async listWorkspaceWakeups(filters: WorkspaceWakeupFilters): Promise<WorkspaceWakeupPage> {
const params = new URLSearchParams(Object.entries(filters).map(([key, value]) => [key, String(value)]));
const raw = await this.fetch<unknown>(`/api/issue-wakeups?${params}`);
const parsed = parseWithFallback<WorkspaceWakeupPage | null>(raw, WorkspaceWakeupPageSchema, null, { endpoint: "GET /api/issue-wakeups" });
if (!parsed) throw new Error("Could not load workspace wakeups");
return parsed;
}
async listIssueWakeups(issueId: string): Promise<IssueWakeup[]> {
const raw = await this.fetch<unknown>(`/api/issues/${encodeURIComponent(issueId)}/wakeups`);
const parsed = parseWithFallback<IssueWakeup[] | null>(raw, IssueWakeupSchema.array(), null, { endpoint: "GET /api/issues/:id/wakeups" });
if (!parsed) throw new Error("Could not load wakeups");
return parsed;
}
async listIssueWakeupSummaries(): Promise<IssueWakeupSummaryRow[]> {
const raw = await this.fetch<unknown>("/api/issue-wakeup-summaries");
const parsed = parseWithFallback<IssueWakeupSummaryRow[] | null>(raw, IssueWakeupSummaryRowSchema.array(), null, { endpoint: "GET /api/issue-wakeup-summaries" });
if (!parsed) throw new Error("Could not load wakeup summaries");
return parsed;
}
async enableIssueWakeup(issueId: string, wakeupId: string, input: { revision: number; at?: string; rearm?: boolean }): Promise<void> {
await this.fetch(`/api/issues/${encodeURIComponent(issueId)}/wakeups/${encodeURIComponent(wakeupId)}/enable`, { method: "POST", body: JSON.stringify(input) });
}
async editIssueWakeupInstruction(issueId: string, wakeupId: string, input: { instruction: string; expected_instruction: string; revision: number }): Promise<void> {
await this.fetch(`/api/issues/${encodeURIComponent(issueId)}/wakeups/${encodeURIComponent(wakeupId)}/instruction`, { method: "PATCH", body: JSON.stringify(input) });
}
async createIssueWakeup(issueId: string, input: IssueWakeupInput): Promise<void> {
await this.fetch(`/api/issues/${encodeURIComponent(issueId)}/wakeups`, { method: "POST", body: JSON.stringify(input) });
}
async listIssueSystemWakeups(issueId: string): Promise<SystemWakeup[]> {
const raw = await this.fetch<unknown>(`/api/issues/${encodeURIComponent(issueId)}/system-wakeups`);
const parsed = parseWithFallback<SystemWakeup[] | null>(raw, SystemWakeupSchema.array(), null, { endpoint: "GET /api/issues/:id/system-wakeups" });
if (!parsed) throw new Error("Could not load system wakeups");
return parsed;
}
async listIssueWakeupRuns(issueId: string, wakeupId: string): Promise<WakeupRun[]> {
const raw = await this.fetch<unknown>(`/api/issues/${encodeURIComponent(issueId)}/wakeups/${encodeURIComponent(wakeupId)}/runs`);
const parsed = parseWithFallback<WakeupRun[] | null>(raw, WakeupRunSchema.array(), null, { endpoint: "GET /api/issues/:id/wakeups/:wakeupId/runs" });
if (!parsed) throw new Error("Could not load wakeup runs");
return parsed;
}
async listPausedWakeups(): Promise<PausedWakeup[]> {
const raw = await this.fetch<unknown>("/api/issue-wakeup-paused");
return parseWithFallback<PausedWakeup[]>(raw, PausedWakeupSchema.array(), [], { endpoint: "GET /api/issue-wakeup-paused" });
}
async triggerIssueWakeup(issueId: string, wakeupId: string): Promise<void> {
await this.fetch(`/api/issues/${encodeURIComponent(issueId)}/wakeups/${encodeURIComponent(wakeupId)}/trigger`, { method: "POST" });
}
async deleteIssueWakeup(issueId: string, wakeupId: string): Promise<void> {
await this.fetch(`/api/issues/${encodeURIComponent(issueId)}/wakeups/${encodeURIComponent(wakeupId)}`, { method: "DELETE" });
}
async updateIssueSystemWakeup(issueId: string, rule: SystemWakeup["rule"], input: { enabled?: boolean; instruction?: string }): Promise<void> {
await this.fetch(`/api/issues/${encodeURIComponent(issueId)}/system-wakeups/${encodeURIComponent(rule)}`, { method: "PUT", body: JSON.stringify(input) });
}
async listWorkspaceSystemWakeups(): Promise<WorkspaceSystemWakeup[]> {
const raw = await this.fetch<unknown>("/api/system-wakeups");
const parsed = parseWithFallback<WorkspaceSystemWakeup[] | null>(raw, WorkspaceSystemWakeupSchema.array(), null, { endpoint: "GET /api/system-wakeups" });
if (!parsed) throw new Error("Could not load system wakeups");
return parsed;
}
async updateWorkspaceSystemWakeup(rule: WorkspaceSystemWakeup["rule"], input: { enabled?: boolean; instruction?: string }): Promise<void> {
await this.fetch(`/api/system-wakeups/${encodeURIComponent(rule)}`, { method: "PUT", body: JSON.stringify(input) });
}
async disableIssueWakeup(issueId: string, wakeupId: string): Promise<void> {
await this.fetch(`/api/issues/${encodeURIComponent(issueId)}/wakeups/${encodeURIComponent(wakeupId)}/disable`, { method: "POST" });
}
async getIssue(id: string, options?: { signal?: AbortSignal }): Promise<Issue> {
const raw = await this.fetch<unknown>(
`/api/issues/${encodeURIComponent(id)}`,
options?.signal ? { signal: options.signal } : undefined,
);
const issue = parseWithFallback<Issue | null>(raw, IssueSchema, null, {
endpoint: "GET /api/issues/:id",
});
if (!issue) {
// parseWithFallback already logged the zod issues + raw payload.
throw new Error("GET /api/issues/:id returned a malformed issue");
}
return issue;
}
async createIssue(
data: CreateIssueRequest,
workspaceSlug?: string,
): Promise<Issue> {
const requestedProperties = requestedIssueCreateProperties(data);
if (requestedProperties) {
const config = await this.getConfig();
if (config.issue_create_properties_supported !== true) {
throw new Error(
"This server version does not support atomic custom properties on issue creation. Update the server before creating this 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",
headers: workspaceHeader(workspaceSlug),
body: JSON.stringify(data),
});
const issue = parseWithFallback<Issue | null>(raw, CreateIssueResponseSchema, null, {
endpoint: "POST /api/issues",
});
if (!issue) {
throw new Error();
}
assertIssueCreatePropertiesSnapshot(requestedProperties, issue);
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 getCommentSubIssuePreview(anchorCommentId: string): Promise<SourceContextPreview> {
const raw = await this.fetch<unknown>(`/api/comments/${anchorCommentId}/sub-issue-preview`);
const preview = parseWithFallback<SourceContextPreview | null>(
raw,
SourceContextPreviewSchema,
null,
{ endpoint: "GET /api/comments/:id/sub-issue-preview" },
);
if (!preview) throw new Error("Invalid source context preview response");
return preview;
}
async createCommentSubIssue(
anchorCommentId: string,
data: CreateCommentSubIssueManualRequest,
): Promise<Issue>;
async createCommentSubIssue(
anchorCommentId: string,
data: CreateCommentSubIssueAgentRequest,
): Promise<{ task_id: string }>;
async createCommentSubIssue(
anchorCommentId: string,
data: CreateCommentSubIssueRequest,
): Promise<Issue | { task_id: string }> {
try {
const requestedProperties =
data.mode === "manual" ? requestedIssueCreateProperties(data.issue) : undefined;
if (requestedProperties) {
const config = await this.getConfig();
if (config.issue_create_properties_supported !== true) {
throw new Error(
"This server version does not support atomic custom properties on issue creation. Update the server before creating this issue.",
);
}
}
const raw = await this.fetch<unknown>(`/api/comments/${anchorCommentId}/sub-issues`, {
method: "POST",
body: JSON.stringify(data),
});
if (data.mode === "manual") {
const issue = parseWithFallback<Issue | null>(raw, CreateIssueResponseSchema, null, {
endpoint: "POST /api/comments/:id/sub-issues (manual)",
});
if (!issue) throw new Error("Invalid sub-issue response");
assertIssueCreatePropertiesSnapshot(requestedProperties, issue);
return issue;
}
const task = parseWithFallback<{ task_id: string } | null>(
raw,
CommentSubIssueTaskResponseSchema,
null,
{ endpoint: "POST /api/comments/:id/sub-issues (agent)" },
);
if (!task) throw new Error("Invalid quick-create response");
return task;
} catch (error) {
if (error instanceof ApiError && (error.status === 404 || error.status === 405)) {
throw new ApiError(
"Source-context sub-issues require a newer server",
error.status,
error.statusText,
{ code: "source_context_server_unsupported" },
);
}
throw error;
}
}
async createFeedback(data: {
message: string;
url?: string;
workspace_id?: string;
kind?: FeedbackKind;
context?: FeedbackContext;
}): 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 listIssueDuplicates(id: string): Promise<IssueDuplicates> {
const raw = await this.fetch<unknown>(`/api/issues/${id}/duplicates`);
return parseWithFallback(
raw,
IssueDuplicatesResponseSchema,
{ duplicate_of: null, duplicates: [] },
{ endpoint: "GET /api/issues/:id/duplicates" },
);
}
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 }[];
}> {
const raw = await this.fetch<unknown>("/api/issues/child-progress");
return parseWithFallback(
raw,
ChildIssueProgressResponseSchema,
{ progress: [] },
{ endpoint: "GET /api/issues/child-progress" },
);
}
async getIssueLimitUsage(): Promise<IssueLimitUsage | null> {
const raw = await this.fetch<unknown>("/api/issues/limit-usage");
if (raw == null) return null;
return parseWithFallback<IssueLimitUsage | null>(
raw,
IssueLimitUsageSchema,
null,
{ endpoint: "GET /api/issues/limit-usage" },
);
}
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[],
steerTaskIds?: 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 } : {}),
...(steerTaskIds?.length ? { steer_task_ids: steerTaskIds } : {}),
}),
});
}
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[], contentBase?: string, expectedRevision?: number): Promise<Comment> {
return this.fetch(`/api/comments/${commentId}`, {
method: "PUT",
body: JSON.stringify({
content,
attachment_ids: attachmentIds,
...(suppressAgentIds?.length ? { suppress_agent_ids: suppressAgentIds } : {}),
...(contentBase !== undefined ? { content_base: contentBase } : {}),
...(expectedRevision !== undefined ? { expected_revision: expectedRevision } : {}),
}),
});
}
/**
* `keepReplies` calls the route only servers that keep a deleted comment's
* replies expose (#8296): if the request reaches an older server it fails
* instead of deleting the replies too. Pass it only when the server declared
* `comment_delete_keep_replies_supported`.
*/
async deleteComment(commentId: string, opts: { keepReplies?: boolean } = {}): Promise<void> {
const path = opts.keepReplies === true
? `/api/comments/${commentId}/keep-replies`
: `/api/comments/${commentId}`;
await this.fetch(path, { 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> {
await this.fetch(`/api/issues/${issueId}/unsubscribe`, {
method: "POST",
body: JSON.stringify(subscriberTarget(userId, userType)),
});
}
/**
* Leaves this issue and every descendant, and keeps future children of the
* tree from re-subscribing the user — the escape hatch for an agent-built
* tree that keeps growing (MUL-5483).
*
* Deliberately its own endpoint rather than a `subtree` flag on
* `unsubscribeFromIssue`. Web/desktop staging ships on merge while the
* backend is deployed by hand, so this client regularly runs against an
* older server; one that predates the feature would ignore an unknown body
* field, unsubscribe only the root, and still answer 200. A distinct path
* 404s there, which surfaces as a failed mutation instead of a silent lie.
*/
async unsubscribeFromIssueSubtree(
issueId: string,
userId?: string,
userType?: string,
): Promise<void> {
await this.fetch(`/api/issues/${issueId}/unsubscribe/subtree`, {
method: "POST",
body: JSON.stringify(subscriberTarget(userId, userType)),
});
}
// 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> {
assertAgentConversationStartersWriteSupported(data);
return this.fetch("/api/agents", {
method: "POST",
body: JSON.stringify(data),
});
}
/**
* Provisions the workspace's built-in Chief of Staff, or returns the
* existing one.
*
* Only a runtime and a language are sent: name, description, avatar,
* permissions, and the system instruction layer are server constants, so a
* client cannot mint an agent that would claim them. The server is also the
* idempotency boundary — calling twice yields the same agent.
*/
async createMikaAgent(
data: {
runtime_id: string;
language: "en" | "zh" | "ko" | "ja";
/** Empty means "whatever the runtime defaults to". */
model?: string;
/** Label for the onboarding conversation, used only if this call is the
* one that creates it. The session's identity is the member and Mika,
* never this string — it is localized. */
session_title?: string;
},
workspaceSlug?: string,
): Promise<MikaBootstrapResponse> {
return this.fetch("/api/agents/mika", {
method: "POST",
headers: workspaceHeader(workspaceSlug),
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" },
);
}
/**
* The caller's unfinished agent-creation conversations.
*
* Builder sessions are hidden from every chat list (their carrier agent is
* `kind = 'system'`), so this is the only route back to one. A 404 means the
* backend predates the endpoint: degrade to "no drafts" instead of erroring
* the Agents page, exactly as listChatDraftRestores does.
*/
async listAgentBuilderSessions(): Promise<AgentBuilderSessionSummary[]> {
let raw: unknown;
try {
raw = await this.fetch<unknown>("/api/agent-builder/sessions");
} catch (err) {
if (err instanceof ApiError && err.status === 404) return [];
throw err;
}
return parseWithFallback(
raw,
AgentBuilderSessionListSchema,
EMPTY_AGENT_BUILDER_SESSION_LIST,
{ endpoint: "GET /api/agent-builder/sessions" },
).sessions;
}
/**
* Stores the configuration a creation conversation has arrived at, including
* edits the user typed but has not sent. Whole-object last-write-wins: one
* conversation has one editor, so a field-level merge could only reconstruct
* a state nobody saw. Read back through `listAgentBuilderSessions`.
*/
async saveAgentBuilderDraft(
sessionId: string,
draft: StoredAgentDraft,
): Promise<void> {
await this.fetch(`/api/agent-builder/sessions/${sessionId}/draft`, {
method: "PUT",
body: JSON.stringify({ draft }),
});
}
/** 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 updateAgent(id: string, data: UpdateAgentRequest): Promise<Agent> {
assertAgentConversationStartersWriteSupported(data);
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" },
workspaceSlug?: string,
): 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);
// workspace_id alone is not enough: the server resolves the workspace from
// the slug header first, so a caller listing another workspace's runtimes
// must override the header too.
return this.fetch(`/api/runtimes?${search}`, {
headers: workspaceHeader(workspaceSlug),
});
}
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" },
);
}
// Workspace subscriptions — the server resolves the workspace from the
// authenticated request context, so no caller names one.
//
// Two distinct failure paths, both of which a caller must render as
// "unavailable" and neither of which may look like Free:
//
// - non-2xx (older cloud without the route, 403, 503) throws ApiError from
// `fetch`, so a React Query caller sees `isError`;
// - a 2xx body that does not match the contract returns null here.
async getWorkspaceSubscriptionSummary(): Promise<WorkspaceSubscriptionSummary | null> {
const raw = await this.fetch<unknown>("/api/cloud-subscriptions/summary");
return parseWithFallback<WorkspaceSubscriptionSummary | null>(
raw,
WorkspaceSubscriptionSummarySchema,
null,
{ endpoint: "GET /api/cloud-subscriptions/summary" },
);
}
async getWorkspaceSubscriptionPrices(): Promise<WorkspaceSubscriptionPrices | null> {
const raw = await this.fetch<unknown>("/api/cloud-subscriptions/prices");
return parseWithFallback<WorkspaceSubscriptionPrices | null>(
raw,
WorkspaceSubscriptionPricesSchema,
null,
{ endpoint: "GET /api/cloud-subscriptions/prices" },
);
}
async createWorkspaceSubscriptionCheckout(
data: CreateWorkspaceSubscriptionCheckoutRequest,
): Promise<CreateWorkspaceSubscriptionCheckoutResponse | null> {
const res = await this.fetchRaw(
"/api/cloud-subscriptions/checkout-sessions",
{
method: "POST",
body: JSON.stringify({
interval: data.interval,
idempotency_key: data.idempotencyKey,
}),
extraHeaders: {
"Content-Type": "application/json",
"Idempotency-Key": data.idempotencyKey,
},
},
);
const raw = (await res.json()) as unknown;
return parseWithFallback<CreateWorkspaceSubscriptionCheckoutResponse | null>(
raw,
CreateWorkspaceSubscriptionCheckoutResponseSchema,
null,
{ endpoint: "POST /api/cloud-subscriptions/checkout-sessions" },
);
}
async reconcileWorkspaceSubscriptionSeats(): Promise<
WorkspaceSubscriptionSeatReconcileResult | null
> {
const res = await this.fetchRaw("/api/cloud-subscriptions/seats/reconcile", {
method: "POST",
});
const raw = (await res.json()) as unknown;
return parseWithFallback<WorkspaceSubscriptionSeatReconcileResult | null>(
raw,
WorkspaceSubscriptionSeatReconcileResultSchema,
null,
{ endpoint: "POST /api/cloud-subscriptions/seats/reconcile" },
);
}
async previewWorkspaceSeatPurchase(
data: PreviewWorkspaceSeatPurchaseRequest,
): Promise<WorkspaceSeatPurchasePreview | null> {
const res = await this.fetchRaw(
"/api/cloud-subscriptions/seats/purchase-preview",
{
method: "POST",
body: JSON.stringify({ additional_seats: data.additionalSeats }),
extraHeaders: { "Content-Type": "application/json" },
},
);
const raw = (await res.json()) as unknown;
return parseWithFallback<WorkspaceSeatPurchasePreview | null>(
raw,
WorkspaceSeatPurchasePreviewSchema,
null,
{ endpoint: "POST /api/cloud-subscriptions/seats/purchase-preview" },
);
}
async purchaseWorkspaceSeats(
data: PurchaseWorkspaceSeatsRequest,
): Promise<PurchaseWorkspaceSeatsResponse | null> {
const res = await this.fetchRaw(
"/api/cloud-subscriptions/seats/purchases",
{
method: "POST",
body: JSON.stringify({
additional_seats: data.additionalSeats,
expected_current_seats: data.expectedCurrentSeats,
expected_purchase_version: data.expectedPurchaseVersion,
accepted_proration_amount: data.acceptedProrationAmount,
currency: data.currency,
idempotency_key: data.idempotencyKey,
}),
extraHeaders: {
"Content-Type": "application/json",
"Idempotency-Key": data.idempotencyKey,
},
},
);
const raw = (await res.json()) as unknown;
return parseWithFallback<PurchaseWorkspaceSeatsResponse | null>(
raw,
PurchaseWorkspaceSeatsResponseSchema,
null,
{ endpoint: "POST /api/cloud-subscriptions/seats/purchases" },
);
}
async createWorkspaceSubscriptionPortal(
idempotencyKey: string,
): Promise<CreateWorkspaceSubscriptionPortalResponse | null> {
const res = await this.fetchRaw("/api/cloud-subscriptions/portal-sessions", {
method: "POST",
extraHeaders: { "Idempotency-Key": idempotencyKey },
});
const raw = (await res.json()) as unknown;
return parseWithFallback<CreateWorkspaceSubscriptionPortalResponse | null>(
raw,
CreateWorkspaceSubscriptionPortalResponseSchema,
null,
{ endpoint: "POST /api/cloud-subscriptions/portal-sessions" },
);
}
async deleteRuntime(runtimeId: string): Promise<void> {
await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" });
}
// Confirmed 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 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.
//
// The agents are UNBOUND, not archived or deleted (MUL-5559): they keep their
// configuration, chats and task history and need a new runtime to run again.
// `agents_archived` is the server's deprecated mirror of `agents_unbound`,
// kept because installed clients read it; prefer `agents_unbound`.
async unbindAgentsAndDeleteRuntime(
runtimeId: string,
expectedActiveAgentIds: string[],
): Promise<{
status: string;
agents_unbound?: number;
agents_archived?: number;
tasks_cancelled: number;
autopilots_paused?: number;
}> {
return this.fetch(`/api/runtimes/${runtimeId}/unbind-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 parseWithFallback(
res.runtime_profiles ?? [],
RuntimeProfileListSchema,
[] as RuntimeProfile[],
{ endpoint: "listRuntimeProfiles" },
);
}
async getRuntimeProfile(
workspaceId: string,
profileId: string,
): Promise<RuntimeProfile> {
const result = await this.fetch<RuntimeProfile>(
`/api/workspaces/${workspaceId}/runtime-profiles/${profileId}`,
);
return parseWithFallback(result, RuntimeProfileSchema, result, {
endpoint: "runtimeProfile",
});
}
async createRuntimeProfile(
workspaceId: string,
body: CreateRuntimeProfileRequest,
): Promise<RuntimeProfile> {
const result = await this.fetch<RuntimeProfile>(
`/api/workspaces/${workspaceId}/runtime-profiles`,
{
method: "POST",
body: JSON.stringify(body),
},
);
return parseWithFallback(result, RuntimeProfileSchema, result, {
endpoint: "runtimeProfile",
});
}
async updateRuntimeProfile(
workspaceId: string,
profileId: string,
patch: UpdateRuntimeProfileRequest,
): Promise<RuntimeProfile> {
const result = await this.fetch<RuntimeProfile>(
`/api/workspaces/${workspaceId}/runtime-profiles/${profileId}`,
{
method: "PATCH",
body: JSON.stringify(patch),
},
);
return parseWithFallback(result, RuntimeProfileSchema, result, {
endpoint: "runtimeProfile",
});
}
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,
options: { force?: boolean } = {},
): Promise<RuntimeModelListRequest> {
const query = options.force === true ? "?force=true" : "";
const raw = await this.fetch<unknown>(
`/api/runtimes/${runtimeId}/models${query}`,
{
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 listAgentTasksPage(
agentId: string,
options: { limit?: number; before?: string; signal?: AbortSignal } = {},
): Promise<{ tasks: AgentTask[]; nextCursor: string | null }> {
const search = new URLSearchParams({ limit: String(options.limit ?? 200) });
if (options.before) search.set("before", options.before);
const response = await this.fetchRaw(`/api/agents/${agentId}/tasks?${search}`, {
signal: options.signal,
});
const tasks: unknown = await response.json();
return parseWithFallback(
{ tasks, nextCursor: response.headers.get("X-Agent-Tasks-Next-Cursor") },
AgentTaskPageSchema,
{ tasks: [], nextCursor: null },
{ endpoint: "GET /api/agents/:id/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[]> {
const raw = await this.fetch<unknown>(`/api/agent-activity-30d`);
return parseWithFallback<AgentActivityBucket[]>(raw, AgentActivityBucketListSchema, [], {
endpoint: "GET /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[]> {
const raw = await this.fetch<unknown>(`/api/tasks/${taskId}/messages`);
return parseWithFallback<TaskMessagePayload[]>(raw, TaskMessageListSchema, [], {
endpoint: "GET /api/tasks/:id/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 retryTaskSupplement(issueId: string, taskId: string, commentId: string): Promise<void> {
await this.fetch(`/api/issues/${issueId}/tasks/${taskId}/supplements/${commentId}/retry`, {
method: "POST",
});
}
async getIssueUsage(issueId: string): Promise<IssueUsageSummary> {
return this.fetch(`/api/issues/${issueId}/usage`);
}
async cancelTask(issueId: string, taskId: string): Promise<AgentTask> {
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/tasks/${taskId}/cancel`, {
method: "POST",
});
const task = parseWithFallback<AgentTask | null>(raw, AgentTaskSchema, null, {
endpoint: "POST /api/issues/:id/tasks/:taskId/cancel",
});
if (!task) throw new Error("Invalid task cancellation response");
return task;
}
async rerunIssue(issueId: string, taskId?: string): Promise<AgentTask> {
return this.fetch(`/api/issues/${issueId}/rerun`, {
method: "POST",
body: JSON.stringify(taskId ? { task_id: taskId } : {}),
});
}
async retrySourceContextQuickCreate(taskId: string): Promise<AgentTask> {
const raw = await this.fetch<unknown>(`/api/tasks/${taskId}/retry-source-context`, {
method: "POST",
});
const task = parseWithFallback<AgentTask | null>(raw, AgentTaskSchema, null, {
endpoint: "POST /api/tasks/:id/retry-source-context",
});
if (!task) throw new Error("Invalid source-context retry response");
return task;
}
// Inbox
async listInbox(): Promise<InboxItem[]> {
const raw = await this.fetch<unknown>("/api/inbox");
return parseWithFallback(raw, InboxItemListSchema, EMPTY_INBOX_ITEMS, {
endpoint: "GET /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",
});
}
private archivedInboxParams(filters: InboxFilters): URLSearchParams {
const params = new URLSearchParams();
if (filters.statuses.length) params.set("statuses", [...filters.statuses].sort().join(","));
if (filters.priorities.length) params.set("priorities", [...filters.priorities].sort().join(","));
if (filters.actors.length) params.set("actors", [...filters.actors].sort().join(","));
if (filters.unreadOnly) params.set("unread_only", "true");
return params;
}
async listArchivedInboxPage(filters: InboxFilters, options: {
cursor?: string | null; groupId?: string; signal?: AbortSignal;
} = {}): Promise<ArchivedInboxPage> {
const params = this.archivedInboxParams(filters);
params.set("limit", "50");
if (options.cursor) params.set("cursor", options.cursor);
if (options.groupId) params.set("group_id", options.groupId);
const raw = await this.fetch<unknown>(`/api/inbox/archived/page?${params}`, { signal: options.signal });
const page = parseWithFallback<ArchivedInboxPage | null>(raw, ArchivedInboxPageSchema, null, {
endpoint: "GET /api/inbox/archived/page",
});
if (!page) throw new Error("Invalid archived inbox page response");
return page;
}
async getArchivedInboxFacets(filters: InboxFilters, signal?: AbortSignal): Promise<ArchivedInboxFacets> {
const raw = await this.fetch<unknown>(`/api/inbox/archived/facets?${this.archivedInboxParams(filters)}`, { signal });
const facets = parseWithFallback<ArchivedInboxFacets | null>(raw, ArchivedInboxFacetsSchema, null, {
endpoint: "GET /api/inbox/archived/facets",
});
if (!facets) throw new Error("Invalid archived inbox facets response");
return facets;
}
async unarchiveInbox(id: string): Promise<InboxItem> {
return this.fetch(`/api/inbox/${id}/unarchive`, { method: "POST" });
}
// Raw unread ROW count — not the number any badge shows. The inbox renders
// one row per issue, so a single issue with three unread notifications
// counts once there and three times here. `getInboxUnreadSummary` is the
// deduplicated, per-workspace count the UI is built on (see
// `useInboxUnreadCount`); reach for this one only when raw rows are what
// you actually mean.
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: workspaceHeader(workspaceSlug),
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; issue_prefix?: 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),
});
}
async listPluginInstallations(workspaceId: string): Promise<PluginInstallationListResponse> {
let raw: unknown;
try {
raw = await this.fetch<unknown>(`/api/workspaces/${workspaceId}/plugins`);
} catch (error) {
if (error instanceof ApiError && error.status === 404) return EMPTY_PLUGIN_INSTALLATION_LIST;
throw error;
}
return parseWithFallback(raw, PluginInstallationListResponseSchema, EMPTY_PLUGIN_INSTALLATION_LIST, {
endpoint: "GET /api/workspaces/{id}/plugins",
});
}
/**
* The workspace's MCP server library. The response carries identity and
* transport only — the stored entries are write-only server-side, so there
* is nothing here to redact.
*/
async listWorkspaceMcpServers(workspaceId: string): Promise<WorkspaceMcpServer[]> {
const raw = await this.fetch<unknown>(`/api/workspaces/${workspaceId}/mcp-servers`);
return parseWithFallback(raw, WorkspaceMcpServerListSchema, [] as WorkspaceMcpServer[], {
endpoint: "GET /api/workspaces/{id}/mcp-servers",
});
}
/**
* Adds a server to the library. It is assigned to NO agent — an agent gets
* it only through addAgentMcpServer.
*/
async createWorkspaceMcpServer(
workspaceId: string,
name: string,
config: Record<string, unknown>,
): Promise<WorkspaceMcpServer> {
const raw = await this.fetch<unknown>(`/api/workspaces/${workspaceId}/mcp-servers`, {
method: "POST",
body: JSON.stringify({ name, config }),
});
return parseWithFallback(raw, WorkspaceMcpServerSchema, EMPTY_WORKSPACE_MCP_SERVER, {
endpoint: "POST /api/workspaces/{id}/mcp-servers",
});
}
/**
* Renames a library entry, replaces its configuration, or both. Agents keep
* their assignment across a rename because assignments key off the id.
*/
async updateWorkspaceMcpServer(
workspaceId: string,
serverId: string,
update: { name?: string; config?: Record<string, unknown> },
): Promise<WorkspaceMcpServer> {
const raw = await this.fetch<unknown>(
`/api/workspaces/${workspaceId}/mcp-servers/${encodeURIComponent(serverId)}`,
{ method: "PUT", body: JSON.stringify(update) },
);
return parseWithFallback(raw, WorkspaceMcpServerSchema, EMPTY_WORKSPACE_MCP_SERVER, {
endpoint: "PUT /api/workspaces/{id}/mcp-servers/{serverId}",
});
}
/** Removes a library entry and every assignment to it. */
async deleteWorkspaceMcpServer(workspaceId: string, serverId: string): Promise<void> {
await this.fetch<unknown>(
`/api/workspaces/${workspaceId}/mcp-servers/${encodeURIComponent(serverId)}`,
{ method: "DELETE" },
);
}
/** The workspace MCP servers assigned to this agent, with their toggles. */
async listAgentMcpServers(agentId: string): Promise<WorkspaceMcpServer[]> {
const raw = await this.fetch<unknown>(`/api/agents/${agentId}/mcp-servers`);
return parseWithFallback(raw, WorkspaceMcpServerListSchema, [] as WorkspaceMcpServer[], {
endpoint: "GET /api/agents/{id}/mcp-servers",
});
}
/**
* Gives one workspace server to this agent. Every write returns the
* resulting assignment list, so the client never has to guess the state.
*/
async addAgentMcpServer(agentId: string, serverId: string): Promise<WorkspaceMcpServer[]> {
const raw = await this.fetch<unknown>(`/api/agents/${agentId}/mcp-servers`, {
method: "POST",
body: JSON.stringify({ server_id: serverId }),
});
return parseWithFallback(raw, WorkspaceMcpServerListSchema, [] as WorkspaceMcpServer[], {
endpoint: "POST /api/agents/{id}/mcp-servers",
});
}
async setAgentMcpServerEnabled(
agentId: string,
serverId: string,
enabled: boolean,
): Promise<WorkspaceMcpServer[]> {
const raw = await this.fetch<unknown>(
`/api/agents/${agentId}/mcp-servers/${encodeURIComponent(serverId)}/enabled`,
{ method: "PUT", body: JSON.stringify({ enabled }) },
);
return parseWithFallback(raw, WorkspaceMcpServerListSchema, [] as WorkspaceMcpServer[], {
endpoint: "PUT /api/agents/{id}/mcp-servers/{serverId}/enabled",
});
}
async removeAgentMcpServer(agentId: string, serverId: string): Promise<WorkspaceMcpServer[]> {
const raw = await this.fetch<unknown>(
`/api/agents/${agentId}/mcp-servers/${encodeURIComponent(serverId)}`,
{ method: "DELETE" },
);
return parseWithFallback(raw, WorkspaceMcpServerListSchema, [] as WorkspaceMcpServer[], {
endpoint: "DELETE /api/agents/{id}/mcp-servers/{serverId}",
});
}
/** Everything published into this workspace, with its versions. */
async listPluginPackages(workspaceId: string): Promise<PluginPackageListResponse> {
const raw = await this.fetch<unknown>(`/api/workspaces/${workspaceId}/plugins/packages`);
return parseWithFallback(raw, PluginPackageListResponseSchema, EMPTY_PLUGIN_PACKAGE_LIST, {
endpoint: "GET /api/workspaces/{id}/plugins/packages",
});
}
/**
* Publishes an artifact bundle: a zip holding the manifest and every file it
* names. This is the whole publishing path — a plugin author needs no server
* of their own, and nothing about a published version changes afterwards.
*
* Not routed through `this.fetch`, for the same reason uploadFile is not: the
* browser has to set the multipart boundary itself.
*/
async publishPluginPackage(workspaceId: string, bundle: File): Promise<PluginPackage> {
const formData = new FormData();
formData.append("bundle", bundle);
const credentialUsed = this.getToken();
const res = await fetch(`${this.baseUrl}/api/workspaces/${workspaceId}/plugins/packages`, {
method: "POST",
headers: this.authHeaders(),
body: formData,
credentials: "include",
});
if (!res.ok) {
if (res.status === 401) this.handleUnauthorized(credentialUsed);
throw new Error(await this.parseErrorMessage(res, `Publishing failed: ${res.status}`));
}
const raw = (await res.json()) as unknown;
return parseWithFallback(raw, PluginPackageSchema, EMPTY_PLUGIN_PACKAGE, {
endpoint: "POST /api/workspaces/{id}/plugins/packages",
});
}
/**
* Publishes from a directory the operator hosts (MULTICA_PLUGIN_DIR) — the
* development channel, so iterating on a surface does not mean zipping and
* uploading after every edit. It still produces an immutable version.
*/
async publishLocalPluginPackage(workspaceId: string, name: string): Promise<PluginPackage> {
const raw = await this.fetch<unknown>(`/api/workspaces/${workspaceId}/plugins/packages/local`, {
method: "POST",
body: JSON.stringify({ name }),
});
return parseWithFallback(raw, PluginPackageSchema, EMPTY_PLUGIN_PACKAGE, {
endpoint: "POST /api/workspaces/{id}/plugins/packages/local",
});
}
async deletePluginPackage(workspaceId: string, packageId: string): Promise<void> {
await this.fetch<void>(`/api/workspaces/${workspaceId}/plugins/packages/${packageId}`, { method: "DELETE" });
}
/** Mint one hosted document URL and its single-use bridge proof. */
async getPluginSurfaceLaunch(workspaceId: string, installationId: string, surfaceKey: string): Promise<PluginSurfaceLaunch> {
const raw = await this.fetch<unknown>(
`/api/workspaces/${workspaceId}/plugins/${installationId}/surfaces/${encodeURIComponent(surfaceKey)}/launch`,
);
return parseWithFallback(raw, PluginSurfaceLaunchSchema, EMPTY_PLUGIN_SURFACE_LAUNCH, {
endpoint: "GET /api/workspaces/{id}/plugins/{installationId}/surfaces/{surfaceKey}/launch",
});
}
/**
* Step one of the two-step install. Nothing is written: the response is what
* the consent screen shows before an installation exists.
*/
async previewPlugin(workspaceId: string, request: PluginPreviewRequest): Promise<PluginPreview> {
const raw = await this.fetch<unknown>(`/api/workspaces/${workspaceId}/plugins/preview`, {
method: "POST",
body: JSON.stringify(request),
});
return parseWithFallback(raw, PluginPreviewSchema, EMPTY_PLUGIN_PREVIEW, {
endpoint: "POST /api/workspaces/{id}/plugins/preview",
});
}
async installPlugin(workspaceId: string, request: PluginInstallRequest): Promise<PluginInstallation> {
const raw = await this.fetch<unknown>(`/api/workspaces/${workspaceId}/plugins`, {
method: "POST",
body: JSON.stringify(request),
});
return parseWithFallback(raw, PluginInstallationSchema, EMPTY_PLUGIN_INSTALLATION, {
endpoint: "POST /api/workspaces/{id}/plugins",
});
}
async configurePlugin(workspaceId: string, installationId: string, request: PluginConfigRequest): Promise<PluginInstallation> {
const raw = await this.fetch<unknown>(`/api/workspaces/${workspaceId}/plugins/${installationId}/config`, {
method: "PUT",
body: JSON.stringify(request),
});
return parseWithFallback(raw, PluginInstallationSchema, EMPTY_PLUGIN_INSTALLATION, {
endpoint: "PUT /api/workspaces/{id}/plugins/{installationId}/config",
});
}
async setPluginEnabled(workspaceId: string, installationId: string, enabled: boolean): Promise<PluginInstallation> {
const action = enabled ? "enable" : "disable";
const raw = await this.fetch<unknown>(`/api/workspaces/${workspaceId}/plugins/${installationId}/${action}`, {
method: "POST",
});
return parseWithFallback(raw, PluginInstallationSchema, EMPTY_PLUGIN_INSTALLATION, {
endpoint: `POST /api/workspaces/{id}/plugins/{installationId}/${action}`,
});
}
/**
* Performs one Action API call on behalf of a plugin surface.
*
* The surface has no credential — it asked the host over postMessage, and
* this re-issues the call on the signed-in user's session. The installation
* travels in a header the iframe cannot set for itself; the server derives
* the workspace from it rather than trusting anything the client sends.
*/
async callPluginAction(
installationId: string,
request: { method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; path: string; body?: unknown; issueId?: string },
): Promise<unknown> {
const query = request.path === "/context" && request.issueId
? `?issue_id=${encodeURIComponent(request.issueId)}`
: "";
return this.fetch<unknown>(`/api/plugin-bridge/v1${request.path}${query}`, {
method: request.method,
headers: { "X-Multica-Plugin-Installation": installationId },
body: request.body === undefined ? undefined : JSON.stringify(request.body),
});
}
/**
* Invokes one hook on behalf of a person.
*
* `ui` comes from a button inside a surface, `manual` from the issue actions
* menu or the command palette. Both block this request and only this request:
* somebody is waiting for the answer. The `event` trigger never comes through
* here — the host dispatches it, so no client can ask for one and inherit an
* identity that is supposed to be the plugin's.
*/
async invokePluginHook(
installationId: string,
hookKey: string,
request: { trigger: "ui" | "manual"; issueId?: string; input?: unknown },
): Promise<PluginHookResult> {
const raw = await this.fetch<unknown>(`/api/plugin-bridge/v1/hooks/${encodeURIComponent(hookKey)}`, {
method: "POST",
headers: { "X-Multica-Plugin-Installation": installationId },
body: JSON.stringify({ trigger: request.trigger, issue_id: request.issueId, input: request.input }),
});
return parseWithFallback(raw, PluginHookResultSchema, {
status: "ok",
hook_key: hookKey,
trigger: request.trigger,
latency_ms: 0,
attempts: 1,
}, { endpoint: "POST /api/plugin-bridge/v1/hooks/{key}" });
}
async listPluginInvocations(workspaceId: string, installationId: string): Promise<PluginInvocation[]> {
const raw = await this.fetch<unknown>(`/api/workspaces/${workspaceId}/plugins/${installationId}/invocations`);
return parseWithFallback(raw, PluginInvocationListSchema, { invocations: [] }, {
endpoint: "GET /api/workspaces/{id}/plugins/{installationId}/invocations",
}).invocations;
}
async rotatePluginToken(workspaceId: string, installationId: string): Promise<PluginTokenIssue> {
const raw = await this.fetch<unknown>(`/api/workspaces/${workspaceId}/plugins/${installationId}/token`, {
method: "POST",
});
return parseWithFallback(raw, PluginTokenIssueSchema, { token: "", signing_secret: "" }, {
endpoint: "POST /api/workspaces/{id}/plugins/{installationId}/token",
});
}
async revokePluginToken(workspaceId: string, installationId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}/plugins/${installationId}/token`, { method: "DELETE" });
}
/**
* Lists what an `mcp`-transport hook's server currently offers.
*
* Read-only: discovering a tool adopts nothing. approvePluginMCPTools is the
* grant, which is the difference from an `http` hook — that one declares a
* single endpoint in a manifest an administrator already read, while an MCP
* server decides its own tool list at runtime and can change it later.
*/
async listPluginMCPTools(workspaceId: string, installationId: string, hookKey: string): Promise<PluginMCPTool[]> {
const raw = await this.fetch<unknown>(
`/api/workspaces/${workspaceId}/plugins/${installationId}/mcp/${encodeURIComponent(hookKey)}/tools`,
);
return parseWithFallback(raw, PluginMCPToolListSchema, { tools: [] }, {
endpoint: "GET /api/workspaces/{id}/plugins/{installationId}/mcp/{hookKey}/tools",
}).tools;
}
/**
* Pins the approved tool set for one hook.
*
* `tools` is the COMPLETE set, not a delta — removing one is the same request
* shape as adding one, so there is no way to think you revoked something and
* have it stay. An empty array withdraws the hook entirely.
*/
async approvePluginMCPTools(
workspaceId: string,
installationId: string,
hookKey: string,
tools: string[],
): Promise<PluginInstallation> {
const raw = await this.fetch<unknown>(
`/api/workspaces/${workspaceId}/plugins/${installationId}/mcp/${encodeURIComponent(hookKey)}/tools`,
{ method: "PUT", body: JSON.stringify({ tools }) },
);
return parseWithFallback(raw, PluginInstallationSchema, EMPTY_PLUGIN_INSTALLATION, {
endpoint: "PUT /api/workspaces/{id}/plugins/{installationId}/mcp/{hookKey}/tools",
});
}
async uninstallPlugin(workspaceId: string, installationId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}/plugins/${installationId}`, {
method: "DELETE",
});
}
// 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 createShareLink(workspaceId: string, data: { role?: string; expires_in?: number; max_uses?: number }): Promise<ShareLink> {
const raw = await this.fetch<unknown>(`/api/workspaces/${workspaceId}/share-links`, {
method: "POST",
body: JSON.stringify(data),
});
return parseWithFallback(raw, ShareLinkSchema, EMPTY_SHARE_LINK, {
endpoint: "POST /api/workspaces/{id}/share-links",
});
}
async listShareLinks(workspaceId: string): Promise<ShareLink[]> {
const raw = await this.fetch<unknown>(`/api/workspaces/${workspaceId}/share-links`);
return parseWithFallback(raw, ShareLinkListResponseSchema, [], {
endpoint: "GET /api/workspaces/{id}/share-links",
});
}
async revokeShareLink(workspaceId: string, linkId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}/share-links/${linkId}`, {
method: "DELETE",
});
}
async joinByShareLink(code: string): Promise<{ member: MemberWithUser; workspace_id: string; workspace_slug: string }> {
const raw = await this.fetch<unknown>("/api/share-links/join", {
method: "POST",
body: JSON.stringify({ code }),
});
return parseWithFallback(raw, JoinShareLinkResponseSchema, EMPTY_JOIN_SHARE_LINK_RESPONSE, {
endpoint: "POST /api/share-links/join",
});
}
async getShareLinkInfo(code: string): Promise<ShareLinkInfo> {
const raw = await this.fetch<unknown>(`/api/share-links/${encodeURIComponent(code)}`);
return parseWithFallback(raw, ShareLinkInfoSchema, EMPTY_SHARE_LINK_INFO, {
endpoint: "GET /api/share-links/{code}",
});
}
async deleteWorkspace(workspaceId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}`, {
method: "DELETE",
});
}
// Skills
async listSkills(): Promise<SkillSummary[]> {
const raw = await this.fetch<unknown>("/api/skills");
return parseWithFallback(raw, SkillSummaryListSchema, EMPTY_SKILL_SUMMARY_LIST, {
endpoint: "GET /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),
});
}
/**
* Imports a skill from a local .skill / .zip archive. Not routed through
* `this.fetch`: the browser has to set the multipart boundary itself.
*
* The archive path always returns a structured `{ status, skill, reason }`
* body. Created/updated responses yield the skill; anything else throws
* with the server's reason (or `error`) so the dialog can show it.
*/
async importSkillArchive(
file: File,
onConflict?: "fail" | "overwrite" | "rename" | "skip",
): Promise<Skill> {
const formData = new FormData();
formData.append("file", file, file.name || "skill.zip");
if (onConflict) formData.append("on_conflict", onConflict);
let res: Response;
try {
res = await this.fetchRaw("/api/skills/import", {
method: "POST",
body: formData,
});
} catch (err) {
throw remapSkillImportError(err);
}
const raw = (await res.json()) as unknown;
return skillFromImportResult(raw, "POST /api/skills/import");
}
// Re-downloads the skill from its stored config.origin source, replacing
// name/description/content/files in place while preserving the skill id and
// its agent bindings.
async refreshSkill(id: string): Promise<Skill> {
const raw = await this.fetch<unknown>(`/api/skills/${id}/refresh`, {
method: "POST",
});
return parseWithFallback(raw, SkillSchema, EMPTY_SKILL, {
endpoint: "POST /api/skills/:id/refresh",
});
}
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 credentialUsed = this.getToken();
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(credentialUsed);
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 },
workspaceSlug?: string,
): Promise<ChatSession[]> {
const query = params?.status ? `?status=${params.status}` : "";
const raw: unknown = await this.fetch(`/api/chat/sessions${query}`, {
headers: workspaceHeader(workspaceSlug),
});
return parseWithFallback(raw, ChatSessionListSchema, EMPTY_CHAT_SESSION_LIST, {
endpoint: "GET /api/chat/sessions",
});
}
async getChatSession(id: string): Promise<ChatSession> {
const raw: unknown = await this.fetch(`/api/chat/sessions/${id}`);
return parseWithFallback(raw, ChatSessionSchema, EMPTY_CHAT_SESSION, {
endpoint: "GET /api/chat/sessions/:id",
});
}
async createChatSession(
data: {
agent_id: string;
title?: string;
project_id?: string | null;
},
workspaceSlug?: string,
): Promise<ChatSession> {
return this.fetch("/api/chat/sessions", {
method: "POST",
headers: workspaceHeader(workspaceSlug),
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[],
): Promise<SendChatMessageResponse> {
const body: {
content: string;
attachment_ids?: string[];
} = { content };
if (attachmentIds && attachmentIds.length > 0) {
body.attachment_ids = attachmentIds;
}
const raw = await this.fetch<unknown>(`/api/chat/sessions/${sessionId}/messages`, {
method: "POST",
body: JSON.stringify(body),
});
const response = parseWithFallback<SendChatMessageResponse | null>(
raw,
SendChatMessageResponseSchema,
null,
{ endpoint: "POST /api/chat/sessions/:id/messages" },
);
if (!response) throw new Error("invalid send chat message response");
return response;
}
async startMikaOnboarding(
sessionId: string,
data: {
language: "en" | "zh" | "ko" | "ja";
},
workspaceSlug?: string,
): Promise<StartMikaOnboardingResponse> {
const raw = await this.fetch<unknown>(`/api/chat/sessions/${sessionId}/onboarding`, {
method: "POST",
headers: workspaceHeader(workspaceSlug),
body: JSON.stringify(data),
});
return parseWithFallback(
raw,
StartMikaOnboardingResponseSchema,
{ started: false },
{ endpoint: "POST /api/chat/sessions/:id/onboarding" },
);
}
async getPendingChatTask(sessionId: string): Promise<ChatPendingTask> {
const raw = await this.fetch<unknown>(`/api/chat/sessions/${sessionId}/pending-task`);
return parseWithFallback(raw, ChatPendingTaskSchema, EMPTY_CHAT_PENDING_TASK, {
endpoint: "GET /api/chat/sessions/:id/pending-task",
});
}
async prioritizeQueuedChatTask(
sessionId: string,
taskId: string,
): Promise<PrioritizeQueuedChatTaskResponse> {
const raw = await this.fetch<unknown>(
`/api/chat/sessions/${sessionId}/queued-tasks/${taskId}/prioritize`,
{ method: "POST" },
);
return parseWithFallback(
raw,
PrioritizeQueuedChatTaskResponseSchema,
EMPTY_PRIORITIZE_QUEUED_CHAT_TASK_RESPONSE,
{ endpoint: "POST /api/chat/sessions/:id/queued-tasks/:taskId/prioritize" },
);
}
async clearQueuedChatTasks(sessionId: string): Promise<void> {
await this.fetch(`/api/chat/sessions/${sessionId}/queued-tasks`, {
method: "DELETE",
});
}
/**
* 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,
options?: { queuedAction?: "edit" | "remove"; sessionId?: string },
): Promise<CancelTaskResponse> {
const params = new URLSearchParams();
if (options?.queuedAction) {
if (!options.sessionId) throw new Error("sessionId is required for queued-only cancellation");
params.set("expected_status", "queued");
params.set("chat_session_id", options.sessionId);
params.set("queue_action", options.queuedAction);
}
const query = params.size > 0 ? `?${params}` : "";
const raw = await this.fetch<unknown>(`/api/tasks/${taskId}/cancel${query}`, {
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}",
});
}
// The Markdown download path resolves the attachment's own workspace and
// checks membership there. Its link may be pasted into another workspace.
async getAttachmentForDownload(id: string): Promise<Attachment> {
const raw = await this.fetch<unknown>(`/api/attachments/${id}/download-metadata`);
return parseWithFallback(raw, AttachmentResponseSchema, EMPTY_ATTACHMENT, {
endpoint: "GET /api/attachments/{id}/download-metadata",
});
}
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" });
}
// Workspace workflows (MUL-7420). Reads are open to any workspace member;
// creating, editing and deleting a workflow is owner/admin only. Adoption
// (creating a workflow, pointing a project at one) is gated server-side by
// the project_workflows_v1 flag and returns 403 when it is off.
async listIssueWorkflows(): Promise<ListIssueWorkflowsResponse> {
const raw = await this.fetch<unknown>(`/api/issue-workflows`);
return parseWithFallback(raw, ListIssueWorkflowsResponseSchema, EMPTY_LIST_ISSUE_WORKFLOWS_RESPONSE, {
endpoint: "GET /api/issue-workflows",
});
}
async createIssueWorkflow(data: IssueWorkflowWriteRequest): Promise<IssueWorkflow> {
const raw = await this.fetch<unknown>(`/api/issue-workflows`, {
method: "POST",
body: JSON.stringify(data),
});
return parseWithFallback(raw, IssueWorkflowSchema, EMPTY_ISSUE_WORKFLOW, {
endpoint: "POST /api/issue-workflows",
});
}
/**
* Saves a workflow edit. When the edit removes statuses that issues of the
* workflow's projects still use, the server answers 409
* `workflow_status_mapping_required` with a plan; resend with
* `status_mapping`. `dryRunIssueWorkflowUpdate` fetches that plan up front.
*/
async updateIssueWorkflow(id: string, data: IssueWorkflowWriteRequest): Promise<IssueWorkflow> {
const raw = await this.fetch<unknown>(`/api/issue-workflows/${id}`, {
method: "PATCH",
body: JSON.stringify({ ...data, dry_run: false }),
});
return parseWithFallback(raw, IssueWorkflowSchema, EMPTY_ISSUE_WORKFLOW, {
endpoint: "PATCH /api/issue-workflows/{id}",
});
}
async dryRunIssueWorkflowUpdate(id: string, data: IssueWorkflowWriteRequest): Promise<IssueWorkflowDryRunResponse> {
const raw = await this.fetch<unknown>(`/api/issue-workflows/${id}`, {
method: "PATCH",
body: JSON.stringify({ ...data, dry_run: true }),
});
return parseWithFallback(raw, IssueWorkflowDryRunResponseSchema, EMPTY_ISSUE_WORKFLOW_DRY_RUN, {
endpoint: "PATCH /api/issue-workflows/{id} (dry run)",
});
}
async deleteIssueWorkflow(id: string): Promise<void> {
await this.fetch(`/api/issue-workflows/${id}`, { method: "DELETE" });
}
/**
* Renders the brief a column's handler would receive, from an unsaved
* handoff. `project_id` names the project the preview is for, if any.
*/
async previewWorkflowBrief(
workflowId: string,
data: SetWorkflowHandoffRequest & { status_key: string; project_id?: string },
): Promise<string> {
const raw = await this.fetch<unknown>(`/api/issue-workflows/${workflowId}/brief`, {
method: "POST",
body: JSON.stringify(data),
});
const parsed = parseWithFallback(raw, IssueWorkflowBriefPreviewSchema, { brief: "" }, {
endpoint: "POST /api/issue-workflows/{id}/brief",
});
return parsed.brief;
}
/**
* Saves a workflow's handoff for one column, for every project using it;
* handler "none" clears it. With `copyForProjectId` the workflow stays as
* it is: the handoff lands on a copy that project switches to, and the copy
* is returned. Admin only.
*/
async setWorkflowHandoff(
workflowId: string,
statusKey: string,
data: SetWorkflowHandoffRequest,
copyForProjectId?: string,
): Promise<IssueWorkflow> {
const body = copyForProjectId ? { ...data, copy_for_project_id: copyForProjectId } : data;
const raw = await this.fetch<unknown>(
`/api/issue-workflows/${workflowId}/handoffs/${encodeURIComponent(statusKey)}`,
{ method: "PUT", body: JSON.stringify(body) },
);
return parseWithFallback(raw, IssueWorkflowSchema, EMPTY_ISSUE_WORKFLOW, {
endpoint: "PUT /api/issue-workflows/{id}/handoffs/{status}",
});
}
/**
* What moving an issue to `status` would do under its project's workflow:
* who it goes to, the current agent's active runs, and the brief. Writes
* nothing; a status outside the workflow answers 400. With `projectId` it
* previews moving the issue into that project instead, entering the column
* it lands on (`status` may then be empty).
*/
async previewWorkflowHandoff(issueId: string, status: string, projectId?: string): Promise<WorkflowHandoffPreview> {
const query = new URLSearchParams();
if (status) query.set("status", status);
if (projectId) query.set("project_id", projectId);
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/workflow-handoff?${query.toString()}`);
return parseWithFallback(raw, WorkflowHandoffPreviewSchema, EMPTY_WORKFLOW_HANDOFF_PREVIEW, {
endpoint: "GET /api/issues/{id}/workflow-handoff",
});
}
/**
* Switches a project's workflow; issues on unlisted statuses need a mapping.
* Callers refetch the project and its issues, so the response is not read.
*/
async setProjectWorkflow(projectId: string, data: SetProjectWorkflowRequest): Promise<void> {
await this.fetch<unknown>(`/api/projects/${projectId}/workflow`, {
method: "POST",
body: JSON.stringify({ ...data, dry_run: false }),
});
}
async dryRunProjectWorkflow(projectId: string, workflowId: string | null): Promise<IssueWorkflowDryRunResponse> {
const raw = await this.fetch<unknown>(`/api/projects/${projectId}/workflow`, {
method: "POST",
body: JSON.stringify({ workflow_id: workflowId, dry_run: true }),
});
return parseWithFallback(raw, IssueWorkflowDryRunResponseSchema, EMPTY_ISSUE_WORKFLOW_DRY_RUN, {
endpoint: "POST /api/projects/{id}/workflow (dry run)",
});
}
// Issue status catalog (MUL-6243). Reads are open to any workspace member;
// the mutations below are owner/admin only and return 403 otherwise.
async listIssueStatuses(includeArchived = false): Promise<ListIssueStatusesResponse> {
const query = includeArchived ? "?include_archived=true" : "";
const raw = await this.fetch<unknown>(`/api/issue-statuses${query}`);
return parseWithFallback(raw, ListIssueStatusesResponseSchema, EMPTY_LIST_ISSUE_STATUSES_RESPONSE, {
endpoint: "GET /api/issue-statuses",
});
}
async createIssueStatus(data: CreateIssueStatusRequest): Promise<IssueStatusEntry> {
const raw = await this.fetch<unknown>(`/api/issue-statuses`, {
method: "POST",
body: JSON.stringify(data),
});
return parseWithFallback(raw, IssueStatusEntrySchema, EMPTY_ISSUE_STATUS_ENTRY, {
endpoint: "POST /api/issue-statuses",
});
}
async updateIssueStatus(id: string, data: UpdateIssueStatusRequest): Promise<IssueStatusEntry> {
const raw = await this.fetch<unknown>(`/api/issue-statuses/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
});
return parseWithFallback(raw, IssueStatusEntrySchema, EMPTY_ISSUE_STATUS_ENTRY, {
endpoint: "PATCH /api/issue-statuses/{id}",
});
}
/**
* Rewrites one category's status order in a single server-side
* statement. Not expressible as a sequence of `updateIssueStatus` calls: a
* row rejected mid-sequence would leave the earlier rows already reordered
* while the caller sees a failure. (MUL-6243)
*/
async reorderIssueStatuses(
category: IssueStatusCategory,
ids: string[],
includeSystem = false,
): Promise<ListIssueStatusesResponse> {
const raw = await this.fetch<unknown>(`/api/issue-statuses/reorder`, {
method: "PATCH",
body: JSON.stringify({ category, ids, include_system: includeSystem }),
});
return parseWithFallback(raw, ListIssueStatusesResponseSchema, EMPTY_LIST_ISSUE_STATUSES_RESPONSE, {
endpoint: "PATCH /api/issue-statuses/reorder",
});
}
/**
* Archives a custom status, retiring it from future use. Issues already on it
* keep it and keep behaving as their category prescribes; only new
* assignments are refused. Built-in statuses return 403.
*/
async archiveIssueStatus(id: string): Promise<IssueStatusEntry> {
const raw = await this.fetch<unknown>(`/api/issue-statuses/${id}`, { method: "DELETE" });
return parseWithFallback(raw, IssueStatusEntrySchema, EMPTY_ISSUE_STATUS_ENTRY, {
endpoint: "DELETE /api/issue-statuses/{id}",
});
}
// 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}`,
});
}
// Saved issue views (MUL-4796). Responses go through zod so installed
// desktop builds survive backend drift; a malformed list degrades to []
// (selector shows only built-ins) rather than blanking the page.
async listIssueViews(params: {
scope_type: string;
scope_id?: string | null;
}): Promise<IssueView[]> {
const qs = new URLSearchParams({ scope_type: params.scope_type });
if (params.scope_id) qs.set("scope_id", params.scope_id);
const raw = await this.fetch<unknown>(`/api/issue-views?${qs.toString()}`);
return parseWithFallback(raw, IssueViewListSchema, [], {
endpoint: "GET /api/issue-views",
});
}
async createIssueView(data: CreateIssueViewRequest): Promise<IssueView | null> {
const raw = await this.fetch<unknown>("/api/issue-views", {
method: "POST",
body: JSON.stringify(data),
});
// null fallback: the create itself succeeded server-side; a response we
// cannot parse must not crash the dialog — callers refetch the list.
return parseWithFallback(raw, IssueViewSchema.nullable(), null, {
endpoint: "POST /api/issue-views",
});
}
async updateIssueView(
id: string,
data: {
name?: string;
visibility?: "private" | "workspace";
scope_variant?: string | null;
query?: Record<string, unknown>;
display?: Record<string, unknown>;
expected_revision: number;
},
): Promise<IssueView | null> {
const raw = await this.fetch<unknown>(`/api/issue-views/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
});
return parseWithFallback(raw, IssueViewSchema.nullable(), null, {
endpoint: "PATCH /api/issue-views/{id}",
});
}
async getIssueView(id: string): Promise<IssueView | null> {
const raw = await this.fetch<unknown>(`/api/issue-views/${id}`);
return parseWithFallback(raw, IssueViewSchema.nullable(), null, {
endpoint: "GET /api/issue-views/{id}",
});
}
async deleteIssueView(id: string): Promise<void> {
await this.fetch(`/api/issue-views/${id}`, { method: "DELETE" });
}
async getIssueViewPreference(params: {
scope_type: string;
scope_id?: string | null;
}): Promise<IssueViewPreference> {
const qs = new URLSearchParams({ scope_type: params.scope_type });
if (params.scope_id) qs.set("scope_id", params.scope_id);
const raw = await this.fetch<unknown>(`/api/issue-view-preferences?${qs.toString()}`);
return parseWithFallback(raw, IssueViewPreferenceSchema, EMPTY_ISSUE_VIEW_PREFERENCE, {
endpoint: "GET /api/issue-view-preferences",
});
}
async putIssueViewPreference(data: {
scope_type: string;
scope_id?: string | null;
prefs: { hidden: string[]; order: string[] };
}): Promise<IssueViewPreference> {
const raw = await this.fetch<unknown>("/api/issue-view-preferences", {
method: "PUT",
body: JSON.stringify(data),
});
return parseWithFallback(raw, IssueViewPreferenceSchema, EMPTY_ISSUE_VIEW_PREFERENCE, {
endpoint: "PUT /api/issue-view-preferences",
});
}
// Pins
async listPins(): Promise<PinnedItem[]> {
// include=view is the capability opt-in: the server withholds view pins
// from clients that don't declare support (old builds treated any
// non-issue pin as a project pin and auto-deleted it on 404).
return this.fetch("/api/pins?include=view");
}
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" usually returns a run, including downstream admission
// failures. Quota rejection is the exception and returns 429. The UI
// handles both paths without presenting a false-success toast.
const raw = await this.fetch<unknown>(`/api/autopilots/${id}/trigger`, {
method: "POST",
headers: { "Idempotency-Key": createSafeId() },
});
return parseWithFallback(raw, AutopilotRunSchema, FALLBACK_AUTOPILOT_RUN, {
endpoint: "POST /api/autopilots/:id/trigger",
});
}
async getAutopilotQuotaUsage(): Promise<AutopilotQuotaUsage> {
const raw = await this.fetch<unknown>("/api/autopilots/usage");
return parseWithFallback(
raw,
AutopilotQuotaUsageSchema,
{
action: "off",
used: null,
reserved: null,
total: null,
limit: null,
reached: null,
period_start: null,
period_end: null,
reset_at: null,
blocked_counts: null,
},
{ endpoint: "GET /api/autopilots/usage" },
);
}
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", headers: { "Idempotency-Key": createSafeId() } },
);
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<IssuePullRequestsResponse> {
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" },
);
}
/** Link a PR the workspace already mirrors, by pasted URL or by id (undo). */
async linkIssuePullRequest(
issueId: string,
body: { url: string } | { pull_request_id: string },
): Promise<IssuePullRequestsResponse> {
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/pull-requests`, {
method: "POST",
body: JSON.stringify(body),
});
return parseWithFallback(
raw,
IssuePullRequestsResponseSchema,
EMPTY_ISSUE_PULL_REQUESTS_RESPONSE,
{ endpoint: "POST /api/issues/:id/pull-requests" },
);
}
/** Remove a PR from an issue; later webhooks will not link it again. */
async unlinkIssuePullRequest(issueId: string, pullRequestId: string): Promise<IssuePullRequestsResponse> {
const raw = await this.fetch<unknown>(
`/api/issues/${issueId}/pull-requests/${pullRequestId}`,
{ method: "DELETE" },
);
return parseWithFallback(
raw,
IssuePullRequestsResponseSchema,
EMPTY_ISSUE_PULL_REQUESTS_RESPONSE,
{ endpoint: "DELETE /api/issues/:id/pull-requests/:prId" },
);
}
/** Turn PR auto-complete off (or back on) for one issue. */
async setIssuePRAutoComplete(issueId: string, disabled: boolean): Promise<IssuePullRequestsResponse> {
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/pr-auto-complete`, {
method: "PUT",
body: JSON.stringify({ disabled }),
});
return parseWithFallback(
raw,
IssuePullRequestsResponseSchema,
EMPTY_ISSUE_PULL_REQUESTS_RESPONSE,
{ endpoint: "PUT /api/issues/:id/pr-auto-complete" },
);
}
// 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 }),
});
}
// DingTalk integration
async listDingTalkInstallations(
workspaceId: string,
): Promise<ListDingTalkInstallationsResponse> {
const raw = await this.fetch<unknown>(`/api/workspaces/${workspaceId}/dingtalk/installations`);
return parseWithFallback(
raw,
ListDingTalkInstallationsResponseSchema,
EMPTY_LIST_DINGTALK_INSTALLATIONS_RESPONSE,
{ endpoint: "GET /api/workspaces/:id/dingtalk/installations" },
);
}
async listDingTalkGroups(
workspaceId: string,
params: ListDingTalkGroupsParams = {},
): Promise<ListDingTalkGroupsResponse> {
let raw: unknown;
try {
const search = dingTalkGroupSearch(params);
raw = await this.fetch<unknown>(`/api/workspaces/${workspaceId}/dingtalk/groups${search}`);
} catch (error) {
// Installed clients can be newer than the server they reconnect to. A
// backend predating group discovery 404s this additive endpoint. The
// first admin-only version returns 403 to members; both mean this client
// cannot use the workspace inventory, while real failures stay visible.
if (
error instanceof ApiError &&
(error.status === 404 || error.status === 403)
) {
return EMPTY_LIST_DINGTALK_GROUPS_RESPONSE;
}
throw error;
}
return parseWithFallback(
raw,
ListDingTalkGroupsResponseSchema,
EMPTY_LIST_DINGTALK_GROUPS_RESPONSE,
{ endpoint: "GET /api/workspaces/:id/dingtalk/groups" },
);
}
async listAgentDingTalkGroups(
agentId: string,
params: ListDingTalkGroupsParams = {},
): Promise<ListDingTalkGroupsResponse> {
let raw: unknown;
try {
const search = dingTalkGroupSearch(params);
raw = await this.fetch<unknown>(`/api/agents/${agentId}/dingtalk/groups${search}`);
} catch (error) {
// Keep installed clients compatible with servers that predate agent-
// scoped group discovery. Authorization failures must remain visible.
if (error instanceof ApiError && error.status === 404) {
return EMPTY_LIST_DINGTALK_GROUPS_RESPONSE;
}
throw error;
}
return parseWithFallback(
raw,
ListDingTalkGroupsResponseSchema,
EMPTY_LIST_DINGTALK_GROUPS_RESPONSE,
{ endpoint: "GET /api/agents/:id/dingtalk/groups" },
);
}
async forgetDingTalkGroup(
workspaceId: string,
installationId: string,
conversationId: string,
): Promise<void> {
await this.fetch(
`/api/workspaces/${workspaceId}/dingtalk/installations/${installationId}/groups/${encodeURIComponent(conversationId)}`,
{ method: "DELETE" },
);
}
// registerDingTalkBYO performs a bring-your-own-app install: the agent owner
// or a workspace owner/admin pastes the AppKey (client id) + AppSecret
// (client secret) of the DingTalk Stream-mode robot they created, and the
// backend validates + persists it, returning the new installation.
async registerDingTalkBYO(
workspaceId: string,
agentId: string,
body: RegisterDingTalkBYORequest,
): Promise<DingTalkInstallation> {
const search = new URLSearchParams({ agent_id: agentId });
const raw = await this.fetch<unknown>(
`/api/workspaces/${workspaceId}/dingtalk/install/byo?${search.toString()}`,
{
method: "POST",
body: JSON.stringify(body),
},
);
return parseWithFallback(raw, DingTalkInstallationSchema, EMPTY_DINGTALK_INSTALLATION, {
endpoint: "POST /api/workspaces/:id/dingtalk/install/byo",
});
}
async deleteDingTalkInstallation(workspaceId: string, installationId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}/dingtalk/installations/${installationId}`, {
method: "DELETE",
});
}
async redeemDingTalkBindingToken(
token: string,
): Promise<RedeemDingTalkBindingTokenResponse> {
const raw = await this.fetch<unknown>(`/api/dingtalk/binding/redeem`, {
method: "POST",
body: JSON.stringify({ token }),
});
return parseWithFallback(
raw,
RedeemDingTalkBindingTokenResponseSchema,
EMPTY_REDEEM_DINGTALK_BINDING_TOKEN_RESPONSE,
{ endpoint: "POST /api/dingtalk/binding/redeem" },
);
}
// WeCom smart-bot ("智能机器人" / aibot) integration. The bot dials a
// WebSocket long connection to wss://openws.work.weixin.qq.com and stays
// authenticated with (bot_id, secret); no public callback URL is required.
// These three methods drive the Settings-page BYO Connect dialog + list +
// disconnect only — the inbound WebSocket loop runs entirely server-side.
async listWecomInstallations(workspaceId: string): Promise<ListWecomInstallationsResponse> {
const raw = await this.fetch<unknown>(`/api/workspaces/${workspaceId}/wecom/installations`);
return parseWithFallback(
raw,
ListWecomInstallationsResponseSchema,
EMPTY_LIST_WECOM_INSTALLATIONS_RESPONSE,
{ endpoint: "GET /api/workspaces/:id/wecom/installations" },
);
}
// registerWecomBYO performs a bring-your-own-app install: the admin pastes
// the bot id and long-connection secret from the WeCom admin console,
// and the backend seals the secret with MULTICA_WECOM_SECRET_KEY before
// persisting, returning the new installation.
async registerWecomBYO(
workspaceId: string,
agentId: string,
body: RegisterWecomBYORequest,
): Promise<WecomInstallation> {
const search = new URLSearchParams({ agent_id: agentId });
const raw = await this.fetch<unknown>(
`/api/workspaces/${workspaceId}/wecom/install/byo?${search.toString()}`,
{
method: "POST",
body: JSON.stringify(body),
},
);
return parseWithFallback(raw, WecomInstallationSchema, EMPTY_WECOM_INSTALLATION, {
endpoint: "POST /api/workspaces/:id/wecom/install/byo",
});
}
async deleteWecomInstallation(workspaceId: string, installationId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}/wecom/installations/${installationId}`, {
method: "DELETE",
});
}
// redeemWecomBindingToken binds the WeCom aibot userid carried by the
// token to the logged-in Multica user. Called by the /wecom/bind redeem
// page after the user clicks through the "link your Multica account"
// prompt the bot sent in WeCom. Status codes:
// 410 Gone → invalid / expired / already consumed
// 409 Conflict → the WeCom user is already bound to a different user
// 403 Forbidden → the logged-in user is not a workspace member
async redeemWecomBindingToken(token: string): Promise<RedeemWecomBindingTokenResponse> {
const raw = await this.fetch<unknown>(`/api/wecom/binding/redeem`, {
method: "POST",
body: JSON.stringify({ token }),
});
return parseWithFallback(
raw,
RedeemWecomBindingTokenResponseSchema,
EMPTY_REDEEM_WECOM_BINDING_TOKEN_RESPONSE,
{ endpoint: "POST /api/wecom/binding/redeem" },
);
}
async listTelegramInstallations(
workspaceId: string,
): Promise<ListTelegramInstallationsResponse> {
const raw = await this.fetch<unknown>(`/api/workspaces/${workspaceId}/telegram/installations`);
return parseWithFallback(
raw,
ListTelegramInstallationsResponseSchema,
EMPTY_LIST_TELEGRAM_INSTALLATIONS_RESPONSE,
{ endpoint: "GET /api/workspaces/:id/telegram/installations" },
);
}
async registerTelegramBot(
workspaceId: string,
agentId: string,
body: RegisterTelegramRequest,
): Promise<TelegramInstallation> {
const search = new URLSearchParams({ agent_id: agentId });
const raw = await this.fetch<unknown>(
`/api/workspaces/${workspaceId}/telegram/install?${search.toString()}`,
{
method: "POST",
body: JSON.stringify(body),
},
);
return parseWithFallback(raw, TelegramInstallationSchema, EMPTY_TELEGRAM_INSTALLATION, {
endpoint: "POST /api/workspaces/:id/telegram/install",
});
}
async deleteTelegramInstallation(workspaceId: string, installationId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}/telegram/installations/${installationId}`, {
method: "DELETE",
});
}
async redeemTelegramBindingToken(token: string): Promise<RedeemTelegramBindingTokenResponse> {
const raw = await this.fetch<unknown>(`/api/telegram/binding/redeem`, {
method: "POST",
body: JSON.stringify({ token }),
});
return parseWithFallback(
raw,
RedeemTelegramBindingTokenResponseSchema,
EMPTY_REDEEM_TELEGRAM_BINDING_TOKEN_RESPONSE,
{ endpoint: "POST /api/telegram/binding/redeem" },
);
}
// Platform admin (/api/admin). Every write carries a reason that the server
// records in admin_audit_log; the UI collects it before calling.
private adminQuery(params: Record<string, string | number | boolean | undefined>): string {
const search = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === "" || v === false) continue;
search.set(k, String(v));
}
const qs = search.toString();
return qs ? `?${qs}` : "";
}
async adminMe(): Promise<AdminMe> {
const raw = await this.fetch<unknown>("/api/admin/me");
return parseWithFallback(raw, AdminMeSchema, EMPTY_ADMIN_ME, {
endpoint: "GET /api/admin/me",
});
}
async adminOverview(): Promise<AdminOverview> {
const raw = await this.fetch<unknown>("/api/admin/overview");
return parseWithFallback(raw, AdminOverviewSchema, EMPTY_ADMIN_OVERVIEW, {
endpoint: "GET /api/admin/overview",
});
}
async adminListUsers(params: AdminUserListParams = {}): Promise<AdminPage<AdminUser>> {
const raw = await this.fetch<unknown>(
`/api/admin/users${this.adminQuery({
q: params.q,
disabled: params.disabled_only,
page: params.page,
page_size: params.page_size,
})}`,
);
return parseWithFallback(raw, AdminUserPageSchema, emptyAdminPage<AdminUser>(), {
endpoint: "GET /api/admin/users",
});
}
async adminGetUser(id: string): Promise<AdminUserDetail> {
const raw = await this.fetch<unknown>(`/api/admin/users/${id}`);
return parseWithFallback(raw, AdminUserDetailSchema, EMPTY_ADMIN_USER_DETAIL, {
endpoint: "GET /api/admin/users/:id",
});
}
async adminDisableUser(id: string, body: AdminActionRequest): Promise<void> {
await this.fetch(`/api/admin/users/${id}/disable`, {
method: "POST",
body: JSON.stringify(body),
});
}
async adminEnableUser(id: string, body: AdminActionRequest): Promise<void> {
await this.fetch(`/api/admin/users/${id}/enable`, {
method: "POST",
body: JSON.stringify(body),
});
}
async adminRevokeUserToken(
userId: string,
tokenId: string,
body: AdminActionRequest,
): Promise<void> {
await this.fetch(`/api/admin/users/${userId}/tokens/${tokenId}/revoke`, {
method: "POST",
body: JSON.stringify(body),
});
}
async adminListWorkspaces(
params: AdminWorkspaceListParams = {},
): Promise<AdminPage<AdminWorkspace>> {
const raw = await this.fetch<unknown>(
`/api/admin/workspaces${this.adminQuery({
q: params.q,
page: params.page,
page_size: params.page_size,
})}`,
);
return parseWithFallback(raw, AdminWorkspacePageSchema, emptyAdminPage<AdminWorkspace>(), {
endpoint: "GET /api/admin/workspaces",
});
}
async adminGetWorkspace(id: string): Promise<AdminWorkspaceDetail> {
const raw = await this.fetch<unknown>(`/api/admin/workspaces/${id}`);
return parseWithFallback(raw, AdminWorkspaceDetailSchema, EMPTY_ADMIN_WORKSPACE_DETAIL, {
endpoint: "GET /api/admin/workspaces/:id",
});
}
async adminCreateWorkspace(body: AdminCreateWorkspaceRequest): Promise<Workspace> {
return this.fetch("/api/admin/workspaces", {
method: "POST",
body: JSON.stringify(body),
});
}
async adminMarketListings(params: AdminMarketListParams = {}): Promise<AdminPage<AdminMarketListing>> {
const raw = await this.fetch<unknown>(`/api/admin/market/listings${this.adminQuery({ q: params.q, page: params.page, page_size: params.page_size })}`);
return parseWithFallback(raw, AdminMarketPageSchema, emptyAdminPage<AdminMarketListing>(), { endpoint: "GET /api/admin/market/listings" });
}
async adminListAudit(params: AdminAuditListParams = {}): Promise<AdminPage<AdminAuditEntry>> {
const raw = await this.fetch<unknown>(
`/api/admin/audit${this.adminQuery({
actor: params.actor_user_id,
action: params.action,
target_id: params.target_id,
page: params.page,
page_size: params.page_size,
})}`,
);
return parseWithFallback(raw, AdminAuditPageSchema, emptyAdminPage<AdminAuditEntry>(), {
endpoint: "GET /api/admin/audit",
});
}
async adminListPlatformAdmins(): Promise<PlatformAdmin[]> {
const raw = await this.fetch<unknown>("/api/admin/admins");
return parseWithFallback(raw, PlatformAdminListSchema, [], {
endpoint: "GET /api/admin/admins",
});
}
async adminSetPlatformAdmin(body: SetPlatformAdminRequest): Promise<PlatformAdmin> {
const raw = await this.fetch<unknown>("/api/admin/admins", {
method: "PUT",
body: JSON.stringify(body),
});
return parseWithFallback(raw, PlatformAdminSchema, EMPTY_PLATFORM_ADMIN, {
endpoint: "PUT /api/admin/admins",
});
}
async adminRemovePlatformAdmin(userId: string, body: AdminActionRequest): Promise<void> {
await this.fetch(`/api/admin/admins/${userId}`, {
method: "DELETE",
body: JSON.stringify(body),
});
}
}