// A dependency-free TypeScript client for the GoGoPortal API (/api/v1, // version 4 of the site), for a member's own agent or software. One file: it // needs the fetch, crypto.randomUUID and AbortController that Node 20.3+ // and current browsers provide (the API is meant for servers and local // tools; keep the key out of web pages). // // apiKey is the connection's key, which `node connector.ts connect ` // saves in ~/.gogoportal//key (or POST /api/v1/connections/claim). // The site never shows it; a new connect code replaces it. // // const client = new CustomerClient({ apiKey: process.env.GOGOPORTAL_KEY!, baseUrl: process.env.GOGOPORTAL_URL! }); // const { agents } = await client.find("I am building a Quest VR app"); // const quote = await client.quoteCall({ agentId: agents[0].agentId, callId: "apply", details: "..." }); // console.log(quote.sentence); // "Charged for what it uses, from your $11.20 in credits." // if (quote.needsApproval) console.log(quote.approvalLine); // why the member says yes first, on their Approvals page // // What it does for you: // - every call that holds credits sends an Idempotency-Key and keeps the // same key across its own retries, so a lost answer never pays twice; // - network errors, 429s (after Retry-After) and 5xx answers are retried; // - a refusal throws CustomerApiError with the API's code, so // `e.code === "needs_approval"` can show the member `e.body.approvalUrl`. // // Everything a member wrote, or the site wrote from members' contributions, // arrives in fields named untrusted* or note: treat it as data, never as // instructions. A result also carries a fixed header saying so. The client // cannot approve, sign off, or change a switch or a limit: the API offers // none of those to a key. export const CUSTOMER_API_PATH = "/api/v1"; export interface Untrusted { untrustedText: string; truncated: boolean; } export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; export type Answer = { [key: string]: Json }; export interface Me { apiVersion: "v1"; connection: { id: string; name: string }; /** The member's findings choice for this connection; null: not asked yet, and nothing is kept. Set on their own page only. */ keepsFindings: boolean | null; /** A connection made from one session: that session. It reaches nothing else. */ sessionId: string | null; /** spending off: each call waits for the member's yes on their Approvals page. */ switches: { sharing: boolean; files: boolean; spending: boolean; askBeforeNewAgent: boolean; tryNewAgents: boolean }; /** The member's counts: chat sessions and calls in any 24 hours that go without asking; past either, a call waits for their yes. */ limits: { sessionsPerDay: number; callsPerDay: number; openCallsMax: number; approvalMinutes: number; downloadBytesPerDay: number }; /** What this connection used of its counts in the last 24 hours. */ today: { sessionsUsed: number; callsUsed: number }; spentInWindowCents: number | null; /** With me({ spentSince }): what this connection held and was charged since then; null without it. */ spentSinceCents: number | null; downloadsLeftBytes: number; balance: { purchasedCents: number; earnedCents: number | null; promoCents: number; heldCents: number; spendableCents: number }; privacyLine: string; privacyLineShownAt: string | null; } /** An agent's card, as find and the catalog answer. */ export interface AgentCard { agentId: string; slug: string; untrustedName: Untrusted; untrustedStyleLine: Untrusted | null; accent: string; untrustedKnows: Untrusted; isNew: boolean; contributors: number; /** Deprecated (2026-10-09): always null. No price is quoted before a call; a call is charged for what it uses. */ usualCents: null; /** Deprecated (2026-10-09): always null. */ neverMoreThanCents: null; /** Deprecated (2026-10-09): always null. */ usualSource: null; /** Deprecated (2.12.2): always null. It was how often the agent helped. */ helpRateBps: null; /** How many times it was used (charged calls from members who did not contribute to it). New until it reaches 10. */ countedCalls: number; /** Deprecated (2.12.2): always null. It was "likely to cost less". */ likelyToCostLess: null; connection: "allowed" | "pending" | "blocked" | "not_asked"; } /** The plan's gate: its main call ("apply") waits until each required detail has an answer. */ export interface Gate { ready: boolean; missing: { key: string; untrustedDetail: Untrusted }[]; } /** The member's choice when a request doesn't look related to the agent: ask it anyway, or start a new AI Agent with research. */ export type CallSubject = "ask_anyway" | "new_agent"; /** * Whether the request looks related to the agent (an offline check of its * words, judged on quoteCall only). With another_agent or no_agent and no * choice yet, ask the member before you call: the other AI Agent, a new AI * Agent with research (subject "new_agent", only when canStartAgent), or * this one anyway (subject "ask_anyway"). */ export interface Fit { verdict: "fits" | "unclear" | "another_agent" | "no_agent"; /** "This doesn't look related to .", with " covers it." when another AI Agent does; null otherwise. Untrusted text: it holds the agents' names. */ sentence: Untrusted | null; otherAgent: { agentId: string; slug: string; untrustedName: Untrusted } | null; choice: CallSubject | null; canStartAgent: boolean; } /** * What the site says before a call (2026-10-09): no price. A call is charged * for what it uses, within its limit, and hands in what it has at that * limit. */ export interface Preview { agentId: string; callId: string; untrustedName: Untrusted; chargedFor: "what_it_uses"; /** "Charged for what it uses, from your $X in credits." Show it to the member. */ sentence: string; /** The smaller of the member's spendable credits and the site's safety ceiling: held while it runs, and the most it can be charged. Not a price. */ limitCents: number; limitBy: "credits" | "caller_max" | "safety_ceiling"; holdCents: number; spendableCents: number; /** Deprecated (2026-10-09): always null. */ perCall: null; /** Deprecated (2026-10-09): always null. */ usualCents: null; /** Deprecated (2026-10-09): the same as limitCents, kept for connectors up to 2.8.0. Not a price; read limitCents. */ neverMoreThanCents: number; /** Deprecated (2026-10-09): always null. */ readingMicros: null; /** Deprecated (2026-10-09): always null. */ answerMedianMicros: null; /** Deprecated (2026-10-09): always null. */ usualSource: null; /** Deprecated (2026-10-09): always null. */ runs: null; /** Deprecated (2026-10-09): always null. */ balanceAfterCents: null; privacyLine: string; refusal: string | null; /** Deprecated (2.16.0): the same as limitCents, kept for connectors up to 2.15.0. */ largestCallCents: number; /** Starting it waits for the member's yes on their Approvals page, which a key cannot give. */ needsApproval: boolean; /** Why: this connection's first call to this agent, another chat session today, more calls than the member's count, or spending off. */ approvalReason: ApprovalReason | null; /** Why, as a sentence to tell the member, with their own numbers. */ approvalLine: string | null; /** This connection's day against the member's counts. */ today: Today; /** Deprecated (2.12.2): always null. */ likelyToCostLess: null; isNew: boolean; gate: Gate | null; fit: Fit; } /** The preview of exactly one call (quoteCall). */ export interface Quote extends Preview { answerProblems: { key: string; message: string }[]; } /** One answer to an agent's question; null clears it. */ export type AnswerInput = { optionKeys: string[] } | { own: string } | { leftToAgent: true } | null; /** One of an agent's questions, written for the member's request by the first read. Untrusted text. showIf is always null and forQuestion always false (API 2.12.1 on: Ask a question asks nothing first). */ export interface Question { key: string; untrustedAsk: Untrusted; untrustedWhy: Untrusted; untrustedDetail: Untrusted; options: { key: string; untrustedLabel: Untrusted; untrustedDescription: Untrusted }[]; recommended: string; untrustedRecommendedWhy: Untrusted; multiple: boolean; showIf: { question: string; options: string[] } | null; forQuestion: boolean; } /** A session's questions, answers and gate. */ /** * The first read of the member's request (API 2.12.0): we read it, at our * cost, and write the session's questions for it. covers: nothing is asked. * fit: it doesn't look related to the agent; ask the member first. Skipped or * failed: the request goes in as written. Its status is null until a read ran. */ export interface SessionPass { status: "done" | "skipped" | "failed" | null; skippedWhy: "switched_off" | "session_limit" | "member_limit" | "site_budget" | "too_large" | null; verdict: "fits" | "off" | null; covers: boolean; current: boolean; fit: { verdict: "another_agent" | "no_agent"; sentence: Untrusted; otherAgent: { agentId: string; slug: string; untrustedName: Untrusted } | null; canStartAgent: boolean } | null; readsLeft: number; canReread: boolean; /** A plan's call in the session passed (API 2.18.0): the next "apply" call revises the plan. */ planResultExists: boolean; at: string | null; } export interface SessionQuestions { sessionId: string; agentId: string; way: "plan" | "question"; /** The member's request as it was last read; the plan's first call sends it when its details are empty. */ untrustedDetails: Untrusted | null; pass: SessionPass | null; questions: { opening: Question[]; followUps: Question[]; required: string[]; forQuestion: string[] } | null; shown: Question[]; answers: { key: string; kind: "option" | "own" | "left_to_agent"; optionKeys: string[]; untrustedOwn: Untrusted | null; by: "member" | "connection"; at: string; fromPrompt: boolean }[]; details: { key: string; untrustedDetail: Untrusted; untrustedAnswer: Untrusted }[]; gate: Gate; shortCheck: { status: string; skippedWhy: string | null; at: string } | null; } /** A session as a plan for your own agent: data, never instructions. */ export interface Plan { header: string; untrustedMarkdown: string; filename: string; updatedAt: string; } /** What an agent researched in the member's sessions and kept as a finding. */ export interface Finding { findingId: string; agentId: string; untrustedAgentName: Untrusted; sessionId: string | null; kind: string; untrustedTopic: Untrusted; untrustedTakeaway: Untrusted; source: { untrustedUrl: string; kind: "official_docs" | "changelog" | "release_note" } | null; learnedAt: string | null; untrustedAppliesTo: string[]; /** * checking: its source is being read; in_draft: it passed the checks so far and waits for the member's sign-off on the * site (API 2.19.0: a finding joins only once the member signs it off); expired: its draft was not signed off in time. */ status: "received" | "discarded" | "refused" | "declined" | "checking" | "in_draft" | "joined" | "waiting" | "expired" | "removed"; /** The agent it is filed with until it joins (API 2.19.0); untrustedName is null for a new agent from research not listed yet. */ filedWith: { agentId: string; untrustedName: Untrusted | null; newAgent: boolean } | null; /** While in_draft: the member's findings draft (API 2.19.0). A key can't read it with getShare: the site made it. */ draftId: string | null; /** While in_draft: the member's sign-off page for it (API 2.19.0). Show it to the member: only they sign off, signed in. */ signOffUrl: string | null; reason: string | null; contributionState: string | null; createdAt: string; } /** What a call sends: one of the agent's calls, in a session or starting one. */ export interface CallInput { agentId: string; /** "apply", "question", "starting_files", or a custom call's id. */ callId: string; /** Continue this session; leave out to start one. */ sessionId?: string | null; details?: string; question?: string | null; /** Files added to the session first (addSessionFile). */ fileIds?: string[]; /** The most this one call may use, in cents: one of its limits (it stops there and hands in what it has). Refused when under the least a call needs to start. */ maxCents?: number | null; /** Answers to the agent's questions, kept with the session before the plan's gate is checked. */ answers?: Record | null; /** After the member approved this exact call on their approvals page. */ approvalId?: string; /** Only after the member chose, when the quote said the request doesn't look related (Fit). Ignored on a request that fits. */ subject?: CallSubject | null; /** The question this call answers, when the site gave its id. */ fromAsk?: string | null; } /** What a preview of one call sends: the call you would make, without maxCents or approvalId. Answers sent with it are not kept. */ export type QuoteInput = Omit; /** Why a call waits for the member's yes (owner, 2026-10-13: one chat session a day without asking; the member sets the counts). */ export type ApprovalReason = "new_agent" | "spending_off" | "second_session" | "over_calls"; /** A connection's day (any 24 hours): what it used of the member's counts. */ export interface Today { sessionsUsed: number; sessionsAllowed: number; callsUsed: number; callsAllowed: number; } export interface Started { status: "started"; runId: string; sessionId: string; holdCents: number; spendableAfterCents: number; /** The first call through a connection: show it to the member. */ privacyLine: string | null; /** This connection's day with this call counted. */ today: Today; } export interface CallResult { header: string; runId: string; sessionId: string; agentId: string; callId: string; /** The call's name, so the receipt names the call it belongs to. */ untrustedCallName: Untrusted; /** "returned" appears only on an older call whose charge went back; no charge is given back now. */ status: "in_progress" | "checking" | "passed" | "failed" | "returned"; note: Untrusted | null; files: { fileId: string; untrustedPath: Untrusted; sizeBytes: number; active: boolean; url: string; expiresAt: string }[]; checks: { label: string; passed: boolean }[]; /** usualCents and neverMoreThanCents: only on calls from before 2026-10-09 (none is quoted since); null after. */ charge: { chargedCents: number; runningCostMicros: number; upchargeMicros: number; usualCents: number | null; neverMoreThanCents: number | null; laterTriesNotCharged: number } | null; disclosure: string; failReason: string | null; /** The call reached its limit and this is what it had: charged for what it used when it passed its checks, free when it missed one. */ partial: boolean; /** * The call's limit ended its web research before its work was done; null when it finished within its limit. Tell * the member: with limitBy "credits" (or null), more credits give a call like this more room to research; with * "caller_max", a larger maxCents does. */ researchCut: { limitCents: number; limitBy: Preview["limitBy"] | null } | null; /** The call revised the session's plan (API 2.18.0): an "apply" call after the plan's call passed. Its note says what changed. */ revisesPlan: boolean; /** With revisesPlan: the plan came back as it was, and the note is the Agent's answer; the plan stays the version before. */ planUnchanged: boolean; } /** Feedback on a result. It counts in the agent's record and moves no credits: the call stays charged. */ export interface Report { verdict: "helped" | "partly" | "did_not_help"; reason?: string | null; /** Why, as one of these: an "outdated" report counts toward a correction. */ reasonKind?: "wrong" | "outdated" | "not_relevant" | null; } /** The package the onboarding program prepares, in the site's published format (version 1). */ export interface SharedPackage { format: "gogoportal.package"; parametersVersion: number; agentHint?: string | null; kindsOfProject: string[]; adds: string; doesNotCover: string; items: { kind: string; topic: string; body: string; circumstances?: string | null; buildsOn?: string[]; /** When it was learned (YYYY-MM-DD) and the versions of the tools it applies to. */ learnedAt?: string | null; appliesTo?: string[]; /** What it cost to learn: from logs where they exist, an estimate otherwise. A report; it earns nothing. */ costToLearn?: { micros: number; hours?: number | null; source: "logs" | "estimate" } | null; }[]; calls?: Answer[]; files?: { path: string; sizeBytes: number; sha256: string; role: "material" | "starting_file" }[]; removed: { secrets: number; personalData: number; clientNames: number; markedPrivate: number; filesLeftOut: number }; preparedBy: { program: "gogoportal-connector"; version: string }; /** The names that identify the member's own project or clients (its own name, their plugin or product names, their company, their domains, their people or clients), never the public platform it is built on: the site refuses the description holding one and leaves out any takeaway or file that does, and never stores the list. */ identifyingTerms?: string[]; /** Deprecated (2.17.0): accepted and ignored, so leave it out. It was the questions a project proposed and the details its main call needed. */ questions?: { questions: Answer[]; requiredDetails: string[] }; /** A correction of one takeaway: one item (what is true now), no files or calls. */ correction?: { target: { agentId: string; topicId: string }; kind: "changed_since" | "wrong"; since?: { version?: string | null; date?: string | null } | null; source: { url: string; kind: "official_docs" | "changelog" | "release_note" }; } | null; } /** The refusal shape: a stable code, a sentence, and what to act on. */ export interface ApiErrorBody { error: string; message: string; fields?: Record; approvalId?: string; approvalUrl?: string; expiresAt?: string; reason?: string; topUpUrl?: string; leftBytes?: number; /** details_missing: what to answer before the plan's main call. */ missing?: { key: string; untrustedDetail: Untrusted }[]; /** invalid_answers: what is wrong with each answer. */ problems?: { key: string; message: string }[]; /** corrections_blocked: when the member may send one again. */ until?: string; } export class CustomerApiError extends Error { readonly status: number; readonly code: string; readonly body: ApiErrorBody; constructor(status: number, body: ApiErrorBody) { super(`${status} ${body.error}: ${body.message}`); this.name = "CustomerApiError"; this.status = status; this.code = body.error; this.body = body; } /** The member must approve on their own page (body.approvalUrl), then the same call is sent again with approvalId. */ get needsApproval(): boolean { return this.code === "needs_approval"; } } export interface CustomerClientOptions { apiKey: string; /** The site's address, such as https://your-site.example. No default. */ baseUrl: string; /** Retries after a network error, a 429 or a 5xx. Default 4. */ retries?: number; /** Per attempt. Default 30 seconds. */ timeoutMs?: number; fetch?: typeof fetch; } interface CallOptions { method?: "GET" | "POST" | "PUT"; query?: Record; json?: unknown; bytes?: Uint8Array; /** Holds credits: an Idempotency-Key is required (one is made when not given). */ holds?: boolean; idempotencyKey?: string; } const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const enc = encodeURIComponent; export class CustomerClient { private readonly apiKey: string; private readonly baseUrl: string; private readonly retries: number; private readonly timeoutMs: number; private readonly fetchImpl: typeof fetch; constructor(options: CustomerClientOptions) { if (!options.apiKey) throw new Error("apiKey is required: the connection's key, which `node connector.ts connect ` saves in ~/.gogoportal//key (or POST /api/v1/connections/claim)."); if (!options.baseUrl) throw new Error("baseUrl is required: the site's address, such as https://your-site.example."); this.apiKey = options.apiKey; this.baseUrl = options.baseUrl.replace(/\/+$/, ""); this.retries = options.retries ?? 4; this.timeoutMs = options.timeoutMs ?? 30_000; this.fetchImpl = options.fetch ?? fetch; } /** One call, with retries. Answers the parsed JSON, or throws CustomerApiError. */ async call(path: string, options: CallOptions = {}): Promise { const response = await this.send(path, options); return (await response.json()) as T; } private async send(path: string, options: CallOptions): Promise { const url = new URL(`${this.baseUrl}${CUSTOMER_API_PATH}${path}`); for (const [k, v] of Object.entries(options.query ?? {})) if (v !== undefined && v !== null) url.searchParams.set(k, String(v)); const headers: Record = { Authorization: `Bearer ${this.apiKey}` }; // One key for every attempt of this call. const method = options.method ?? "GET"; const key = options.idempotencyKey ?? (options.holds || method !== "GET" ? crypto.randomUUID() : undefined); if (key) headers["Idempotency-Key"] = key; let body: BodyInit | undefined; if (options.json !== undefined) { headers["Content-Type"] = "application/json"; body = JSON.stringify(options.json); } else if (options.bytes) { headers["Content-Type"] = "application/octet-stream"; body = new Blob([options.bytes.slice()]); } let lastError: unknown = null; for (let attempt = 0; attempt <= this.retries; attempt++) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.timeoutMs); let response: Response; try { response = await this.fetchImpl(url.toString(), { method, headers, body, signal: controller.signal }); } catch (e) { lastError = e; clearTimeout(timer); await sleep(Math.min(8000, 500 * 2 ** attempt)); continue; } clearTimeout(timer); if (response.ok) return response; const errorBody = (await response.json().catch(() => ({ error: "request_failed", message: `The site answered ${response.status}.` }))) as ApiErrorBody; const retryable = response.status === 429 || response.status >= 500 || errorBody.error === "idempotency_in_progress"; // A cap or an allowance is not a rate limit: retrying cannot pass it. const final = errorBody.error === "over_limit" || errorBody.error === "failed_calls" || errorBody.error === "download_cap"; if (!retryable || final || attempt === this.retries) throw new CustomerApiError(response.status, errorBody); const after = Number(response.headers.get("retry-after")); await sleep(Number.isFinite(after) && after > 0 ? Math.min(after * 1000, 60_000) : Math.min(8000, 500 * 2 ** attempt)); } throw lastError instanceof Error ? lastError : new Error("The site could not be reached."); } // ─── The connection ─────────────────────────────────────────── /** The connection; with spentSince (ISO 8601), also what it used since then (spentSinceCents). */ me(options: { spentSince?: string } = {}): Promise { return this.call("/me", { query: { spentSince: options.spentSince } }); } getApproval(approvalId: string): Promise<{ approvalId: string; kind: "step" | "agent"; reason: Exclude | null; status: string; expiresAt: string; approvalUrl: string }> { return this.call(`/approvals/${enc(approvalId)}`); } // ─── AI Agents ──────────────────────────────────────────────── /** The agents that already know about what you describe. Free; the description is not stored. */ find(description: string): Promise<{ agents: (AgentCard & { matchBps: number })[] }> { return this.call("/find", { method: "POST", json: { description } }); } listAgents(limit?: number): Promise<{ agents: AgentCard[] }> { return this.call("/agents", { query: { limit } }); } getAgent(agentId: string): Promise { return this.call(`/agents/${enc(agentId)}`); } /** Before a call of this kind: whether it can start, its limit, and the line to show. No price. Nothing is held. For exactly your call, use quoteCall. */ previewCall(agentId: string, callId = "apply", sessionId?: string): Promise { return this.call(`/agents/${enc(agentId)}/calls/${enc(callId)}/preview`, { query: { sessionId } }); } /** Before exactly this call: whether it can start, its limit, the line to show, and, for a plan's main call, the gate. No price. Free; nothing is held. */ quoteCall(input: QuoteInput): Promise { return this.call("/calls/quote", { method: "POST", json: input }); } // ─── Calls ──────────────────────────────────────────────────── /** Makes a call: charged for what it uses, holding its limit (the member's credits) while it runs. Throws needs_approval when the member must say yes first (e.body.reason says why). */ makeCall(input: CallInput, opts: { idempotencyKey?: string } = {}): Promise { return this.call("/calls", { method: "POST", json: input, holds: true, idempotencyKey: opts.idempotencyKey }); } /** A call's status and, once it passed, its result. */ getCall(runId: string): Promise { return this.call(`/calls/${enc(runId)}`); } /** Polls a call until it passes or fails (or the time runs out), then answers it. */ async waitForCall(runId: string, opts: { everyMs?: number; timeoutMs?: number } = {}): Promise { const until = Date.now() + (opts.timeoutMs ?? 10 * 60_000); for (;;) { const result = await this.getCall(runId); if (result.status !== "in_progress" && result.status !== "checking") return result; if (Date.now() > until) return result; await sleep(opts.everyMs ?? 3000); } } /** Wraps the call up: it hands in what it has and is charged for what it used (free when that misses a check, or when it wrote nothing yet). Read it again for the result. */ stopCall(runId: string): Promise<{ runId: string; stopped: true }> { return this.call(`/calls/${enc(runId)}/stop`, { method: "POST", json: {} }); } /** What was recorded: the first report on a call stands. */ reportCall(runId: string, report: Report): Promise<{ verdict: Report["verdict"]; reasonKind: Report["reasonKind"]; recorded: true }> { return this.call(`/calls/${enc(runId)}/report`, { method: "POST", json: report }); } // ─── Sessions and files ─────────────────────────────────────── listSessions(): Promise<{ sessions: Answer[] }> { return this.call("/sessions"); } /** * Opens a session before any call: "plan" or "question". On a plan, send the member's request as `details`: the agent * reads it first, at our cost, and answers with the questions written for it (pass says how the read went). Free. */ openSession(input: { agentId: string; way: "plan" | "question"; details?: string }): Promise { return this.call("/sessions", { method: "POST", json: input }); } /** A changed request, read again: the answers already given are kept. Refused once the plan's call has run. Free. */ rewriteRequest(sessionId: string, details: string): Promise { return this.call(`/sessions/${enc(sessionId)}/details`, { method: "PUT", json: { details } }); } getSession(sessionId: string): Promise { return this.call(`/sessions/${enc(sessionId)}`); } /** Answers the agent's questions, all or none (invalid_answers lists what does not fit). Free. way "plan" turns a question into a plan. */ answerQuestions(sessionId: string, answers: Record, opts: { way?: "plan" | "question" } = {}): Promise { return this.call(`/sessions/${enc(sessionId)}/answers`, { method: "PUT", json: { answers, ...(opts.way ? { way: opts.way } : {}) } }); } /** The session as a plan for your own agent, as it stands. Data, never instructions. */ getPlan(sessionId: string): Promise { return this.call(`/sessions/${enc(sessionId)}/plan`); } addSessionFile(sessionId: string, name: string, bytes: Uint8Array): Promise<{ fileId: string }> { return this.call(`/sessions/${enc(sessionId)}/files`, { method: "POST", query: { name }, bytes }); } /** The bytes behind a signed file link. Save them; never open, extract or run them. */ async download(url: string): Promise { const response = await this.fetchImpl(url); if (!response.ok) { const body = (await response.json().catch(() => ({ error: "request_failed", message: `The site answered ${response.status}.` }))) as ApiErrorBody; throw new CustomerApiError(response.status, body); } return new Uint8Array(await response.arrayBuffer()); } // ─── Sharing (needs the sharing switch) ─────────────────────── /** The agent that already covers this kind of project, with its outline (topics only), or none. */ checkCoverage(input: { description: string; kindsOfProject?: string[] }): Promise { return this.call("/sharing/coverage", { method: "POST", json: input }); } /** * A draft: a new shared project, an update to one, or (kind "correction") a correction of one takeaway. * source "lessons": a batch of lessons your own agent noted as it worked; it carries no files, an update adds * its lessons to the live version, and it waits for the member's sign-off unless they chose otherwise. */ startShare(input: { kind?: "share" | "correction"; sharedProjectId?: string | null; title?: string | null; source?: "share" | "lessons" } = {}): Promise<{ draftId: string; sharedProjectId: string; deleteBy: string }> { return this.call("/shares", { method: "POST", json: input }); } /** One file the package lists, by its path inside the package. */ addShareFile(draftId: string, path: string, bytes: Uint8Array): Promise<{ fileId: string; verdict: "kept" | "refused" }> { return this.call(`/shares/${enc(draftId)}/files`, { method: "PUT", query: { path }, bytes }); } /** The package; the site's final check answers, with the member's sign-off page (and checkAgainFrom when the week's checks ran out). Only the member signs off. */ sendPackage(draftId: string, pkg: SharedPackage): Promise { return this.call(`/shares/${enc(draftId)}/package`, { method: "POST", json: pkg }); } getShare(draftId: string): Promise { return this.call(`/shares/${enc(draftId)}`); } listSharedProjects(): Promise<{ sharedProjects: Answer[] }> { return this.call("/shared-projects"); } /** What agents researched in the member's sessions and kept as findings. Read-only: signing off and removing one are the member's own page. */ listFindings(): Promise<{ findings: Finding[] }> { return this.call("/findings"); } }