Files
openworker/surfaces/gui/src/api.ts
T
Rohit C Prasad 62ad9dbdca tools: give coworkers the user's real toolchain, and stop silent skips
Sidecar inherits the login shell's env; toolchain resolves absolute paths with pinned
installs; request_tool replaces the 'tool missing -> STOP' instruction that hid a check.
2026-08-14 15:28:57 -07:00

2175 lines
74 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 { GroupedQuestion, QuestionOption, SessionInfo, WsEvent } from "./types";
declare const __COWORKER_DEV_TOKEN__: string;
// Endpoint resolution order: runtime-injected globals (Tauri sets `window.__COWORKER_HTTP__`
// for its dynamically-chosen sidecar port) → Vite env → the 127.0.0.1:8765 dev default. This
// keeps a single codebase: browser `npm run dev` hits 8765; the desktop shell hits its sidecar.
const httpBase = (): string =>
(globalThis as any).__COWORKER_HTTP__ ||
(import.meta as any).env?.VITE_COWORKER_HTTP ||
"http://127.0.0.1:8765";
const wsBase = (): string =>
(globalThis as any).__COWORKER_WS__ ||
(import.meta as any).env?.VITE_COWORKER_WS ||
"ws://127.0.0.1:8765";
const apiToken = (): string =>
(globalThis as any).__COWORKER_API_TOKEN__ ||
(import.meta as any).env?.VITE_COWORKER_API_TOKEN ||
(typeof __COWORKER_DEV_TOKEN__ === "string" ? __COWORKER_DEV_TOKEN__ : "");
// All local REST calls pass through this module, so a module-local wrapper applies launch
// authentication without asking every endpoint helper to remember the security header.
const fetch = (
input: RequestInfo | URL,
init: RequestInit = {},
): Promise<Response> => {
const headers = new Headers(init.headers);
const token = apiToken();
if (token) headers.set("X-OpenWorker-Token", token);
return globalThis.fetch(input, { ...init, headers });
};
const openWebSocket = (url: string): WebSocket => {
const token = apiToken();
return token
? new WebSocket(url, ["openworker", token])
: new WebSocket(url);
};
export interface Health {
status: string;
default_workspace: string | null;
model: string;
}
export interface RecentWorkspace {
path: string;
name: string;
exists: boolean;
}
export interface WorkspaceCommandTrust {
workspace: string;
requested_commands: string[];
trusted: boolean;
required: boolean;
exists?: boolean;
}
export async function getHealth(): Promise<Health> {
const res = await fetch(`${httpBase()}/v1/health`);
return res.json();
}
export async function getRecentWorkspaces(): Promise<RecentWorkspace[]> {
const res = await fetch(`${httpBase()}/v1/workspaces/recent`);
return (await res.json()).workspaces ?? [];
}
/** Ask the LOCAL sidecar to open the OS folder picker — the browser GUI can't obtain absolute
* paths from web file dialogs. Blocks until the user picks or cancels; null on cancel/unavailable. */
export async function pickFolderViaServer(): Promise<string | null> {
try {
const res = await fetch(`${httpBase()}/v1/workspaces/pick`, { method: "POST" });
const d = await res.json();
return d.ok && d.path ? d.path : null;
} catch {
return null;
}
}
export async function openWorkspace(
path: string,
create = false,
): Promise<{
path: string;
ok: boolean;
error?: string;
git_branch?: string | null;
command_trust?: WorkspaceCommandTrust;
}> {
const res = await fetch(`${httpBase()}/v1/workspaces/open`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, create }),
});
return res.json();
}
/** UX-029 "Start in a temporary folder": create the conversation's temp dir at send time
* (git-init'd for code-family work). Idempotent. */
export async function createTempWorkspace(
sessionId: string,
git = true,
): Promise<{ ok: boolean; path?: string; git?: boolean; error?: string }> {
const res = await fetch(`${httpBase()}/v1/workspaces/temp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId, git }),
});
return res.json();
}
/** UX-029 "Save as project…": move a session's temporary folder to a real location.
* Callers reconnect afterwards so the engine rebinds to the new path. */
export async function saveSessionAsProject(
sessionId: string,
path: string,
): Promise<{ ok: boolean; path?: string; error?: string }> {
const res = await fetch(
`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/save-as-project`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
},
);
return res.json();
}
export async function getTrustedWorkspaces(): Promise<WorkspaceCommandTrust[]> {
const res = await fetch(`${httpBase()}/v1/workspaces/trusted`);
return (await res.json()).workspaces ?? [];
}
export async function setWorkspaceTrusted(
path: string,
trusted: boolean,
): Promise<{ ok: boolean; error?: string } & WorkspaceCommandTrust> {
const res = await fetch(`${httpBase()}/v1/workspaces/trust`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, trusted }),
});
return res.json();
}
export async function getSessions(workspace?: string): Promise<SessionInfo[]> {
const q = workspace ? `?workspace=${encodeURIComponent(workspace)}` : "";
const res = await fetch(`${httpBase()}/v1/sessions${q}`);
return (await res.json()).sessions ?? [];
}
// A structured connector-delivered inbound message (§3.1). Attached to the user message it framed,
// for display only — the model still sees the framed `content`; this drives the ConnectorMessageCard.
export interface MessageSource {
connector: string; // platform id, e.g. "slack"
kind: "channel" | "dm";
channel_id: string; // e.g. "C0BD7KZ1AH5"
channel_name: string; // resolved; may equal the id (e.g. "#ocw-test")
sender_id: string;
sender_name: string; // resolved; may equal the id
ts: number; // epoch seconds
text: string; // the RAW message (what the card shows)
}
// A transcript message from GET /v1/sessions/{id}/messages. Kept permissive (open shape) because
// itemsFromMessages reads several role-specific fields; `source` is the optional connector sidecar.
export interface ConversationMessage {
role: string;
content?: any;
tool_calls?: any[];
tool_call_id?: string;
source?: MessageSource;
// Token counts for the round-trip that produced an assistant message
// ({model, input, output, cache_read, cache_write}); absent on older servers.
usage?: import("./types").TurnUsage;
[key: string]: any;
}
export async function getSessionMessages(sessionId: string): Promise<ConversationMessage[]> {
const res = await fetch(`${httpBase()}/v1/sessions/${sessionId}/messages`);
return (await res.json()).messages ?? [];
}
export async function renameSession(sessionId: string, title: string): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
return res.json();
}
export async function setSessionFlags(
sessionId: string,
flags: { pinned?: boolean; archived?: boolean },
): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(flags),
});
return res.json();
}
export async function deleteSession(sessionId: string): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" });
return res.json();
}
export interface ArtifactInfo {
path: string; // workspace-relative (the display/API identifier)
abs_path?: string; // absolute — what "Copy path" copies
name: string;
kind: "markdown" | "html" | "image" | "code" | "text" | string;
size: number;
modified_at: number;
}
export interface ArtifactContent {
ok: boolean;
error?: string;
path: string;
kind: string;
content?: string;
data_url?: string;
truncated?: boolean;
// kind === "folder": a directory listing (models sometimes link a whole package dir).
entries?: { name: string; dir: boolean; size: number }[];
}
export async function getArtifacts(sessionId: string): Promise<ArtifactInfo[]> {
const res = await fetch(`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/artifacts`);
return (await res.json()).artifacts ?? [];
}
export async function readArtifact(sessionId: string, path: string): Promise<ArtifactContent> {
const q = new URLSearchParams({ path });
const res = await fetch(`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/artifacts/read?${q.toString()}`);
return res.json();
}
/** Show the artifact in the OS file manager ("reveal") or open it with its default app ("open"). */
export async function revealArtifact(
sessionId: string,
path: string,
mode: "reveal" | "open" = "reveal",
): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/artifacts/reveal`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, mode }),
});
return res.json();
}
// -- session roots (orphan Cowork: scratch + added folders) -------------------
export interface RootInfo {
path: string;
writable: boolean;
label: string;
primary: boolean;
exists: boolean;
}
export async function getRoots(sessionId: string): Promise<RootInfo[]> {
const res = await fetch(`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/roots`);
return (await res.json()).roots ?? [];
}
export async function addRoot(
sessionId: string,
path: string,
writable: boolean,
): Promise<{ ok: boolean; error?: string; roots?: RootInfo[] }> {
const res = await fetch(`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/roots`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, writable }),
});
return res.json();
}
export async function removeRoot(
sessionId: string,
path: string,
): Promise<{ ok: boolean; error?: string; roots?: RootInfo[] }> {
const q = new URLSearchParams({ path });
const res = await fetch(
`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/roots?${q.toString()}`,
{ method: "DELETE" },
);
return res.json();
}
// -- MCP servers --------------------------------------------------------------
export interface McpServer {
name: string;
enabled: boolean;
transport: string;
requires_approval: boolean;
// "connected" | "configured" | "disabled" | and for auth:"oauth" servers:
// "needs_auth" (no tokens yet) | "authorizing" (browser sign-in in flight)
status: string;
auth?: "oauth" | null;
last_error?: string | null;
tool_count: number | null;
config: Record<string, any>;
}
export async function getMcpServers(): Promise<McpServer[]> {
const res = await fetch(`${httpBase()}/v1/mcp`);
return (await res.json()).servers ?? [];
}
export async function addMcpServer(name: string, config: Record<string, any>) {
const res = await fetch(`${httpBase()}/v1/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, config }),
});
return res.json();
}
export async function patchMcpServer(name: string, changes: Record<string, any>) {
const res = await fetch(`${httpBase()}/v1/mcp/${encodeURIComponent(name)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(changes),
});
return res.json();
}
export async function deleteMcpServer(name: string) {
const res = await fetch(`${httpBase()}/v1/mcp/${encodeURIComponent(name)}`, { method: "DELETE" });
return res.json();
}
export async function getMcpTools(
name: string,
): Promise<{ ok: boolean; error?: string; tools: { name: string; description: string }[] }> {
const res = await fetch(`${httpBase()}/v1/mcp/${encodeURIComponent(name)}/tools`);
return res.json();
}
export async function reloadMcp() {
const res = await fetch(`${httpBase()}/v1/mcp/reload`, { method: "POST" });
return res.json();
}
/** Connect one MCP server now. For OAuth servers this opens the system browser;
* poll getMcpServers() for the status flip (authorizing → connected / needs_auth). */
export async function connectMcp(name: string): Promise<{ ok: boolean; started?: boolean }> {
const res = await fetch(`${httpBase()}/v1/mcp/${encodeURIComponent(name)}/connect`, {
method: "POST",
});
return res.json();
}
/** Drop the connection and forget the stored OAuth tokens. */
export async function signoutMcp(name: string): Promise<{ ok: boolean }> {
const res = await fetch(`${httpBase()}/v1/mcp/${encodeURIComponent(name)}/signout`, {
method: "POST",
});
return res.json();
}
// -- connectors ---------------------------------------------------------------
export interface ConnectorField {
key: string;
label: string;
secret: boolean;
required: boolean;
help: string;
placeholder: string;
}
// A message from a sender not (yet) on the allow-list — parked instead of dropped (§19).
export interface ParkedMessage {
id: string;
platform: string;
chat_id: string;
chat_name: string | null;
user_id: string;
user_name: string | null;
chat_type: string;
text: string;
ts: number;
team_id?: string | null; // workspace (managed Slack relay); null on manual Socket Mode
}
// One connected Slack workspace (managed relay is multi-workspace; ids are workspace-scoped,
// so each workspace carries its OWN allow-list).
export interface SlackWorkspace {
team_id: string;
account: string;
domain?: string; // slack.com subdomain — unique even when display names collide
allowed_users: string[];
allow_all: boolean;
allowed_user_names?: Record<string, string | null>;
approval_owner_ids?: string[];
approval_owner_names?: Record<string, string | null>;
// Who installed this workspace (authed_user) — pre-added to the allow-list on
// connect (UX-027); the GUI marks their chip "you" and keys the setup card copy.
installer_user_id?: string;
installer_name?: string;
}
// One connected GitHub App installation (managed relay is multi-installation;
// sender logins are global but each installation keeps its OWN allow-list).
export interface GithubInstallation {
installation_id: string;
account_login: string; // the org/user the App is installed on
account_type: string; // "Organization" | "User"
repo_selection: string; // "all" | "selected"
github_login: string; // the connecting user's own login
allowed_users: string[]; // sender logins allowed to trigger work
allow_all: boolean;
}
// One connected HubSpot portal (multi-portal: `hubspot:portal:<hub_id>` profiles).
export interface HubSpotPortal {
hub_id: string;
name: string;
sandbox: boolean;
default: boolean;
managed: boolean;
access: "read" | "write" | ""; // consent tier granted ("" = manual token, unknown)
}
// One connected Google account (multi-account: `gmail:account:<email>` /
// `google_calendar:account:<email>` profiles — same shape for both).
export interface GmailAccount {
email: string;
default: boolean;
managed: boolean;
scopes: string;
needs_reauth: boolean;
}
// "Never show agents" — enforced locally in the tool layer; agents see silent
// omissions, the user sees counts on tool cards + Activity rows.
export interface GmailFilters {
senders: string[];
labels: string[];
}
// One account of a generic multi-account connector (`<name>:account:<id>`
// profiles — Notion workspaces, PostHog projects, …). Gmail/Calendar predate
// the generic layer and keep their email-keyed shape above.
export interface AccountRow {
account_id: string;
name: string; // display identity captured at connect (workspace name, email, …)
default: boolean;
managed: boolean;
}
export interface Connector {
name: string;
title: string;
icon: string;
blurb: string;
// Pre-connect detail page copy (UX-DECISIONS §38): optional About paragraph
// (empty → group omitted) + honest Access bullets.
about?: string;
access?: string[];
auth: string;
two_way: boolean;
// Chat-platform capability, narrower than two_way: sessions can subscribe to channels.
channels: boolean;
available: boolean;
fields: ConnectorField[];
instructions: string[];
connected: boolean;
account: string | null;
enabled: boolean;
brand_color: string; // hex brand color, e.g. "#611f69" (fallback gray "#6b7280")
logo: string; // stable logo id keyed into the frontend registry (empty → fallback glyph)
aliases?: string[]; // extra typeahead terms ("calendar" surfaces Outlook)
mcp?: boolean; // MCP-backed one-click (vendor-hosted MCP + local OAuth — no cloud sign-in)
allowed_users: string[]; // the allow-list (managed inline in the Connectors tab)
allowed_user_names?: Record<string, string | null>; // id → display name (people directory)
approval_owner_ids?: string[]; // Manual Slack: humans allowed to resolve approvals
approval_owner_names?: Record<string, string | null>;
recent?: RecentSender[]; // recently-seen senders on a connected two-way connector
unauthorized?: ParkedMessage[]; // parked messages from unallowed senders (§19)
tools: ConnectorTool[];
managed: boolean; // one-click managed OAuth available (needs cloud sign-in)
managed_paused?: boolean; // one-click temporarily off (e.g. Google CASA pending) — badge "Coming soon"
managed_profile: boolean; // current profile came from managed OAuth (vs manual paste)
mode?: string; // "relay" for the managed cloud path; "" for manual/token connect
workspaces?: SlackWorkspace[]; // Slack only: connected workspaces (managed relay)
// Gmail/Calendar: email-keyed rows; generic account connectors (notion,
// attio, posthog, …): AccountRow. The detail pages narrow by connector.
accounts?: GmailAccount[] | AccountRow[];
filters?: GmailFilters; // Gmail only: "Never show agents" senders/labels
portals?: HubSpotPortal[]; // HubSpot only: connected portals (multi-portal)
hidden_fields?: string[]; // HubSpot only: properties stripped from agent reads
installations?: GithubInstallation[]; // GitHub only: App installations (managed relay)
}
// --- OpenWorker Cloud (optional sign-in; manual token paste always works) ---
export interface CloudStatus {
signed_in: boolean;
account: string;
user_id: string;
telemetry_enabled?: boolean; // Phase 5 opt-out; signed-out users send nothing regardless
}
/** Flip the product-telemetry preference (local; only meaningful when signed in). */
export async function setCloudTelemetry(
enabled: boolean,
): Promise<{ ok: boolean; telemetry_enabled?: boolean }> {
const res = await fetch(`${httpBase()}/v1/cloud/telemetry`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
return res.json();
}
export async function getCloudStatus(): Promise<CloudStatus> {
const res = await fetch(`${httpBase()}/v1/cloud/status`);
return res.json();
}
export async function cloudLogin(): Promise<{ ok: boolean }> {
// The sidecar opens the system browser; the GUI just polls status after.
const res = await fetch(`${httpBase()}/v1/cloud/login`, { method: "POST" });
return res.json();
}
/** Poll cloud status until the browser sign-in lands (or the bound runs out).
*
* Fast 500ms polls for the first 20s — the moment the user finishes in the
* browser they're staring at the app waiting for it to flip, and a 2s interval
* reads as "sign-in is slow" (owner complaint, 2026-07-16) — then relaxes to 2s
* for the long tail (~2min total). Calls `onDone` with the signed-in status, or
* null when it timed out. Returns a cancel function (call on unmount). */
export function waitForCloudSignIn(
onDone: (s: CloudStatus | null) => void,
): () => void {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let polls = 0;
const tick = async () => {
polls += 1;
const s = await getCloudStatus().catch(() => null);
if (cancelled) return;
if (s?.signed_in) return onDone(s);
if (polls >= 90) return onDone(null); // 40×500ms + 50×2s ≈ 2min
timer = setTimeout(tick, polls < 40 ? 500 : 2000);
};
timer = setTimeout(tick, 500);
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}
export async function cloudLogout(): Promise<{ ok: boolean }> {
const res = await fetch(`${httpBase()}/v1/cloud/logout`, { method: "POST" });
return res.json();
}
export async function connectManaged(
name: string,
options?: { access?: "read" | "write" },
): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(
`${httpBase()}/v1/connectors/${encodeURIComponent(name)}/connect-managed`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
// `access` names a broker-defined consent tier (hubspot read | write).
// GitHub needs no flow choice: the broker is authorize-first — one connect
// links an existing App installation or redirects on to the install page.
body: JSON.stringify({
...(options?.access ? { access: options.access } : {}),
}),
},
);
return res.json();
}
/** One-click connect for an MCP-backed connector (monday, asana, jira): the sidecar
* opens the vendor's sign-in in the browser (local OAuth, no cloud account needed);
* poll getConnectors until the card flips to connected. */
export async function connectMcpBacked(name: string): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(
`${httpBase()}/v1/connectors/${encodeURIComponent(name)}/mcp-connect`,
{ method: "POST" },
);
return res.json();
}
export interface ConnectorTool {
name: string;
label: string;
kind: "read" | "write" | string;
description: string;
enabled: boolean;
requires_approval: boolean;
}
export async function getConnectors(): Promise<Connector[]> {
const res = await fetch(`${httpBase()}/v1/connectors`);
return (await res.json()).connectors ?? [];
}
export async function connectConnector(
name: string,
fields: Record<string, string>,
): Promise<{ ok: boolean; account?: string; error?: string }> {
const res = await fetch(`${httpBase()}/v1/connectors/${encodeURIComponent(name)}/connect`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fields }),
});
return res.json();
}
export async function disconnectConnector(name: string): Promise<{ ok: boolean }> {
const res = await fetch(`${httpBase()}/v1/connectors/${encodeURIComponent(name)}/disconnect`, {
method: "POST",
});
return res.json();
}
export async function updateConnectorTools(
name: string,
enabled: Record<string, boolean>,
): Promise<{ ok: boolean; error?: string; tools?: Record<string, boolean> }> {
const res = await fetch(`${httpBase()}/v1/connectors/${encodeURIComponent(name)}/tools`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
return res.json();
}
export interface AuditEvent {
id: number;
timestamp: string;
session_id: string;
agent: string;
workspace: string;
connector: string;
tool: string;
stage: string;
status: string;
approval: string;
args: Record<string, any>;
result_preview: string;
reason: string;
resource: string;
}
export async function getAudit(params: {
limit?: number;
session_id?: string;
connector?: string;
tool?: string;
} = {}): Promise<AuditEvent[]> {
const q = new URLSearchParams();
if (params.limit) q.set("limit", String(params.limit));
if (params.session_id) q.set("session_id", params.session_id);
if (params.connector) q.set("connector", params.connector);
if (params.tool) q.set("tool", params.tool);
const res = await fetch(`${httpBase()}/v1/audit${q.toString() ? "?" + q.toString() : ""}`);
return (await res.json()).events ?? [];
}
export interface BrowserState {
open: boolean;
url: string;
title: string;
status: string;
last_action: string;
last_result: string;
last_error: string;
screenshot_data_url: string;
updated_at: string | null;
controls: any[];
}
export async function getBrowserState(): Promise<BrowserState> {
const res = await fetch(`${httpBase()}/v1/browser/state`);
return res.json();
}
export async function takeBrowserScreenshot(): Promise<BrowserState & { ok?: boolean; error?: string }> {
const res = await fetch(`${httpBase()}/v1/browser/screenshot`, { method: "POST" });
return res.json();
}
export async function closeBrowser(): Promise<{ ok?: boolean; error?: string }> {
const res = await fetch(`${httpBase()}/v1/browser/close`, { method: "POST" });
return res.json();
}
// -- settings (model API key, default model, onboarding) ----------------------
export interface SurfaceVisibility {
cowork: boolean; // always true
chat: boolean;
code: boolean;
}
export interface ModelSettings {
provider: string;
model: string;
models: string[];
has_key: boolean;
model_ready: boolean; // can the default model's provider actually run (any provider)?
source: "env" | "store" | null;
onboarded: boolean;
surfaces: SurfaceVisibility;
scratch_base: string;
secrets_path: string; // OS-native on-disk location the server reports (not hardcoded)
// Sidebar layout preference (§7): "flat" = the persona accordions / today's list; "grouped" =
// bounded per-persona cards. Defaults to "flat" (absent → flat) so the GUI is robust to an older
// backend that hasn't shipped the field yet.
nav_layout?: "flat" | "grouped";
// Sidebar: sessions shown per group before "Show more" (default 5, 150).
sessions_peek?: number;
// Composer: show the context-window fill bar (default FALSE; absent → the chip shows
// the session total). The usage popover keeps both numbers regardless.
context_bar?: boolean;
// Curated-matrix display names ({full id → "GLM-5.2 · via Together"}); custom models absent.
model_labels?: Record<string, string>;
// {full id → context window in tokens}, verified matrix entries only — drives the
// composer's context-fill meter (absent id → the meter hides). Optional for older backends.
model_context_windows?: Record<string, number>;
// Token savings (PDF attachments): fallback for models without native PDF support,
// and attach-time thresholds. Optional so the GUI is robust to an older backend.
pdf_fallback?: "text" | "images";
pdf_max_pages?: number; // default 20, 1100
pdf_max_mb?: number; // default 10, 110
// Auto-compaction of long histories (OPE-27): trigger = min(threshold% × context
// window, cap tokens); model pins the summarizer ("" → the session's own model).
// Optional so the GUI is robust to an older backend.
compaction_threshold_pct?: number; // default 0.8, 0.100.95
compaction_cap_tokens?: number; // default 250000
compaction_model?: string;
}
export interface PdfSettings {
pdf_fallback: "text" | "images";
pdf_max_pages: number;
pdf_max_mb: number;
}
/** Persist the Token-savings PDF settings (fallback mode + attach thresholds). */
export async function setPdfSettings(
patch: Partial<PdfSettings>,
): Promise<{ ok: boolean; error?: string } & Partial<PdfSettings>> {
const res = await fetch(`${httpBase()}/v1/settings/pdf`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
return res.json();
}
export interface CompactionSettings {
compaction_threshold_pct: number;
compaction_cap_tokens: number;
compaction_model: string;
}
/** Persist the auto-compaction overrides (threshold %, token cap, summarizer model). */
export async function setCompactionSettings(
patch: Partial<CompactionSettings>,
): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(`${httpBase()}/v1/settings/compaction`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
return res.json();
}
/** Local page/size probe for a PDF data URL — the composer's attach-time threshold check. */
export async function inspectPdf(
dataUrl: string,
): Promise<{ ok: boolean; pages?: number; bytes?: number; error?: string }> {
const res = await fetch(`${httpBase()}/v1/attachments/inspect-pdf`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data_url: dataUrl }),
});
return res.json();
}
/** Persist whether the composer shows the context-window fill bar. */
export async function setContextBar(
shown: boolean,
): Promise<{ ok: boolean; context_bar?: boolean; error?: string }> {
const res = await fetch(`${httpBase()}/v1/settings/context-bar`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ context_bar: shown }),
});
return res.json();
}
/** Persist how many sessions a sidebar group shows before "Show more". */
export async function setSessionsPeek(
n: number,
): Promise<{ ok: boolean; sessions_peek?: number; error?: string }> {
const res = await fetch(`${httpBase()}/v1/settings/sessions-peek`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessions_peek: n }),
});
return res.json();
}
export async function setScratchBase(
path: string,
): Promise<{ ok: boolean; error?: string; scratch_base?: string }> {
const res = await fetch(`${httpBase()}/v1/settings/scratch-base`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
});
return res.json();
}
export async function setSurfaces(
flags: { chat?: boolean; code?: boolean },
): Promise<{ ok: boolean; surfaces: SurfaceVisibility }> {
const res = await fetch(`${httpBase()}/v1/settings/surfaces`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(flags),
});
return res.json();
}
/** Persist the sidebar layout preference (flat ↔ grouped-by-persona); read back from getSettings. */
export async function setNavLayout(
layout: "flat" | "grouped",
): Promise<{ ok: boolean; nav_layout?: "flat" | "grouped"; error?: string }> {
const res = await fetch(`${httpBase()}/v1/settings/nav-layout`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nav_layout: layout }),
});
return res.json();
}
// Fired after a cloud sign-in/out completes so the account row (§26) refreshes without
// waiting for the next window focus.
export const CLOUD_CHANGED = "coworker:cloud-changed";
export function announceCloudChanged() {
window.dispatchEvent(new CustomEvent(CLOUD_CHANGED));
}
// Fired the first time Inbox machinery is engaged (an item parks, or a session goes
// Unattended) — the account row's inbox chip unlocks stickily on it (§26).
export const INBOX_UNLOCK = "coworker:inbox-unlock";
export function announceInboxUnlock() {
window.dispatchEvent(new CustomEvent(INBOX_UNLOCK));
}
// -- Personas -----------------------------------------------------------------
// Fired after any persona mutation (enable/disable/install/delete) so always-mounted
// consumers (the sidebar's new-session picker) refetch instead of going stale.
export const PERSONAS_CHANGED = "coworker:personas-changed";
function announcePersonasChanged() {
window.dispatchEvent(new CustomEvent(PERSONAS_CHANGED));
}
export interface Persona {
id: string;
name: string;
icon: string;
tagline: string;
needs_workspace: boolean;
builtin: boolean;
family: string;
workspace: string; // "git" | "project" | "deliverable" | "none" — drives project-scoping
tools: string[];
enabled: boolean;
surfaced: boolean;
default: boolean;
version?: string;
installed_at?: string;
}
export interface PersonaConsent {
id: string;
name: string;
description: string;
tools: string[];
risk: string[];
connectors: boolean;
mcp: string[];
messaging: boolean;
recommended_mode: string;
recommended_models: string[];
recommends?: { kind: string; ref: string; reason: string; tier: string }[];
version?: string;
replaces?: { version: string; installed_at: string; capabilities_grew: boolean } | null;
source: string | null;
builtin: boolean;
}
export async function getPersonas(): Promise<Persona[]> {
const res = await fetch(`${httpBase()}/v1/personas`);
return (await res.json()).personas;
}
export async function updatePersona(
id: string,
body: { enabled?: boolean; surfaced?: boolean; default?: boolean },
): Promise<{ ok: boolean; personas?: Persona[]; error?: string }> {
const res = await fetch(`${httpBase()}/v1/personas/${encodeURIComponent(id)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const out = await res.json();
if (out.ok !== false) announcePersonasChanged();
return out;
}
/** Uninstall a non-builtin persona (its snapshot + state). Local; works signed out. */
export async function deletePersona(
id: string,
): Promise<{ ok: boolean; personas?: Persona[]; error?: string }> {
const res = await fetch(`${httpBase()}/v1/personas/${encodeURIComponent(id)}`, {
method: "DELETE",
});
const out = await res.json();
if (out.ok) announcePersonasChanged();
return out;
}
// A curated persona card from the cloud gallery (metadata only — the manifest
// is fetched server-side at install and runs through the normal consent flow).
export interface GalleryPersona {
slug: string;
version: number;
name: string;
icon: string;
tagline: string;
description: string;
family: string;
workspace: string;
publisher: string;
recommended_connectors: string[];
risk_summary: string;
featured?: boolean; // publisher-flagged for the gallery's featured carousel
}
export async function getCloudGallery(): Promise<{
ok: boolean;
personas: GalleryPersona[];
error?: string;
}> {
const res = await fetch(`${httpBase()}/v1/cloud/gallery`);
return res.json();
}
// Solo page for one gallery coworker. `capabilities` is the desktop's own
// consent summary derived from the manifest (same parser as install), so the
// page shows exactly what installing would ask the user to approve.
export interface GalleryDetail {
ok: boolean;
error?: string;
card?: GalleryPersona & { pitch_markdown: string };
capabilities?: {
tools: string[];
risk: string[];
connectors: boolean;
mcp: string[];
messaging: boolean;
recommended_mode: string;
recommended_models: string[];
};
recommends?: { kind: string; ref: string; reason: string; tier: string }[];
}
export async function getCloudGalleryDetail(slug: string): Promise<GalleryDetail> {
const res = await fetch(`${httpBase()}/v1/cloud/gallery/${encodeURIComponent(slug)}`);
return res.json();
}
/** Sharing v1 (OPE-7): zip a coworker's bundle into `dir`; the zip is the import format. */
export async function exportPersona(
id: string,
dir: string,
): Promise<{ ok: boolean; path?: string; error?: string }> {
const res = await fetch(`${httpBase()}/v1/personas/${encodeURIComponent(id)}/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dir }),
});
return res.json();
}
export async function installPersona(
body: { dir?: string; git_url?: string; gallery_slug?: string; zip_b64?: string; filename?: string },
): Promise<{ ok: boolean; consent?: PersonaConsent[]; personas?: Persona[]; error?: string }> {
const res = await fetch(`${httpBase()}/v1/personas/install`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const out = await res.json();
if (out.ok) announcePersonasChanged();
return out;
}
// -- Persona detail + connection defaults (§5) --------------------------------
// A persona's declared recommendation (manifest `recommends`): a connector or MCP server it works
// best with, with a reason + tier (core/optional). `connected` is annotated server-side from the
// connector list so the detail page can show connect state without a second round-trip.
export interface PersonaRecommendation {
kind: string; // "connector" | "mcp" | …
ref: string; // connector id (e.g. "github") or mcp/server name
reason: string;
tier: string; // "core" | "optional"
connected: boolean;
}
// A persona-default connection (the middle of the §4 hierarchy): for a connected connector, whether
// new sessions of this persona get it enabled by default.
export interface PersonaDefaultConnection {
connector: string; // connector id
enabled: boolean; // persona-default on/off
connected: boolean; // is the account actually connected (else the toggle is disabled)
}
export interface PersonaDetail {
id: string;
name: string;
icon: string;
tagline: string;
description: string;
enabled: boolean; // persona on/off (shown in the picker)
tools: string[];
recommended_models: string[];
default_permission_mode: string;
workspace: string;
recommends: PersonaRecommendation[];
default_connections: PersonaDefaultConnection[];
}
export async function getPersonaDetail(id: string): Promise<PersonaDetail> {
const res = await fetch(`${httpBase()}/v1/personas/${encodeURIComponent(id)}`);
return res.json();
}
/** Set a persona-default connection (new sessions of this persona get it on/off by default). */
export async function setPersonaConnection(
id: string,
connector: string,
enabled: boolean,
): Promise<{ ok: boolean; default_connections?: PersonaDefaultConnection[]; error?: string }> {
const res = await fetch(`${httpBase()}/v1/personas/${encodeURIComponent(id)}/connections`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ connector, enabled }),
});
return res.json();
}
/** Enable/disable the persona (whether it surfaces in the new-session picker). */
export async function setPersonaEnabled(
id: string,
enabled: boolean,
): Promise<{ ok: boolean; personas?: Persona[]; error?: string }> {
const res = await fetch(`${httpBase()}/v1/personas/${encodeURIComponent(id)}/enable`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
const out = await res.json();
if (out.ok) announcePersonasChanged();
return out;
}
// -- Per-session connections (Sources bar + drawer, §6) -----------------------
// An effective-enabled connector for a session, with a short human detail (e.g. "#ocw-test · DMs").
// `enabled` reflects the session override/persona default so the drawer toggle shows correct state.
export interface SessionConnectedConnector {
connector: string;
enabled: boolean;
detail: string;
}
// A persona-recommended connector not yet connected (drives the `⚠ N` attention count).
export interface SessionRecommendedConnector {
connector: string;
reason: string;
tier: string;
connected: boolean;
}
export interface SessionConnections {
connected: SessionConnectedConnector[];
recommended: SessionRecommendedConnector[];
attention: number; // ⚠ count = recommended connectors not yet connected
}
/** `persona` = the active persona hint — required for brand-new sessions (no server-side
* record yet), otherwise the view resolves to the default persona's defaults/recommends. */
export async function getSessionConnections(
sessionId: string,
persona?: string,
): Promise<SessionConnections> {
const q = persona ? `?persona=${encodeURIComponent(persona)}` : "";
const res = await fetch(
`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/connections${q}`,
);
return res.json();
}
/**
* Set a per-session connection override (mute/unmute a connector for THIS session). Pass
* `clear: true` to drop the override and inherit the persona default again.
*/
export async function setSessionConnection(
sessionId: string,
connector: string,
enabled: boolean,
clear = false,
): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/connections`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ connector, enabled, ...(clear ? { clear: true } : {}) }),
});
return res.json();
}
// -- Skills (SKILLS-SPEC §4) ----------------------------------------------------
// Scope = folder location: "global" (every session) or "project" (one workspace).
// The session endpoints resolve the effective menu (Settings disables + session mutes).
export interface SkillRow {
name: string;
description: string;
instructions: string;
scope: "global" | "project";
source: string; // "local" | "uploaded"
enabled: boolean;
path: string;
files?: number; // bundled resources beyond SKILL.md (§6 — rich skills are visible)
}
export interface SessionSkillRow {
name: string;
description: string;
scope: "global" | "project";
enabled: boolean; // false = muted for this session only
}
export interface SkillUploadPreview {
ok: boolean;
error?: string;
token?: string;
name?: string;
description?: string;
instructions?: string;
files?: string[];
}
const skillUrl = (path = "") => `${httpBase()}/v1/skills${path}`;
const jsonPost = (body: unknown, method = "POST") => ({
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
export async function listSkills(workspace?: string): Promise<SkillRow[]> {
const qs = workspace ? `?workspace=${encodeURIComponent(workspace)}` : "";
const res = await fetch(skillUrl(qs));
return (await res.json()).skills ?? [];
}
export async function createSkill(body: {
name: string;
description: string;
instructions: string;
scope?: "global" | "project";
workspace?: string;
}): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(skillUrl(), jsonPost(body));
return res.json();
}
export async function updateSkill(
name: string,
patch: { description?: string; instructions?: string; enabled?: boolean; workspace?: string },
): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(skillUrl(`/${encodeURIComponent(name)}`), jsonPost(patch, "PATCH"));
return res.json();
}
export async function revealSkill(name: string): Promise<{ ok: boolean; error?: string }> {
// §6 "Show folder": the backend opens the skill's folder in the OS file manager.
const res = await fetch(skillUrl(`/${encodeURIComponent(name)}/reveal`), jsonPost({}));
return res.json();
}
export async function deleteSkill(
name: string,
workspace?: string,
): Promise<{ ok: boolean; error?: string }> {
const qs = workspace ? `?workspace=${encodeURIComponent(workspace)}` : "";
const res = await fetch(skillUrl(`/${encodeURIComponent(name)}${qs}`), { method: "DELETE" });
return res.json();
}
export async function moveSkill(
name: string,
scope: "global" | "project",
workspace?: string,
): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(skillUrl(`/${encodeURIComponent(name)}/move`), jsonPost({ scope, workspace }));
return res.json();
}
export async function stageSkillUpload(
dataB64: string,
filename = "",
): Promise<SkillUploadPreview> {
const res = await fetch(skillUrl("/upload"), jsonPost({ data_b64: dataB64, filename }));
return res.json();
}
export async function confirmSkillUpload(
token: string,
scope: "global" | "project" = "global",
workspace?: string,
): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(skillUrl("/upload/confirm"), jsonPost({ token, scope, workspace }));
return res.json();
}
export async function sessionSkills(
sessionId: string,
workspace?: string,
): Promise<SessionSkillRow[]> {
const qs = workspace ? `?workspace=${encodeURIComponent(workspace)}` : "";
const res = await fetch(
`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/skills${qs}`,
);
return (await res.json()).skills ?? [];
}
export async function setSessionSkill(
sessionId: string,
skill: string,
enabled: boolean,
opts: { clear?: boolean; workspace?: string } = {},
): Promise<{ skills?: SessionSkillRow[]; ok?: boolean; error?: string }> {
const res = await fetch(
`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/skills`,
jsonPost({
skill,
enabled,
...(opts.clear ? { clear: true } : {}),
...(opts.workspace ? { workspace: opts.workspace } : {}),
}),
);
return res.json();
}
// -- Inbox + Unattended -------------------------------------------------------
export interface InboxItem {
id: string;
session_id: string;
kind: "approval" | "question" | "notification" | "directory" | "plan";
title: string;
body: string;
state: "pending" | "resolved";
resolution: string | null;
inbox: string;
created_at: string;
resolved_at: string | null;
visibility?: "inline" | "inbox";
// Question metadata (ask_user): quick-reply choices + a free-text escape. Options may be rich
// {label, description, recommended, preview} objects (OPE-51); `questions` is the grouped form
// (stepper), whose resolution is a JSON object string keyed by header-or-question.
options?: QuestionOption[];
allow_text?: boolean;
multi?: boolean;
header?: string;
questions?: GroupedQuestion[];
// Kind-specific payload (directory: {path, writable}; …).
data?: Record<string, any>;
// Originating-session context (server-joined) so the Inbox is self-contained.
session_title?: string;
session_agent?: string | null;
session_workspace?: string | null;
session_exists?: boolean;
}
export async function getInbox(sessionId?: string, state?: string): Promise<InboxItem[]> {
const q = new URLSearchParams();
if (sessionId) q.set("session_id", sessionId);
if (state) q.set("state", state);
const res = await fetch(`${httpBase()}/v1/inbox?${q.toString()}`);
return (await res.json()).items;
}
export async function resolveInboxItem(
id: string,
resolution: string,
): Promise<{ ok: boolean }> {
const res = await fetch(`${httpBase()}/v1/inbox/${encodeURIComponent(id)}/resolve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ resolution }),
});
return res.json();
}
// -- channel subscriptions (view-only) ----------------------------------------
export interface Subscription {
session_id: string;
session_title: string;
agent: string;
channel: string;
channel_name?: string | null; // resolved display name ("ocw-test"); address stays the id
routing_target: string | null;
collision: boolean; // inbound subscription == outbound Inbox routing on the same channel
}
export interface RecentChannel {
channel: string;
name?: string | null; // resolved display name, e.g. "ocw-test" (falls back to the address)
last_from: string | null;
last_text: string | null;
}
export async function getSubscriptions(): Promise<Subscription[]> {
const res = await fetch(`${httpBase()}/v1/subscriptions`);
return (await res.json()).subscriptions ?? [];
}
// -- inbox routing (where Unattended approvals/questions get mirrored) ---------
export interface InboxBinding {
name: string;
channel: string | null; // platform, e.g. "slack" (null = in-app Inbox only)
target: string; // chat_id, e.g. "C0BEJNCQQ8Y"
}
export async function getInboxRouting(): Promise<InboxBinding[]> {
const res = await fetch(`${httpBase()}/v1/inbox/routing`);
return (await res.json()).bindings ?? [];
}
export async function setInboxBinding(
name: string,
channel: string | null,
target: string,
): Promise<{ ok: boolean; bindings?: InboxBinding[]; error?: string }> {
const res = await fetch(`${httpBase()}/v1/inbox/routing/binding`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, channel, target }),
});
return res.json();
}
export interface UnroutedItem {
source: string;
sender: string;
text: string;
reason: string;
ts: number;
}
export async function getUnrouted(): Promise<UnroutedItem[]> {
const res = await fetch(`${httpBase()}/v1/unrouted`);
return (await res.json()).items ?? [];
}
export async function getRecentChannels(): Promise<RecentChannel[]> {
const res = await fetch(`${httpBase()}/v1/channels/recent`);
return (await res.json()).channels ?? [];
}
export async function subscribeChannel(
sessionId: string,
channel: string,
): Promise<{ ok: boolean; channel?: string; error?: string }> {
const res = await fetch(`${httpBase()}/v1/subscriptions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId, channel }),
});
return res.json();
}
export async function unsubscribeChannel(
sessionId: string,
channel: string,
): Promise<{ ok: boolean; removed?: boolean }> {
const res = await fetch(`${httpBase()}/v1/subscriptions/remove`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId, channel }),
});
return res.json();
}
export async function getUnattended(sessionId: string): Promise<boolean> {
const res = await fetch(
`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/unattended`,
);
return (await res.json()).unattended;
}
export async function setUnattended(
sessionId: string,
unattended: boolean,
): Promise<{ ok: boolean; unattended: boolean }> {
const res = await fetch(
`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/unattended`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ unattended }),
},
);
return res.json();
}
export async function getSettings(): Promise<ModelSettings> {
const res = await fetch(`${httpBase()}/v1/settings`);
return res.json();
}
export async function setModelKey(
apiKey: string,
): Promise<{ ok: boolean; error?: string; has_key?: boolean; source?: string }> {
const res = await fetch(`${httpBase()}/v1/settings/model-key`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key: apiKey }),
});
return res.json();
}
export async function setDefaultModel(
model: string,
): Promise<{ ok: boolean; error?: string; model?: string }> {
const res = await fetch(`${httpBase()}/v1/settings/default-model`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model }),
});
return res.json();
}
export async function addModel(model: string): Promise<ModelSettings & { ok: boolean; error?: string }> {
const res = await fetch(`${httpBase()}/v1/settings/models/add`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model }),
});
return res.json();
}
export async function removeModel(model: string): Promise<ModelSettings & { ok: boolean }> {
const res = await fetch(`${httpBase()}/v1/settings/models/remove`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model }),
});
return res.json();
}
export async function setOnboarded(value: boolean): Promise<{ ok: boolean; onboarded: boolean }> {
const res = await fetch(`${httpBase()}/v1/settings/onboarded`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value }),
});
return res.json();
}
// -- Memory (MEMORY-SPEC §5.3/§6: the memory screen, user rules, toast Undo) ----
export interface MemoryEntry {
id: number;
scope: string;
content: string;
summary: string;
created_at: string;
}
export interface MemorySettings {
enabled: boolean;
user_rules: string;
}
// Fired whenever memory changes from OUTSIDE the memory screen — today the agent
// saving or editing one mid-conversation. The screen only loads its list on mount, so
// without this it sits there stale and the user reads "Nothing yet" seconds after a
// save actually landed (owner-hit 2026-07-28).
export const MEMORY_CHANGED = "coworker:memory-changed";
export function announceMemoryChanged() {
window.dispatchEvent(new CustomEvent(MEMORY_CHANGED));
}
export async function getMemory(): Promise<MemoryEntry[]> {
const res = await fetch(`${httpBase()}/v1/memory`);
return (await res.json()).memory ?? [];
}
export async function updateMemory(
id: number,
content: string,
): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(`${httpBase()}/v1/memory/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
return res.json();
}
export async function deleteMemory(id: number): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(`${httpBase()}/v1/memory/${id}`, { method: "DELETE" });
return res.json();
}
export async function deleteAllMemory(): Promise<{ ok: boolean; deleted: number }> {
const res = await fetch(`${httpBase()}/v1/memory`, { method: "DELETE" });
return res.json();
}
export async function getMemorySettings(): Promise<MemorySettings> {
const res = await fetch(`${httpBase()}/v1/memory/settings`);
return res.json();
}
export async function setMemorySettings(
patch: Partial<MemorySettings>,
): Promise<MemorySettings> {
const res = await fetch(`${httpBase()}/v1/memory/settings`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
return res.json();
}
// -- model providers (OpenAI, Ollama, …) --------------------------------------
export interface ProviderField {
key: string;
label: string;
secret: boolean;
required: boolean;
help: string;
placeholder: string;
default?: string; // pre-filled editable value (e.g. an OpenAI-compatible vendor's endpoint)
// Non-empty → segmented choice, not a text input. tag = tiny badge ("Easiest");
// desc = one-liner atop the method panel; command = copyable terminal command.
choices?: { value: string; label: string; tag?: string; desc?: string; command?: string }[];
show_when?: Record<string, string> | null; // render only while these fields hold these values
}
export interface ProviderInfo {
name: string;
title: string;
needs_key: boolean;
fields: ProviderField[];
configured: boolean;
values: Record<string, string>; // non-secret stored values (e.g. base_url), for prefilling
suggested_models: string[]; // bare model-name suggestions for the "add model" datalist
recommended_model: string | null; // pre-filled default for this provider (e.g. qwen3-coder:30b)
blurb?: string; // one-line note under the title ("Uses X's OpenAI-compatible API…")
key_set_at?: string | null; // ISO date the key was last (re)saved — absent for env-only config
last_used_at?: number | null; // epoch secs the provider last served a completion
}
export async function getProviders(): Promise<ProviderInfo[]> {
const res = await fetch(`${httpBase()}/v1/providers`);
return res.json();
}
export async function setProvider(
name: string,
fields: Record<string, string>,
): Promise<{ ok: boolean; error?: string; provider?: string; recommended_model?: string | null }> {
const res = await fetch(`${httpBase()}/v1/providers`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, fields }),
});
return res.json();
}
/** Forget a provider's stored config (Settings ▸ Models "Remove key…"). */
export async function removeProvider(name: string): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(`${httpBase()}/v1/providers/${encodeURIComponent(name)}`, {
method: "DELETE",
});
return res.json();
}
/** Live read-only credential check (does NOT save the key). Triggered by the user's "Test" click. */
export async function verifyProvider(
name: string,
fields: Record<string, string>,
): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(`${httpBase()}/v1/providers/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, fields }),
});
return res.json();
}
/** Client-side provider guess from an API key's shape (mirrors the server's detect_provider). */
export function detectProvider(apiKey: string): string | null {
const key = (apiKey || "").trim();
if (!key) return null;
if (key.startsWith("sk-ant-")) return "anthropic";
if (key.startsWith("sk-or-")) return "openrouter";
if (key.startsWith("AIza")) return "gemini";
if (key.startsWith("sk-") || key.startsWith("sk_")) return "openai";
return null;
}
// -- super-agent --------------------------------------------------------------
export interface RecentSender {
user_id: string;
user_name: string | null;
chat_id: string;
chat_type: string;
target: string;
authorized: boolean;
team_id?: string | null; // workspace (managed relay); null on manual Socket Mode
}
// -- direct-message routing ---------------------------------------------------
export async function getDmRoute(): Promise<string | null> {
const res = await fetch(`${httpBase()}/v1/messaging/dm-route`);
return (await res.json()).dm_session ?? null;
}
export async function setDmRoute(sessionId: string): Promise<{ ok: boolean; dm_session: string | null }> {
const res = await fetch(`${httpBase()}/v1/messaging/dm-route`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId }),
});
return res.json();
}
// -- automations (scheduled tasks) --------------------------------------------
export interface Automation {
id: string;
title: string;
instructions: string;
schedule: string;
schedule_raw?: { kind: string; cron?: string | null; fire_at?: string | null; timezone?: string };
workspace: string;
agent: string;
enabled: boolean;
next_run: number | null;
last_run: number | null;
last_status: string | null;
run_count: number;
notify_on_completion: boolean;
// UX-023 sidebar badges: runs started since the user last opened this automation's
// detail; `unseen_failed` = the newest unseen run errored (danger tint).
unseen_runs?: number;
unseen_failed?: boolean;
seen_runs_at?: number;
// Standing scoped approvals (§25): target-bound rules this automation may exercise
// without asking. `entry` is the raw record entry — the revoke handle; `target` is
// null for legacy name-only entries.
always_allowed: { entry: string; tool: string; target: string | null }[];
}
export interface AutomationRun {
run_id: string;
task_id: string;
session_id: string;
started_at: number;
finished_at: number | null;
status: string;
result_text: string | null;
artifacts: string[];
error: string | null;
trigger: string;
}
export async function getAutomations(): Promise<Automation[]> {
const res = await fetch(`${httpBase()}/v1/automations`);
return (await res.json()).tasks ?? [];
}
// Fired after any automation mutation the sidebar should reflect immediately
// (mark-seen, create, delete) — its poll covers the rest.
export const AUTOMATIONS_CHANGED = "coworker:automations-changed";
export function announceAutomationsChanged() {
window.dispatchEvent(new CustomEvent(AUTOMATIONS_CHANGED));
}
/** App-wide event stream (/ws/events): session-independent server pushes — today
* automation_run_started (the UX-026 toast). Quietly reconnects while the app is
* open; the returned cleanup stops it for good. */
export function connectEvents(
onEvent: (msg: { type: string; data?: Record<string, unknown> }) => void
): () => void {
let ws: WebSocket | null = null;
let timer: number | null = null;
let closed = false;
const open = () => {
if (closed) return;
ws = openWebSocket(`${wsBase()}/ws/events`);
ws.onmessage = (e) => {
try {
onEvent(JSON.parse(e.data));
} catch {
/* malformed frame — ignore */
}
};
ws.onclose = () => {
if (!closed) timer = window.setTimeout(open, 5000);
};
};
open();
return () => {
closed = true;
if (timer !== null) window.clearTimeout(timer);
ws?.close();
};
}
/** Advance the automation's seen mark — clears its unseen-runs badge (UX-023). */
export async function markAutomationSeen(id: string): Promise<{ ok: boolean }> {
const res = await fetch(`${httpBase()}/v1/automations/${id}/seen`, { method: "POST" });
return res.json();
}
export async function createAutomation(payload: {
title: string;
instructions: string;
cron?: string;
fire_at?: string;
timezone?: string;
// §25 standing grants (the creating surface rendered them; submit IS the consent).
// Only target-bound write entries survive server-side validation.
permissions?: { tool: string; target: string; access: "read" | "write" }[];
}): Promise<{ ok: boolean; error?: string; task?: Automation }> {
const res = await fetch(`${httpBase()}/v1/automations`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
return res.json();
}
export async function getAutomation(id: string): Promise<{ task: Automation; runs: AutomationRun[] }> {
const res = await fetch(`${httpBase()}/v1/automations/${encodeURIComponent(id)}`);
return res.json();
}
export async function updateAutomation(id: string, changes: Record<string, any>) {
const res = await fetch(`${httpBase()}/v1/automations/${encodeURIComponent(id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(changes),
});
return res.json();
}
export async function deleteAutomation(id: string) {
const res = await fetch(`${httpBase()}/v1/automations/${encodeURIComponent(id)}`, { method: "DELETE" });
return res.json();
}
export interface PreparedRun {
ok: boolean;
error?: string;
run_id: string;
session_id: string;
workspace: string;
agent: string;
prompt: string;
}
/** Prepare a live manual run: returns the session to open + the opening prompt to send. */
export async function runAutomation(id: string): Promise<PreparedRun> {
const res = await fetch(`${httpBase()}/v1/automations/${encodeURIComponent(id)}/run`, { method: "POST" });
return res.json();
}
/** Mark a manual run complete after its first turn finished. */
export async function finalizeAutomationRun(id: string, runId: string) {
const res = await fetch(
`${httpBase()}/v1/automations/${encodeURIComponent(id)}/runs/${encodeURIComponent(runId)}/finalize`,
{ method: "POST" },
);
return res.json();
}
export async function allowUser(
name: string,
userId: string,
teamId?: string | null,
displayName?: string,
) {
const res = await fetch(`${httpBase()}/v1/connectors/${encodeURIComponent(name)}/allow`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: userId,
...(teamId ? { team_id: teamId } : {}),
// Directory picks carry the display name so the chip is readable at once.
...(displayName ? { name: displayName } : {}),
}),
});
return res.json();
}
// One workspace member from the roster (people picker; users:read, cached locally).
export interface SlackMember {
id: string;
name: string;
handle: string;
guest: boolean;
}
// One channel from the workspace roster. Private channels appear only where the
// bot is a member (Slack API constraint); is_member=false → "invite @OpenWorker" hint.
export interface SlackChannelEntry {
id: string;
name: string;
is_private: boolean;
is_member: boolean;
}
/** Workspace member roster for the people picker (teamId "default" = manual Socket Mode). */
export async function getSlackDirectory(
teamId: string,
q = "",
): Promise<{ ok: boolean; error?: string; members?: SlackMember[] }> {
const res = await fetch(
`${httpBase()}/v1/connectors/slack/workspaces/${encodeURIComponent(teamId)}/directory?q=${encodeURIComponent(q)}`,
);
return res.json();
}
/** Channel roster for the channel typeahead (name → id resolution). */
export async function getSlackChannels(
teamId: string,
q = "",
): Promise<{ ok: boolean; error?: string; channels?: SlackChannelEntry[] }> {
const res = await fetch(
`${httpBase()}/v1/connectors/slack/workspaces/${encodeURIComponent(teamId)}/channels?q=${encodeURIComponent(q)}`,
);
return res.json();
}
/** Resolve a parked unauthorized message (§19): dismiss / allow / allow_deliver. */
export async function resolveUnauthorized(
name: string,
itemId: string,
action: "dismiss" | "allow" | "allow_deliver",
): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(
`${httpBase()}/v1/connectors/${encodeURIComponent(name)}/unauthorized/${encodeURIComponent(itemId)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
},
);
return res.json();
}
export async function disallowUser(name: string, userId: string, teamId?: string | null) {
const res = await fetch(`${httpBase()}/v1/connectors/${encodeURIComponent(name)}/disallow`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(teamId ? { user_id: userId, team_id: teamId } : { user_id: userId }),
});
return res.json();
}
export async function addSlackApprovalOwner(
userId: string,
displayName?: string,
): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(`${httpBase()}/v1/connectors/slack/approval-owners/add`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: userId,
...(displayName ? { name: displayName } : {}),
}),
});
return res.json();
}
export async function removeSlackApprovalOwner(
userId: string,
): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(`${httpBase()}/v1/connectors/slack/approval-owners/remove`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: userId }),
});
return res.json();
}
/** Stop relaying one managed Slack workspace (the app stays installed in Slack). */
export async function disconnectSlackWorkspace(teamId: string): Promise<{ ok: boolean; error?: string; remaining_workspaces?: number }> {
const res = await fetch(
`${httpBase()}/v1/connectors/slack/workspaces/${encodeURIComponent(teamId)}/disconnect`,
{ method: "POST" },
);
return res.json();
}
/** Drop ONE Gmail mailbox; the default pointer moves to the next account. */
export async function disconnectGmailAccount(email: string): Promise<{ ok: boolean; error?: string; remaining_accounts?: number }> {
const res = await fetch(
`${httpBase()}/v1/connectors/gmail/accounts/${encodeURIComponent(email)}/disconnect`,
{ method: "POST" },
);
return res.json();
}
export async function setGmailDefaultAccount(email: string): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(
`${httpBase()}/v1/connectors/gmail/accounts/${encodeURIComponent(email)}/default`,
{ method: "POST" },
);
return res.json();
}
/** Drop ONE Google Calendar account; the default pointer moves to the next one. */
export async function disconnectGcalAccount(email: string): Promise<{ ok: boolean; error?: string; remaining_accounts?: number }> {
const res = await fetch(
`${httpBase()}/v1/connectors/google_calendar/accounts/${encodeURIComponent(email)}/disconnect`,
{ method: "POST" },
);
return res.json();
}
export async function setGcalDefaultAccount(email: string): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(
`${httpBase()}/v1/connectors/google_calendar/accounts/${encodeURIComponent(email)}/default`,
{ method: "POST" },
);
return res.json();
}
/** Drop ONE account of a generic multi-account connector (notion, attio,
* posthog, …); the default pointer moves to the next account. */
export async function disconnectAccount(connector: string, accountId: string): Promise<{ ok: boolean; error?: string; remaining_accounts?: number }> {
const res = await fetch(
`${httpBase()}/v1/connectors/${encodeURIComponent(connector)}/accounts/${encodeURIComponent(accountId)}/disconnect`,
{ method: "POST" },
);
return res.json();
}
export async function setDefaultAccount(connector: string, accountId: string): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(
`${httpBase()}/v1/connectors/${encodeURIComponent(connector)}/accounts/${encodeURIComponent(accountId)}/default`,
{ method: "POST" },
);
return res.json();
}
/** Replace the "Never show agents" lists (senders and/or labels; omit to keep). */
export async function setGmailFilters(filters: { senders?: string[]; labels?: string[] }): Promise<{ ok: boolean; filters?: GmailFilters; error?: string }> {
const res = await fetch(`${httpBase()}/v1/connectors/gmail/filters`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(filters),
});
return res.json();
}
// GitHub relay health, the Slack three-layer shape: shared relay socket /
// cloud sign-in / per-installation token health (+ missed-event counts).
export interface GithubStatus {
ok: boolean;
mode: string;
relay: { state: string; reconnects: number; last_event_at: number | null; last_error: string };
signed_in: boolean;
installs: Record<string, { token_ok: boolean }>;
missed: Record<string, number>;
}
export async function getGithubStatus(): Promise<GithubStatus> {
const res = await fetch(`${httpBase()}/v1/connectors/github/status`);
return res.json();
}
/** Stop relaying ONE GitHub App installation to this computer. */
export async function disconnectGithubInstallation(installationId: string): Promise<{ ok: boolean; error?: string; remaining_installs?: number }> {
const res = await fetch(
`${httpBase()}/v1/connectors/github/installations/${encodeURIComponent(installationId)}/disconnect`,
{ method: "POST" },
);
return res.json();
}
/** Drop ONE HubSpot portal; the default pointer moves to the next portal. */
export async function disconnectHubSpotPortal(hubId: string): Promise<{ ok: boolean; error?: string; remaining_portals?: number }> {
const res = await fetch(
`${httpBase()}/v1/connectors/hubspot/portals/${encodeURIComponent(hubId)}/disconnect`,
{ method: "POST" },
);
return res.json();
}
export async function setHubSpotDefaultPortal(hubId: string): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(
`${httpBase()}/v1/connectors/hubspot/portals/${encodeURIComponent(hubId)}/default`,
{ method: "POST" },
);
return res.json();
}
/** Replace the hidden-fields denylist (properties stripped from agent reads). */
export async function setHubSpotHiddenFields(fields: string[]): Promise<{ ok: boolean; hidden_fields?: string[]; error?: string }> {
const res = await fetch(`${httpBase()}/v1/connectors/hubspot/hidden-fields`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hidden_fields: fields }),
});
return res.json();
}
/** Slack health, three honest layers: relay socket / cloud sign-in / per-team tokens. */
export interface SlackStatus {
mode: string; // "relay" | "" (manual/off)
relay: {
state: "live" | "reconnecting" | "offline";
reconnects: number;
last_event_at: number | null;
last_error: string;
};
signed_in: boolean;
teams: Record<string, { token_ok: boolean }>;
}
export async function getSlackStatus(): Promise<SlackStatus> {
const res = await fetch(`${httpBase()}/v1/connectors/slack/status`);
return res.json();
}
export type Handlers = {
onEvent: (event: WsEvent) => void;
onOpen?: () => void;
onClose?: () => void;
};
export class Session {
private ws: WebSocket;
// Payloads sent before the socket finished opening, replayed on `onopen`. Belt-and-suspenders
// against the first message being dropped if the user sends in the connect window.
private outbox: object[] = [];
constructor(sessionId: string, workspace: string, agent: string, handlers: Handlers) {
const q = `?workspace=${encodeURIComponent(workspace)}&agent=${encodeURIComponent(agent)}`;
this.ws = openWebSocket(`${wsBase()}/ws/session/${sessionId}${q}`);
this.ws.onmessage = (e) => handlers.onEvent(JSON.parse(e.data));
this.ws.onopen = () => {
this.flush();
handlers.onOpen?.();
};
this.ws.onclose = () => handlers.onClose?.();
}
private flush() {
if (this.ws.readyState !== WebSocket.OPEN) return;
const pending = this.outbox;
this.outbox = [];
for (const p of pending) this.ws.send(JSON.stringify(p));
}
private send(payload: object) {
if (this.ws.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(payload));
// Still connecting: queue and flush on open rather than silently dropping.
else if (this.ws.readyState === WebSocket.CONNECTING) this.outbox.push(payload);
}
/** `model` = the composer's CURRENT selection, carried on every message so the turn uses
* exactly what the user sees — immune to set_model races across reconnects (a new cowork
* session always reconnects once to adopt its scratch dir, which could drop a queued
* set_model and leave the engine on a stale/resumed model; found 2026-07-04). */
userMessage(text: string, attachments?: unknown[], model?: string, skill?: string) {
this.send({
type: "user_message",
text,
...(model ? { model } : {}),
...(attachments?.length ? { attachments } : {}),
// Force-run (SKILLS-SPEC §4.1): the composer's /skill pick rides as its own field;
// the server validates it against the session's effective menu and frames the turn.
...(skill ? { skill } : {}),
});
}
approve(decision: string) {
this.send({ type: "approval", decision });
}
// Reply to a `request_directory` prompt: grant a folder (with access level) or decline.
respondDirectory(granted: boolean, path?: string, writable?: boolean) {
this.send({ type: "directory_response", granted, ...(path ? { path } : {}), writable: !!writable });
}
// Reply to a `request_tool` prompt: install the pinned build, or skip the check.
respondTool(approved: boolean) {
this.send({ type: "tool_response", approved });
}
// Reply to a `propose_plan` prompt: approve (choosing the execution mode) or reject with feedback.
respondPlan(approved: boolean, mode?: string, feedback?: string) {
this.send({
type: "plan_response",
approved,
...(mode ? { mode } : {}),
...(feedback ? { feedback } : {}),
});
}
// Answer a live `ask_user` prompt (attended sessions; unattended ones answer via the Inbox).
respondQuestion(answer: string) {
this.send({ type: "question_response", answer });
}
interrupt() {
this.send({ type: "interrupt" });
}
// Re-run a turn that ended in a provider error — no new user message; the server
// guards on the history tail so a stray frame is a no-op.
retry() {
this.send({ type: "retry" });
}
setMode(mode: string) {
this.send({ type: "set_mode", mode });
}
setModel(model: string) {
this.send({ type: "set_model", model });
}
close() {
// Detach before closing: this socket's async `close` event may land AFTER the
// successor session's `open` (observed when switching into an automation-run
// session), and a torn-down socket must not clobber the new one's connected state.
this.ws.onopen = null;
this.ws.onmessage = null;
this.ws.onclose = null;
this.ws.close();
}
}