uuu.bz API — notes, tables and files with an agent-friendly HTTP API. AUTH: every /api request needs the header Authorization: Bearer (this help page and /s/ public links are the only exceptions) QUICKSTART curl -H "$AUTH" https://uuu.bz/api/d # list notes curl -H "$AUTH" https://uuu.bz/api/d/plan # read a note (plain markdown) printf '# Plan\n\n- [ ] task\n' | curl -X PUT -H "$AUTH" --data-binary @- https://uuu.bz/api/d/plan curl -H "$AUTH" https://uuu.bz/api/t/budget # read a table (plain CSV) RULES - Everything is addressed by a human slug (a-z, 0-9, hyphen), not by id. - PUT to a missing slug CREATES the item — no separate create call needed. - Notes travel as plain markdown text. The title is ALWAYS the first line of the text — there is no separate title field to set. Rename = edit line one. - Tables travel as CSV (first row = column names) or JSON. Cell values are strings; there are no formulas — you do the math, you write the result. - A row's cells always match the column list: a new key extends the columns, and dropping a column also drops its values. - Non-ASCII in query params must be percent-encoded: curl -G --data-urlencode "q=пицца" .../api/search - Deletes are soft (trash); PUT on the same slug or POST .../restore revives. NOTES (markdown): GET /api/d list: slug, title, preview, pinned, updated_at, todo: {done, total} when the note has checkboxes GET /api/d/{slug} note body as plain markdown GET /api/d/{slug}.json {slug, title, content, share_url, updated_at, backlinks: [{slug, title, kind, via}]} GET /api/d/{slug}/backlinks who links here with [[Title]] (same for /api/m, /api/p) PUT /api/d/{slug} body = markdown; replaces (creates if missing) PUT /api/d/{slug}.json body = {"content": "..."} (title follows line one) POST /api/d/{slug}/append append text to the end POST /api/d {"title", "slug"?, "content"?} — create (409 if taken; "unique": true takes the next free name instead) DELETE /api/d/{slug} move to trash (PUT on same slug revives) GET /api/d/{slug}/versions edit history [{id, saved_at, size}] (last 100) GET /api/d/{slug}/versions/{id} that version's markdown TABLES (CSV; values are strings, no formulas): GET /api/t list: slug, title, columns, rows, pinned, updated_at GET /api/t/{slug} whole table as CSV, first row = columns GET /api/t/{slug}?q=text only rows containing the substring GET /api/t/{slug}.json {columns, rows: [{id, pos, cells, color?}], share_url} PUT /api/t/{slug} body = whole CSV; replaces (creates if missing) POST /api/t/{slug}/rows add rows: JSON object / array of objects / CSV lines (new keys in JSON auto-extend the columns); row edits answer with the table's new updated_at PATCH /api/t/{slug}/rows/{id} edit cells: {"col": "value", "clear_me": null} PATCH /api/t/{slug}/rows/{id}?pos=2.5 move a row (body {}), pos between neighbours PATCH /api/t/{slug}/rows/{id}?color=red highlight: red|yellow|green|blue|purple|gray, empty clears; color shows in .json, never in CSV DELETE /api/t/{slug}/rows/{id} delete a row PATCH /api/t/{slug} {"title"?, "columns"?: [...], "renames"?: {"old":"new"}} columns = the full list: order, add, or drop (values of a dropped column are removed) POST /api/t {"title", "slug"?, "columns"?} — create empty ("unique": true takes the next free name instead of 409) GET /api/t/{slug}/versions snapshots taken before destructive changes [{id, rows, reason, saved_at}] (last 20) GET /api/t/{slug}/versions/{id} that snapshot as CSV POST /api/t/{slug}/versions take a snapshot now ({"reason"?}); empty table → skipped POST /api/t/{slug}/versions/{id}/restore put the table back to that snapshot DELETE /api/t/{slug} move to trash POST /api/import {"url": "..."} — build a table from a link: a Google Sheet ("Anyone with the link") or any CSV/TSV. A workbook with several sheets becomes several tables, one per sheet, named after the sheets → {"sheets": N, "tables": [...]}. Add "sheets": false to take only the sheet in the link. EVERYTHING AT ONCE: GET /api/home?tz=Asia/Bangkok one response with every list the home screen shows: docs, tables, mdocs, prompts (light), files, trash, tts, days, links, reminders, voices, remStatus, agents, researches, rsStatus. One round trip instead of fifteen; a list that fails comes back as null, the rest still arrive. DOCS (.md library — a separate section for markdown documents; same engine as notes): GET /api/m list: slug, title, preview, pinned, updated_at GET /api/m/{slug} document as plain markdown (?download=1 → as a file) PUT /api/m/{slug} body = markdown; replaces (creates if missing) POST /api/m {"title", "slug"?, "content"?} — create (409 if taken; title is used for the slug, the shown name is line one) GET /api/m/export.zip the whole library as one ZIP of .md files Everything else works like notes on the same slug: /api/m/{slug}/append, /versions, /share, /pin, /restore, DELETE. Slugs are shared with notes. PROMPTS (your prompt library; same engine as notes, own list): GET /api/p list: slug, title, preview, vars, content, pinned GET /api/p?light=1 same list without content (add size instead) — much smaller when you only need titles and variables GET /api/p/{slug} the prompt as raw text — paste it straight into a model GET /api/p/{slug}.json {slug, title, content, vars: ["topic", ...], share_url} GET /api/p/all.md EVERY prompt in one markdown file: read it once, then fetch the one you need by slug PUT /api/p/{slug} body = text; replaces (creates if missing) POST /api/p {"title", "slug"?, "content"?} — create (409 if taken; add "unique": true and a taken name gets the next free one) {{name}} inside a prompt is a variable: substitute it before use. Everything else works like notes on the same slug: /append, /versions, /share, /pin, /restore, DELETE. Slugs are shared with notes and docs. FILES (up to 500 MB each; video up to 3 GB): GET /api/f list: id, name, size, mime, share_url, created_at POST /api/f upload: body = raw bytes, name in X-File-Name (percent-encoded) or ?name= curl -X POST --data-binary @site.zip -H "X-File-Name: site.zip" .../api/f GET /api/f/{id} download (?inline=1 shows images/PDF/video in the browser instead of downloading; range requests supported, so audio seeks) PATCH /api/f/{id} {"pos": seconds, "dur": seconds} — where you stopped listening; shared across devices. {"name": "new name.mp3"} renames (id and link stay) DELETE /api/f/{id} to trash; POST /api/f/{id}/restore revives POST /api/f/{id}/share public link; raw bytes: GET /api/s//file (?inline=1 works there too; images and PDFs preview right on the /s/ page) No folders by design: ship a set of files as one ZIP. VIDEO (a view over the same files — every file with mime video/*, no second store): POST /api/f upload as any file, up to 3 GB (mp4 and webm play everywhere; .mov from an iPhone plays in Safari and, if it is H.264, in Chrome too — nothing is re-encoded here) GET /api/f/{id}?inline=1 the bytes, with range requests — that is what the player uses PUT /api/f/{id}/poster body = one JPEG frame (up to 4 MB). The browser grabs it while uploading; it becomes the thumbnail in the list, the still in the player and the picture in a messenger card GET /api/f/{id}/poster that frame (public copy: GET /api/s//poster) PATCH /api/f/{id} {"pos", "dur"} — where you stopped watching, same as audio Player pages: /v/{id} for you, /s/ for anyone with the link (same player, read-only, with a Download button). A link can point at a moment: /v/{id}?t=90. AUDIO FROM TEXT (a note or doc becomes mp3 in Audio; Microsoft neural voices, no key): POST /api/tts {"slug", "voice"?, "clean"?: true} — queue a document; voice: ru-RU-SvetlanaNeural (default for Russian), ru-RU-DmitryNeural, en-US-GuyNeural (default for English), en-US-JennyNeural; English paragraphs in a Russian text get the English voice by themselves. clean: true asks the model (UUU_AGENT_API_KEY) to strip page numbers, footnotes and OCR noise first — costs tokens. → 201 {id, status: "queued", units, estimated_seconds} GET /api/tts jobs, newest first: status queued|running|done|failed| cancelled, progress 0..1, file_ids of the finished mp3 GET /api/tts/{id} one job DELETE /api/tts/{id} cancel a queued/running job, or drop a finished one from the list GET /api/tts/voices the voice list One job at a time; long texts become parts of ~1.5 h, named "Title — 01 of 03.mp3". PASSWORDS (notes with logins, passwords and API keys; kind 'password', kept out of "All"): POST /api/d {slug?, title, content, kind:"password"} create one; read/write/history as any note via /api/d/{slug} POST /api/d/{slug}/kind {kind} move: note/doc/prompt → "password", password → "note". Slug and links stay. To find one: uuu_search {"query": "", "kinds": ["password"]}, then uuu_read the ref (REST: GET /api/agent/search?q=cloudflare&kinds=password, GET /api/workspace/list?section=passwords). Search by the service name (github, cloudflare, beget…), not by the word "password" — most entries only say "API Key" or hold the bare value. New credentials go here: uuu_write uuu://password/ mode "create". Never copy a secret into prompts, commits, logs or public /s/ links; use {{VARIABLE}}. DAYS (a journal: one document per date, kind 'day', slug = YYYY-MM-DD): GET /api/day?tz=Asia/Bangkok {today, tz, days: [{slug, title, entries, preview, updated_at}]} POST /api/day {"date"?: "YYYY-MM-DD", "tz"?} — get or create that day's page → {slug, ref, title, created} POST /api/day/append {"text", "date"?, "tz"?} (or plain text body) — add one line "**HH:MM** text" to the day (created if missing). The built-in agent, finished audio and voice memos add their own lines. Everything else works like notes on the same slug: /api/d/{date}, /api/day/{date}/versions, /share… Zone: tz from the request, else UUU_TZ from .env, else the workspace default. LINKS (a saved, readable copy of a web page; kind 'link', text is a normal document): GET /api/l list: slug, title, url, host, words, feed, follow, read_at, fetched_at, checked_at, fetch_error, pinned, updated_at POST /api/l {"url", "follow"?: true, "title"?} — fetch the page, extract the article, save it (201; 200 + existing:true if that url is already saved). An RSS/Atom feed becomes a subscription document listing its items; the newest 5 are saved at once. PATCH /api/l/{slug} {"read"?: bool, "follow"?: bool} — marks only, no text change POST /api/l/{slug}/refresh check now: a feed → new items, a page → updated text Followed links are re-checked every hour by the server. Read the text with GET /api/d/{slug} (or /api/l/{slug}); .json also carries url, follow, read_at. Turn an article into mp3: POST /api/tts {"slug"} as with any document. REMINDERS (the only thing here that knows about the future): GET /api/r?state=open open (default) | upcoming | overdue | done | all → [{id, at, text, ref, row_id, href, ref_title, state, created_by, done_at, fired_at}] POST /api/r {"at": ISO date-time with zone, "text", "ref"?: "uuu://…", "row_id"?} → 201 PATCH /api/r/{id} {"done": true|false, "at"?, "text"?}; a new "at" re-arms it DELETE /api/r/{id} remove for good GET /api/r/status {telegram: bool, open, overdue, tz} When "at" passes, the server marks fired_at once, sends a Telegram message if UUU_TG_BOT_TOKEN and UUU_TG_CHAT_ID are set, and open uuu.bz tabs show a toast (they poll GET /api/changes, which now includes "reminders"). VOICE (a voice memo = an audio file, kind 'voice', + its transcript as a note): POST /api/voice?name=&dur= body = raw audio bytes (m4a/mp3/wav/ogg/webm); the file is stored on the Disk and queued for transcription → 201 GET /api/voice memos newest first: id, name, dur, transcript (note slug), transcript_status queued|running|done|failed, title, preview POST /api/voice/{id}/transcribe transcribe again (after a failure, or for any audio file) GET /api/voice/status {configured, model} Transcription uses the same key as the built-in agent with an audio-capable model (UUU_VOICE_MODEL, default google/gemini-2.5-flash). The transcript is a normal note whose first sentence is the title; the day journal gets a line. AGENTS (saved work that runs itself; an agent is a document, kind 'agent'): GET /api/ag list: slug, title, instruction, schedule, watch, enabled, next_run, last_run, model, scopes, target, column, last: {status, summary, changes, tokens, …} POST /api/ag {"title", "content"?, "schedule"?, "watch"?, "model"?, "scopes"?, "target"?, "column"?, "enabled"?} — create GET /api/ag/{slug} one agent + its last runs PATCH /api/ag/{slug} change any of the settings above PUT /api/ag/{slug} body = markdown — the instruction, in plain words. Everything else works like notes on the same slug: /versions, /share, /pin, /restore, DELETE. POST /api/ag/{slug}/run run once now: {"dry"?: true, "input"?, "wait"?: true} → the run row (202-style: work continues in the background unless "wait": true). A dry run changes nothing and still reports what it would have done. GET /api/ag/{slug}/runs run log: trigger, status, summary, changes, tokens, error GET /api/ag/runs runs happening right now · GET /api/ag/runs/{id} — one POST /api/ag/runs/{id}/stop stop a running one (between rows / between steps) schedule: "daily 08:00" · "hourly" · "every 30m" · "mon,thu 09:00" · "weekdays 09:30" watch: "link" · "voice" · "note" · "doc" · "day" · "file" · "video" · "table:" — the agent wakes up when one of those appears (its own edits do not count). target + column: the table pipeline — it fills that column row by row, only where empty, takes a snapshot first and continues next run where it stopped. An agent runs unattended: anything that would need a person to confirm (a public link, a mass delete) is SKIPPED and said so, never done quietly. RESEARCH (a question answered from the web; the report is a document, kind 'research'): GET /api/rs list: slug, title, query, type, status, step, sources, cost, words, started_at, finished_at POST /api/rs {"query", "type"?} — order a report. type is "research_report" (default) or "deep" (slower, dearer). Answers at once with the slug; the work goes on in the background, one at a time. GET /api/rs/{slug} state: queued | running | done | failed | stopped POST /api/rs/{slug}/stop cancel a running one GET /api/rs/status is the engine installed, is a model connected, what it searches with (Tavily if a key is set, else DuckDuckGo) The finished report is an ordinary document: /api/d/{slug}, /versions, /share, DELETE. Engine: gpt-researcher on this server; models and the search key — in /api/settings. REPOS (git repositories; a bare repo per name, the server runs real git): GET /api/git list: name, description, default_branch, pinned, head {sha, short, author, date, subject} — null when empty POST /api/git {"name", "description"?} — create an empty repo. name: lowercase letters, digits, "-", "_", ".". GET /api/git/{name} card + branches, head, commit count, root entries and README PATCH /api/git/{name} {"description"?, "default_branch"?, "name"?} (rename) DELETE /api/git/{name} to the trash (needs trash scope); /restore, /pin as usual GET /api/git/{name}/tree?ref=&path= entries: name, path, type (tree|blob), size, sha GET /api/git/{name}/blob?ref=&path= one file: content (text), sha, size; binary or too_big (> 2 MB) come without content — use /raw GET /api/git/{name}/raw?ref=&path= the bytes; &download=1 forces a download PUT /api/git/{name}/blob?ref=&path= write a file = ONE COMMIT on that branch. JSON {"content" | "base64", "message"?, "expected"?} or a plain-text body (+ ?message=). "expected" is the blob sha from GET /blob — mismatch answers 409 version_conflict, "" means "must not exist yet". Empty repo: the first commit creates the branch. Files up to 5 MB. DELETE /api/git/{name}/blob?ref=&path= {"message"?, "expected"?} — delete = a commit GET /api/git/{name}/commits?ref=&path=&n=&skip= history (newest first, n ≤ 200) GET /api/git/{name}/commit/{sha} message, author, files [+/-] and the patch (≤ 1 MB) GET /api/git/status is git installed on the server ref defaults to the default branch; a sha works too. Clone, pull and push over HTTPS: git clone https:///git/{name}.git — username anything, password = this token (read scope pulls, write scope pushes). Git sends it in Basic auth; the token is the same. AGENT SETTINGS (admin scope only — these are provider keys and money): GET /api/settings {settings, providers, models, configured, budget, tz} providers carry only the last 4 characters of a key PATCH /api/settings any of: model, voice_model, agents_on, max_runs_day, max_tokens_day, max_rows_run, max_calls_run, notify, quiet_from, quiet_to, day_log, tz, default_scopes, dry_first, house_rules, research_model, research_fast_model, research_retriever, research_words POST /api/keys {"provider", "key", "base_url"?, "model"?} — stored encrypted, checked with a live request, never returned POST /api/keys/{provider}/test check the stored key again POST /api/keys/{provider}/try {"text"?, "model"?} — one real answer with this key, plus how long it took (ms) DELETE /api/keys/{provider} forget that key Providers: openrouter, moonshot (Kimi), dashscope (Qwen), openai, anthropic, deepseek, groq, custom — all through the same OpenAI-compatible /chat/completions. Plus tavily, which is not a model but the search key for Research. SHARING (public read-only, no auth): POST /api/d/{slug}/share turn on → {"url": "/s/"}; same for /api/t, /api/f DELETE /api/d/{slug}/share turn off (the token dies) GET /s/{token} viewer page · /s/{token}.md raw note · /s/{token}.csv raw table PINNING (pinned items come first in lists): POST /api/d/{slug}/pin pin; DELETE to unpin; same for /api/t TRASH: GET /api/trash what's in the trash (notes, tables, files) POST /api/d/{slug}/restore restore; /api/t/... and /api/f/{id}/restore likewise DELETE /api/trash empty PERMANENTLY (also wipes file bytes) FOR AI AGENTS: a shorter, task-oriented guide lives at GET /api/agent.md — canonical refs (uuu://note/plan), one overview call, safe writes with expected_version, row-level table edits, prompt rendering, MCP at POST /mcp. EVERYTHING ELSE: GET /api/search?q=text search notes, table cells and file names (doc hits carry kind: note | doc) GET /api/changes?since=ISO what changed since a moment — poll this instead of re-reading everything (files also count a listening-position update as a change; "reminders" lists the ones that fired since then) ERRORS: always JSON {"error": "human message"} with an honest status code: 400 bad request (incl. malformed JSON) · 401 missing/bad token · 404 no such item 409 slug taken (POST only) · 413 too big · 502 upstream fetch failed