Files
multica/packages/core/api/client.ts
Jiang Bohan 6d64141e2c feat(settings): allow editing workspace issue prefix (MUL-2369)
Workspace admins can now change the issue prefix from Settings → General.
The change is gated by a confirmation dialog that warns about external
references (PR titles, branch names, links) breaking, because issue
identifiers are rendered as `prefix-N` on the fly — changing the prefix
effectively renames every existing issue.

Refs https://github.com/multica-ai/multica/issues/2797

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 18:29:17 +08:00

1713 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
Issue,
CreateIssueRequest,
UpdateIssueRequest,
GroupedIssuesResponse,
ListIssuesResponse,
SearchIssuesResponse,
SearchProjectsResponse,
UpdateMeRequest,
CreateMemberRequest,
UpdateMemberRequest,
ListIssuesParams,
ListGroupedIssuesParams,
Agent,
CreateAgentRequest,
AgentTemplate,
AgentTemplateSummary,
CreateAgentFromTemplateRequest,
CreateAgentFromTemplateResponse,
UpdateAgentRequest,
AgentTask,
AgentActivityBucket,
AgentRunCount,
AgentRuntime,
InboxItem,
IssueSubscriber,
Comment,
Reaction,
IssueReaction,
Workspace,
WorkspaceRepo,
MemberWithUser,
User,
Skill,
SkillSummary,
CreateSkillRequest,
UpdateSkillRequest,
SetAgentSkillsRequest,
PersonalAccessToken,
CreatePersonalAccessTokenRequest,
CreatePersonalAccessTokenResponse,
RuntimeUsage,
IssueUsageSummary,
RuntimeHourlyActivity,
RuntimeUsageByAgent,
RuntimeUsageByHour,
DashboardUsageDaily,
DashboardUsageByAgent,
DashboardAgentRunTime,
DashboardRunTimeDaily,
RuntimeUpdate,
RuntimeModelListRequest,
RuntimeLocalSkillListRequest,
CreateRuntimeLocalSkillImportRequest,
RuntimeLocalSkillImportRequest,
TimelineEntry,
AssigneeFrequencyEntry,
TaskMessagePayload,
Attachment,
ChatSession,
ChatMessage,
ChatPendingTask,
PendingChatTasksResponse,
SendChatMessageResponse,
Project,
CreateProjectRequest,
UpdateProjectRequest,
ListProjectsResponse,
ProjectResource,
CreateProjectResourceRequest,
ListProjectResourcesResponse,
Label,
CreateLabelRequest,
UpdateLabelRequest,
ListLabelsResponse,
IssueLabelsResponse,
PinnedItem,
CreatePinRequest,
PinnedItemType,
ReorderPinsRequest,
Invitation,
Autopilot,
AutopilotTrigger,
AutopilotRun,
CreateAutopilotRequest,
UpdateAutopilotRequest,
CreateAutopilotTriggerRequest,
UpdateAutopilotTriggerRequest,
ListAutopilotsResponse,
GetAutopilotResponse,
ListAutopilotRunsResponse,
ListWebhookDeliveriesResponse,
WebhookDelivery,
NotificationPreferenceResponse,
NotificationPreferences,
GitHubPullRequest,
ListGitHubInstallationsResponse,
GitHubConnectResponse,
Squad,
SquadMember,
SquadMemberStatusListResponse,
} from "../types";
import type { OnboardingCompletionPath } from "../onboarding/types";
import { type Logger, noopLogger } from "../logger";
import { createRequestId } from "../utils";
import { getCurrentSlug } from "../platform/workspace-storage";
import { parseWithFallback } from "./schema";
import {
AgentTemplateSchema,
AgentTemplateSummaryListSchema,
AttachmentResponseSchema,
ChildIssuesResponseSchema,
CommentsListSchema,
CreateAgentFromTemplateResponseSchema,
DashboardAgentRunTimeListSchema,
DashboardRunTimeDailyListSchema,
DashboardUsageByAgentListSchema,
DashboardUsageDailyListSchema,
EMPTY_AGENT_TEMPLATE_DETAIL,
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
EMPTY_ATTACHMENT,
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
EMPTY_GROUPED_ISSUES_RESPONSE,
EMPTY_LIST_ISSUES_RESPONSE,
EMPTY_SQUAD_MEMBER_STATUS_LIST,
EMPTY_TIMELINE_ENTRIES,
EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE,
EMPTY_WEBHOOK_DELIVERY,
GroupedIssuesResponseSchema,
ListIssuesResponseSchema,
ListWebhookDeliveriesResponseSchema,
SquadMemberStatusListResponseSchema,
SubscribersListSchema,
TimelineEntriesSchema,
WebhookDeliveryResponseSchema,
} 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;
/** Operating system the client is running on: "macos" | "windows" | "linux". */
os?: string;
}
export interface ApiClientOptions {
logger?: Logger;
onUnauthorized?: () => void;
/** Identifies the client to the server. Sent as X-Client-* headers. */
identity?: ApiClientIdentity;
}
export interface LoginResponse {
token: string;
user: User;
}
// --- Starter content (post-onboarding import) -----------------------------
// Shape mirrors the Go request/response in handler/onboarding.go.
//
// The client sends both branches of sub-issues and an unbound welcome
// issue template (title + description, no `agent_id`). The SERVER picks
// the branch by inspecting the workspace's agent list inside the
// import transaction. This removes the client as a trusted decider —
// even if the client has a stale agent cache or lies, the server uses
// the DB as source of truth.
export interface ImportStarterIssuePayload {
title: string;
description: string;
status: string;
priority: string;
/** Server uses `user_id` (per app-wide AssigneePicker convention)
* as assignee when true. No member_id is threaded through. */
assign_to_self: boolean;
}
export interface ImportStarterWelcomeIssueTemplate {
title: string;
description: string;
/** Defaults to "high" on server when empty. */
priority: string;
}
export interface ImportStarterContentPayload {
workspace_id: string;
project: { title: string; description: string; icon: string };
/** Always sent. Server creates it only when an agent exists in the
* workspace; ignored otherwise. Agent id is picked by the server. */
welcome_issue_template: ImportStarterWelcomeIssueTemplate;
/** Used when the workspace has at least one agent. */
agent_guided_sub_issues: ImportStarterIssuePayload[];
/** Used when the workspace has zero agents. */
self_serve_sub_issues: ImportStarterIssuePayload[];
}
export interface ImportStarterContentResponse {
user: User;
project_id: string;
/** Non-null when server took the agent-guided branch. */
welcome_issue_id: string | null;
}
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;
}
}
// 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";
}
}
export class ApiClient {
private baseUrl: string;
private token: string | null = null;
private logger: Logger;
private options: ApiClientOptions;
constructor(baseUrl: string, options?: ApiClientOptions) {
this.baseUrl = baseUrl;
this.options = options ?? {};
this.logger = options?.logger ?? noopLogger;
}
getBaseUrl(): string {
return this.baseUrl;
}
setToken(token: string | null) {
this.token = token;
}
private readCsrfToken(): string | null {
if (typeof document === "undefined") return null;
const match = document.cookie
.split("; ")
.find((c) => c.startsWith("multica_csrf="));
return match ? match.split("=")[1] ?? null : null;
}
private authHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
const slug = getCurrentSlug();
if (slug) headers["X-Workspace-Slug"] = slug;
const csrf = this.readCsrfToken();
if (csrf) headers["X-CSRF-Token"] = csrf;
const id = this.options.identity;
if (id?.platform) headers["X-Client-Platform"] = id.platform;
if (id?.version) headers["X-Client-Version"] = id.version;
if (id?.os) headers["X-Client-OS"] = id.os;
return headers;
}
private handleUnauthorized() {
this.token = null;
// Workspace id is owned by the URL-driven workspace-storage singleton
// (set by [workspaceSlug]/layout.tsx). On 401, the auth flow navigates
// to /login which leaves the workspace route, and the next workspace
// entry will overwrite the id. No clear needed here.
this.options.onUnauthorized?.();
}
private async parseErrorMessage(res: Response, fallback: string): Promise<string> {
try {
const data = await res.json() as { error?: string };
if (typeof data.error === "string" && data.error) return data.error;
} catch {
// Ignore non-JSON error bodies.
}
return fallback;
}
// Reads the response body once for both human-readable error message and
// structured fields. The Response stream can only be consumed once, so
// both pieces have to come from a single read.
private async parseErrorBody(res: Response, fallback: string): Promise<{ message: string; body: unknown }> {
try {
const data = await res.json() as { error?: string };
const message = typeof data.error === "string" && data.error ? data.error : fallback;
return { message, body: data };
} catch {
return { message: fallback, body: undefined };
}
}
// Sends the request with the standard headers (auth, CSRF, request id,
// client identity) and runs the shared error path (401 → handleUnauthorized,
// structured ApiError, status-aware log level). Returns the raw Response so
// callers can decide how to decode the body — JSON for the typed `fetch<T>`
// path, plain text for the attachment-preview proxy, etc.
private async fetchRaw(
path: string,
init?: RequestInit & { extraHeaders?: Record<string, string> },
): Promise<Response> {
const rid = createRequestId();
const start = Date.now();
const method = init?.method ?? "GET";
const headers: Record<string, string> = {
"X-Request-ID": rid,
...this.authHeaders(),
...(init?.extraHeaders ?? {}),
...((init?.headers as Record<string, string>) ?? {}),
};
this.logger.info(`${method} ${path}`, { rid });
const res = await fetch(`${this.baseUrl}${path}`, {
...init,
headers,
credentials: "include",
});
if (!res.ok) {
if (res.status === 401) this.handleUnauthorized();
const { message, body } = await this.parseErrorBody(res, `API error: ${res.status} ${res.statusText}`);
const logLevel = res.status === 404 ? "warn" : "error";
this.logger[logLevel](`${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message });
throw new ApiError(message, res.status, res.statusText, body);
}
this.logger.info(`${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms` });
return res;
}
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await this.fetchRaw(path, {
...init,
extraHeaders: { "Content-Type": "application/json" },
});
// Handle 204 No Content
if (res.status === 204) {
return undefined as T;
}
return res.json() as Promise<T>;
}
// Auth
async sendCode(email: string): Promise<void> {
await this.fetch("/auth/send-code", {
method: "POST",
body: JSON.stringify({ email }),
});
}
async verifyCode(email: string, code: string): Promise<LoginResponse> {
return this.fetch("/auth/verify-code", {
method: "POST",
body: JSON.stringify({ email, code }),
});
}
async googleLogin(code: string, redirectUri: string): Promise<LoginResponse> {
return this.fetch("/auth/google", {
method: "POST",
body: JSON.stringify({ code, redirect_uri: redirectUri }),
});
}
async logout(): Promise<void> {
await this.fetch("/auth/logout", { method: "POST" });
}
async issueCliToken(): Promise<{ token: string }> {
return this.fetch("/api/cli-token", { method: "POST" });
}
async getMe(): Promise<User> {
return this.fetch("/api/me");
}
async markOnboardingComplete(payload?: {
completion_path?: OnboardingCompletionPath;
workspace_id?: string;
}): Promise<User> {
return this.fetch("/api/me/onboarding/complete", {
method: "POST",
body: payload ? JSON.stringify(payload) : undefined,
});
}
async joinCloudWaitlist(payload: {
email: string;
reason?: string;
}): Promise<User> {
return this.fetch("/api/me/onboarding/cloud-waitlist", {
method: "POST",
body: JSON.stringify(payload),
});
}
async patchOnboarding(payload: {
questionnaire?: Record<string, unknown>;
}): Promise<User> {
return this.fetch("/api/me/onboarding", {
method: "PATCH",
body: JSON.stringify(payload),
});
}
/**
* Imports the Getting Started project + optional welcome issue + sub-issues
* in a single server-side transaction. Gated by an atomic
* starter_content_state: NULL → 'imported' claim — a second call returns
* 409 (already decided) and creates nothing new.
*
* The content templates live in TypeScript (see
* @multica/views/onboarding/utils/starter-content-templates) and are
* rendered from the user's questionnaire answers before being sent.
*/
async importStarterContent(
payload: ImportStarterContentPayload,
): Promise<ImportStarterContentResponse> {
return this.fetch("/api/me/starter-content/import", {
method: "POST",
body: JSON.stringify(payload),
});
}
async dismissStarterContent(payload?: {
workspace_id?: string;
}): Promise<User> {
return this.fetch("/api/me/starter-content/dismiss", {
method: "POST",
body: payload ? JSON.stringify(payload) : undefined,
});
}
async updateMe(data: UpdateMeRequest): Promise<User> {
return this.fetch("/api/me", {
method: "PATCH",
body: JSON.stringify(data),
});
}
// 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?.status) search.set("status", params.status);
if (params?.priority) search.set("priority", params.priority);
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?.open_only) search.set("open_only", "true");
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.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);
const raw = await this.fetch<unknown>(`/api/issues/grouped?${search}`);
return parseWithFallback(raw, GroupedIssuesResponseSchema, EMPTY_GROUPED_ISSUES_RESPONSE, {
endpoint: "GET /api/issues/grouped",
});
}
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");
return this.fetch(`/api/issues/search?${search}`, params.signal ? { signal: params.signal } : undefined);
}
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");
return this.fetch(`/api/projects/search?${search}`, params.signal ? { signal: params.signal } : undefined);
}
async getIssue(id: string): Promise<Issue> {
return this.fetch(`/api/issues/${id}`);
}
async createIssue(data: CreateIssueRequest): Promise<Issue> {
return this.fetch("/api/issues", {
method: "POST",
body: JSON.stringify(data),
});
}
async quickCreateIssue(data: {
agent_id?: string;
squad_id?: string;
prompt: string;
project_id?: string | null;
}): Promise<{ task_id: string }> {
return this.fetch("/api/issues/quick-create", {
method: "POST",
body: JSON.stringify(data),
});
}
async createFeedback(data: {
message: string;
url?: string;
workspace_id?: string;
}): Promise<{ id: string; created_at: string }> {
return this.fetch("/api/feedback", {
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 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",
});
}
async getChildIssueProgress(): Promise<{ progress: { parent_issue_id: string; total: number; done: number }[] }> {
return this.fetch("/api/issues/child-progress");
}
async deleteIssue(id: string): Promise<void> {
await this.fetch(`/api/issues/${id}`, { method: "DELETE" });
}
async batchUpdateIssues(issueIds: string[], updates: UpdateIssueRequest): Promise<{ updated: number }> {
return this.fetch("/api/issues/batch-update", {
method: "POST",
body: JSON.stringify({ issue_ids: issueIds, updates }),
});
}
async batchDeleteIssues(issueIds: string[]): Promise<{ deleted: number }> {
return this.fetch("/api/issues/batch-delete", {
method: "POST",
body: JSON.stringify({ issue_ids: issueIds }),
});
}
// Comments
async listComments(issueId: string): Promise<Comment[]> {
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/comments`);
return parseWithFallback(raw, CommentsListSchema, [], {
endpoint: "GET /api/issues/:id/comments",
});
}
async createComment(issueId: string, content: string, type?: string, parentId?: string, attachmentIds?: string[]): 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 } : {}),
}),
});
}
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[]): Promise<Comment> {
return this.fetch(`/api/comments/${commentId}`, {
method: "PUT",
body: JSON.stringify({ content, attachment_ids: attachmentIds }),
});
}
async deleteComment(commentId: string): Promise<void> {
await this.fetch(`/api/comments/${commentId}`, { method: "DELETE" });
}
async resolveComment(commentId: string): Promise<Comment> {
return this.fetch(`/api/comments/${commentId}/resolve`, { method: "POST" });
}
async unresolveComment(commentId: string): Promise<Comment> {
return this.fetch(`/api/comments/${commentId}/resolve`, { method: "DELETE" });
}
async addReaction(commentId: string, emoji: string): Promise<Reaction> {
return this.fetch(`/api/comments/${commentId}/reactions`, {
method: "POST",
body: JSON.stringify({ emoji }),
});
}
async removeReaction(commentId: string, emoji: string): Promise<void> {
await this.fetch(`/api/comments/${commentId}/reactions`, {
method: "DELETE",
body: JSON.stringify({ emoji }),
});
}
async addIssueReaction(issueId: string, emoji: string): Promise<IssueReaction> {
return this.fetch(`/api/issues/${issueId}/reactions`, {
method: "POST",
body: JSON.stringify({ emoji }),
});
}
async removeIssueReaction(issueId: string, emoji: string): Promise<void> {
await this.fetch(`/api/issues/${issueId}/reactions`, {
method: "DELETE",
body: JSON.stringify({ emoji }),
});
}
// Subscribers
async listIssueSubscribers(issueId: string): Promise<IssueSubscriber[]> {
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/subscribers`);
return parseWithFallback(raw, SubscribersListSchema, [], {
endpoint: "GET /api/issues/:id/subscribers",
});
}
async subscribeToIssue(issueId: string, userId?: string, userType?: string): Promise<void> {
const body: Record<string, string> = {};
if (userId) body.user_id = userId;
if (userType) body.user_type = userType;
await this.fetch(`/api/issues/${issueId}/subscribe`, {
method: "POST",
body: JSON.stringify(body),
});
}
async unsubscribeFromIssue(issueId: string, userId?: string, userType?: string): Promise<void> {
const body: Record<string, string> = {};
if (userId) body.user_id = userId;
if (userType) body.user_type = userType;
await this.fetch(`/api/issues/${issueId}/unsubscribe`, {
method: "POST",
body: JSON.stringify(body),
});
}
// Agents
async listAgents(params?: { workspace_id?: string; include_archived?: boolean }): Promise<Agent[]> {
const search = new URLSearchParams();
if (params?.workspace_id) search.set("workspace_id", params.workspace_id);
if (params?.include_archived) search.set("include_archived", "true");
return this.fetch(`/api/agents?${search}`);
}
async getAgent(id: string): Promise<Agent> {
return this.fetch(`/api/agents/${id}`);
}
async createAgent(data: CreateAgentRequest): Promise<Agent> {
return this.fetch("/api/agents", {
method: "POST",
body: JSON.stringify(data),
});
}
async listAgentTemplates(): Promise<AgentTemplateSummary[]> {
const raw = await this.fetch<unknown>("/api/agent-templates");
return parseWithFallback(
raw,
AgentTemplateSummaryListSchema,
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
{ endpoint: "GET /api/agent-templates" },
);
}
async getAgentTemplate(slug: string): Promise<AgentTemplate> {
const raw = await this.fetch<unknown>(
`/api/agent-templates/${encodeURIComponent(slug)}`,
);
// Round-trip the requested slug into the fallback so a malformed
// detail response still produces a navigable record matching the URL
// the user clicked.
return parseWithFallback(
raw,
AgentTemplateSchema,
{ ...EMPTY_AGENT_TEMPLATE_DETAIL, slug },
{ endpoint: "GET /api/agent-templates/:slug" },
);
}
/** Creates an agent from a curated template. The server fetches every
* referenced skill URL in parallel, materializes them into the workspace
* (find-or-create by name), and writes the agent + skill bindings in a
* single transaction. On any upstream fetch failure, the entire write is
* rolled back and the API returns 422 with `failed_urls`. */
async createAgentFromTemplate(
data: CreateAgentFromTemplateRequest,
): Promise<CreateAgentFromTemplateResponse> {
const raw = await this.fetch<unknown>("/api/agents/from-template", {
method: "POST",
body: JSON.stringify(data),
});
return parseWithFallback(
raw,
CreateAgentFromTemplateResponseSchema,
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
{ endpoint: "POST /api/agents/from-template" },
);
}
async updateAgent(id: string, data: UpdateAgentRequest): Promise<Agent> {
return this.fetch(`/api/agents/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
async archiveAgent(id: string): Promise<Agent> {
return this.fetch(`/api/agents/${id}/archive`, { method: "POST" });
}
async restoreAgent(id: string): Promise<Agent> {
return this.fetch(`/api/agents/${id}/restore`, { method: "POST" });
}
// Bulk-cancel every active task (queued/dispatched/running) for the agent.
// Permission: agent owner or workspace admin/owner. Server returns the
// count of cancelled rows; broadcasts task:cancelled for each so other
// surfaces can clear their live cards.
async cancelAgentTasks(id: string): Promise<{ cancelled: number }> {
return this.fetch(`/api/agents/${id}/cancel-tasks`, { method: "POST" });
}
async listRuntimes(params?: { workspace_id?: string; owner?: "me" }): Promise<AgentRuntime[]> {
const search = new URLSearchParams();
if (params?.workspace_id) search.set("workspace_id", params.workspace_id);
if (params?.owner) search.set("owner", params.owner);
return this.fetch(`/api/runtimes?${search}`);
}
async deleteRuntime(runtimeId: string): Promise<void> {
await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" });
}
async updateRuntime(
runtimeId: string,
patch: { timezone?: string; visibility?: "private" | "public" },
): Promise<AgentRuntime> {
return this.fetch(`/api/runtimes/${runtimeId}`, {
method: "PATCH",
body: JSON.stringify(patch),
});
}
async getRuntimeUsage(runtimeId: string, params?: { days?: number }): Promise<RuntimeUsage[]> {
const search = new URLSearchParams();
if (params?.days) search.set("days", String(params.days));
return this.fetch(`/api/runtimes/${runtimeId}/usage?${search}`);
}
async getRuntimeTaskActivity(runtimeId: string): Promise<RuntimeHourlyActivity[]> {
return this.fetch(`/api/runtimes/${runtimeId}/activity`);
}
async getRuntimeUsageByAgent(
runtimeId: string,
params?: { days?: number },
): Promise<RuntimeUsageByAgent[]> {
const search = new URLSearchParams();
if (params?.days) search.set("days", String(params.days));
return this.fetch(`/api/runtimes/${runtimeId}/usage/by-agent?${search}`);
}
async getRuntimeUsageByHour(
runtimeId: string,
params?: { days?: number },
): Promise<RuntimeUsageByHour[]> {
const search = new URLSearchParams();
if (params?.days) search.set("days", String(params.days));
return this.fetch(`/api/runtimes/${runtimeId}/usage/by-hour?${search}`);
}
// ---------------------------------------------------------------------------
// 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 },
): 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);
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 },
): 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);
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 },
): 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);
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 },
): 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);
const raw = await this.fetch<unknown>(`/api/dashboard/runtime/daily?${search}`);
return parseWithFallback<DashboardRunTimeDaily[]>(
raw,
DashboardRunTimeDailyListSchema,
[],
{ endpoint: "GET /api/dashboard/runtime/daily" },
);
}
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}`);
}
async initiateListModels(runtimeId: string): Promise<RuntimeModelListRequest> {
return this.fetch(`/api/runtimes/${runtimeId}/models`, { method: "POST" });
}
async getListModelsResult(
runtimeId: string,
requestId: string,
): Promise<RuntimeModelListRequest> {
return this.fetch(`/api/runtimes/${runtimeId}/models/${requestId}`);
}
async initiateListLocalSkills(
runtimeId: string,
): Promise<RuntimeLocalSkillListRequest> {
return this.fetch(`/api/runtimes/${runtimeId}/local-skills`, {
method: "POST",
});
}
async getListLocalSkillsResult(
runtimeId: string,
requestId: string,
): Promise<RuntimeLocalSkillListRequest> {
return this.fetch(`/api/runtimes/${runtimeId}/local-skills/${requestId}`);
}
async initiateImportLocalSkill(
runtimeId: string,
data: CreateRuntimeLocalSkillImportRequest,
): Promise<RuntimeLocalSkillImportRequest> {
return this.fetch(`/api/runtimes/${runtimeId}/local-skills/import`, {
method: "POST",
body: JSON.stringify(data),
});
}
async getImportLocalSkillResult(
runtimeId: string,
requestId: string,
): Promise<RuntimeLocalSkillImportRequest> {
return this.fetch(`/api/runtimes/${runtimeId}/local-skills/import/${requestId}`);
}
async listAgentTasks(agentId: string): Promise<AgentTask[]> {
return this.fetch(`/api/agents/${agentId}/tasks`);
}
// Workspace-scoped agent task snapshot: every active task
// (queued/dispatched/running) plus each agent's most recent terminal task.
// Powers the front-end's "active wins, else latest terminal" presence
// derivation; one fetch backs every per-agent presence read in the app.
// Workspace is resolved server-side from the X-Workspace-Slug header.
async getAgentTaskSnapshot(): Promise<AgentTask[]> {
return this.fetch(`/api/agent-task-snapshot`);
}
// Per-agent daily activity for the last 30 days, anchored on
// completed_at. One workspace-wide fetch backs both the Agents-list
// sparkline (uses trailing 7 buckets) and the agent detail "Last 30
// days" panel (uses all 30).
async getWorkspaceAgentActivity30d(): Promise<AgentActivityBucket[]> {
return this.fetch(`/api/agent-activity-30d`);
}
// Per-agent 30-day total run count for the Agents-list RUNS column.
async getWorkspaceAgentRunCounts(): Promise<AgentRunCount[]> {
return this.fetch(`/api/agent-run-counts`);
}
async getActiveTasksForIssue(issueId: string): Promise<{ tasks: AgentTask[] }> {
return this.fetch(`/api/issues/${issueId}/active-task`);
}
async listTaskMessages(taskId: string): Promise<TaskMessagePayload[]> {
return this.fetch(`/api/tasks/${taskId}/messages`);
}
async listTasksByIssue(issueId: string): Promise<AgentTask[]> {
return this.fetch(`/api/issues/${issueId}/task-runs`);
}
async getIssueUsage(issueId: string): Promise<IssueUsageSummary> {
return this.fetch(`/api/issues/${issueId}/usage`);
}
async cancelTask(issueId: string, taskId: string): Promise<AgentTask> {
return this.fetch(`/api/issues/${issueId}/tasks/${taskId}/cancel`, {
method: "POST",
});
}
async rerunIssue(issueId: string): Promise<AgentTask> {
return this.fetch(`/api/issues/${issueId}/rerun`, {
method: "POST",
});
}
// Inbox
async listInbox(): Promise<InboxItem[]> {
return this.fetch("/api/inbox");
}
async markInboxRead(id: string): Promise<InboxItem> {
return this.fetch(`/api/inbox/${id}/read`, { method: "POST" });
}
async archiveInbox(id: string): Promise<InboxItem> {
return this.fetch(`/api/inbox/${id}/archive`, { method: "POST" });
}
async getUnreadInboxCount(): Promise<{ count: number }> {
return this.fetch("/api/inbox/unread-count");
}
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
async getNotificationPreferences(): Promise<NotificationPreferenceResponse> {
return this.fetch("/api/notification-preferences");
}
async updateNotificationPreferences(preferences: NotificationPreferences): Promise<NotificationPreferenceResponse> {
return this.fetch("/api/notification-preferences", {
method: "PUT",
body: JSON.stringify({ preferences }),
});
}
// App Config
async getConfig(): Promise<{
cdn_domain: string;
allow_signup: boolean;
google_client_id?: string;
posthog_key?: string;
posthog_host?: string;
analytics_environment?: string;
}> {
return this.fetch("/api/config");
}
// Workspaces
async listWorkspaces(): Promise<Workspace[]> {
return this.fetch("/api/workspaces");
}
async getWorkspace(id: string): Promise<Workspace> {
return this.fetch(`/api/workspaces/${id}`);
}
async createWorkspace(data: { name: string; slug: string; description?: string; context?: string }): Promise<Workspace> {
return this.fetch("/api/workspaces", {
method: "POST",
body: JSON.stringify(data),
});
}
async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record<string, unknown>; repos?: WorkspaceRepo[]; issue_prefix?: string }): Promise<Workspace> {
return this.fetch(`/api/workspaces/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
// Members
async listMembers(workspaceId: string): Promise<MemberWithUser[]> {
return this.fetch(`/api/workspaces/${workspaceId}/members`);
}
async createMember(workspaceId: string, data: CreateMemberRequest): Promise<Invitation> {
return this.fetch(`/api/workspaces/${workspaceId}/members`, {
method: "POST",
body: JSON.stringify(data),
});
}
async updateMember(workspaceId: string, memberId: string, data: UpdateMemberRequest): Promise<MemberWithUser> {
return this.fetch(`/api/workspaces/${workspaceId}/members/${memberId}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
async deleteMember(workspaceId: string, memberId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}/members/${memberId}`, {
method: "DELETE",
});
}
async leaveWorkspace(workspaceId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}/leave`, {
method: "POST",
});
}
// Invitations
async listWorkspaceInvitations(workspaceId: string): Promise<Invitation[]> {
return this.fetch(`/api/workspaces/${workspaceId}/invitations`);
}
async revokeInvitation(workspaceId: string, invitationId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}/invitations/${invitationId}`, {
method: "DELETE",
});
}
async listMyInvitations(): Promise<Invitation[]> {
return this.fetch("/api/invitations");
}
async getInvitation(invitationId: string): Promise<Invitation> {
return this.fetch(`/api/invitations/${invitationId}`);
}
async acceptInvitation(invitationId: string): Promise<MemberWithUser> {
return this.fetch(`/api/invitations/${invitationId}/accept`, {
method: "POST",
});
}
async declineInvitation(invitationId: string): Promise<void> {
await this.fetch(`/api/invitations/${invitationId}/decline`, {
method: "POST",
});
}
async deleteWorkspace(workspaceId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}`, {
method: "DELETE",
});
}
// Skills
async listSkills(): Promise<SkillSummary[]> {
return this.fetch("/api/skills");
}
async getSkill(id: string): Promise<Skill> {
return this.fetch(`/api/skills/${id}`);
}
async createSkill(data: CreateSkillRequest): Promise<Skill> {
return this.fetch("/api/skills", {
method: "POST",
body: JSON.stringify(data),
});
}
async updateSkill(id: string, data: UpdateSkillRequest): Promise<Skill> {
return this.fetch(`/api/skills/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
async deleteSkill(id: string): Promise<void> {
await this.fetch(`/api/skills/${id}`, { method: "DELETE" });
}
async importSkill(data: { url: string }): Promise<Skill> {
return this.fetch("/api/skills/import", {
method: "POST",
body: JSON.stringify(data),
});
}
async listAgentSkills(agentId: string): Promise<SkillSummary[]> {
return this.fetch(`/api/agents/${agentId}/skills`);
}
async setAgentSkills(agentId: string, data: SetAgentSkillsRequest): Promise<void> {
await this.fetch(`/api/agents/${agentId}/skills`, {
method: "PUT",
body: JSON.stringify(data),
});
}
// 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 },
): Promise<Attachment> {
const formData = new FormData();
formData.append("file", file);
if (opts?.issueId) formData.append("issue_id", opts.issueId);
if (opts?.commentId) formData.append("comment_id", opts.commentId);
if (opts?.chatSessionId) formData.append("chat_session_id", opts.chatSessionId);
const rid = createRequestId();
const start = Date.now();
this.logger.info("→ POST /api/upload-file", { rid });
const res = await fetch(`${this.baseUrl}/api/upload-file`, {
method: "POST",
headers: this.authHeaders(),
body: formData,
credentials: "include",
});
if (!res.ok) {
if (res.status === 401) this.handleUnauthorized();
const message = await this.parseErrorMessage(res, `Upload failed: ${res.status}`);
this.logger.error(`${res.status} /api/upload-file`, { rid, duration: `${Date.now() - start}ms`, error: message });
throw new Error(message);
}
this.logger.info(`${res.status} /api/upload-file`, { rid, duration: `${Date.now() - start}ms` });
const raw = (await res.json()) as unknown;
return parseWithFallback(raw, AttachmentResponseSchema, EMPTY_ATTACHMENT, {
endpoint: "POST /api/upload-file",
});
}
// Chat Sessions
async listChatSessions(params?: { status?: string }): Promise<ChatSession[]> {
const query = params?.status ? `?status=${params.status}` : "";
return this.fetch(`/api/chat/sessions${query}`);
}
async getChatSession(id: string): Promise<ChatSession> {
return this.fetch(`/api/chat/sessions/${id}`);
}
async createChatSession(data: { agent_id: string; title?: string }): Promise<ChatSession> {
return this.fetch("/api/chat/sessions", {
method: "POST",
body: JSON.stringify(data),
});
}
async deleteChatSession(id: string): Promise<void> {
await this.fetch(`/api/chat/sessions/${id}`, { method: "DELETE" });
}
async updateChatSession(id: string, data: { title: string }): Promise<ChatSession> {
return this.fetch(`/api/chat/sessions/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
async listChatMessages(sessionId: string): Promise<ChatMessage[]> {
return this.fetch(`/api/chat/sessions/${sessionId}/messages`);
}
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;
}
return this.fetch(`/api/chat/sessions/${sessionId}/messages`, {
method: "POST",
body: JSON.stringify(body),
});
}
async getPendingChatTask(sessionId: string): Promise<ChatPendingTask> {
return this.fetch(`/api/chat/sessions/${sessionId}/pending-task`);
}
async listPendingChatTasks(): Promise<PendingChatTasksResponse> {
return this.fetch(`/api/chat/pending-tasks`);
}
async markChatSessionRead(sessionId: string): Promise<void> {
await this.fetch(`/api/chat/sessions/${sessionId}/read`, { method: "POST" });
}
async cancelTaskById(taskId: string): Promise<void> {
await this.fetch(`/api/tasks/${taskId}/cancel`, { method: "POST" });
}
async listAttachments(issueId: string): Promise<Attachment[]> {
return this.fetch(`/api/issues/${issueId}/attachments`);
}
// Fetches a fresh attachment metadata record. The server re-signs
// `download_url` on every call (30 min expiry), so the click-time
// download flow uses this endpoint to avoid handing the user a stale
// signed URL cached in TanStack Query.
async getAttachment(id: string): Promise<Attachment> {
const raw = await this.fetch<unknown>(`/api/attachments/${id}`);
return parseWithFallback(raw, AttachmentResponseSchema, EMPTY_ATTACHMENT, {
endpoint: "GET /api/attachments/{id}",
});
}
async deleteAttachment(id: string): Promise<void> {
await this.fetch(`/api/attachments/${id}`, { method: "DELETE" });
}
// Fetches the raw bytes of a text-previewable attachment.
//
// The endpoint sidesteps CloudFront CORS (not configured on the CDN) and
// bypasses Content-Disposition: attachment for the `text/*` family, both
// of which would otherwise prevent the renderer from getting the body.
// The server always replies with `text/plain; charset=utf-8` for safety;
// the original MIME ships back in the `X-Original-Content-Type` header so
// the preview dispatcher can choose between markdown / html / plain code.
//
// Routes through `fetchRaw` so it inherits the standard auth headers,
// 401 → handleUnauthorized recovery, request-id logging, and ApiError
// shape. 413 / 415 are translated to typed `Preview*Error` instances so
// the modal can render specific fallbacks instead of generic failure.
async getAttachmentTextContent(
id: string,
): Promise<{ text: string; originalContentType: string }> {
let res: Response;
try {
res = await this.fetchRaw(`/api/attachments/${id}/content`);
} catch (err) {
if (err instanceof ApiError) {
if (err.status === 413) throw new PreviewTooLargeError();
if (err.status === 415) throw new PreviewUnsupportedError();
}
throw err;
}
return {
text: await res.text(),
originalContentType: res.headers.get("X-Original-Content-Type") ?? "",
};
}
// 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 deleteProjectResource(
projectId: string,
resourceId: string,
): Promise<void> {
await this.fetch(`/api/projects/${projectId}/resources/${resourceId}`, {
method: "DELETE",
});
}
// Labels
async listLabels(): Promise<ListLabelsResponse> {
return this.fetch(`/api/labels`);
}
async getLabel(id: string): Promise<Label> {
return this.fetch(`/api/labels/${id}`);
}
async createLabel(data: CreateLabelRequest): Promise<Label> {
return this.fetch(`/api/labels`, {
method: "POST",
body: JSON.stringify(data),
});
}
async updateLabel(id: string, data: UpdateLabelRequest): Promise<Label> {
return this.fetch(`/api/labels/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
async deleteLabel(id: string): Promise<void> {
await this.fetch(`/api/labels/${id}`, { method: "DELETE" });
}
async listLabelsForIssue(issueId: string): Promise<IssueLabelsResponse> {
return this.fetch(`/api/issues/${issueId}/labels`);
}
async attachLabel(issueId: string, labelId: string): Promise<IssueLabelsResponse> {
return this.fetch(`/api/issues/${issueId}/labels`, {
method: "POST",
body: JSON.stringify({ label_id: labelId }),
});
}
async detachLabel(issueId: string, labelId: string): Promise<IssueLabelsResponse> {
return this.fetch(`/api/issues/${issueId}/labels/${labelId}`, {
method: "DELETE",
});
}
// Pins
async listPins(): Promise<PinnedItem[]> {
return this.fetch("/api/pins");
}
async createPin(data: CreatePinRequest): Promise<PinnedItem> {
return this.fetch("/api/pins", {
method: "POST",
body: JSON.stringify(data),
});
}
async deletePin(itemType: PinnedItemType, itemId: string): Promise<void> {
await this.fetch(`/api/pins/${itemType}/${itemId}`, { method: "DELETE" });
}
async reorderPins(data: ReorderPinsRequest): Promise<void> {
await this.fetch("/api/pins/reorder", {
method: "PUT",
body: JSON.stringify(data),
});
}
// Squads
async listSquads(): Promise<Squad[]> {
return this.fetch(`/api/squads`);
}
async getSquad(id: string): Promise<Squad> {
return this.fetch(`/api/squads/${id}`);
}
async createSquad(data: { name: string; description?: string; leader_id: string; avatar_url?: string }): Promise<Squad> {
return this.fetch("/api/squads", { method: "POST", body: JSON.stringify(data) });
}
async updateSquad(id: string, data: { name?: string; description?: string; instructions?: string; leader_id?: string; avatar_url?: string }): Promise<Squad> {
return this.fetch(`/api/squads/${id}`, { method: "PUT", body: JSON.stringify(data) });
}
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);
return this.fetch(`/api/autopilots?${search}`);
}
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" });
}
async triggerAutopilot(id: string): Promise<AutopilotRun> {
return this.fetch(`/api/autopilots/${id}/trigger`, { method: "POST" });
}
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 rotateAutopilotTriggerWebhookToken(
autopilotId: string,
triggerId: string,
): Promise<AutopilotTrigger> {
return this.fetch(
`/api/autopilots/${autopilotId}/triggers/${triggerId}/rotate-webhook-token`,
{ method: "POST" },
);
}
// Webhook deliveries — list is slim (no raw_body / selected_headers /
// response_body); detail returns the full row. Both responses are parsed
// through a lenient schema so an unknown server-side `status` /
// `signature_status` value degrades to a generic row instead of dropping
// the whole list.
async listAutopilotDeliveries(
autopilotId: string,
params?: { limit?: number; offset?: number },
): Promise<ListWebhookDeliveriesResponse> {
const search = new URLSearchParams();
if (params?.limit) search.set("limit", params.limit.toString());
if (params?.offset) search.set("offset", params.offset.toString());
const raw = await this.fetch<unknown>(
`/api/autopilots/${autopilotId}/deliveries?${search}`,
);
return parseWithFallback(
raw,
ListWebhookDeliveriesResponseSchema,
EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE,
{ endpoint: "GET /api/autopilots/:id/deliveries" },
);
}
async getAutopilotDelivery(
autopilotId: string,
deliveryId: string,
): Promise<WebhookDelivery> {
const raw = await this.fetch<unknown>(
`/api/autopilots/${autopilotId}/deliveries/${deliveryId}`,
);
return parseWithFallback(
raw,
WebhookDeliveryResponseSchema,
{ ...EMPTY_WEBHOOK_DELIVERY, id: deliveryId, autopilot_id: autopilotId },
{ endpoint: "GET /api/autopilots/:id/deliveries/:deliveryId" },
);
}
// Replay creates a NEW delivery row referencing the original via
// `replayed_from_delivery_id`. Server rejects replays of
// signature-invalid / rejected deliveries with 400 — the UI keeps the
// button disabled for those rows, but the server is the source of truth.
async replayAutopilotDelivery(
autopilotId: string,
deliveryId: string,
): Promise<WebhookDelivery> {
const raw = await this.fetch<unknown>(
`/api/autopilots/${autopilotId}/deliveries/${deliveryId}/replay`,
{ method: "POST" },
);
return parseWithFallback(
raw,
WebhookDeliveryResponseSchema,
{ ...EMPTY_WEBHOOK_DELIVERY, autopilot_id: autopilotId },
{ endpoint: "POST /api/autopilots/:id/deliveries/:deliveryId/replay" },
);
}
// GitHub integration
async getGitHubConnectURL(workspaceId: string): Promise<GitHubConnectResponse> {
return this.fetch(`/api/workspaces/${workspaceId}/github/connect`);
}
async listGitHubInstallations(workspaceId: string): Promise<ListGitHubInstallationsResponse> {
return this.fetch(`/api/workspaces/${workspaceId}/github/installations`);
}
async deleteGitHubInstallation(workspaceId: string, installationId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}/github/installations/${installationId}`, {
method: "DELETE",
});
}
async listIssuePullRequests(issueId: string): Promise<{ pull_requests: GitHubPullRequest[] }> {
return this.fetch(`/api/issues/${issueId}/pull-requests`);
}
}