# txt — Full API & SDK Reference # Generated: 2026-08-23T21:14:56.632Z # Base URL: https://www.txt-fil.es =========================================================================== ## 0. Mental model — remote text filesystem =========================================================================== txt notes are files. Cloud sync (dots) exposes them as a read/write HTTP filesystem for other apps (CLI, React Native, MCP, desktop, agents). Stable developer routes use /api/v1. Existing /api/me and /api/files routes remain supported for installed clients. GET /api/v1 is the live discovery document. v1 adds scoped/expiring keys, cursor pagination, CORS, DB-backed quotas, X-Request-ID, If-Match conditional writes, and Idempotency-Key replay. list / readdir GET /api/me | /api/me/files | /api/me/folders skills GET /api/v1/skills read GET /api/me/files/:id | …/raw write PUT /api/me/files/:id create POST /api/me/files unlink DELETE /api/me/files/:id stat HEAD /api/me/files/:id | …/raw connect GET /api/me/files/:id/connections Every file and folder can carry: context human/agent-readable purpose, scope, people, and constraints tags normalized reusable labels for deterministic connections Performance contract: - Directory listings NEVER include file bodies (only size + mtime). - GET file/raw returns ETag + Last-Modified. - If-None-Match → 304 (empty body) when the revision is unchanged. - Folder GET defaults to metadata; pass ?include=content for bodies. - Versions list omits bodies unless ?include=content. Human API page: https://www.txt-fil.es/api Interactive docs: https://www.txt-fil.es/api/playground Developer dashboard: https://www.txt-fil.es/api/dashboard Short agent doc: https://www.txt-fil.es/llms.txt Mint a key: https://txt-fil.es/sync (vanity redirect to the working sync page) =========================================================================== ## 1. Authentication =========================================================================== All API endpoints accept authentication in two forms: 1. Browser session cookie (set automatically when signed in with dots at https://www.txt-fil.es/auth/sign-in) 2. Bearer API key: Authorization: Bearer txt_ First-party native clients may instead send a dots OAuth Authorization Code + PKCE access token as Bearer auth. Store it in Keychain/secure storage and refresh it without discarding offline edits. Generate API keys at https://txt-fil.es/sync or via POST /api/me/keys (session). Keys are prefixed with "txt_" and hashed at rest. Key creation accepts scopes plus expiresInDays (1, 7, 30, 90, 365, or null). Available scopes: files:read, files:write, folders:read, folders:write, collaboration:read, collaboration:write, shares:read, shares:write, ai:use. Raw keys are returned once. Rotation preserves scope/expiry; revocation is immediate. =========================================================================== ## 2. REST API Reference =========================================================================== All responses are JSON unless noted. v1 infrastructure errors return { error: { code, message, requestId } }; legacy route errors retain { error: string } for compatibility. Every v1 response includes X-Request-ID. --------------------------------------------------------------------------- ### 2.1 User & File Tree (directory listing) --------------------------------------------------------------------------- GET /api/me Returns the file tree — metadata only, no bodies. Response: { user: { id, email, name }, folders: [ { id, name, context, tags, fileCount, createdAt, updatedAt, endpoint, files: [...] } ], rootFiles: [ { id, name, context, tags, type, size, createdAt, updatedAt, endpoint, raw } ] } GET /api/me/files Flat file index (preferred by desktop / SDK). Metadata only. POST /api/me/files Create a file. Body: { name, content?, context?, tags?, fileType?, folderId?, availability? } availability is "cloud" or "cloud+local" (default). A local-only file is intentionally outside the cloud API until a client uploads it. DELETE /api/me/files/:id Unlink a file. --------------------------------------------------------------------------- ### 2.2 Files (read / write) --------------------------------------------------------------------------- GET /api/me/files/:id Returns full file metadata and content. Headers: ETag, Last-Modified, Cache-Control: private, no-cache If-None-Match → 304 when unchanged. Response: { id, name, content, context, tags, type, availability, folderId, size, createdAt, updatedAt } HEAD /api/me/files/:id Stat only (ETag, Content-Length, no body). GET /api/me/files/:id/raw Raw text bytes (no JSON). Content-Type: text/plain or text/markdown Same ETag / 304 / HEAD behavior as JSON GET. PUT /api/me/files/:id Update a file's content and/or name. Body: { content?: string, name?: string, context?: string, tags?: string[], fileType?, folderId?, availability? } Response: { file: { id, name, content, context, tags, type, folderId, size, createdAt, updatedAt } } GET /api/me/files/:id/connections Ranked related files. Explicit links score highest, then shared tags and same-folder membership. Each result includes score, reasons, tags, and context. POST /api/me/files/:id/connections Persist a direct link. Body: { targetFileId, reason? } DELETE /api/me/files/:id/connections?targetFileId=... Remove a direct link. GET /api/me/files/:id/plan Return { plan: { enabled, scope, trust, source, updatedAt } }. PUT /api/me/files/:id/plan Owner-only. Body: { enabled, scope: "manual"|"project"|"global", trust: "unreviewed"|"approved", source? }. Approval permits reference use; plan text remains untrusted and cannot authorize actions or override higher-priority instructions. GET /api/me/context Workspace metadata plus organization coverage. POST /api/me/context AI-assisted, reviewable context/tag/folder/connection proposals. Body: { prompt?, includeApprovedPlans? }. Plan bodies are excluded by default. When true, only enabled, approved, non-manual plans are included. Proposals do not mutate files until explicitly applied. GET /api/identity/jazz Optional/experimental capability discovery for dots-provisioned Jazz sync. The dots token stays server-side. Connect when configured is true, syncMode is not "off", and syncServer is present. A client without the required dots scopes receives DOTS_REAUTH_REQUIRED. The core Turso/API filesystem remains the supported fallback and does not require Jazz. GET|PUT /api/me/files/:id/sync GET|PUT /api/me/folders/:id/sync Read or set the dots sync policy. PUT body: { mode: "inherit"|"on"|"off" }. Folder policy is inherited; a note-level policy can override it. GET|POST|PATCH|DELETE /api/me/files/:id/share-dots List, grant, change, or revoke writer/reader access for a dots user. GET|POST|PATCH|DELETE /api/me/folders/:id/share-dots Manage folder-level dots collaborators. POST can include descendants and materializes grants for the current sync-enabled notes (100-note limit). --------------------------------------------------------------------------- ### 2.3 Folders --------------------------------------------------------------------------- GET /api/me/folders List folder metadata. POST /api/me/folders Create a folder or subfolder. Body: { name, context?, tags?, parentFolderId? } GET /api/me/folders/:id Directory listing. Metadata only by default. Pass ?include=content to embed bodies (heavier). Response: { id, name, context, tags, parentFolderId, createdAt, updatedAt, files: [...] } PUT /api/me/folders/:id Update folder name, context, tags, or parentFolderId. Circular nesting is rejected. DELETE /api/me/folders/:id Delete the folder and promote direct files and subfolders one level. --------------------------------------------------------------------------- ### 2.4 Version History --------------------------------------------------------------------------- GET /api/me/files/:id/versions List immutable versions, newest first (metadata by default). Pass ?include=content for bodies. Response includes version_number, content_hash, byte_size, author, source, label, and created_at. POST /api/me/files/:id/versions Commit an immutable version. Body: { content?, label?, source?, commitId? }. Omit content to snapshot the server working copy. commitId makes retries idempotent. Writers may commit; readers may only list/fetch. GET /api/me/files/:id/versions/:versionId Fetch one immutable version including content. --------------------------------------------------------------------------- ### 2.5 Comments (multiplayer) --------------------------------------------------------------------------- GET /api/files/:id/comments Browser-session only. Response fields include id, file_id, user_id, user_email, user_name, body, selection_start, selection_end, selected_text, resolved, resolved_by, resolved_at, created_at, and updated_at. Polls every 5 s in the web app UI. POST /api/files/:id/comments Body: { body, selectionStart?, selectionEnd?, selectedText? }. Response: { comment: {...} } PATCH /api/files/:id/comments Resolve or re-open a comment. Body: { commentId, resolved }. DELETE /api/files/:id/comments Query: ?commentId=... --------------------------------------------------------------------------- ### 2.6 Suggestions (track-changes) --------------------------------------------------------------------------- GET /api/files/:id/suggestions Browser-session only. Response fields include original_text, suggested_text, selection_start, selection_end, status, reviewer, and timestamps. status is one of: pending | accepted | rejected POST /api/files/:id/suggestions Propose a change without modifying the live file. Body: { originalText?, suggestedText, selectionStart, selectionEnd }. Response: { suggestion: {...} } PATCH /api/files/:id/suggestions Accept or reject a suggestion. Body: { suggestionId, status: "accepted" | "rejected" }. --------------------------------------------------------------------------- ### 2.7 API Keys --------------------------------------------------------------------------- GET /api/me/keys Response: { keys: [ { id, label, token_prefix, created_at, last_used_at } ] } POST /api/me/keys Body: { label?: string } Response: { id, label, token: "txt_...", token_prefix } IMPORTANT: the full token is only returned once — store it securely. PATCH /api/me/keys Rotate an existing key. Body: { id: string } Response: { id, label, token: "txt_...", token_prefix, rotated_from } IMPORTANT: the old key is revoked and the replacement token is shown once. DELETE /api/me/keys Body: { id: string } --------------------------------------------------------------------------- ### 2.8 AI credentials and usage --------------------------------------------------------------------------- Human configuration: https://www.txt-fil.es/ai GET /api/me/ai User-activated provider preference, usage, and credential metadata only. AI is off when provider is null. Accepts a browser session or Bearer txt_ key. Never returns provider secrets. PUT /api/me/ai Select an already-configured user-funded provider: { provider: "gateway"|"openai"|"anthropic" } GET|PUT|DELETE /api/me/ai/credentials Browser-session-only write-only vault. Provider credentials are verified, AES-256-GCM encrypted, user/provider-bound, and never returned. Do not ask an agent to collect, print, save, or rotate a human's provider key. POST /api/ai/assist Uses the account preference and rejects request-scoped X-Txt-AI-Key headers. txt-cli BYOK bypasses this endpoint and calls the chosen provider directly. --------------------------------------------------------------------------- ### 2.9 Public links --------------------------------------------------------------------------- POST /api/shares Create a live capability link from { fileId, mode, password? }. The server reads the canonical file; callers do not upload a second content snapshot. Modes may be editable, readonly, password, or password-readonly. Returns a readable slug/publicUrl and revocable token. GET /api/shares?slug=... or ?token=... Read the current canonical file. Password modes use X-Txt-Share-Password; passwords never enter URLs or logs. GET /api/shares?fileId=... Authenticated owner lookup for the file's active link; returns { share: null } when the file has no link. PUT /api/shares Update the canonical file through an editable link with { token, content, password? }. DELETE /api/shares Revoke an owned link with { token }. --------------------------------------------------------------------------- ### 2.10 Devices & per-device availability (Dropbox-style three-state) --------------------------------------------------------------------------- The server-wide file column "availability" ("cloud" | "cloud+local") is the default for every device. A registered device can override it per file: "online-only" (tracked placeholder, no bytes on that device), "available" (cached, evictable), or "pinned" (guaranteed local, eviction refused). Metadata always syncs everywhere; only content follows the state. POST /api/v1/devices Register an installation once: { name?, platform? } → { device: { id } }. Store the id; it is the handle for all state operations. GET /api/v1/devices List this account's registered devices. GET /api/v1/devices/:id The device plus its explicit per-file state overrides. Files without an override follow their availability default. PUT /api/v1/devices/:id/state Bulk pin-policy write (up to 500 entries): { states: [{ fileId, state: "online-only"|"available"|"pinned"|null }] } null clears the override. State changes appear on GET /api/v1/changes as resourceType "device", so any client (or UI) can react — including pinning a file onto a *different* device remotely. DELETE /api/v1/devices/:id Forget a device and all its overrides. Invariants clients must honor: local edits always upload regardless of state (a write re-materializes an online-only file); never evict a file whose local content is unsynced; never auto-evict a pinned file. =========================================================================== ## 3. txt-cli, folder mirrors, and plan files =========================================================================== Install: npm install --global txt-cli (binary name: txt) txt auth txt ls txt sync ./notes --folder FOLDER_SLUG txt sync ./notes --watch txt pin ./notes/keep.md txt evict ./notes/big-old.md txt hydrate ./notes/big-old.md txt state ./notes txt plan link launch-plan.md txt plan sync --once txt plan init --global txt skills where txt skills sync --reveal -w txt skills ls --remote txt skills share txt security doctor Folder mirrors use sync.txt for remote IDs and last-agreed hashes, never credentials. Plan links use .plans/.txt-sync.json and do not require Git. The global plan home defaults to ~/.agents/.plans, beside shared agent skills. Plan bodies remain untrusted plaintext reference data; never store secrets in them. Agent skills stay local until `txt skills sync --reveal`; the published catalog is GET /api/v1/skills and https://txt.mn/skills. Human CLI guide: https://www.txt-fil.es/cli =========================================================================== ## 4. MCP Server =========================================================================== Endpoint: https://www.txt-fil.es/mcp Transport: Streamable HTTP (GET + POST) Connect with any MCP client (Claude Desktop, Cursor, Windsurf): { "mcpServers": { "txt": { "url": "https://www.txt-fil.es/mcp", "headers": { "Authorization": "Bearer txt_" } } } } Available tools: list-files — list all files and folders list-skills — list published agent skills, including collaborator shares get-file — get content of a specific file (required: fileId) update-file — write content to a file (required: fileId, content) create-file — create a new file (required: name; optional: content, folderId, fileType) trash_note — move a note to reversible trash (required: id) restore_note — restore a trashed note (required: id) purge_trashed_note — permanently delete an already-trashed note with explicit confirmation get-clue — inspect aclue's brain state and suggestions run_aclue — explicitly run aclue self-reasoning (required confirmation: RUN ACLUE) =========================================================================== ## 5. SDK — TypeScript / JavaScript / React Native =========================================================================== TypeScript source (copy-paste or import): https://www.txt-fil.es/sdk Pre-built ESM bundle (no build step): https://www.txt-fil.es/sdk.js --------------------------------------------------------------------------- ### 5.1 Install --------------------------------------------------------------------------- Option A — copy the TypeScript source into your project: curl https://www.txt-fil.es/sdk > lib/txt-sdk.ts Option B — dynamic import in Node / Deno / browser: const { TxtClient } = await import("https://www.txt-fil.es/sdk.js") Option C — npm (if published): npm install txt-sdk # or pnpm add / yarn add --------------------------------------------------------------------------- ### 5.2 Basic usage --------------------------------------------------------------------------- import { TxtClient } from "./txt-sdk" const txt = new TxtClient({ baseUrl: "https://www.txt-fil.es", apiKey: "txt_...", }) // List all files const tree = await txt.list() // Read a file const file = await txt.getFile("file_id") console.log(file.content) // Write to a file await txt.updateFile("file_id", { content: "Hello from the SDK!" }) // Get raw text (no JSON) const raw = await txt.getRaw("file_id") --------------------------------------------------------------------------- ### 5.3 Real-time sync (ETag polling) --------------------------------------------------------------------------- // Uses If-None-Match — unchanged polls return 304 (no body) const stop = txt.sync("file_id", (content, file) => { console.log("File updated:", content) }, 5000) // Stop syncing stop() // Watch multiple files at once const stopAll = txt.syncMany( ["file_a", "file_b"], (fileId, content) => console.log(fileId, content), ) --------------------------------------------------------------------------- ### 5.4 React hook (web + React Native) --------------------------------------------------------------------------- import { TxtClient, useTxtFile } from "./txt-sdk" const client = new TxtClient({ baseUrl: "https://www.txt-fil.es", apiKey: "txt_..." }) function NoteScreen({ fileId }) { const { content, loading, error, update } = useTxtFile(client, fileId) if (loading) return return ( update(text)} multiline /> ) } --------------------------------------------------------------------------- ### 5.5 React Native — quick notes app pattern --------------------------------------------------------------------------- // 1. Store the API key securely import * as SecureStore from "expo-secure-store" await SecureStore.setItemAsync("txt_key", "txt_...") const key = await SecureStore.getItemAsync("txt_key") // 2. Create the client const txt = new TxtClient({ baseUrl: "https://www.txt-fil.es", apiKey: key }) // 3. List notes on mount useEffect(() => { txt.list().then(tree => setFiles(tree.rootFiles)) }, []) // 4. Sync a single note live useEffect(() => { if (!activeFileId) return return txt.sync(activeFileId, (content) => setNote(content)) }, [activeFileId]) // 5. Save on blur / debounce const save = useDebouncedCallback( (content) => txt.updateFile(activeFileId, { content }), 800, ) --------------------------------------------------------------------------- ### 5.6 Comments & suggestions from code --------------------------------------------------------------------------- // Post a comment anchored to selected text await txt.createComment(fileId, "Check this phrasing", "selected text here") // Propose a change without editing the live doc await txt.createSuggestion(fileId, "old text", "new text") // Accept a suggestion (applies the diff to the file) await txt.reviewSuggestion(fileId, suggestionId, "accepted") =========================================================================== ## 6. SDK Source =========================================================================== /** * 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 } }