/** * txt SDK — zero-dependency client for the txt filesystem API. * * Treats remote notes as a read/write text filesystem: * list() / listFiles() → directory listing (metadata) * getFile() / getRaw() → read * updateFile() → write * createFile() / deleteFile() → create / unlink * * Works in Node.js, browsers, React Native (Expo), and any fetch-capable runtime. * * Quick start: * import { TxtClient } from "@/lib/sdk" * const txt = new TxtClient({ baseUrl: "https://your-app.vercel.app", apiKey: "txt_..." }) * const tree = await txt.list() * const file = await txt.getFile(tree.rootFiles[0].id) * * Efficient sync (uses If-None-Match → 304 when unchanged): * const stop = txt.sync("file_id", (content) => setNote(content), 5000) * // call stop() to cancel */ // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface TxtClientOptions { /** Base URL of your txt deployment, e.g. "https://your-app.vercel.app" */ baseUrl: string /** API key (txt_...) or a dots OAuth access token on first-party/native clients. */ apiKey: string } export interface TxtChange { sequence: string resourceType: "file" | "folder" | "device" resourceId: string action: "upsert" | "delete" revision: number | null changedAt: string endpoint: string | null } export interface TxtChangePage { changes: TxtChange[] nextCursor: string highWaterMark: string hasMore: boolean expired?: boolean resetCursor?: string } export interface TxtBatchOperation { id?: string method: "GET" | "HEAD" | "POST" | "PUT" | "DELETE" path: string headers?: Record body?: unknown } export interface TxtBatchResult { id: string status: number ok: boolean body: unknown headers: Record } export interface TxtWebhook { id: string name: string url: string events: Array<"file.upsert" | "file.delete" | "folder.upsert" | "folder.delete"> enabled: boolean createdAt?: string updatedAt?: string deliveries?: { delivered: number; pending: number; failed: number } } export interface TxtWriteOptions { /** Safe retry key; repeated mutations replay the first response for 24 hours on API v1. */ idempotencyKey?: string /** ETag returned by GET/HEAD; stale writes fail with HTTP 412. */ ifMatch?: string } export interface TxtFile { id: string name: string context: string tags: string[] type: string availability: "cloud" | "cloud+local" size: number folderId?: string | null createdAt: string updatedAt: string endpoint: string raw: string access: "owner" | "shared" role: "owner" | "writer" | "reader" } export interface TxtFolder { id: string name: string context: string tags: string[] parentFolderId: string | null fileCount: number createdAt: string updatedAt: string endpoint: string files: TxtFile[] } export interface TxtSkill { id: string name: string context: string tags: string[] folderId: string | null folderName: string | null type: string updatedAt: string access: "owner" | "shared" role: "owner" | "writer" | "reader" scope: "global" | "project" | "unknown" project: string | null agent: string | null skill: string endpoint: string raw: string } export interface TxtTree { user: { id: string; email: string; name: string } folders: TxtFolder[] rootFiles: TxtFile[] } export interface TxtFileDetail { id: string name: string content: string context: string tags: string[] type: string availability: "cloud" | "cloud+local" folderId: string | null size?: number createdAt: string updatedAt: string access: "owner" | "shared" role: "owner" | "writer" | "reader" ideation?: TxtIdeation } export interface TxtVersion { id: string file_id: string content: string version_number: number created_at: string content_hash: string | null byte_size: number author_user_id: string | null author_name: string | null source: string label: string | null commit_id: string | null } export interface TxtComment { id: string file_id: string user_id: string author: string body: string selection: string | null resolved: boolean created_at: string updated_at: string } export interface TxtSuggestion { id: string file_id: string user_id: string author: string original: string suggested: string status: "pending" | "accepted" | "rejected" created_at: string updated_at: string } export interface TxtApiKey { id: string label: string token_prefix: string created_at: string last_used_at: string | null } export interface TxtConnection { id: string name: string context: string tags: string[] type: string folderId: string | null updatedAt: string score: number reasons: Array< | { kind: "explicit"; reason: string } | { kind: "shared_tags"; tags: string[] } | { kind: "same_folder" } > explicit?: boolean endpoint: string } export type TxtShareMode = "public" | "readonly" | "password" | "password-readonly" export interface TxtShareLink { token: string slug: string urlPath: string publicUrl: string tokenUrl: string ownerPath: string | null live: boolean mode?: TxtShareMode createdAt?: string updatedAt?: string } export interface TxtContextSuggestion { fileId: string summary: string recommendedContext?: string | null addTags: string[] folderId?: string | null connectToFileIds: string[] reason: string confidence: number } export interface TxtIdeation { enabled: boolean seed: string status: "exploring" | "settled" passes: number lastRunAt: string | null } export interface TxtIdeationRun { fileId: string name: string content: string context: string tags: string[] folder: { id: string; name: string; created: boolean } | null connected: Array<{ fileId: string; reason: string }> openThreads: string[] ideation: TxtIdeation billing: "byok" | "subscription" provider: string model: string usage: { inputTokens: number; outputTokens: number; totalTokens: number } } export interface TxtContextAnalysis { summary: string themes: Array<{ name: string; description: string; fileIds: string[] }> suggestions: TxtContextSuggestion[] } // --------------------------------------------------------------------------- // TxtClient // --------------------------------------------------------------------------- export class TxtClient { protected base: string protected key: string constructor(options: TxtClientOptions) { this.base = options.baseUrl.replace(/\/$/, "") this.key = options.apiKey } protected headers(): Record { return { Authorization: `Bearer ${this.key}`, "Content-Type": "application/json", } } protected route(path: string) { return path } protected async request(path: string, init?: RequestInit): Promise { const res = await fetch(`${this.base}${this.route(path)}`, { ...init, headers: { ...this.headers(), ...(init?.headers ?? {}) }, }) if (!res.ok) { const text = await res.text().catch(() => "") let details: unknown = null try { details = text ? JSON.parse(text) : null } catch { details = text } throw new TxtError(res.status, text || res.statusText, path, details, res.headers.get("x-request-id")) } return res.json() as Promise } protected async requestText(path: string, init?: RequestInit): Promise { const res = await fetch(`${this.base}${this.route(path)}`, { ...init, headers: { ...this.headers(), ...(init?.headers ?? {}) }, }) if (!res.ok) { const text = await res.text().catch(() => "") let details: unknown = null try { details = text ? JSON.parse(text) : null } catch { details = text } throw new TxtError(res.status, text || res.statusText, path, details, res.headers.get("x-request-id")) } return res.text() } // ------------------------------------------------------------------------- // Files & Folders // ------------------------------------------------------------------------- /** List all files and folders for the authenticated user. */ async list(): Promise { return this.request("/api/me") } /** Flat file list (preferred by the native desktop client). */ async listFiles(): Promise { const res = await this.request<{ files: TxtFile[] }>("/api/me/files") return res.files } /** Published agent skills, including notes shared by collaborators. */ async listSkills(): Promise { const res = await this.request<{ skills: TxtSkill[]; reveal?: string }>("/api/me/skills") return res.skills } /** Realtime search across txt/groupSkills and the locally mirrored skills.sh index. */ async searchSkillCatalog(query = "", options: { view?: "trending" | "hot" | "all-time" | "official" | "txt" owner?: string limit?: number } = {}) { const params = new URLSearchParams() if (query.trim()) params.set("q", query.trim()) if (options.view) params.set("view", options.view) if (options.owner?.trim()) params.set("owner", options.owner.trim()) if (options.limit) params.set("limit", String(options.limit)) const suffix = params.size ? `?${params}` : "" return this.request<{ source: string; query: string; skills: Array<{ id: string slug: string name: string source: string publisher: string description?: string | null installs: number url: string installUrl: string | null origin: "skills.sh" | "txt" official: boolean installed: boolean txtInstalls: number rank?: number | null auditStatus?: string | null auditRisk?: string | null }> stats: { catalogSkills: number officialSkills: number upstreamInstalls: number txtInstalls: number yourInstalls: number lastSyncedAt: string | null syncStatus: string } }>(`/api/me/skills/catalog${suffix}`) } /** Read a skills.sh package, including file contents and security audits. */ async getSkillCatalogDetail(id: string, agent: "agents" | "claude" | "cursor" = "agents") { const params = new URLSearchParams({ id, agent }) return this.request<{ skill: { id: string; slug: string; source: string; installs: number; hash: string | null; url: string } files: Array<{ path: string; remoteName: string; contents: string; fileType: "md" | "txt"; bytes: number }> audit: { status: "pass" | "warn" | "unknown"; results: unknown[] } installable: boolean blockedBy: string | null }>(`/api/me/skills/catalog/detail?${params}`) } /** Cache a skills.sh package in .viewed-skills and return its main txt note. */ async viewSkill(id: string) { return this.request<{ file: { id: string; name: string; path: string; created: boolean } folder: { id: string; name: string } files: Array<{ id: string; name: string; path: string; created: boolean }> audit: { status: "pass" | "warn" | "unknown"; results: unknown[] } installable: boolean blockedBy: string | null }>("/api/me/skills/catalog/view", { method: "POST", body: JSON.stringify({ id }), }) } /** Add all or selected skills.sh files to a user-owned txt folder. */ async installSkill( id: string, agent: "agents" | "claude" | "cursor" = "agents", options: { folderId?: string; files?: string[] } = {}, ) { return this.request<{ skill: { id: string; slug: string; source: string } folder: { id: string; name: string } files: Array<{ id: string; name: string; created: boolean }> audit: { status: "pass" | "warn" | "unknown"; results: unknown[] } selection: { selected: number; available: number } destination: { stage: "txt-cloud" folder: { id: string; name: string } remoteNames: string[] syncCommand: string localHomes: string[] } reveal: string }>("/api/me/skills/install", { method: "POST", body: JSON.stringify({ id, agent, folderId: options.folderId, files: options.files }), }) } /** Create a new file. */ async createFile(input: { name: string content?: string context?: string tags?: string[] fileType?: "txt" | "md" availability?: "cloud" | "cloud+local" folderId?: string | null }, options: TxtWriteOptions = {}): Promise { const res = await this.request<{ file: TxtFileDetail }>("/api/me/files", { method: "POST", headers: options.idempotencyKey ? { "Idempotency-Key": options.idempotencyKey } : undefined, body: JSON.stringify(input), }) return res.file } /** Transcribe a handwritten-note image into a normal synced txt/Markdown file. */ async transcribeImage(input: { imageBase64?: string imageUrl?: string mediaType?: "image/jpeg" | "image/png" | "image/gif" | "image/webp" mode?: "structured" | "verbatim" prompt?: string name?: string fileType?: "txt" | "md" folderId?: string | null }, options: TxtWriteOptions = {}) { return this.request<{ file: TxtFileDetail transcription: { provider: string model: string mode: "structured" | "verbatim" imageSha256: string source: "upload" | "url" usage: { inputTokens: number; outputTokens: number; totalTokens: number } } }>("/api/me/transcriptions", { method: "POST", headers: options.idempotencyKey ? { "Idempotency-Key": options.idempotencyKey } : undefined, body: JSON.stringify(input), }) } /** Delete a file. */ async deleteFile(fileId: string): Promise { await this.request(`/api/me/files/${fileId}`, { method: "DELETE" }) } /** Get full metadata + content of a single file. */ async getFile(fileId: string): Promise { return this.request(`/api/me/files/${fileId}`) } /** Get the raw text content of a file (no JSON envelope). */ async getRaw(fileId: string): Promise { return this.requestText(`/api/me/files/${fileId}/raw`) } /** Update a file's content, metadata, type, or folder. */ async updateFile( fileId: string, patch: { content?: string name?: string context?: string tags?: string[] fileType?: "txt" | "md" availability?: "cloud" | "cloud+local" folderId?: string | null }, options: TxtWriteOptions = {}, ): Promise { const res = await this.request<{ file: TxtFileDetail }>(`/api/me/files/${fileId}`, { method: "PUT", headers: { ...(options.idempotencyKey ? { "Idempotency-Key": options.idempotencyKey } : {}), ...(options.ifMatch ? { "If-Match": options.ifMatch } : {}), }, body: JSON.stringify(patch), }) return res.file } /** Create a revocable live link to the canonical cloud file. */ async createShareLink( fileId: string, input: { mode?: TxtShareMode; password?: string } = {}, ): Promise { return this.request("/api/shares", { method: "POST", body: JSON.stringify({ fileId, mode: input.mode ?? "readonly", password: input.password }), }) } /** Return the file's active link, if one exists. */ async getShareLink(fileId: string): Promise { const res = await this.request<{ share: TxtShareLink | null }>( `/api/shares?fileId=${encodeURIComponent(fileId)}`, ) return res.share } /** Revoke a share capability immediately. */ async revokeShareLink(token: string): Promise { await this.request("/api/shares", { method: "DELETE", body: JSON.stringify({ token }), }) } /** List files in a folder. Pass includeContent for bodies (heavier). */ async getFolder( folderId: string, opts?: { includeContent?: boolean }, ): Promise> }> { const qs = opts?.includeContent ? "?include=content" : "" return this.request(`/api/me/folders/${folderId}${qs}`) } /** List folders without embedding file bodies. */ async listFolders(): Promise { const res = await this.request<{ folders: TxtFolder[] }>("/api/me/folders") return res.folders } /** Create a folder with optional context and tags. */ async createFolder(input: { name: string context?: string tags?: string[] parentFolderId?: string | null }): Promise { const res = await this.request<{ folder: TxtFolder }>("/api/me/folders", { method: "POST", body: JSON.stringify(input), }) return res.folder } /** Update a folder's name, context, tags, or parent. */ async updateFolder( folderId: string, patch: { name?: string; context?: string; tags?: string[]; parentFolderId?: string | null }, ): Promise { const res = await this.request<{ folder: TxtFolder }>(`/api/me/folders/${folderId}`, { method: "PUT", body: JSON.stringify(patch), }) return res.folder } /** Delete a folder and promote its direct files and subfolders one level. */ async deleteFolder(folderId: string): Promise { await this.request(`/api/me/folders/${folderId}`, { method: "DELETE" }) } /** Discover explicit and inferred related files. */ async connections(fileId: string): Promise { const res = await this.request<{ connections: TxtConnection[] }>( `/api/me/files/${fileId}/connections`, ) return res.connections } /** Persist a direct relationship between two files. */ async connectFiles(fileId: string, targetFileId: string, reason = ""): Promise { await this.request(`/api/me/files/${fileId}/connections`, { method: "POST", body: JSON.stringify({ targetFileId, reason }), }) } /** Remove a direct relationship between two files. */ async disconnectFiles(fileId: string, targetFileId: string): Promise { await this.request( `/api/me/files/${fileId}/connections?targetFileId=${encodeURIComponent(targetFileId)}`, { method: "DELETE" }, ) } /** Ask the context engine to propose tags, folders, and explicit connections. */ async analyzeContext(prompt = ""): Promise { const res = await this.request<{ analysis: TxtContextAnalysis }>("/api/me/context", { method: "POST", body: JSON.stringify({ prompt }), }) return res.analysis } /** Start a note that explores itself from the context brain. */ async createIdeation(seed = "") { return this.request("/api/me/ideation", { method: "POST", body: JSON.stringify({ seed }), }) } /** List ideation notes and whether exploration is unlocked. */ async listIdeations() { return this.request<{ allowed: boolean billing: "byok" | "subscription" | null reason: string | null notes: Array<{ fileId: string name: string seed: string status: string passes: number lastRunAt: string | null updatedAt: string endpoint: string }> }>("/api/me/ideation") } /** Grow an existing note from the context brain. Promotes a regular note on first pass. */ async growIdeation(fileId: string, seed = "") { return this.request(`/api/me/files/${fileId}/ideate`, { method: "POST", body: JSON.stringify({ seed }), }) } async getIdeation(fileId: string) { const res = await this.request<{ ideation: TxtIdeation }>(`/api/me/files/${fileId}/ideation`) return res.ideation } // ------------------------------------------------------------------------- // Versions // ------------------------------------------------------------------------- /** List saved versions of a file (newest first). */ async listVersions(fileId: string, opts?: { includeContent?: boolean }): Promise { const query = opts?.includeContent ? "?include=content" : "" const res = await this.request<{ versions: TxtVersion[] }>( `/api/me/files/${fileId}/versions${query}`, ) return res.versions } /** Commit an immutable version. Content is optional; omitted snapshots the server working copy. */ async saveVersion( fileId: string, input: { content?: string; label?: string; source?: string; commitId?: string } = {}, ): Promise { const res = await this.request<{ version: TxtVersion }>(`/api/me/files/${fileId}/versions`, { method: "POST", body: JSON.stringify(input), }) return res.version } /** Fetch one immutable version, including its content. */ async getVersion(fileId: string, versionId: string): Promise { const res = await this.request<{ version: TxtVersion }>( `/api/me/files/${fileId}/versions/${versionId}`, ) return res.version } // ------------------------------------------------------------------------- // Comments // ------------------------------------------------------------------------- /** List all comments on a file. */ async listComments(fileId: string): Promise { const res = await this.request<{ comments: TxtComment[] }>(`/api/files/${fileId}/comments`) return res.comments } /** Post a new comment (optionally anchored to a text selection). */ async createComment(fileId: string, body: string, selection?: string): Promise { const res = await this.request<{ comment: TxtComment }>(`/api/files/${fileId}/comments`, { method: "POST", body: JSON.stringify({ body, selection }), }) return res.comment } /** Resolve or re-open a comment. */ async resolveComment(fileId: string, commentId: string, resolved: boolean): Promise { await this.request(`/api/files/${fileId}/comments`, { method: "PATCH", body: JSON.stringify({ id: commentId, resolved }), }) } /** Delete a comment. */ async deleteComment(fileId: string, commentId: string): Promise { await this.request(`/api/files/${fileId}/comments`, { method: "DELETE", body: JSON.stringify({ id: commentId }), }) } // ------------------------------------------------------------------------- // Suggestions // ------------------------------------------------------------------------- /** List all suggestions on a file. */ async listSuggestions(fileId: string): Promise { const res = await this.request<{ suggestions: TxtSuggestion[] }>(`/api/files/${fileId}/suggestions`) return res.suggestions } /** Submit a suggestion (track-change style). */ async createSuggestion(fileId: string, original: string, suggested: string): Promise { const res = await this.request<{ suggestion: TxtSuggestion }>(`/api/files/${fileId}/suggestions`, { method: "POST", body: JSON.stringify({ original, suggested }), }) return res.suggestion } /** Accept or reject a suggestion. */ async reviewSuggestion(fileId: string, suggestionId: string, status: "accepted" | "rejected"): Promise { await this.request(`/api/files/${fileId}/suggestions`, { method: "PATCH", body: JSON.stringify({ id: suggestionId, status }), }) } // ------------------------------------------------------------------------- // API Keys // ------------------------------------------------------------------------- /** List all API keys for the current user. */ async listApiKeys(): Promise { const res = await this.request<{ keys: TxtApiKey[] }>("/api/me/keys") return res.keys } /** Create a new API key with an optional label. Returns the token once — store it. */ async createApiKey(label?: string): Promise<{ key: TxtApiKey; token: string }> { return this.request("/api/me/keys", { method: "POST", body: JSON.stringify({ label: label ?? "sdk-key" }), }) } /** Revoke an API key by ID. */ async deleteApiKey(keyId: string): Promise { await this.request("/api/me/keys", { method: "DELETE", body: JSON.stringify({ id: keyId }), }) } // ------------------------------------------------------------------------- // Realtime sync (polling) // ------------------------------------------------------------------------- /** * Poll a file for changes. Uses If-None-Match so unchanged polls return 304 * (no body) — cheap revalidation for remote-filesystem sync. * * @param fileId - The file to watch. * @param onChange - Called with new content whenever it changes. * @param intervalMs - Poll interval in ms (default: 5000). * @returns - A `stop()` function to cancel the sync. * * @example * const stop = txt.sync("file_abc", (content) => setNote(content)) * // later: stop() */ sync(fileId: string, onChange: (content: string, file: TxtFileDetail) => void, intervalMs = 5000): () => void { let etag: string | undefined let handle: ReturnType | null = null let stopped = false const path = `/api/me/files/${fileId}` const poll = async () => { if (stopped) return try { const res = await fetch(`${this.base}${path}`, { headers: { ...this.headers(), ...(etag ? { "If-None-Match": etag } : {}), }, }) if (res.status === 304) return if (!res.ok) { const text = await res.text().catch(() => "") throw new TxtError(res.status, text || res.statusText, path) } const nextEtag = res.headers.get("etag") ?? undefined const file = (await res.json()) as TxtFileDetail etag = nextEtag onChange(file.content, file) } catch { // silently retry on network errors } } // immediate first fetch, then poll poll() handle = setInterval(poll, intervalMs) return () => { stopped = true if (handle !== null) clearInterval(handle) } } /** * Watch multiple files at once. * @returns A `stop()` function to cancel all watches. */ syncMany( fileIds: string[], onChange: (fileId: string, content: string, file: TxtFileDetail) => void, intervalMs = 5000, ): () => void { const stops = fileIds.map((id) => this.sync(id, (c, f) => onChange(id, c, f), intervalMs)) return () => stops.forEach((s) => s()) } } /** Stable public API client. Legacy-only extras continue to fall back transparently. */ export class TxtV1Client extends TxtClient { protected override route(path: string) { if (path === "/api/me/files" || /^\/api\/me\/files\/[^/]+(?:\/raw|\/versions(?:\/[^/]+)?|\/connections|\/plan|\/sync|\/ideate|\/ideation)?$/.test(path)) { return path.replace("/api/me/files", "/api/v1/files") } if (path === "/api/me/skills" || path.startsWith("/api/me/skills?")) return path.replace("/api/me/skills", "/api/v1/skills") if (path.startsWith("/api/me/skills/catalog")) return path.replace("/api/me/skills/catalog", "/api/v1/skills/catalog") if (path === "/api/me/skills/install") return "/api/v1/skills/install" if (/^\/api\/folders\/[^/]+\/share$/.test(path)) { return path.replace("/api/folders", "/api/v1/folders") } if (/^\/api\/files\/[^/]+\/(?:comments|suggestions)$/.test(path)) { return path.replace("/api/files", "/api/v1/files") } if (path === "/api/me/folders" || /^\/api\/me\/folders\/[^/]+(?:\/sync)?$/.test(path)) { return path.replace("/api/me/folders", "/api/v1/folders") } if (path === "/api/shares") return "/api/v1/shares" if (path === "/api/me/context") return "/api/v1/context" if (path === "/api/me/ideation") return "/api/v1/ideation" if (path === "/api/ai/assist") return "/api/v1/ai/assist" if (path === "/api/me/transcriptions") return "/api/v1/transcriptions" return path } async listFilesPage(options: { limit?: number; cursor?: string } = {}) { const params = new URLSearchParams() if (options.limit) params.set("limit", String(options.limit)) if (options.cursor) params.set("cursor", options.cursor) const query = params.size ? `?${params}` : "" return this.request<{ files: TxtFile[]; nextCursor: string | null }>(`/api/me/files${query}`) } /** Read durable file/folder changes after a cursor, including delete tombstones. */ async changes(cursor: string | number = 0, limit = 200) { const params = new URLSearchParams({ cursor: String(cursor), limit: String(limit) }) return this.request(`/api/v1/changes?${params}`) } /** Execute up to 25 ordered operations. Retry the complete batch with one idempotency key. */ async batch(operations: TxtBatchOperation[], options: { stopOnError?: boolean; idempotencyKey?: string } = {}) { const { idempotencyKey, ...bodyOptions } = options return this.request<{ results: TxtBatchResult[]; complete: boolean }>("/api/v1/batch", { method: "POST", headers: idempotencyKey ? { "Idempotency-Key": idempotencyKey } : undefined, body: JSON.stringify({ operations, ...bodyOptions }), }) } /** Push local mutations and pull changes since cursor in one deterministic round trip. */ async syncRound(input: { cursor: string | number mutations?: TxtBatchOperation[] changeLimit?: number stopOnError?: boolean idempotencyKey?: string }) { const { idempotencyKey, ...body } = input return this.request<{ results: TxtBatchResult[]; pull: TxtChangePage }>("/api/v1/sync", { method: "POST", headers: idempotencyKey ? { "Idempotency-Key": idempotencyKey } : undefined, body: JSON.stringify(body), }) } async session() { return this.request<{ user: { id: string; email: string; name: string }; auth: { type: "api_key" | "dots_oauth"; scopes?: string[]; keyId?: string } }>("/api/v1/session") } async listWebhooks() { return this.request<{ webhooks: TxtWebhook[] }>("/api/v1/webhooks") } /** The signing secret is returned once. Store it in a secret manager. */ async createWebhook(input: { name?: string; url: string; events?: TxtWebhook["events"] }) { return this.request<{ webhook: TxtWebhook; signingSecret: string }>("/api/v1/webhooks", { method: "POST", body: JSON.stringify(input), }) } async updateWebhook(id: string, patch: { name?: string; url?: string; events?: TxtWebhook["events"]; enabled?: boolean }) { return this.request<{ webhook: TxtWebhook }>(`/api/v1/webhooks/${id}`, { method: "PATCH", body: JSON.stringify(patch), }) } async deleteWebhook(id: string) { return this.request<{ deleted: true }>(`/api/v1/webhooks/${id}`, { method: "DELETE" }) } } // --------------------------------------------------------------------------- // TxtError // --------------------------------------------------------------------------- export class TxtError extends Error { constructor( public readonly status: number, message: string, public readonly path: string, public readonly details: unknown = null, public readonly requestId: string | null = null, ) { super(`[txt] ${status} ${message} (${path})`) this.name = "TxtError" } } // --------------------------------------------------------------------------- // React hook (web / React Native) // --------------------------------------------------------------------------- /** * useTxtFile — React hook for live-synced file content. * * Compatible with React Native and web (no DOM dependency). * * @example * const { content, file, loading, error, update } = useTxtFile(client, "file_id") */ export function useTxtFile( client: TxtClient, fileId: string | null | undefined, intervalMs = 5000, ) { // Lazy import React so this file stays importable in non-React environments // eslint-disable-next-line @typescript-eslint/no-require-imports const { useState, useEffect, useCallback } = require("react") as typeof import("react") const [content, setContent] = useState("") const [file, setFile] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { if (!fileId) return setLoading(true) setError(null) const stop = client.sync( fileId, (c, f) => { setContent(c) setFile(f) setLoading(false) }, intervalMs, ) return stop }, [client, fileId, intervalMs]) const update = useCallback( async (newContent: string) => { if (!fileId) return const updated = await client.updateFile(fileId, { content: newContent }) setContent(updated.content) setFile(updated) }, [client, fileId], ) return { content, file, loading, error, update } }