Docs›More

The app's API

· fork").

Everything the app's pages do goes through its HTTP API, and agents use the same API (with the app_api tool) to read the app's state and change it for you. This topic lists the endpoints by area, with what each takes and returns. It is written for agents and developers; you don't need it to use the app.

How agents use it#

  • An agent calls an endpoint with app_api (method, path, body), as the signed-in user, inside the app itself. Reading (GET) runs at once.
  • A change shows the user a card first, with what changes (before → after for a setting) and the call itself. Give summary, the change in a few plain words, as its title. The user can allow it once, allow such changes for the rest of the session, or say no. Deleting, starting agent work, adding MCP servers or machines, changing what agents may do and similar changes ask every time.
  • To have the user fill in or check details on that card, give form: fields as a JSON schema's properties (type, title, description, enum, default, format…), each named by its address in body (name, settings.results), filled in from body; the user's answers go into body before the call.
  • Read the current state before changing it: most settings are read with GET at the same address their PUT or PATCH writes.
  • In Plan mode (and Codex's Read only) an agent only reads. With Claude Code's Bypass or Codex's Full access no card shows; Custom's Full access asks only for the changes that ask every time.
  • Never through app_api: the password and signing in, keys' values (ask with request_secret), making access tokens and API keys, answering approval cards and the cards that ask the user to decide, a session's permissions and spending cap, files, terminals, notebooks and git (agents use their own tools), the live browser (the browser tools), the Mac's screen, mouse and keyboard and macOS's permissions for them (the computer tools; the user allows those in Settings).
  • Links for the user: the app's address plus the page: /s/<session key>, /spaces/<id>, /apps/<id>, /pages/<id>, /settings?s=<section>, /guide/<topic>#<section>.

Settings kept on each device#

These live in the user's browser, one set per device, so they are not in the API. An agent changes them on the devices showing its session, with no card:

  • PUT /device — Body: { theme?: 'dark'|'light'|'system', themeName?: string — a theme's name ("Midnight", "Sage", or one of the user's), language?: 'en'|'zh'|'el' — reloads the page, sound?: boolean — a chime with each notice, enter?: 'join'|'queue' — what Enter does while an agent works, terminalSuggestions?: boolean, sidebar?: boolean — shown on a computer, groupSessions?: boolean — the sidebar's groups for projects and folders (false: the sessions by day only) }.

The guide and Agent Guidelines#

  • GET /api/guide — the whole guide. Returns: { topics: [{ id, title, summary, body (Markdown), sections: [{ id, title, text }] }] }. Agents read it with app_guide instead.
  • GET /api/guide/images/:name — a picture in the guide (docs/guide/images), such as spaces.webp. Query: theme? (light: its light version, when there is one). Returns: the image.
  • GET /api/guidelines — Agent Guidelines: for every session, and a project's or a session's. Query: key? (a session key: its own guidelines, and its project's), space? (a project's id). Returns: { global: string, space?: { id, name, text }, session?: { key, text } }.
  • PUT /api/guidelines — changes the levels given, leaving the others. Body: { global?: string, space?: { id: string, text: string }, session?: { key: string, text: string } } (each at most 20,000 characters; an empty text clears that level). Returns: the same as GET for that session and project. A changed session's agent follows the new guidelines from the next message.
  • GET /api/setup/instructions — the instruction files Claude Code or Codex reads on its own for a session in a folder (CLAUDE.md, AGENTS.md and the like), in the order it reads them. Query: agent: 'claude'|'codex', cwd? (the folder; the app's default when left out). Returns: { files: [{ path, scope: 'user'|'parent'|'project'|'local'|'rules', exists: boolean, size?, primary? }] }. A file's text is read and changed with an agent's own file tools.
  • POST /api/setup/instructions — creates one of those files where it is missing (with a title line). Body: { agent: 'claude'|'codex', path: string — one the GET lists, cwd?: string }. Returns: { path }.

Conventions#

  • Session keys are claude:<id>, codex:<id> or acp:<id> (after the colon, 4 to 80 letters, digits, ., _, -); URL-encode them in paths (claude%3A…). Other keys get 400 "Bad session key".
  • Times are Unix milliseconds. Errors come back as { error: string } with an HTTP status (400 bad input, 404 not found, 409 not possible right now).
  • Attachments, where a body takes them: images?: [{ mediaType: 'image/png'|'image/jpeg'|'image/gif'|'image/webp', data: string (base64), name?: string }] (at most 8 are kept) and files?: [{ name: string, data: string (base64) }] (at most 20; saved in the app's uploads folder and listed by path at the end of the message as "Attached files:").
  • A SessionSummary has: key, agent ('claude'|'codex'|'acp'), id, title, cwd, gitBranch, model, createdAt, updatedAt, firstPrompt, lastPrompt, stats { userMessages, turns, toolCalls, commands, filesChanged, linesAdded, linesRemoved, tokensIn, tokensOut, tokensCached, costUsd }, live ({ status: 'starting'|'idle'|'working'|'waiting'|'stopping'|'exited'|'error', model, effort, mode, pendingApprovals, contextTokens, contextWindow, backgroundTasks, goal } or null when the agent is not running), external ({ status, client, pid } when it runs in VS Code or a terminal), archived, pinned, order, snoozedUntil, doneAt, seenAt (unread when doneAt > seenAt), queue [{ id, text, at, att }], queueHeld, goal { objective, status: 'active'|'paused'|'blocked'|'limited' }, worktree { path, branch, base, repo }, pr { url, number, title, state, checks }, resumeAt, account, acpAgent, acpName, extra { memory, assistant, capUsd, parent, space, swarm, research, arena }, prefs { model, effort, mode }, forkFrom, handoffFrom, handoffTo.
  • Careful marks a route that, used on a session other than the one the agent is in, does something the user would not expect (spends money, sends words in their name, stops work, deletes, changes files or publishes). Use it on another session only when the user asked for exactly that.

The app and the start page#

  • GET /api/bootstrap — what the pages load first. Returns: { version, home, dataDir, defaultCwd (the default folder), autoFolders: string[] (folders no one picks: where the app makes task folders, apps' runs and worktrees, its data folder and the temporary folder; the sidebar groups no sessions by them), folderNames: { [folder]: name } (folders Codex keeps projects in, by the project's name, such as a ChatGPT project's folder), folderOrigins: { [folder]: [{ agent, account?, sources: string[], sessions }] } (each folder two or more sessions worked in: empty when its first session was started in Hydor; else where its sessions from outside came from, by agent and added account, with the apps they were started in as the agent wrote them down, such as claude-vscodeorcli, the most sessions first), host (machine name), start (the start page's last picks, as PUT /api/start), prefs { claude, codex } (each agent's last model/effort/mode/account), accounts [{ id, agent, name }] (added accounts), acpAgents [{ id, name, command, builtin }] (Custom is id 'console'), themes, update, tourSeen, indexing }.
  • PUT /api/start — changes what the start page opens with next time, on every device (merges: send only what changes; null clears a field). Body: { agent?: 'claude'|'codex'|'acp:<id>' (Custom: 'acp:console'), runs?: { '<agent key as above>': { model?: string|null, effort?: string|null, mode?: string|null, account?: string ('' = the agent's own sign-in) } } (joins that agent's earlier run; null = the agent's default), assistant?: string (assistant id: general, researcher, writer, coder, tutor, planner, analyst or your own), space?: string (a project's id), task?: boolean (true = a new task folder), cwd?: string (absolute path; left out on read if the folder is gone), worktree?: boolean, memory?: false|null (false = start without memory), capUsd?: number|null (> 0, ≤ 1,000,000) }. Returns: the whole start settings after the change. Open start pages update at once.
  • GET /api/catalog/:agent — an agent's models, permissions modes and commands. :agent is claude, codex or acp (then ?acp=<id>, console for Custom; another ACP agent's lists are known only after it has run once). Returns: { models [{ value, label, isDefault, efforts: string[], defaultEffort, description }], modes [{ value, label, description }], defaultMode, commands? [{ name, description }] (Claude Code's slash commands and skills), modeNote?, prefs }. Mode values: Claude Code default (Ask), acceptEdits, auto, plan, bypassPermissions; Codex plan, read-only, auto, auto-review, full; Custom ask, edits, auto, full, plan.
  • GET /api/fs/recent?picked=1 — the folders the start page offers: up to 12 folders sessions worked in lately, newest first, without the ones Hydor made (task folders, app runs, worktrees). Without picked=1: up to 30 folders, those included. Returns: { dirs: string[] }.
  • GET /api/git/repo-info?path=<folder> — whether a folder is in a git repository (the start page then offers a worktree). Returns: { info: { repo, branch, sub } | null }.

Finding sessions#

  • GET /api/sessions — the session list, newest first, pinned first. Query: agent?=claude|codex|acp, archived?=1 (only archived ones; default: only not archived), limit? (1–1000, default 300), offset?, cwd? (exact folder). Returns: { sessions: SessionSummary[], total }. Child sessions are listed too; their parent is extra.parent.
  • GET /api/search?q=<words> — the sidebar's session search: titles, first messages and folders, then the words said in messages. Archived sessions are included. Returns: { hits: [{ key, agent, title, cwd, updatedAt, kind ('title' or where the words were found), snippet (matches between \u0001 and \u0002), ts }] }.
  • GET /api/find?q=<words> — what the search box (Ctrl+K) asks the server (up to 300 characters). Query: in?: string — places to look in, comma-separated (sessions, messages, spaces, pages, templates (apps), files, memory, tasks, assistants, skills, connections, settings, actions, guide); none: everywhere. Returns: { meaning (search by meaning answered), sessions [{ key, agent, title, firstPrompt, cwd, updatedAt, score }] (by title, and by meaning when on), messages [as /api/search hits] (up to 10), notes [{ id, scope, scopeKey, kind, text, updatedAt }] (memory notes, when memory is on), alike [{ id, score }] (other things alike in meaning, by the search box's ids: go:<page>, sec:<section>, opt:<section>:<words>, app:…, task:…, asst:…, skill:…, page:…, conn:…, space:…) }.
  • GET /api/find/scope — where the search box looks: its Search in menu, the same on every device. Returns: { in: string[] } (the places picked; empty: everywhere).
  • PUT /api/find/scope — sets it; open search boxes change at once. Body: { in: string[] — the places to look in, from: sessions, messages (words said in sessions), spaces, pages, templates (apps), files, memory, tasks (scheduled tasks), assistants, skills, connections, settings (with themes), actions (the app's pages and actions: Go to, Actions), guide (Help); [] or all of them: everywhere }. Returns: { in } (in the menu's order). A name it does not know is refused (400).
  • GET /api/sessions/:key — one session. Returns: { summary: SessionSummary|null, live, overlay { items, approvals [{ id, title, kind, detail, options [{ id, label }], questions? }] } }. The approvals are what the session waits on the user for; agents must leave them to the user.
  • GET /api/sessions/:key/timeline — the conversation, a page at a time. Query: before? (an item index; default the end), limit? (default 300, at most 2000), whole?=1 (start the page at a turn's start). Returns: { items [{ id, seq, kind: 'user'|'assistant'|'tool'|'thinking'|'system'|'turn', ts, text?, tool? { name, category, title, status, output (shortened), files } }], total, start }.
  • GET /api/sessions/:key/items/:id — one timeline item in full (output and diffs not shortened). Returns: the item.
  • GET /api/sessions/:key/find?q=<text> — your messages and the agent's that contain the text, any case (at least 2 characters; up to 500 matches), oldest first. Returns: { matches [{ seq, kind: 'user'|'assistant', snippet (match between \u0001 and \u0002) }] }.
  • GET /api/sessions/:key/changes — every file the session changed, the most changed first. Returns: { files [{ path, added, removed, edits [{ itemId, ts, change { path, op: 'edit'|'create'|'write'|'delete'|'move', added, removed, diff (cut at 60,000 characters: diffTruncated) } }] }] }.
  • GET /api/sessions/:key/commands — every shell command the agent ran, oldest first. Returns: { commands: [timeline tool items] } (tool.title is the command, tool.status, tool.exitCode, tool.output shortened).
  • GET /api/sessions/:key/notes — what the app added under turns (memory updates, second opinions, spending cap notes, child session cards). Returns: { notes [{ id, kind, at, anchor, data }] }.
  • GET /api/commits/:key/show?hash= — one commit an agent made in session :key (from a session note of kind "commits", whose data.commits lists { hash, subject, branch?, repo, own?, author?, at, files, added, removed }). Returns: { hash, author, email?, ts, message, parents, refs, head, pushed, files: [{ path, added, removed, binary?, status?, from?, diff }] }. 404 when the commit is not in one of the session's notes.
  • GET /api/sessions/:key/extra — the session's settings beyond its agent's own. Returns: { extra { memory?, assistant?, capUsd?, parent?, space?, swarm?, research?, arena?, connections? } }.
  • GET /api/sessions/:key/tasks/:taskId/log — a background task's latest output (Claude Code sessions only; task ids are in live.backgroundTasks). Returns: { kind: 'output'|'agent', text?, entries?, size?, truncated?, updatedAt }.

The inbox (read only)#

  • GET /api/inbox — everything waiting on the user on this machine, newest first: approvals, questions and forms in sessions, a browser that needs them, a key an agent asked for, a skill to review, sessions waiting in VS Code or a terminal. Returns: { items [{ id, kind: 'approval'|'question'|'form'|'browser'|'key'|'skill'|'elsewhere', title, detail?, key? (session), session? (its title), agent?, at, path (where it is answered in the app), approval? }] }. Report these to the user; answering them is theirs to do.

Starting sessions#

  • POST /api/sessions — starts a session, as the start page does; the agent begins on prompt at once. Body: { agent: 'claude'|'codex'|'acp', acpAgent?: string (required for 'acp': 'console' for Custom, or an id from Settings → More agents), prompt?: string, cwd?: string (an existing folder; ~ works; empty = the default folder), model?: string, effort?: string, mode?: string (a mode value from /api/catalog; default Claude Code 'default', Codex 'auto'), account?: string (an added account's id), worktree?: boolean (a new branch and folder; cwd must be in a git repository), title?: string, extra?: { assistant?: string, memory?: false, capUsd?: number, space?: string (a project's or a group's id: the session gets its guidelines; pass its folder as cwd and its assistant), connections?: { '<connection id>': true }, research?: { breadth (1–8), depth (1–5), queries (10–500), format: 'page'|'document' }, swarm?: true, arena?: { with: [{ agent, acpAgent?, model? }] } (up to 2 more models: each gets the same prompt in a session of its own), help?: true | string (a question asked on the Help page, or about the guide topic with this id: the agent answers from the guide, and the session's model, effort and mode don't become the agent's remembered defaults) }, images?, files?, paths?: string[] (files and folders already on this computer, up to 20: the message lists them by path under "Attached files", a folder with a slash at its end, and PNG, JPEG, GIF or WebP pictures up to 5 MB also go to the model to look at; paths that aren't there are left out) }. Returns: { key }. To start in a new task folder as the start page does, first POST /api/assistants/task-folder { prompt } → { path } and pass that as cwd. The model, effort, mode and account also become that agent's remembered defaults. Careful: research, swarm and arena run several agents and cost several times as much; say so before using them.
  • POST /api/sessions/:key/fork — a new session that starts with this conversation (all of it, or up to at), then text; the original stays. Claude Code, Codex and Custom only; not for a session open in another app (409). Body: { text: string (required, unless attachments), at?: string (Claude Code: a reply's entry id from the timeline; Custom: a turn id), images?, files? }. Returns: { key } (titled "
  • POST /api/sessions/:key/handoff — "Continue in …": a new session on another agent in the same folder, starting with this conversation (first request plus the latest messages within about 60,000 characters) and text. From Claude Code or Codex sessions only, to the other one or to Custom. Body: { agent: 'claude'|'codex'|'acp', acpAgent?: 'console' (required with 'acp'), text?: string (default "Continue where the conversation left off."), model?, effort?, mode?, account?, images?, files? }. Returns: { key }. The two sessions link to each other.
  • POST /api/sessions/:key/rewind — "Edit from here": continues from just before one of your messages with new text, in a copy that takes the title; the original is archived. Claude Code, Codex and Custom; 409 "Stop the agent first" while a turn runs. Body: { itemId: string (a user message's id from the timeline), text: string (required), restoreFiles?: boolean (put the files back as they were before that message: Claude Code, Custom, or any session in a worktree; Codex needs a worktree) }. Returns: { key }. Careful: restoreFiles changes files on disk.

Talking to a session#

  • POST /api/sessions/:key/message — sends a message now: into the running turn for Claude Code and Codex, or as a new turn; ACP agents (Custom included) queue it while they work. Starts the agent first if it is stopped (409 if the session runs in another app: use takeover). A reached spending cap refuses it. Body: { text: string, images?, files? } (text or an attachment required). Returns: { ok }. Careful: on another session this speaks in the user's name and makes that agent work.
  • POST /api/sessions/:key/queue — adds a message to send when the running turn ends (sent at once if the agent is running, idle, and the queue is not paused; a stopped agent's queue waits until it runs again). Body: { text: string, images?, files? }. Returns: { ok }. Careful as for message.
  • POST /api/sessions/:key/queue/:id/send — sends one queued message now, into the running turn (starts the agent if needed). Returns: { ok } (404 if it already went out). Careful as for message.
  • POST /api/sessions/:key/queue/:id/edit — changes a queued message. Body: { text: string, keep?: string[] (refs of attachments to keep, 'img:<n>'/'file:<n>' from queue[].att; leave out to keep all), images?, files? (new ones, with keep) }. Returns: { ok }.
  • POST /api/sessions/:key/queue/order — reorders the queue. Body: { ids: string[] } (ids missing keep their place after these). Returns: { ok }.
  • POST /api/sessions/:key/queue/:id/delete — takes a message off the queue; its attachments are deleted. Returns: { ok }.
  • POST /api/sessions/:key/queue/resume — lets a paused queue go out again (after a stop or a restart). Returns: { ok }.

Running and stopping#

  • POST /api/sessions/:key/start — resumes a stopped session's agent with its whole conversation (nothing is sent). Body: { model?, effort?, mode? } (default: what it last used). Returns: { ok }. 409 if it runs in VS Code or a terminal.
  • POST /api/sessions/:key/interrupt — stops the running turn (as Stop or Esc); the agent stays ready and the queue pauses. Returns: { ok } (409 if the agent is not running). Careful: stops another session's work.
  • POST /api/sessions/:key/stop — ends the agent's process; the session and its history stay; the queue pauses. Returns: { ok }. Careful as for interrupt.
  • POST /api/sessions/:key/tasks/:taskId/stop — stops one background task (ids in live.backgroundTasks). Returns: { ok }. Careful: its work ends unfinished.
  • POST /api/sessions/:key/takeover — continues here a Claude Code session open in VS Code or a terminal: ends that process (stopping what it does), then resumes it here, with a message if given. Not for Codex (409). Body: { text?, images?, files? }. Returns: { ok }. Careful: always ask the user first; they may be working in that app.
  • POST /api/sessions/:key/resume-at — "Resume at reset": continues a Claude Code or Codex session stopped by a usage limit by sending "Continue where you left off." (the page uses the reset time plus a minute); replaces an earlier one. Only for limits: the app keeps the time on the account's reset as it moves, and continues at once when the account is not at a limit. Body: { at: number (ms, not more than a minute in the past) }. Returns: { ok }. Not a way to schedule messages (use a scheduled task), and not snooze (that is /flags).
  • DELETE /api/sessions/:key/resume-at — cancels that. Returns: { ok }.

Session settings#

  • POST /api/sessions/:key/config — changes the model, effort or permissions mode; applies to the running agent at once and is kept for the session (and as that agent's remembered default). Body: { model?: string|null, effort?: string|null, mode?: string|null } (send only what changes; values from /api/catalog). Returns: { ok }. Careful: a mode like bypassPermissions or full lets the agent act without asking; never raise permissions without the user's explicit word.
  • PATCH /api/sessions/:key/extra — merges into the session's settings beyond its agent's own; null removes a field. The page uses it for memory: { memory: false } turns memory off for this session, { memory: null } back on. Returns: { extra }. The spending cap has its own route (PUT /api/budget/:key { capUsd }, from $0.01 to $100,000), and connected accounts theirs.
  • POST /api/sessions/:key/goal — a goal the agent keeps working towards, turn after turn. Body: { action: 'set'|'pause'|'resume'|'clear', objective?: string (required for set; up to 4,000 characters; say what "done" looks like) }. Claude Code: set and clear only (sent as a /goal message: at once when the agent is free, queued while it works); Codex and Custom: all four; other ACP agents: 400. Returns: { ok }. Careful: a goal keeps an agent working (and spending) until it holds.
  • POST /api/sessions/:key/compact — replaces the conversation so far with a summary to free context (Claude Code and Codex; 400 for ACP agents; 409 while a turn runs). Starts the agent if stopped. Returns: { ok }.

Organizing sessions#

  • POST /api/sessions/:key/rename — sets the title. Body: { title: string } (trimmed, up to 200 characters; empty gives back the agent's own title). Returns: { ok }.
  • POST /api/sessions/:key/flags — pin, archive or snooze (send only what changes). Body: { pinned?: boolean, archived?: boolean, snoozedUntil?: number|null (ms in the future; null unsnoozes) }. Returns: { ok }. A snoozed session comes back at that time, or as soon as it needs the user.
  • POST /api/sessions/order — the order of a sidebar group (Active or Pinned) after a drag. Body: { keys: string[] } (top to bottom, up to 1,000 session keys). Returns: { ok }.
  • POST /api/sessions/:key/settle — "Settle: done for now": stops the agent (409 "It is still working; stop it first" while a turn runs) and marks the session read; history and worktree stay. Returns: { ok }.
  • POST /api/sessions/:key/seen — marks the session read (it finished since the user last looked). Returns: { ok }.
  • DELETE /api/sessions/:key — stops the session and deletes its history from disk (Claude Code's and Codex's own files too), its queued messages and its notes. Cannot be undone. Returns: { ok }. Careful: confirm with the user, naming the session; prefer archiving.

Worktrees#

  • GET /api/sessions/:key/worktree — the session's worktree (404 if it has none). Returns: { path, branch, base, repo, exists, dirty (uncommitted files), ahead, behind (commits against base) }.
  • POST /api/sessions/:key/worktree/remove — removes the worktree's folder (stops the agent first); the session keeps its history; other sessions in the same worktree lose it too. The branch stays unless asked, or when it has nothing new. Body: { deleteBranch?: boolean, force?: boolean (needed when there are uncommitted changes, which are then lost) }. Returns: { branchKept: boolean }. Careful: deletes files; force and deleteBranch can lose work.

Pull requests and review (from a session's branch)#

  • GET /api/sessions/:key/pr/draft — what a pull request would hold. Returns: { head, base, remote, host? { provider, owner, repo } (missing when no account in Settings → Source control covers it), commits [{ sha, subject }], stat { files, added, removed }, dirty, pushed, existing? }.
  • POST /api/sessions/:key/pr/describe — a model drafts a title and description from the commits and diff (needs commits). Returns: { title, body }.
  • POST /api/sessions/:key/pr — commits (if asked), pushes the branch and opens the pull request on GitHub or Gitea, linked to the session. Body: { title: string (required), body?: string (Markdown), draft?: boolean, commitAll?: boolean (commit uncommitted changes first, with the title as message) }. Returns: { pr { url, number, title, state, checks?, repo } }. Careful: publishes to the remote; only when the user asked.
  • POST /api/sessions/:key/pr/link — links an existing pull request by its address. Body: { url: string }. Returns: { pr }.
  • POST /api/sessions/:key/pr/refresh — reads its state and checks now. Returns: { pr }.
  • DELETE /api/sessions/:key/pr — unlinks it (it stays on its host). Returns: { ok }.
  • GET /api/sessions/:key/review — everything the branch changes since its base, uncommitted work included. Returns: { head, base, files [{ path, status, added, removed, diff, binary? }], uncommitted, guide? }.
  • POST /api/sessions/:key/review/guide — a model writes a reading order with a note per file. Returns: { guide { summary, steps [{ path, note }] } }.
  • GET /api/sessions/:key/guest-links — the links that work now.
  • POST /api/sessions/:key/guest-links — makes a link ({ days?: number|null }, at most 365; null = until turned off); returns { id, token }, the address being /g/<token>. Careful: anyone with it can read the session.
  • DELETE /api/guest-links/:id — turns one off.

Projects#

A project: { id ("sp…"), name, color, description?, folder (where its sessions work; fixed once made), instructions? (its guidelines, told to every session in it), assistant? (the id of the assistant its sessions start as), sharedKnowledge?: false (present only when its sessions leave the shared knowledge out), members? (project ids: it is a group), createdAt }.

  • GET /api/spaces — every project, oldest first, with counts. Returns: { spaces: [{ …space, sessions: number (sessions started in it), lastAt?: ms (when one of them was last at work), docs: number (documents in its own knowledge bases), skills: number (skills of its own), library: number (what it holds in the Library: pages, decks, documents, spreadsheets, LaTeX documents, pictures and videos) }] }.
  • GET /api/spaces/suggestions — folders worth a project of their own: those sessions worked in, or changed files in, in the last 90 days, that no project has yet (neither its folder nor one of the folders it works with), the most used and most recent first, at most 6. A session counts for the folder it ran in and, less, for each other folder whose files it changed; a folder inside a git repository counts as the repository; sessions in a project don't count. The home folder, hidden folders, temporary ones, the app's own (task folders, apps' runs, worktrees) and those hidden with the route below are left out. Returns: { folders: [{ path, sessions: number — sessions that worked in it or changed its files, changed: number — of those, how many only changed its files, lastAt: ms }] }.
  • POST /api/spaces/suggestions/hide — "Not a project": stops suggesting a folder (the folder itself is untouched). Body: { folder: string — its full path } (400 "Which folder?" otherwise). Returns: { folders } as above.
  • GET /api/spaces/:id — one project and its sessions. Returns: { space, sessions: string[] — session keys, the last at work first, at most 100 }. 404 "No such project".
  • POST /api/spaces — makes a project, or a group when members is given. Body: { name: string — required, cut at 60 chars (400 "Give the project a name" when empty); color?: "amber"|"blue"|"green"|"pink"|"violet"|"teal"|"orange"|"slate" — default: the next in turn, any other value becomes blue; description?: string — what it is about, cut at 300; folder?: string — an existing folder to make the project around (a full path or ~/…; 400 "No folder at …" when it isn't one); left out, the app makes a new one: <task folders>/Projects/<name in lowercase with dashes> (default ~/Hydor/Projects/…, with -2, -3… when taken); folders?: string[] — other folders of the user's it works with, where they are (full paths or ~/…; 400 "No folder at …" when one isn't a folder): each once, at most 12, its own folder and folders inside it left out; its sessions are told of them, and Claude Code works in them as in its own; instructions?: string — its guidelines (Agent Guidelines), cut at 20,000; assistant?: string — an assistant id (GET /api/assistants), cut at 80; sharedKnowledge?: boolean — false: its sessions search only its own documents (default: the shared knowledge too); members?: string[] — project ids to bring together: existing projects that aren't groups, each once, at most 12 (others are silently dropped) }. Returns: the project.
  • PATCH /api/spaces/:id — changes a project. It merges: only the fields given change, the rest are kept. Body: the same fields as POST except folder, which can't change. "" clears description, instructions or assistant; folders replaces the list of folders it works with ([] leaves none; the folders themselves are untouched); sharedKnowledge: true (or anything but false) brings the shared knowledge back; members: [] turns a group back into a plain project; giving members to a project that is itself in a group fails with 400 ("… is in a group already…"). Returns: the project. 404 "No such project".
  • DELETE /api/spaces/:id — deletes a project. Its folder, files, sessions and memory notes stay; its knowledge bases become shared; its skills join the skill library (one whose name the library has already gets the project's after it: research → research-thesis); it leaves every group it was in (a group left with no projects becomes a plain project). Returns: { ok: true, skills: string[] — the names its skills have in the skill library now }.
  • GET /api/spaces/:id/skills — a project's own skills (only its sessions get them, with the skill library; a group's sessions get its projects' too), the ones removed that can come back, and the skills it switches on or off for its own sessions. Returns: { skills: [{ name, description (when to use it), triggers: string[] (keywords for Custom), overrides?: true (the library has a skill of that name, which this one takes the place of in the project), path, updatedAt: ms, uses: number (times agents loaded it in the project or a group that brings it in), lastUsed?: ms }], removed: [{ name, at }], switches: { [key]: boolean } (key: a skill of the library or of the project by its name, Claude Code's or Codex's own as "claude:<name>" or "codex:<name>"; true: on for the project's sessions, false: off; a skill not named is as Settings → Skills has it, the project's own on), members?: [{ id, name, skills: [as above] }] (a group: the skills of the projects it brings together, which its sessions get too) }. 404 "No such project".
  • PUT /api/spaces/:id/skill-switches — turns a skill on or off for the project's sessions alone, whatever Settings → Skills says for every other session, or back to that. Body: { name: string — the skill's name as the skills list shows it, agent?: 'claude'|'codex' — for one of that agent's own skills (GET /api/setup/skills?cwd=<the project's folder>); left out, a skill of the library or of the project, on: boolean | null — null: as Settings → Skills has it again }. A project's sessions get it when they start (Custom from its next turn): the library's and the project's skills in their skill list, Claude Code's own as its settings for the session, Codex's own as its thread's settings; the agents' own settings files are not changed. At most 400 switches. Returns: { switches } as in GET /api/spaces/:id/skills. 400 for a name that isn't a skill's, another agent, or an on that is not true, false or null; 404 "No such project".
  • DELETE /api/spaces/:id/skill-switches — every skill back to what Settings → Skills says, for the project's sessions too. Returns: { switches: {} }. 404 "No such project".
  • GET /api/spaces/:id/skills/:name — one of a project's skills, whole, with its history of changes. Returns: { name, text (the whole SKILL.md), source: 'space', space: { id, name }, file, history: [{ id, name, at, action: 'create'|'update'|'remove'|'restore', key?, diff, added, removed }] }. 404 when the project has no skill of that name.
  • POST /api/spaces/:id/skills — adds a skill to the project, or copies one of the skill library into it to change for the project alone. Body: { name: string — lowercase words with dashes (other text is turned into that: "Citation style" → citation-style; 400 when it ends up shorter than 2 characters), description: string — when to use it, one line of at least 10 characters ("Use when citing a source in the thesis"), body: string — the steps, checks and mistakes to avoid, in Markdown (more than a few words; the whole skill at most 20,000 characters), triggers?: string[] — up to 12 keywords that bring it into Custom's turn }, or { from: string — a library skill's name: copied with its other files }. A skill that looks like it holds a secret is refused (400). Returns: the skill, as in the list. 409 when the project has a skill of that name already; 404 when from names no library skill.
  • PUT /api/spaces/:id/skills/:name — changes one of the project's skills. It merges: only the fields given change (its keywords stay unless triggers is given). Body: { name?, description?, body?, triggers? } as for POST; a new name renames it, its other files, history and switch going along (409 when the project has a skill of that name). Returns: the skill. 404 when the project has no skill of that name.
  • DELETE /api/spaces/:id/skills/:name — removes one of the project's skills: its sessions stop seeing it (or get the library's skill of that name again, as Settings → Skills has it: the removed skill's switch goes with it). Its text stays in its history. Returns: { ok: true }. 404 when the project has no skill of that name.
  • POST /api/spaces/:id/skills/:name/restore — brings back a skill the project had, as it was when removed. No body. Returns: { ok: true }. 404 when there is nothing to bring back; 409 when the project has a skill of that name. To start a session in a project as the start page does, call POST /api/sessions (Starting sessions, above) with cwd: the project's folder, and extra: { space: <space id>, assistant?: <its assistant> }. The server then adds the project's guidelines, what its folder holds now and the other folders it works with (in a group, each member project's name, folders and guidelines too; Claude Code gets access to all those folders) and scopes the session's document search to the project; it doesn't fill in the folder or the assistant from the project. To add a file to a project as the Files page does, copy it into the project's folder with your own tools; to add a folder, append it to the project's folders with PATCH.

Shared knowledge and a project's documents#

A knowledge base: { id ("kb…"), name, description?, space? (the project it belongs to: only that project's sessions search it; none: shared), createdAt, sources: [{ id, kind: "files"|"folder", path, name, addedAt }] (kind "files" is the base's own "Files you added" folder, made with it), filesDir (where dropped files are kept, in the app's data folder), docs, passages, embedded (passages ready for search by meaning), errors: [{ path, error }] (documents that could not be read, at most 20), indexing?: { done, total } (being read now), indexedAt? }.

  • GET /api/knowledge — every knowledge base, and whether search by meaning is on (Settings → Memory). Returns: { bases: KnowledgeBase[], meaning: boolean }. A base whose space no longer exists counts as shared.
  • GET /api/knowledge/:id/docs — the documents a base holds, by path, at most 2,000. Returns: { docs: [{ id, base, path, title (the file's name), format, size, passages, status: "indexed"|"empty" (no text in it)|"error", error?, indexedAt }] }. 404 "No such knowledge base".
  • POST /api/knowledge/search — searches the documents as agents do. Body: { query: string — a question; "exact words" in quotes or a /pattern/ is searched for exactly; mode?: "auto" (words and meaning, the default)|"words"|"meaning"|"exact"; space?: string|null — search only what a session in that project searches (null or "": a session in no project, so the shared knowledge alone); left out: every base; bases?: string[] — only these bases, by id or name; limit?: number — default 10, at most 30 }. Returns: { hits: [{ ref ("kb:<n>"), base, baseName, docId, path, title, part? (the page, slide, sheet or heading), text (an excerpt, ≤900 chars), score, by: ("words"|"meaning"|"exact")[] }], meaning: boolean — whether search by meaning took part }. Exact hits come in reading order, the others best first.
  • POST /api/knowledge — makes an empty knowledge base. Body: { name?: string — cut at 60, default "My documents"; description?: string — cut at 300; space?: string — a project's id to make it that project's own; left out or "": shared }. Returns: the knowledge base.
  • PATCH /api/knowledge/:id — renames a base or moves it. It merges: only the fields given change. Body: { name?: string — cut at 60, not empty (400 "Give it a name"); description?: string — "" clears it; space?: string|null — a project's id to move it into, null or "" to make it shared }. Returns: the knowledge base.
  • POST /api/knowledge/:id/folders — adds a folder on this computer to a base. It is read where it is (nothing copied), at once, then again a minute after the app starts and every hour: new and changed files are read, deleted ones leave. Hidden files and folders, tool folders (node_modules, .git, dist, build, venv…) and pictures are skipped; at most 20,000 files, 12 levels deep. Body: { path: string — a full path or ~/… (400 "Which folder?" when empty, "No folder at …" or "… is not a folder") }. Adding a folder the base already has changes nothing. Returns: the source { id, kind: "folder", path, name, addedAt }.
  • POST /api/knowledge/:id/scan — reads the base again now: files just dropped in, folders that changed. No body. Returns: { ok: true }; GET /api/knowledge shows indexing while it reads.
  • DELETE /api/knowledge/:id/sources/:sid — stops reading a folder: its documents leave the base; the folder itself is untouched. 400 for the "files" source ("Remove the files themselves instead"). Returns: { ok: true }.
  • DELETE /api/knowledge/:id/docs/:doc — removes a document that was dropped into the base, deleting the base's copy of it. 400 for a document in a folder on this computer (remove it there, or stop reading the folder). Returns: { ok: true }.
  • DELETE /api/knowledge/:id — deletes a base: its passages, their vectors and the files dropped into it. Folders on this computer stay as they are. Returns: { ok: true }. Files from the user's device are added by uploading them into the base's filesDir (the files routes, as the page does) and then calling POST /api/knowledge/:id/scan. An agent adds documents that are already on this computer with POST /api/knowledge/:id/folders.

Assistants#

An assistant object: { id, name, icon, description, instructions, agent?, acpAgent?, model?, tools?, connections?, skills?, mode?, folder?, builtin?, source? }. The seven built-ins (ids general, researcher, writer, coder, tutor, planner, analyst) come first, have builtin: true, and can be duplicated (read one, then POST it under a new name) but not changed or deleted.

  • GET /api/assistants — every assistant (built-ins first, then the user's own) and the settings beside them. Returns: { assistants: Assistant[], taskRoot: string — where new task folders go, defaultTaskRoot: string — ~/Hydor (~/Naget where an older install made it), cleanUp: boolean — agents clean up after a task }.
  • GET /api/assistants/import?cwd= — Claude Code agent files that can be imported: those in ~/.claude/agents and, with cwd (optional, a folder path), in <cwd>/.claude/agents (up to 200 per folder). Returns: { files: [{ path, scope: "user"|"project", name, description, imported?: string — the id of the assistant already made from it }] }.
  • POST /api/assistants — makes a new assistant of the user's own (at most 200; its id is made from the name, like bookkeeper-3f9a). Body: { name: string — required, ≤60 chars; icon?: "sparkles"|"telescope"|"pen-line"|"code"|"graduation-cap"|"calendar-check"|"chart-column"|"bot"|"briefcase"|"book-open"|"calculator"|"chef-hat"|"dumbbell"|"heart"|"languages"|"lightbulb"|"megaphone"|"music"|"palette"|"plane"|"house"|"mail"|"scale"|"shield"|"wallet"|"stethoscope"|"leaf"|"newspaper"|"wrench"|"users" — any other value becomes "bot"; description?: string — one line, ≤160; instructions?: string — who it is and how it works, ≤20,000; agent?: "claude"|"codex"|"acp" — the agent it suggests, left out = keep the start page's; acpAgent?: string — required with agent "acp": "console" is Custom, else an agent id from Settings → More agents; model?: string — the model it suggests, in that agent's own names (kept only with an agent); tools?: ("read"|"edit"|"run"|"web"|"mcp")[] — what it may use (read files, create and change files, run commands, use the web, other tools = MCP servers and plugins); left out or all five = everything, [] = none of them (the app's own tools stay on); connections?: string[] — connection ids (Settings → Connections) turned on in its sessions, ≤50; skills?: string[] — skill names to favor, ≤30; mode?: "ask"|"edits"|"full"|"plan" — its permission mode, left out = keep the start page's; folder?: "task"|"project" — where its sessions start when no folder is picked: a new task folder (default) or the folder on the start page }. Returns: { assistant: Assistant, assistants, taskRoot, defaultTaskRoot, cleanUp }.
  • PUT /api/assistants/:id — changes one of the user's own assistants. It replaces the assistant whole with the body (same fields as POST; any field left out is cleared, so send the full object back with your change; send source back to keep an imported one linked to its file). 400 for a built-in, 404 for an unknown id. Returns: { assistant, assistants, taskRoot, defaultTaskRoot, cleanUp }.
  • DELETE /api/assistants/:id — deletes one of the user's own assistants (400 for a built-in). Sessions started with it carry on without its instructions. Returns: { assistants, taskRoot, defaultTaskRoot, cleanUp }.
  • PUT /api/assistants/session/:key — switches a session to another assistant, as its message box does: its agent works as that one from the next message on (it is told of the switch with that message; an idle Claude Code or Codex agent starts afresh first, with the new instructions and limits, and the conversation carries on). The connections the assistant turns on replace the previous one's in the session (the session's other choices stay); its permission mode is set on the session, and its model when it suggests the session's own agent. Body: { id: string — an assistant id (GET /api/assistants) }. Returns: { extra: SessionExtra — the session's settings now, model?: string, mode?: string — what it set, in the agent's own names }. 404 "No such assistant" or "No such session".
  • PUT /api/assistants/settings — changes the settings beside the list; only the fields given change. Body: { taskRoot?: string — the folder new task folders go in (new projects' own folders go in its Projects subfolder): a full path or one starting with ~/, "" for the default ~/Hydor; cleanUp?: boolean — every agent cleans up after a task (on by default) }. Returns: { assistants, taskRoot, defaultTaskRoot, cleanUp }.
  • POST /api/assistants/import — makes an assistant from a Claude Code agent file (name, description, model, tools, permissionMode and skills from its frontmatter; the text below as its instructions; it works in the folder on the start page). Importing the same file path again updates the assistant made from it (its icon kept). Body: { path?: string — an .md agent file on this computer, ≤256 KB; text?: string — the file's text instead of a path, ≤256 KB; name?: string — the file name, used for pasted text whose frontmatter has no name } (one of path or text). Returns: { assistant, assistants, taskRoot, defaultTaskRoot, cleanUp }.
  • POST /api/assistants/task-folder — makes a new, empty task folder now, the way the start page does for a session without a folder: under taskRoot, named by today's date in the user's time zone and a few words of the prompt (2026-10-31-plan-trip-lisbon, -2 when taken). Body: { prompt?: string — the task, for the name }. Returns: { path: string }. To start a session as an assistant the way the start page does, call POST /api/sessions (Starting sessions, above) with extra: { assistant: <id>, connections?: { <connection id>: true, … } } (its connections), the agent, model and mode it suggests (the mode in that agent's names: ask/edits/full/plan are default/acceptEdits/bypassPermissions/plan for Claude Code, read-only/auto/full/plan for Codex, ask/edits/full/plan for Custom), and cwd from POST /api/assistants/task-folder when its folder is "task" and the user named no folder. The server adds the assistant's instructions, skills and tool limits by itself; it does not fill in the agent, model, mode, connections or folder.

Memory#

Notes ("entries") belong to a place: scope "global" (about the user, scopeKey ""), "repo" (a project: scopeKey is the project's folder as memory keys it, i.e. the root of its Git repository, the main repository's for a worktree, or the folder itself outside Git; never the home folder) or "session" (scopeKey is the session's key). Kinds: "note", "topic" (sums up related notes) and "profile" (a place's summary agents get first). Times are milliseconds since 1970. An entry as the routes return it: { id, scope, scopeKey, kind, text, status: "active"|"deleted", createdAt, updatedAt, expiresAt?, usedAt?, uses, covers? (topics/profiles: notes summed up), session?, turn? (who wrote or changed it last), source: "user"|"agent"|"auto" (auto = the helper's tidy-up or an expiry), scopeLabel ("About you", the project's folder name, or the session's title), sessionTitle? }. A change (in a tidy-up report or a memory chip): { entry, action: "added"|"updated"|"deleted"|"expiry", scope, scopeKey, kind?, before?, after?, expiresAt?, expiredBefore?, why?, source, undone? }.

  • GET /api/memory — memory's state: the switches, counts, the user's profile, how many notes agents start with, search by meaning, the last tidy-up. Returns: { enabled: boolean (memory for all sessions), counts: { global, repo, session } (active notes, past-end-date ones and the user's profile left out), profile: entry | null (the user's profile; its text may have several lines), profileWords: number (the most words it may have: 1,000 unless changed), profileFit?: { running: boolean, at, error? } (the helper model shortening a profile longer than that, or why it could not lately), topK: number (how many notes and topics about the user and the project agents start with, besides the profile: 10 unless changed), meaning: { on, status: "off"|"setting-up"|"starting"|"ready"|"failed", detail? (progress or why it failed), model, where: "auto"|"cpu", device? ("CPU" or the GPU's name), embedded, total }, tidyOn: boolean, tidy?: { at, running?, changes: change[], changesAt? (set when the latest found nothing and these are an earlier run's), checked, error? }, stale: number (notes suggested for removal) }.
  • PUT /api/memory — changes the switches; only the fields given change. Body: { enabled?: boolean — memory for all sessions, topK?: integer 1–50 — how many notes agents start with (Settings offers 5, 10, 20, 40), profileWords?: integer 50–5000 — the most words the user's profile may have (Settings offers 250, 500, 1000, 2000; a profile longer than a new limit is shortened by the helper model in the background), meaning?: boolean — search by meaning (turning it on sets it up: a few minutes, then a 1.2 GB model download), device?: "auto"|"cpu" — where it runs (auto: a GPU with room, else the CPU), retry?: true — try again after it failed, tidy?: boolean — tidy up each night }. Returns: the state, as GET /api/memory.
  • GET /api/memory/entries — lists notes. Query: q?: string — search: with status active, every word must match (the last as a word's start) plus, when search by meaning runs, notes alike in meaning, best first, at most 100; with deleted/all, a plain all-words filter, scope?: "global"|"repo"|"session", key?: string — one place's scopeKey, status?: "active" (default)|"deleted"|"all", kind?: "note"|"topic"|"profile", ids?: "1,2,3" — these entries in any status (at most 200), stale?: "1" — only the notes no agent needed for 120 days (about the user or projects, plain notes without an end date; at most 100; other parameters ignored). Without q, newest change first. Returns: { entries: entry[] }.
  • GET /api/memory/profile — the user's profile: what every agent gets at the start of a session while memory is on, right after the user's guidelines. Returns: { profile: entry | null, maxWords: number }.
  • PUT /api/memory/profile — writes the user's profile as the user (agents use their own tool, update_user_profile). Body: { text: string — the whole profile, lines kept; "" clears it (it stays among the deleted notes for 30 days); refused (400) when it seems to hold a password, key, token or card number, or has more than 40,000 characters }. Longer than its words, it is saved as written and the helper model shortens it to fit in the background (GET /api/memory shows profileFit). Returns: { profile: entry | null, maxWords: number, fitting: boolean (a shortening started) }.
  • POST /api/memory/profile/fit — shortens the user's profile to its words now, with the helper model in the background (after it could not). Returns: { started: boolean } (false when it fits already).
  • GET /api/memory/entries/:id/history — one note's history, newest first. Returns: { history: [{ id, entry, at, action: "added"|"updated"|"deleted"|"restored"|"expiry", before?, after? (for expiry: the end date as an ISO time, none when taken away), why?, session?, sessionTitle?, turn?, source }] }.
  • GET /api/memory/entries/:id/children — the notes a topic or profile sums up (any status, at most 500). Returns: { entries: entry[] }.
  • POST /api/memory/entries — adds a note as the user. Body: { text: string — at least 2 characters; kept as one paragraph, cut at 500; refused (400) when it seems to hold a password, key, token or card number, scope?: "global" (default)|"repo"|"session", key?: string — required unless global: the project's folder exactly as memory keys it (see above; another folder makes another place) or the session's key, expiresAt?: number — when it stops being true, from now to five years on }. Returns: { entry }.
  • PATCH /api/memory/entries/:id — edits a note: its text, its end date, or both (other fields untouched). Body: { text?: string — new text (same rules as above; on a deleted note it restores it with this text), expiresAt?: number|null — new end date, from now to five years on; null takes it away }. Returns: { entry } (404 when the entry is gone).
  • DELETE /api/memory/entries/:id — deletes a note: it stays restorable for 30 days. Query: forever?: "1" — erase it and its history at once (cannot be undone). Returns: { ok: true }.
  • POST /api/memory/entries/:id/restore — brings back a deleted note (an end date that has passed is dropped). Returns: { entry } (404 when it is not deleted).
  • POST /api/memory/entries/:id/keep — keeps a note suggested for removal: it counts as needed now and leaves the stale list. Returns: { ok: true }.
  • POST /api/memory/tidy — starts a tidy-up now with the helper model, in the background (works whether or not the nightly one is on; 400 when memory is off). Returns: { started: true }; GET /api/memory shows tidy.running, then its changes or error.
  • POST /api/memory/tidy/undo — undoes one change of the last tidy-up: what it added goes, what it changed gets its old text back, what it deleted returns, an end date it set is taken away. Body: { index: number — the change's position in tidy.changes }. Returns: { tidy } (the report, that change marked undone).
  • POST /api/memory/notes/:id/undo — undoes one change from the memory chip under a reply (the session note of kind "memory", from GET /api/sessions/:key/notes; its data.changes lists the changes). Body: { index: number — the change's position in data.changes }. An added note goes, an updated one gets its old text back, a removed one returns. Returns: { note } (the session note, that change marked undone).
  • DELETE /api/memory — clears all memory: every note, all history. Cannot be undone. Body: { confirm: "all" } (required). Returns: { cleared: number }.

Skill library#

Settings → Agents → Skills shows these with the agents' own skills (below). Skill names (:name) are lowercase letters, digits and dashes, up to 64 characters.

  • GET /api/skillpack — the skill library: every skill (built in, the user's, and those of enabled plugins), proposals waiting for the user, removed skills of the user's, the keywords switch, and the projects with skills of their own. Returns: { skills: [{ name, description, triggers: string[] (keywords that bring it into Custom's turn), category?: string (its group: work, writing, documents, research, planning, data, creative, coding, ai, app), source: 'bundled'|'learned'|'plugin' (learned: the user's own), plugin?: { name, title } (a plugin's skill), off?: true (turned off: agents do not get it), overrides?: true (the user's in a built-in one's place), path, updatedAt?: ms (learned only), uses: number, lastUsed?: ms }], pending: [proposal] (up to 50, newest first; shape as in GET /api/skillpack/proposals/:id), removed: [{ name, at }] (removed skills of the user's that can be brought back), triggers: boolean, suggest: boolean (a new session's agent hears which skills fit its first message), folders: { bundled, learned, view }, spaces: [{ id, name, skills: [{ name, description }] }] (projects with skills of their own; change those through the project's routes), switched: [{ id, name, switches }] (projects that switch skills on or off for their own sessions; switches as in GET /api/spaces/:id/skills) }.
  • POST /api/skillpack/skills — adds a skill of the user's own, for every agent (no proposal: as if the user wrote it). Body: { name: string (made lowercase with dashes), description: string (when to use it, starting "Use when"), body: string (the instructions, Markdown; at least 40 characters), category?: string (as above), triggers?: string[] (up to 12) }, or { name, text: string (a whole SKILL.md whose name: is the name) }. Returns: { name }. 400 when it lacks a description or instructions, holds a secret, or is over 20,000 characters; 409 when the library has a skill of that name.
  • PUT /api/skillpack/skills/:name — saves a new text for one of the user's skills, or the user's own version of a built-in or a plugin's skill (it takes that one's place for every agent). Body: { text: string (the whole SKILL.md; its name: must stay) }. Kept in the skill's history. Returns: { ok: true }. 404 when the library has no skill of that name.
  • PUT /api/skillpack/skills/:name/off — turns a library skill (built in, the user's, or a plugin's) off or on for every agent, but in projects that switch it the other way for their own sessions (PUT /api/spaces/:id/skill-switches). Body: { off: boolean }. Returns: { off: boolean }. 404 when the library has no skill of that name.
  • GET /api/skillpack/skills/:name — one library skill's whole text and its history of changes. Query: key? (a session key: the skill that session's agent gets, so in a project its own skill of that name, or that of a project its group brings together, before the library's; then source is 'space' with space: { id, name }). Returns: { name, text (the whole SKILL.md), source: 'bundled'|'learned'|'plugin'|'space', space?, plugin?: { name, title }, off?: true, file, history: [{ id, name, at, action: 'create'|'update'|'remove'|'restore', key? (the session it came from), diff (unified), added, removed }] (newest first, up to 100) }. 404 when the library has no skill of that name (the agents' own skills are not in it).
  • GET /api/skillpack/proposals/:id — a skill an agent proposed, or a change it proposed to one. Returns: { proposal: { id, key (the session it came from), kind: 'new'|'patch', name, status: 'pending'|'approved'|'dismissed'|'replaced', reason?, before? (the whole SKILL.md before, for a change), after (as proposed), diff, added, removed, findings: [{ kind: 'secret'|'injection', what, line, excerpt }], bundled?: true (it changes a built-in skill), space?: { id, name } (the project of the session it came from, where it can be kept alone, or the project whose own skill it changes), scope: 'library'|'space' (where it is kept: as proposed while pending, as saved once approved), spaceSkill?: true (it changes a skill of the project's own, which stays the project's), createdAt, decidedAt?, saved? (the text the user edited before approving), note? (the user's note when dismissing) } }. 404 if there is none.
  • PUT /api/skillpack/settings — turns "Bring skills in by keywords" (for Custom) and "Suggest skills that fit" (a new session's agent hears which skills fit its first message, found by words and by meaning) on or off. Body: { triggers?: boolean, suggest?: boolean } (nothing else is read; both on by default). Returns: { triggers: boolean, suggest: boolean }.
  • POST /api/skillpack/skills/:name/remove — removes a learned skill; agents stop seeing it (for the user's version of a built-in skill, the built-in one comes back). Its text stays in the history. No body. Returns: { ok: true }. 404 when it is not a learned skill (built-in ones cannot be removed).
  • POST /api/skillpack/skills/:name/restore — brings back a removed learned skill with the text it had when removed. No body. Returns: { ok: true }. 404 when there is nothing to bring back; 409 when it is in the library already.

Claude Code and Codex skills#

These are the agents' own skills, which only that agent gets (Settings → Agents → Skills shows them under Claude Code's own and Codex's own), not the app's skill library.

  • GET /api/setup/skills?cwd= — Claude Code's and Codex's own skills, for one folder (Settings → Skills lists them for the home folder, a project's Skills tab for the project's folder). Query: cwd?: string (a folder; default the home folder; 400 "Not a folder"). Returns: { skills: [{ agent: 'claude'|'codex', name, description, path (its SKILL.md), scope: 'user'|'project'|'repo'|'system'|'admin'|'plugin' (shown as All folders, This folder, This repository, Built in, Admin, Plugin), enabled: boolean, plugin?: string, deletable: boolean }], errors: string[] }. Claude Code: ~/.claude/skills (user) and /.claude/skills (project; none when cwd is the home folder). Codex: what Codex itself reports for the folder.
  • POST /api/setup/skills — makes a new skill for one agent: its folder and a SKILL.md (name, the when-to-use line, and the instructions, or a placeholder to fill in). Body: { agent?: 'claude'|'codex' (anything else means claude), name: string (made lowercase with dashes, up to 64 characters), description?: string (when to use it; empty gives "Use when …"), body?: string (the instructions, Markdown), scope?: 'user'|'project' (Claude Code only: 'project' puts it in <cwd>/.claude/skills, otherwise ~/.claude/skills; Codex skills always go in ~/.agents/skills), cwd?: string (the folder, for 'project') }. Returns: { path } (the new SKILL.md). 409 when a skill of that name exists there.
  • POST /api/setup/skills/toggle — turns one of the agents' own skills on or off, for every session (and the agent used outside the app too). Body: { agent: 'claude'|'codex', name: string (Claude Code: the skill's name, which is what it records, in ~/.claude/settings.json), path: string (Codex: its SKILL.md, which must exist), enabled: boolean }. Returns: { ok: true }. 400 "No such skill".
  • POST /api/setup/skills/delete — deletes a skill's folder with every file in it; there is no undo. Body: { agent: 'claude'|'codex', path: string (its SKILL.md), cwd?: string (the folder, for a Claude Code project skill) }. Only folders right inside ~/.claude/skills or /.claude/skills (Claude Code), ~/.agents/skills or ~/.codex/skills (Codex); anything else gives 400. Returns: { ok: true }.
  • GET /api/search/settings — the web search providers in the order they are tried, Results per search, and Naget Search's allowance. Returns: { providers: [{ id: "naget" | "searxng" | "brave" | "duckduckgo", name, description, enabled: boolean, key: "required" | "optional" | "none", keyHint?: string — a hint, never the key, keyUrl?, url?: { value: string — "" when none saved, placeholder, required: boolean, default? }, caps: { maxK, sites, excludeSites, categories, dates, languages, fileTypes, homepagesOnly, filteredSnippets }, missing?: "Needs a key" | "Needs your instance’s address", lastError?: { at, kind, message } }], defaultK: number, credits?: { remaining?, limit?, resetAt?, exhausted?, at } }. The first provider that is on is the default.
  • PUT /api/search/settings — changes the order, Results per search, or one provider. It merges: only the fields you send change. Body: { order?: string[] — provider ids to try first, in this order (the rest keep their order after them; unknown ids and repeats are dropped), defaultK?: integer 1–100, provider?: { id: provider id, enabled?: boolean, key?: string | null — (a key: not for agents) "" or null removes it, no spaces, refused for providers that take no key, url?: string | null — http(s) address, https:// added when missing; "", null or the default resets it } }. A new key or address turns the provider on unless enabled is sent. Changing a provider clears its last error. Returns: same as GET. Bad values get a 400 with a message.
  • POST /api/search/test — runs one 5-result search on one provider alone, with no fallback and a 45 s limit. Body: { id: provider id, key?: string — (a key: not for agents) a key not saved yet, url?: string — an address not saved yet }. Returns: { ok: boolean, ms: number, count?: number, sample?: [{ title, url }] — up to 3, error?: string }. With the saved setup, a success clears the provider's last error and a failure is recorded on it.
  • GET /api/site-icon/:host — a site's icon (:host like ign.com), as the sources a reply cites show it: the app fetches it from the site the first time and keeps it. Returns: the picture (PNG, ICO, SVG…); 404 when the site has none (asked again days later), 400 for a host that is not one.

Browser#

  • GET /api/browser/settings — the Browser settings. Returns: { settings: { enabled, chrome: string — "" = the best found, else a path, headless: boolean — true = no window, blockPrivate, screenshots, allow: string[], block: string[] }, chromes: [{ path, source: "system" | "playwright" | "downloaded", label, version?, shell?: true }], chrome: the one in use | null, download: { state: "idle" | "running" | "done" | "failed", received?, total?, version?, error? }, profiles: [{ id, name, createdAt }], noSandbox?: true, open: number — browsers open now }.
  • PUT /api/browser/settings — changes the Browser settings. It merges field by field, and a list you send replaces the old one. Body: { enabled?: boolean — A browser for agents, chrome?: string — "" or a path from chromes, headless?: boolean — false shows a window, blockPrivate?: boolean — Keep pages off this machine and its network, screenshots?: boolean — Screenshots for models that see images, allow?: string[] | string — Only these sites (cut down to bare sites like example.com, a port allowed, at most 500), block?: string[] | string — Never these sites (same rules) }. A Chromium or window change applies when the browser next starts. Returns: same as GET.
  • POST /api/browser/download — starts downloading the current stable Chrome for Testing (about 170 MB) in the background; does nothing while a download is already running. No body. Returns: same as GET, with download.state "running". Poll GET for progress and for the new entry in chromes.
  • POST /api/browser/profiles — makes a profile. Body: { name: string — 1–40 characters, not the same as another's, ignoring case }. Returns: { profile: { id, name, createdAt }, …same as GET }. Errors: 400 with no name, 409 when the name is taken. Signing in to sites in it is the user's job.
  • PATCH /api/browser/profiles/:id — renames a profile. Body: { name: string — 1–40 characters }. Returns: same as GET.
  • DELETE /api/browser/profiles/:id — deletes a profile with its sign-ins, and closes the browsers using it. Returns: same as GET.

Computer use (the Mac app)#

Agents see the Mac's screen and use its mouse and keyboard with their own tools (computer_screenshot, computer_act), only in the Mac app with Computer use on, and only after the user allowed it on a card in the session. ComputerState: { available: boolean — the server runs in the Mac app (elsewhere nothing works), enabled: boolean — Let agents use this Mac (off at first), permissions?: { accessibility: boolean — macOS lets Hydor use the mouse and keyboard, screen: 'granted'|'denied'|'not-determined'|'restricted'|'unknown' — Screen Recording } | null — null when the Mac app did not say, active: { key, title, since } | null — the session whose agent is using the Mac now, allowed: number — sessions the user allowed (until Stop or the app restarts) }.

  • GET /api/computer — Settings → Computer use. Returns: ComputerState.
  • PUT /api/computer/settings — turns computer use on or off. Body: { enabled: boolean }. Turning it off stops what an agent is doing, and every session asks again. Returns: ComputerState.
  • POST /api/computer/permissions/:which — accessibility or screen: macOS's own question the first time, then its System Settings at the right place, where the user turns Hydor on. Only the user does this (agents are refused). Returns: ComputerState with the permissions read again. 400 outside the Mac app.
  • POST /api/computer/stop — Stop: what agents do on the Mac ends, and every session asks again before its agent goes on. Returns: ComputerState.

Deep research#

  • GET /api/research/:key — where the deep research led by session :key stands. Returns: { key, settings: { breadth: number — researchers at a time (1–8), depth: number — rounds (1–5), queries: number — searches in all (10–500), format: "page" | "document" }, used: number — web searches so far, the lead's and its researchers' together, rounds: number — rounds begun (depth + 1 means the checking round), researchers: string[] — the researchers' session keys, oldest first }; for a session that leads none: { key, settings: null }.

Child sessions#

  • GET /api/children/settings — Settings → Child sessions. Returns: { maxRunning: number — children of one session working at once (1–12, default 4), swarmSize: number — members of one swarm working at once (1–16, default 5) }.
  • PUT /api/children/settings — changes either or both; a field left out stays as it is. Body: { maxRunning?: integer 1–12, swarmSize?: integer 1–16 } (400 "Pick a number from 1 to …" otherwise). Returns: the settings, as GET.

Swarm#

  • GET /api/swarm/:key — the board of the swarm led by session :key (404 "That session does not lead a swarm" otherwise). Returns: { swarm: string — the lead's key, size: number — members working at once, members: string[] — member session keys, oldest first, stopped: string[] — members that were stopped, posts: [{ id, swarm, from: string — the lead's or a member's key, to?: string — one member's key or the lead's (everyone when absent), kind: "progress" | "finding" | "question" | "note", text, at: ms }] — the latest 300, oldest first }.
  • POST /api/swarm/:key/stop — stops one member, or every member still working; stopped members keep what they did and posts no longer wake them; the lead gets a message saying who was stopped. Body: { member?: string — one member's session key (404 if it is not one); leave out for all working members }. Returns: { stopped: string[] }.

Second opinion#

  • GET /api/opinion/:key — who may check session :key's work, and its turns. Returns: { reviewers: [{ id: string — "claude", "codex" or one of Custom's model ids, label, detail? }] — only other companies' models, the suggested one first, turns: [{ id: string — the turn's id, text: string — the start of the message that began it, at?: ms, files: number — files it changed }] — the latest 12 finished turns, newest first }. 404 for an unknown session.
  • POST /api/opinion/:key — asks for a second opinion on one turn; its card shows under the turn at once and fills in when the reviewer answers (a minute or two; at most 8 minutes). Body: { turn?: string — a turn id from GET (default: the latest finished turn), reviewer?: string — a reviewer id from GET (default: the first) }. Returns: { note: { id, key, kind: "opinion", at, anchor, data: { status: "running", reviewer, reviewerLabel, turn, request, startedAt, files, trimmed? } } }. Errors: 404 no finished turn yet / not a turn of this session, 409 the turn is still running, 400 reviewer not offered (same company). The answer lands in the same note: read it with GET /api/sessions/:key/notes (notes of kind "opinion"): data.status: "done" with verdict: "good" | "issues" | "unclear", summary?, findings: [{ severity: "critical" | "important" | "minor", title, detail?, file?, line? }]; or "failed" with error.
  • DELETE /api/opinion/note/:id — dismisses a second opinion's card (:id is the note's id; 404 if it is not a second opinion). Returns: { ok: true }.

Model arena#

  • GET /api/arena — matches and which models win (Usage → Arena). Returns: { matches: [{ id, prompt, createdAt, entries: [{ key: string — the model's session, agent: "claude" | "codex" | "acp", acpAgent?: string — for "acp" ("console" is Custom), model?: string }], winner?: string — the kept session's key, decidedAt? }] — the latest 100, newest first, board: [{ id, agent, acpAgent?, model?, rating: number — Elo, from 1000, wins: number, played: number }] — best first }.
  • GET /api/arena/:id — one match (404 "No such match"). Returns: the match, as in matches above.
  • POST /api/arena/:id/winner — keeps one answer: that session wins and is unarchived, the match's other sessions are archived; picking again changes the winner. Body: { key: string — one of the match's session keys (400 otherwise) }. Returns: the match.

Spending caps#

  • GET /api/budget/:key — where session :key stands against its cap. Returns: { key, capUsd?: number — the cap in US dollars (absent: none), spentUsd: number — what counts against it, at API prices (its own and its child sessions'), ownUsd, childrenUsd, unpriced?: true — some tokens have no known price, so it may have spent more, reached: boolean, parent?: { key, title, capUsd, spentUsd, reached } — a child session's parent, whose cap covers it too }.
  • PUT /api/budget/:key — sets, changes or removes the session's cap (it covers its child sessions too); messages its queue held for the old cap go out once the new cap is above what was spent. Body: { capUsd: number | string | null — dollars from 0.01 to 100,000 (strings like "12.50" or "$12.50" work, rounded to cents); null, "" or leaving it out removes the cap } (400 "A spending cap is an amount in dollars, from $0.01 to $100,000" otherwise). Returns: the state, as GET.

Documents#

  • None for agents: every /api/documents* route serves the file viewer's previews (see Not for agents). Agents read documents with their own document tool, and make them with the app's document skills.

Images, videos and audio#

Agents make them with their own tools: generate_image, generate_video and generate_audio, which show the user a form with the model's settings and price first (see the guide's Documents, images, videos and voice). These routes are Settings → Images, video and audio. A model's settings are params: [{ id — OpenRouter's name: aspect_ratio, resolution, quality, background, n, output_format, seed…, kind: enum|range|boolean, values?: [string] — enum, min?, max? — range }].

  • GET /api/images/settings — whether agents may make images, and with which model. Returns: { enabled: boolean, model: string — "" = the app's choice, using: string — the model id in use, usingInfo?: ImageModel — that model, as in /api/images/models, keySet: boolean — an OpenRouter key is set (Settings → Models) }.
  • PUT /api/images/settings — turns making images on or off, or picks the model; merges with what is saved. Body: { enabled?: boolean, model?: string — an OpenRouter model id (letters, digits and . _ ~ : / - only, up to 120 characters); "" = the app's choice; any other value is ignored without an error }. Returns: the same as GET.
  • GET /api/images/models?q=&sort= — OpenRouter's image models, up to 100. q: words that must all appear in the id, name or maker; sort: price (cheapest first), picks (the app's suggestions first, then the newest), anything else newest first. Returns: { models: [ImageModel] }, each { id, label, org?, description?, created?: ms, perImage?: number — US dollars for one picture of about a megapixel, textOut: boolean, edits: boolean — takes pictures to change, maxRefs?: number — how many pictures it takes to work from, params?: [setting] — the settings it takes }.
  • GET /api/videos/settings — whether agents may make videos, and with which model. Returns: { enabled: boolean, model: string — "" = the app's choice, using: string, usingInfo?: VideoModel, keySet: boolean }.
  • PUT /api/videos/settings — turns making videos on or off, or picks the model, as for images. Body: { enabled?: boolean, model?: string }. Returns: the same as GET.
  • GET /api/videos/models?q=&sort= — OpenRouter's video models, up to 100; q and sort (price: the cheapest second first; picks: the app's suggestions first) as for images. Returns: { models: [VideoModel] }, each { id, label, org?, description?, created?: ms, durations: [seconds] — the lengths it makes (none: it keeps the length of the video it is given), resolutions: [string], aspects: [string], audio: boolean — it makes sound, frames: [first_frame|last_frame] — pictures it starts or ends on, videoIn: boolean — it changes a video it is given, perSecond?: number — US dollars a second, the cheapest way (lowest resolution, no sound), perSecondMax?: number — the dearest, skus?: { [sku]: string } — OpenRouter's prices as it lists them }.

AudioModel: { id — OpenRouter's id, label, org?, description?, created?: ms, kind: music|speech — music: songs and pieces (Lyria); speech: voices, and sounds for a model that takes a description, via: speech|chat — OpenRouter's speech API, or a streamed chat completion (Lyria, gpt-audio), prompted?: true — it reads its input as a description of what to make (the voice, its delivery, sound effects, ambience: Seed Audio) rather than words to read aloud, voices?: [string] — the voice ids it speaks in, cloning?: boolean — it speaks in a voice copied from short recordings (from its endpoints; filled in by /api/audio/models), maxSamples?: number — how many recordings it takes at once, instructions?: true — it takes directions for the delivery, multiSpeaker?: true — it voices a conversation in turns, formats: [mp3|wav] — the files it makes, maxSeconds?: number — the longest it makes, perSong?: number — US dollars a song or clip, perSecond?: number — a second of sound, perChar?: number — a character read, perToken?: { input, output } — a token in and out, perMinute?: number — about what a minute of speech costs, however it is sold, free?: true }.

  • GET /api/audio/settings — whether agents may make audio, and with which models. Returns: { enabled: boolean, music: string — the music model picked, "" = the app's choice (Google's Lyria 3 Pro, else Lyria 3 Clip), speech: string — the voice and sound model picked, "" = the app's choice (ByteDance's Seed Audio 1.0, else Gemini 3.8 Flash TTS, else ElevenLabs v3), usingMusic: string, usingSpeech: string — the model ids in use, musicInfo?: AudioModel, speechInfo?: AudioModel — those models, keySet: boolean — an OpenRouter key is set (Settings → Models) }.
  • PUT /api/audio/settings — turns making audio on or off, or picks either model; merges with what is saved. Body: { enabled?: boolean, music?: string, speech?: string — OpenRouter model ids, as for images; "" = the app's choice; any other value is ignored without an error }. Returns: the same as GET.
  • GET /api/audio/models?q=&sort=&kind= — OpenRouter's models that make sound, up to 100: kind music (the chat models that make music) or speech (its speech models and the gpt-audio voices), both when left out; q as for images; sort: price (music: the cheapest song first; voices: about the cheapest minute of speech first), picks (the app's suggestions first), anything else newest first. Returns: { models: [AudioModel] }.

The Library's images, videos and sounds#

What the user keeps of the pictures, videos and sounds agents made (or any on this computer), each copied into the app's data folder with a title and a description a model writes from it (a video: from ten of its frames on one picture; a sound, which no model hears: from its prompt, its lyrics and its model). MediaItem: { id — 10 hex characters, kind: image|video|audio, title, description, mime, bytes, width?, height?, duration? — seconds (a video or a sound), peaks?: [number] — a sound's shape: how loud each of 64 parts of it is, 0 to 1 (with ffmpeg), lyrics? — the words sung or spoken in a sound, prompt? — what it was made from, model? — what made it, sessionKey? — the session it was saved from, space? — the project it is in (none: in no project), source? — the file it was saved from, caption: pending|done|failed|edited — its title and description: being written, written by the model, written from its prompt because no model could (captionError says why), or the user's own, captionError?, captionBy? — the model that wrote them, as Settings names it, createdAt, updatedAt, file — the Library's copy, a path an agent can work from (generate_image's images, generate_video's first_frame) }.

  • GET /api/media — every image, video and sound kept, newest first. Returns: { items: [MediaItem] }.
  • POST /api/media — keeps a file in the Library: a picture (PNG, JPEG, WebP, GIF, AVIF), a video (MP4, WebM, MOV) or a sound (MP3, WAV, M4A, AAC, OGG, OPUS, FLAC), up to 1 GB; at most 5,000 in all. Its title comes from its prompt (or its file's name) until the model has written one. Body: { path: string — the file, prompt?: string — what it was made from, a hint for its description, model?: string, lyrics?: string — a sound's lyrics or script (≤8,000 characters), a hint for its title and description, sessionKey?: string, space?: string|null — the project it goes in (left out: the session's, when it is in one; null: none) }. Returns: { item: MediaItem, existed: boolean — it was kept already (the same file, the same size): that item }. 400 for another kind of file, 404 when it is not there.
  • GET /api/media/:id — one item. Returns: { item: MediaItem }; 404 when there is no such item.
  • PATCH /api/media/:id — the user's own title or description, which stay until written again (caption: edited), and/or the project it is in. Body: { title?: string (≤120, not empty), description?: string (≤2,000), space?: string|null — a project's id, or null for none }. 400 for a project that is not one. Returns: { item: MediaItem }.
  • DELETE /api/media/:id — takes it out of the Library, its copy and still with it (the file it was saved from stays). Returns: { ok: true }.
  • POST /api/media/:id/caption — has the model write its title and description again, in the background (the pages hear when they are written). Returns: { item: MediaItem } (caption: pending).
  • GET /api/media/:id/file — the file, in byte ranges for a video or sound player; ?download=1 to save it, named after its title. GET /api/media/:id/still — a still to show it by (JPEG; a small picture's own file; a sound has none: 404); GET /api/media/:id/frames — a video's ten frames on one JPEG, as the model saw them.
  • GET /api/media/settings — the model that writes titles and descriptions (Settings → Images, video and audio → Captions in the Library). Returns: { captions: { provider: auto|openrouter|claude|ollama|vllm|endpoint, model: string — the one picked for that provider ("" for the first), using?: { provider, model, label } — the model that writes them now, why?: string — why none can now }, ffmpeg: boolean — ffmpeg is on this computer (videos' frames and stills, sounds' lengths and shapes need it) }.
  • PUT /api/media/settings — picks it. Body: { captions: { provider: auto|openrouter|claude|ollama|vllm|endpoint, model?: string — OpenRouter's id ("google/gemini-3.5-flash-lite"), Claude's (haiku, sonnet, opus), or a model of Settings → Models ("ollama/qwen3-vl"); ignored for auto } }. Returns: the same as GET; 400 for another provider.
  • GET /api/media/caption-models?provider=&q= — the models a provider offers for it: OpenRouter's that see images (q: words to search, up to 60), Claude's, or those of Settings → Models (Ollama and vLLM: the ones that see images; an endpoint's: all, vision null). Returns: { models: [{ id, label, detail?, vision?: boolean|null }] }.

Voice#

  • GET /api/voice — speech recognition on the server (Settings → Voice). Returns: { engine: "off" | "local" | "endpoint", ready: boolean, local: { size, english, status: "off" | "installing" | "ready" | "failed", progress?: { step: "program" | "build" | "model", doneBytes, totalBytes, percent?, detail? }, error?, program?: { version?, source: "prebuilt" | "built" | "yours", sizeBytes }, models: [{ size, english, sizeBytes }] — downloaded, needBytes }, endpoint?: { url, model, keyHint? }, ffmpeg }.
  • PUT /api/voice — changes the engine, the model or the service. Merges, except that endpoint replaces the saved address and model. Body: { engine?: "off" | "local" | "endpoint" — "local" downloads whatever is missing (any other engine stops a download); "endpoint" needs an address saved first, size?: "tiny" | "base" | "small" | "medium" — another model downloads at once while "local" is on, english?: boolean — the English-only model, endpoint?: { url: string — http(s); a trailing /audio/transcriptions is dropped, model?: string — default "whisper-1", apiKey?: string | null — (a key: not for agents) null removes it, leaving it out keeps it } }. Errors: 400 for a bad address, size or engine. Returns: the state, as GET.
  • POST /api/voice/cancel — stops a download under way, and turns "local" off. Returns: the state, as GET.
  • POST /api/voice/remove — removes downloaded files. Body: { size?: "tiny" | "base" | "small" | "medium", english?: boolean } removes one model; { all: true } removes the app's whisper.cpp and every model. Removing the model in use, or all of them, while "local" is on turns it off. Errors: 400 without size or all. Returns: the state, as GET.
  • POST /api/voice/test — runs one second of silence through the engine that is on. Returns: { ok: true, ms, text }. Errors: 409 when it is off or not ready, 502 when the service can't be reached.

Apps: read#

Shapes used below. Run: { id, app — app id or tpl:<id>, appName, key? — its session (an app with steps has none: each agent step has its own), source: web|test|api|mcp|public|batch|screen — from its screen|step — an agent step of another run|app — started by another app's step|session — an agent ran it for you in a session (use_app), status: queued|starting|running|waiting|done|failed|stopped, inputs, result? — the answer fields, reply?, error?, costUsd?, agent?, model?, batch?, row?, parent? — the run it is a step of, step? — where in that run's flow, flow? — which of its other flows it ran (absent: the main one), createdAt, startedAt?, endedAt? }. Step (of a run with steps): { path — its id, inside a Repeat "loop#2/read", id, kind — agent|model|tool|decide|repeat|parallel|app|data, or round (a Repeat's round), title, status: running|waiting|done|failed|skipped|stopped, error?, costUsd?, run? — the run behind an agent step or a called app, session? — its session key, startedAt?, endedAt?, values? — what it gave (long ones shortened) }. App: the Recipe below plus { id, slug, mcp, agents: boolean — agents in your sessions may run it (its page's Agents can use it; true unless turned off), fromFile?: true — installed from a file (its page's More says Uninstall), public, template?, createdAt, updatedAt }.

  • GET /api/apps — your apps, the ready-made ones and the latest runs. Returns: { apps: [App & { usage: { runs, spent } — today, lastRunAt?: number — when it last ran }], templates: [Recipe & { id, installed: boolean — they come installed until you uninstall one, agents: boolean — agents in your sessions may run it, lastRunAt? }], recent: [Run] (up to 6, no CSV-row or public-page runs) }.
  • GET /api/apps/:id — one app, by id or address (slug); a ready-made one as tpl:<id> (e.g. tpl:trip-planner). Returns: yours { app: App, keys: [{ id, name, hint, createdAt, lastUsed? }], usage: { runs, spent }, publicUsage: { runs, spent }, workDir, env: Env, secrets: [SecretState] — the keys it needs, view?: { … } — what its own page's view keeps, when its view has pages for it, home?: Home — then its settings there, latest?: number — then when its latest run of its main flow ended well }; ready-made { template, installed: boolean, agents: boolean, secrets: [SecretState], view?, home?, latest? — as for yours, for one that opens on its own page (Explore, tpl:explore) }. A Home is { inputs: { question: answer } — the answers its runs from its page and its daily runs take, its form's defaults until changed, daily: { on: boolean, time: "HH:MM" } — Update every day }. A SecretState is one of its secrets as the vault has it: { …Secret, have: boolean, hint? — the saved value's masked end, blocked?: [host] — hosts the app sends it to that the saved key may not go to, noCommands?: true — the app runs it in commands, which the saved key does not allow }; never a value.
  • GET /api/apps/tools — Hydor's own tools an app may allow (for tools). Returns: { tools: [{ name, description, web: boolean — usable on public pages, step?: true — it can be a Use a tool step on its own, stepOnly?: true — only steps have it (user_profile), not an app's agents, inputs?: [{ name, type?, required?, description? }] — a step tool's inputs (for its with) }] }.
  • GET /api/apps/models — Custom's models (a public page must use one). Returns: { models: [{ id, label, provider }] }.

Agents in your sessions run your apps with their use_app tool: the apps installed whose agents is on and that are made for it (sessions in the recipe; not blocks). What a request leaves out, the user fills in on the app's card in the conversation; its run (source session) shows there too.

Apps: make, change, delete#

Recipe: { name: string (≤60, default "Untitled app"), description?: string (≤300), icon?: sparkles|telescope|plane|scale|file-search|list-checks|mail|graduation-cap|newspaper|scroll-text|app-window|pen-line|lightbulb|calendar|chart|code|globe|book-open|briefcase|heart|shopping-cart|utensils|dumbbell|music|camera|map|message-square|languages|calculator|home|wrench|bot|cloud-sun|sun|trophy|chef-hat|gift|clapperboard|tv|gamepad|ticket|piggy-bank|trending-up|map-pin|history (default sparkles), color?: blue|indigo|violet|pink|red|orange|amber|green|teal|cyan|slate (default blue), category?: string (≤30), assistant?: string — an assistant's id, agent?: claude|codex|acp (default claude), acpAgent?: string — for acp; "console" = Custom (default), model?: string, effort?: string, mode?: string (runs default to auto), cwd?: string — the folder every run works in (absent: a new folder per run), prompt: string — the task (≤30,000), with {{fieldId}}, {{#fieldId}}…{{/fieldId}} (kept when answered and not "No"), {{^fieldId}}…{{/fieldId}} (kept when empty), {{today}}, fields?: [{ id?: snake_case (default from label), label: string (≤80), type: text|long|number|date|choice|yesno|file (default text), required?: boolean, help?: string, placeholder?: string, options?: string[] — choice only (≤30; empty becomes Yes, No), default?: string }] (≤30), answer?: [{ id?, label, type: text|markdown|number|yesno|date|url|list|table|media, description?, required?, columns?: [{ id?, label, type: text|number|yesno|date|url|image, description? }] — table only (≤12) }] (≤30; none: the reply is the answer; a media field is a list of { src, caption?, kind?: image|video|audio|file, name?, poster? }— src an https:// address, or the path of a file on this computer, which submit_result keeps a copy of (a picture on the web is kept too, up to 15 MB) and gives as/app-media//; an image column is one such address), tools?: string[] | null — names from GET /api/apps/tools (null or absent: all), limits?: { runsPerDay?: number (≤100,000), capUsd?: number — US$ per run (≤10,000) }, flow?: Flow — what it does, step by step (absent: one agent does the task in prompt; with a flow, prompt may be empty), data?: Data — what it keeps between runs, memory?: boolean — its agents remember across its runs, in a memory of its own (a project in Settings → Memory), requires?: Requires — what it needs on this computer, secrets?: [Secret] (≤10) — the keys it needs, by name: { name: NAME in the vault (capitals, digits, ), label, help?, url? — where to get one, domains?: [host] — where it may go, commands?: boolean — commands on this computer use it, header?, prefix?, query? — how http_request sends it by default, optional?: boolean }(domains or commands required); values are never part of it, assets?: { [name]: { sha, mime, bytes, caption? } } — its own files (≤100, 150 MB in all), which its view and answer show asasset:; add them with POST /api/apps/:id/assets (or app_save's assets), not by hand, flows?: { [name]: Flow & { title?: string, inputs?: [field] — the form it takes, as fields } } — flows beside the main one, which its screen's forms and buttons (and programs, with flow) start; their outputs are names of their own (≤20), screens?: { [name]: { page, title? } } — made and changed only with the screens routes below (saving a recipe keeps the app's own; a new app starts without any), examples?: [{ id?, name?, flow?, inputs: { [fieldId]: answer }, checks?: [Rule] — each on the run's values: {{answer.field}}, {{stepId.value}}, {{reply}}, the inputs }] (≤20, ≤10 checks each) — tried with the examples routes below, graph?: { at?: { [stepId]: { x, y } } — where a step sits in the graph (those not placed are laid out by themselves), groups?: [{ id, note, steps: [stepId] }] — steps drawn in a frame with a note, bends?: { ["<giver>><user>"]: { x, y } } — where a value's wire bends } — the graph's layout, which the editor keeps (agents may leave it out: saving a whole recipe without it keeps the one saved), block?: boolean — a block: made to be a step of other apps (Add a step offers it; a Call an app step runs it; ready-made ones are in GET /api/apps templates with block: true), sessions?: boolean — made to be run by agents in sessions, its form asked and its answer shown there (default true, false for a block; set false for an app used on its own page: a screen, or a view of what it keeps), ui?: Ui — its view: how its answer looks and what a click there does (see Apps: views) }. Flow: { steps: [Step], outputs?: { [answerFieldId]: "{{stepId.value}}" } — what the run gives back (an answer field it leaves out comes from the last step that gave a value of its name) }; a bare list of steps is a flow too. Steps run in order. Every Step has { id: a letter, then letters, digits or _ (≤40; default from its title; unique in the flow), do: agent|model|tool|decide|repeat|parallel|app|data|ask|code|value, title?, if?: Rule — it runs only when this holds, else it is skipped and its values are empty, failed?: [Step] — run instead when it fails ({{error}} says why; then the flow goes on) | "skip" | "agent" — an agent does its work (a tool, model, code, data or app step): it gets what the step was given and why it failed, and hands in the values the steps after it use (the run's steps then show it with rescued: true and why it failed) (absent: the run stops) }, and by kind: agent { instructions: string (≤30,000), outputs?: Outputs — handed in with submit_result (absent: it gives {{id.reply}}), agent?, acpAgent?, model?, effort?, mode?, assistant?, tools?, capUsd? — the app's own unless set } (also gives {{id.reply}}); model — one answer from a model, no tools: { instructions, outputs?: Outputs (absent: {{id.text}}), model?: one of Custom's (default the helper model) }; tool { tool: web_search|read_page|http_request|read_document|generate_image|knowledge_search|knowledge_read|recall|user_profile|notify_user, with?: { [input]: a value, or text with {{values}} } } — gives {{id.text}}, and {{id.data}} when that is JSON (notify_user { message, title? } sends the user a notification that opens the run; user_profile { sessions?: number — titles of the latest sessions, 200 unless set, at most 1000, notes?: number }, a step's alone, gives { profile, notes: [text], sessions: [{ title, days, times }], count }: the user's profile, memory's notes about them and their latest sessions' titles, newest first, those with memory off, child sessions and unwatched app runs left out); decide { rule?: Rule, ask?: string — a question in words the helper model answers yes or no, then: [Step], else?: [Step] } — gives {{id.yes}}, {{id.why}}; repeat { over?: "{{a list}}" — its steps once per item ({{item}}, {{index}}; text goes by lines; ≤200), until?: Rule, untilAsk?: string — or again until this holds (each round sees the last one's values), max?: 1–50 (default 5), atOnce?: 1–6, as?: the item's name, collect?: the step whose values make each round's record (default its last with values), steps: [Step] } — gives {{id.items}} (one record per round), {{id.count}}, {{id.last}}; parallel { branches: [[Step]] (≤6) }; app { app: another app's address, with?: { [fieldId]: value } } — gives its answer fields and {{id.reply}}; ask — the run waits for the person (status waiting, a notice that opens it, an inbox item): { question: string with {{values}}, fields?: [field] (≤12, no files; absent: yes or no), waitHours?: 1–720 (default 168, then it fails) } — gives {{id.yes}}, or its fields' values; answered with POST /api/apps/runs/:runId/answer; code — a program run in a sandbox (bubblewrap: no network, none of this computer's files but the app's packages): { language: javascript|typescript|python, code: string — the body of a function of input and ctx (async in JavaScript and TypeScript; await import() for packages; TypeScript runs as it is, its types stripped) that returns the step's values (≤50,000 chars), with?: { [name]: value or text with {{values}} } — input, outputs?: Outputs — the fields of the object it returns, seconds?: 1–120 (default 20; the time its ctx calls take is not counted) } — gives the returned object's fields (another value: {{id.result}}) and {{id.printed}}. It reaches out only through ctx, each call a step of the run under it (path "/c1", "/c2"…), kept: after a restart the program runs again, and a call it made before gives what it gave then (a different call there: what was kept from there on is dropped). JavaScript and TypeScript: await ctx.model(instructions, { with?, outputs?, model?, title? }), ctx.agent(instructions, { with?, outputs?, assistant?, agent?, model?, tools?, capUsd?, title? }), ctx.tool(name, inputs), ctx.ask(question, { with?, fields?, waitHours? }), ctx.app(address, inputs), ctx.data.add(collection, record), .get(collection, id), .find(collection, { where?, search?, sort?, limit? }), .search(collection, query), .update(collection, { id?, where?, set }), .remove(collection, { id?, where? }) — each gives the step's values, and throws when it fails; Python the same, without await, with= for with; with's values go in the words by {{name}} (those not named come after them); at most 500 calls a program; value — a value worked out from another, at once and at no cost: { of: "{{a value}}", op: first|last|count|sum|average|min|max|pick|join|unique|sort|number|date, arg?: string — pick: the field; sum, average, min, max: of this field of each; sort: by this field ("-price": highest first); join: between the items (default ", "; \n a new line); date: days to add } — text goes by lines; gives {{id.value}}; data { op: add|get|find|search|update|remove (or the short way: add: "collection"), collection, with?: the record (add) or what to change (update), record?: one record's id, where?: { [field]: value or { "<": 10 } } (=, !=, <, >, <=, >=, contains, in, empty; {} for all), search?: words (find), query?: a question (search: the records that answer it best, by words and by meaning, as knowledge bases are searched), mode?: auto|words|meaning (search), sort?: field or -field, limit?: ≤1,000 (default 100; search ≤100, default 10) } — add gives {{id.record}}, {{id.id}}; get {{id.record}}, {{id.found}}; find {{id.records}}, {{id.count}}, {{id.first}}; search {{id.records}} (best first, each with its score), {{id.count}}, {{id.first}}, {{id.meaning}} (whether meaning took part: search by meaning is on); update {{id.records}}, {{id.count}}; remove {{id.count}}. The knowledge bases and memory notes are searched with tool steps (knowledge_search, recall). Values: {{inputs.fieldId}} (or {{fieldId}}), {{stepId.value}}, {{stepId.list[0].field}}, [-1] for the last; the built-in values {{today}} (2026-11-02), {{now}} (2026-11-02 14:30), {{weekday}}, {{timezone}} (all in the user's time zone), {{run.source}} (web, test, api, mcp, public, batch, task, screen, chat, app, session) and {{run.id}}; sections {{#x}}…{{/x}} (kept when x has a value), {{^x}}…{{/x}}. Lists go into text as lines, rows as a table. A Rule compares values (=, !=, <, >, <=, >=, contains, does not contain, is empty, is not empty, or a value alone: whether it is there), joined by and / or: {{price}} < 100 and {{kind}} = bug. Outputs: { [id]: kind } with kinds text|long|number|yesno|date|url|list|table(column: kind, …), or a list like answer fields (≤30). At most 60 steps, 5 deep. Data: { [collection]: { [field]: kind } | { fields?: Outputs, description?: string } | {} — any records } (≤20 collections; each record has its own id, createdAt and updatedAt; 20,000 records in all, 64 KB each). Requires: { node?: { [package]: version or range } (≤40), python?: ["pandas>=2", …] (≤40), pythonVersion?: "3.12", commands?: ["ffmpeg", …] — looked for, not installed, models?: [{ as: the name its steps use, agent: claude|codex|custom, provider?: openrouter|ollama|vllm|endpoint, model?: the provider's name for it, label? }] — said for you in an app's file (Custom's models with where they run and their provider's name), so another computer finds them }. Packages install in the app's own folder, never for the whole computer: npm without the packages' install scripts, Python in a uv environment (python3 -m venv without uv); names and versions only, no links, git or options. Its agents get them first on PATH (VIRTUAL_ENV, NODE_PATH). Env: { state: none|installing|ready|failed|outdated, missing: [command], log?: the end of the last install's output, error?, at? }. Public: { on?: boolean, model?: string — one of GET /api/apps/models (needed to turn on), web?: boolean — web search and page reading (default true), perIpPerHour?: number (default 5, ≤1000), runsPerDay?: number (default 50), spendPerDay?: number — US$ (default 2) }.

  • POST /api/apps/draft — drafts an app from a session's first request and last reply with the helper model, without saving it. If the helper model fails, the draft is the request in one "Request" field. Body: { key: string — the session's key, claude:…|codex:…|acp:… }. Returns: { recipe: Recipe, note?: string — why it fell back }.
  • POST /api/apps — makes an app. Body: { recipe: Recipe (or its fields at the top level), mcp?: boolean — offered to other agents as a tool (default true), public?: Public, slug?: string — its address (default from the name, made unique), template?: string — the ready-made id it started from }. Gives 400 saying what to fix: an empty prompt without steps, a flow whose values come later or name nothing, a rule that cannot be read, a tool a step cannot call on its own, a collection it does not keep, a package that cannot be installed safely; or public.on without a Custom model. The packages it requires start installing. Returns: { app: App, warnings: string[] — what may surprise, such as a value that is empty when a Decide goes the other way }.
  • POST /api/apps/check — checks a recipe without saving it, as POST does. Body: { recipe: Recipe }. Returns: { errors: string[], warnings: string[] }.
  • PUT /api/apps/:id — changes an app. Body: as POST. The recipe is replaced whole, so GET it first and send every part (its flow, data, memory and requires too); a body with neither a prompt nor steps is refused. public merges into the current public settings. mcp, agents, slug and template keep their values when left out; a new slug changes the address. Returns: { app: App, warnings: string[] }.
  • DELETE /api/apps/:id — deletes an app with its runs, batches, keys, public page and the data it keeps (an app installed from a file, its page's Uninstall, too). Its active runs are stopped first; their sessions stay. Returns: { ok: true }.
  • POST /api/apps/:id/uninstall — uninstalls a ready-made app (tpl:<id>): it leaves your apps and agents' tools, runs no more (409) and waits under Ready-made apps to install on the Apps page; its runs stay, its Update every day task goes. Returns: { template, installed: false, agents }.
  • POST /api/apps/:id/install — installs a ready-made app (tpl:<id>) again. Body (the user's own pages only): { secrets?: { NAME: value }, allow?: [NAME] } — keys it needs, saved to the vault. Returns: { template, installed: true, agents }.
  • PUT /api/apps/:id/agents — whether agents in your sessions may run an app (yours by id or slug, or tpl:<id>): its page's Agents can use it. Body: { on: boolean }. Returns: { agents: boolean, made: boolean — it is made for that (when false, agents never get it) }.

App runs#

  • POST /api/apps/:id/runs — runs an app now, in the background (yours by id or slug, or ready-made as tpl:<id>). Body: { flow?: string — another of its flows (absent or "run": the main one; its inputs are that flow's), inputs?: { [fieldId]: string|number|boolean } — checked against the form (text ≤2,000, long/file ≤60,000 characters; choice must match an option; dates YYYY-MM-DD; yes/no), files?: { [fileFieldId]: { name: string, data: base64 } } (≤25 MB each), agent?: claude|codex|acp, acpAgent?, model?, effort?, mode? — ready-made apps only (default Claude Code, mode auto), recipe?: Recipe — test this recipe instead (:id "test" for one not saved), source?: "test"|"screen", pause?: string[] — a test run (with steps) pauses before these steps until told to go on (POST /api/apps/runs/:runId/go), freeze?: { from: runId, steps: string[] } — a test run's steps that give what they gave in that earlier run of the same app, without running again, secrets?: { NAME: value }, allow?: [NAME] — the user's own pages only: keys it needs, saved to the vault first (allow: saved keys that may now go to its hosts too) }. Gives 400 saying what to fix in the form, 409 { error, secrets: [{ name, label, help?, url? }] } when it needs keys the vault does not have (a run started by an agent: tell the user to add them on its page, or run it with use_app, whose card asks), and 429 past the app's runs a day. Returns: { run: Run }.
  • GET /api/apps/:id/runs — an app's runs, newest first, 50 at a time (ready-made: tpl:<id>). Query: before?: number — createdAt of the oldest run seen, to get older ones. Returns: { runs: [Run] }.
  • GET /api/apps/runs/:runId — one run, with how it is going and its follow-ups. Returns: { run: Run, progress: { steps: [{ title, status: running|ok|error }] (last 8), said?, waiting?, own?: true — a run with steps: the titles are the app's own words, paused?: string[] — a test run paused before these steps (see …/go) }, recipe: { name, icon, color, fields, answer, agent, acpAgent, model, ui?: Ui — the view its answer shows in (its app's as it is now, a test's as it was tried; none for a run of another of its flows), assets?: { [name]: { sha, mime, bytes } } — its app's own files, steps?: true — its main flow works in steps }, slug: string|null, asks: [{ id, text, at, reply?, updated: boolean — handed in a new result }], first?: string — the reply to the task itself, queued: [{ id, text, at }], noAsks?: string — why it takes no follow-ups (runs with steps take none), steps?: [Step] — a run with steps: every step so far, with what it gave, question?: { step, question, fields?, since } — an Ask a person step waiting for the answer }. With a view, also view: { … } — what its view keeps for it (see Apps: views).
  • POST /api/apps/runs/:runId/ask — a follow-up that refines the result. It goes to the run's session and waits its turn if the agent is busy. Body: { text: string — ≤20,000 characters }. Gives 409 for public-page runs, runs not started yet, and runs whose session is gone or open in another program. Returns: { run: Run }.
  • POST /api/apps/:id/examples/run — tries its examples (or those named): a test run each, whose checks are read when it ends. Body: { recipe?: Recipe — try this instead of the app as saved (the builder's, unsaved; :id may be "test"), only?: string[] — example ids, trial?: boolean — a change tried before it is used (Firm it up): its runs' example is "trial:<id>", and they are not the examples' last try }. Returns: { runs: [{ example, run?: Run, error?: string — why it could not start }] }. A run that tried an example has example, and once it has ended checks: [{ rule, ok, why? — what it saw }].
  • GET /api/apps/:id/examples — a saved app's examples, each with its last run. Returns: { examples: [{ id, name?, flow?, inputs, checks?, last?: Run }] }.
  • GET /api/apps/schema.json — the JSON Schema of an app's app.json (its folder's), for an editor's completion and checks.
  • GET /api/apps/:id/folder — its folder of files, if it has one. Returns: { folder: { path, error? — the mistake in it, which changed nothing } | null, suggested: string — where one would go }.
  • POST /api/apps/:id/folder — makes its folder and keeps it in step with the app: each change saved there is a version by "folder", a save here writes it again. Body: { path?: string — an empty folder, or one of this app's (default: Hydor/Apps/<address> in the home folder) }. Returns: { app, folder }. The folder: app.json (the recipe, with { file } for texts kept in files), instructions/.md, steps/.js|py (export default async function step(input) { … } / def step(input):), screens/.html, ui/.html and .css and ui/style.css (its view's elements and style), graph.json, .naget/app.schema.json and step.d.ts.
  • DELETE /api/apps/:id/folder — stops keeping it in step; the files stay. Returns: { app }.
  • GET /api/apps/:id/versions — its versions, newest first: each save that changed what it does (not where its steps sit in the graph, nor its screens). Returns: { versions: [{ n, at, by: "you"|"agent"|"folder"|"install", line — what changed (or the why of the save), examples?: { tried, passed, seconds, costUsd } — its examples tried with it as saved: the latest run of each, and a run's average time and cost }] }. A run's version is the one it ran (none: a try of changes not saved). A save takes why (≤300) for its version's line.
  • GET /api/apps/:id/versions/:n — one version, whole. Returns: { version: { n, at, by, line }, recipe: Recipe }; save the recipe (PUT /api/apps/:id) to bring it back as the newest version.
  • GET /api/apps/:id/advice — what its latest runs (the last 12) say of its steps. Returns: { rescued: [{ step, title, runs, of — in how many of the runs read, error — why it failed the last time, run — that run }] — steps that failed and had an agent do their work, firm: [{ step, title, runs, calls: string[] — the tools its agent called each time, in order, steps: [Step] — fixed steps to put in its place (a tool step for each call, then a model step that keeps its id), agent: { seconds, costUsd } — what its agent took a run, on average, inputs: [object] — the answers of the runs it was seen in }] — agent steps that did the same in each of their last runs (at least 3) }. Put the steps in with the app's flow and try them (examples/run with trial, or a test run) before saving them.
  • POST /api/apps/runs/:runId/answer — answers the run's waiting Ask a person step, and the run goes on. Body: { step?: string — its path, as GET gives it in question.step, yes?: boolean — for a yes or no, values?: { [fieldId]: answer } — for its form }. Gives 409 when nothing waits for an answer there, 400 when the answer does not fit. Returns: { ok: true }.
  • POST /api/apps/runs/:runId/again — runs a run with steps again from one of its steps: the same answers, the app as it is now, and the steps before that one giving what they gave in this run (marked reused in its steps, at no cost). Or, with inputs (what a view's again does), runs any app's run again from the start with some answers changed: the others as they were, its files along. Body: { from?: string — a step's id in the main list of steps (default: the first: all run again), recipe?: Recipe — run this instead of the app as it is (a test from the builder; the run is a test), inputs?: { [fieldId]: answer } — the answers that change }. Gives 409 while it still runs (from a step) or when a file it had is gone, 400 when the app has no such step or question now, or (from a step) the run had files. Returns: { run }.
  • POST /api/apps/runs/:runId/stop — stops a run: one waiting in line ends at once, a working one is interrupted.
  • POST /api/apps/runs/:runId/go — a test run paused before a step (its progress has paused: [stepPath], its status is waiting) goes on. Body: { step?: boolean — run one step, then pause before the next }. Returns: { ok: true }; 409 when it is not paused. Returns: { ok: true }.
  • DELETE /api/apps/runs/:runId — removes an ended run from the lists, with its steps (409 while it is going); sessions stay. Returns: { ok: true }.

Apps: keys and files#

An app names the keys it needs (secrets in its recipe), never their values: the user gives each one on its page, on the card that installs it, or on its card in a session (use_app asks for those the vault lacks), and it goes to the vault under that name with the hosts the app gives (a key the vault has keeps the user's own hosts). Its runs are told each key's name and how to use it (http_request auth: NAME, the browser, run_with_secrets). An app's own files (assets) are kept by the app and show by name as asset:<name>; the files an answer points at are kept too. Both are served at /app-media/<sha>/<name>, an address nobody can guess, which views (in their sandboxed frame) and public pages can reach without the sign-in; anything but a picture, video, sound or PDF downloads instead of opening.

  • GET /api/apps/:id/secrets — the keys it needs and how the vault has them (yours, or tpl:<id>). Returns: { secrets: [SecretState] }.
  • POST /api/apps/:id/secrets — saves values for the keys it needs (the user's pages only: agents are refused, as for any key's value). Body: { values?: { NAME: value } — only its own keys are taken, allow?: [NAME] — saved keys that may now go to its hosts too (and to commands, when it needs that) }. Returns: { secrets: [SecretState] }.
  • POST /api/apps/assets — keeps a file for an app being edited, without changing any app (its Edit saves the entry with the app). Body: { name: string — a file name, no folders (≤100), data?: base64 (≤100 MB), url?: https:// or data: address, caption? }. Returns: { name, asset: { sha, mime, bytes, caption? } }.
  • POST /api/apps/:id/assets — adds one of its files, or replaces the one with that name, and saves the app as a new version. Body: as above. Gives 400 for a name that cannot be one or past 100 files, 413 past 100 MB, or 150 MB for all its files. Returns: { app: App }.
  • DELETE /api/apps/:id/assets/:name — removes one of its files from it (a new version; earlier versions keep theirs). Returns: { app: App }.

Apps: what they keep#

An app with data keeps collections of records between its runs: its steps (Keep data), its agents (with their app_data tool) and these routes share them. Fields with kinds are checked and tidied (numbers, dates, yes or no); unknown fields are refused.

  • GET /api/apps/:id/data — the collections it keeps. Returns: { collections: [{ name, count, fields: [field] | null, description: string | null }], limits: { collections, records, record, find } }.
  • GET /api/apps/:id/data/:collection — records, oldest first; or with ask, those that answer a question best. Query: q?: words anywhere in a record, where?: JSON — { "field": value, "other": { "<": 10 } } (=, !=, <, >, <=, >=, contains, in, empty), sort?: field or -field, limit?: ≤1,000 (default 100); or ask: a question — by words (BM25) and by meaning (each record's vector, when search by meaning is on), joined by rank, mode?: auto|words|meaning, limit?: ≤100 (default 10). Returns: { records: [{ id, createdAt, updatedAt, …fields }], count — how many match in all }; with ask, each record has its score, best first, and meaning: boolean says whether meaning took part.
  • POST /api/apps/:id/data/:collection — adds a record. Body: { record: { …fields, id?: string — keeps this id (letters, digits, - and _) } }. Gives 400 naming a field it does not have or a value that does not fit, 409 for an id it has, 413 past 64 KB or 20,000 records. Returns: { record }.
  • PATCH /api/apps/:id/data/:collection/:recordId — changes a record: the fields in set replace its own; null clears one. Body: { set: { …fields } }. Returns: { record }.
  • DELETE /api/apps/:id/data/:collection/:recordId — removes a record. Returns: { ok: true, removed: 0|1 }.
  • DELETE /api/apps/:id/data/:collection — removes every record of a collection. Returns: { ok: true, removed: number }.

Apps: screens#

An app's screen is a page of its own (Pages keeps it with its versions, and leaves it out of the library) that its page shows in place of its form. It is HTML as any page (<body class="n-page">, the kit's classes and colours) and runs with window.hydor.app for its app (window.naget.app, its name before the rename, works too):

  • hydor.app.flows — [{ id, title, inputs: [field], outputs: [{ id, label }] }] ("run" is the main one); hydor.app.collections — [{ name, fields }].

  • hydor.app.run(flow = "run", inputs) — the run as it starts { id, status, … }; hydor.app.wait(id) — { run, steps } once it has ended; hydor.app.get(id); hydor.app.runs({ flow?, limit? }) — its latest runs.

  • hydor.app.data(name) — .find({ where?, search?, sort?, limit? }) → { records, count }; .search(question, { mode?, limit? }) → the records that answer it best, by words and by meaning; .add(record); .update(id, set); .remove(id); .watch(fn) — called when the list changes.

  • hydor.app.on("run", fn) — each change of its runs; hydor.app.on("data", fn) — a list's name when it changes.

  • hydor.app.chat(text, id?) — a message to its agent: a new conversation, or more of the one named; hydor.app.conversation(id); both give { id, status, working, messages: [{ from: you|agent, text, queued? }], error? }. hydor.app.chats() — its latest conversations [{ id, at, first, status }].

  • App parts, elements that use it: <n-form flow="run" submit="Words" keep> (the flow's inputs as a form that runs it; the page hears an n-run event with { run, steps }), <n-button flow="name" inputs='{"field":"value"}' plain>Words</n-button>, <n-result flow="run"> (its latest answer, live), <n-runs flow="run" limit="5">, <n-table data="list" columns="a,b" edit="aYesNoField" search remove where='{"read":false}' sort="-createdAt" limit="100"> (records, live), <n-count data="list" where='{…}'>, <n-chat placeholder="Words" hint="Words" fresh> (a conversation with its agent: the latest one unless fresh, with a box to write in, New conversation and Earlier conversations).

  • GET /api/apps/:id/screens — its screens. Returns: { screens: [{ name, page, title, version }] }.

  • GET /api/apps/:id/screens/:name — one screen with its HTML. Returns: { screen: { name, page, title, version, html } }.

  • GET /api/apps/:id/chats — its conversations with its agent (a screen's <n-chat>), newest first, 20 at most. Each is a run of its own (kept apart from its runs): read it with GET /api/apps/runs/:runId (its first message is run.inputs.message, the replies first and asks), go on with it with POST /api/apps/runs/:runId/ask. Returns: { chats: [{ id, at, first — its first message, status }] }.

  • POST /api/apps/:id/chats — starts a conversation with its agent: its agent, model, tools, records and memory, and an app_run tool to run its flows and wait for what they give. Counts against its runs a day. Body: { text: string — the first message, ≤20,000 characters }. Returns: { run: Run } (source chat).

  • POST /api/apps/:id/screens — gives it a screen: the HTML given, or one made from its parts (its form, its answer, a table for each list it keeps and a conversation with its agent when it keeps one, its runs). Body: { name?: string — "main" (the default) opens on its page, title?: string, html?: string — ≤2 MB }. Gives 409 when it has a screen of that name. Returns: { screen, app: App }.

  • PUT /api/apps/:id/screens/:name — a new version of a screen (the earlier ones stay, in Pages). Body: { html: string, title?: string }. Returns: { screen }.

  • DELETE /api/apps/:id/screens/:name — removes a screen and its page. Returns: { app: App }.

Apps: views#

An app's view (ui in its recipe) is how its answer looks: pages of blocks, elements of its own that its values fill each time they show, and events, which say what a click does. A run's page shows the run's answer in it (Plain answer shows the card and tables instead); pages with on: "app" show on the app's own page in place of its form. A view draws in the sandbox pages use, with the kit's classes and colours (var(--n-card), var(--n-accent)…), light or dark as the app is. A save checks it and says what to fix; app_try says which names it uses that a try's answer did not give.

Ui: { pages: [Page] (≤12), elements?: { [name]: { html: string — a template (≤40,000), css?: string — its style: rules for what is inside it, & for the element itself (≤20,000), description?: string } } (≤40; a name in small letters, digits and -, not a built-in's), events?: { [name]: [Action] (≤8) } (≤60), style?: string — CSS for the whole view (@keyframes, fonts, classes several share), widgets?: { [name]: Widget } (≤12; names apart from its elements and the built-ins), libs?: { [specifier]: exact version } (≤16) — npm packages its widgets import, loaded from jsDelivr: "three": "0.170.0", "chart.js/auto": "4.4.6", a stylesheet as its path ("leaflet/dist/leaflet.css": "1.9.4"); a package's other modules by path ("three/examples/jsm/controls/OrbitControls.js") come from the same version }. Widget: { code: string — an ES module (≤60,000) whose default export draws it: export default function (el, value, view) { … }; el: the element to draw in, value: what fills it (the block's data, as for an element), view: { with — the block's with, theme: { accent, fg, muted, line, card, soft, bg, dark } (rgb() colours), lang, asset(name) → its file's address, media(src) → an address as it shows, event(name, item?) — fires one of the view's events ({{event.*}} is the item's fields), set(name, value) — a value of the view's state, state, resize() }; it may return a function that cleans up, css?: string — its style (& for itself), description?, height?: CSS length — its height until it draws (default 320px) }. A block places one as it places an element (element: "<widget>"), a template with <n-widget name="<widget>" value="{{result.points|json}}"></n-widget>. Its code runs in the view's sandbox: it reaches the app only through view.event and view.set. Page: { id, title — its tab, on?: run|app (default run), tab?: false — no tab of its own, opened by an event, if?: Value — shown only when that is there, blocks: [Block] (≤40) }. Block: { title?, element?: one of its elements or widgets, or a built-in: text|markdown|list|checklist (ticks kept with the run)|table|chart (bars)|image|video|audio|map|plot|answer (the plain card)|form|runs|chat (the app's own page), html?: a template for this place, text?: Markdown with {{values}} — one of element, html and text, data?: Value — what fills it ("result.days", "inputs", "data.items", "state.picked", "page.item", "last.result"), each?: boolean — drawn once for each item, where?: { field: value | { "<"|">"|"<="|">="|"!="|"contains": value } } — an empty value matches all, sort?: field|-field, limit?: number, layout?: list|grid|row, width?: full|half|third|two-thirds, empty?: text — when there is nothing, if?: Value, with?: { columns?, label?, value?, flow?, limit?, placeholder?, submit? } — a built-in's settings }. Media elements take what fills them as addresses (https://…, /app-media/…, asset:<name>), media items { src, caption? }, or rows with the field named in with.src (and with.caption): image (with: { columns?: 1–6, height?: "180px", fit?: cover|contain }; a click opens it large), video (a file, or a YouTube or Vimeo link, shown as its still and played over the page on a click: their players cannot run in the sandbox; with: { poster? }), audio. map draws rows with a latitude and longitude (with: { lat?, lng? — the fields (lat/latitude, lng/lon/longitude unless named), label?, popup?, line?: true|"dashed" — joined in order, numbered?: true, zoom?, height?, tiles? — https://…{z}/{x}/{y} for other tiles, attribution?, event? — fired with the place clicked, empty? }; OpenStreetMap's tiles by default). plot draws rows with Plotly (with: { kind: line|bar|hbar|scatter|area|pie|donut|histogram|box|heatmap|scatter3d|line3d|surface, x?, y?: field or [fields], z?, series? — one trace per value of a field, stacked?: true (bar), title?, xLabel?, yLabel?, zLabel?, height?, event? }), or a Plotly figure { data, layout } (or a grid of numbers for a surface or heatmap) as its value. Templates may place them too: <n-map value="{{result.stops|json}}" lat="lat" lng="lng" line>, <n-plot value="{{result.costs|json}}" kind="bar" x="day" y="cost">. They load Leaflet and Plotly from jsDelivr, so they need the device online. Templates: HTML with {{name}} (escaped), {{#name}}…{{/name}} (for each item of a list; once for a record, or a value that is there), {{^name}}…{{/name}} (when it is empty) and {{name|format|format:arg}}. Names: result.<answer field>, inputs.<question>, run.status, run.createdAt, run.costUsd, app.name, state.<name> (the view's own values), page.item (what its page was opened with), data.<list> (the records it keeps), last.result (on the app's own page: its latest run), today, now; in an element drawn for an item, the item's fields by name, {{.}} or {{item}} for the item, and @index, @number, @first, @last, @key (its id, else its place in the list, as in state.done.@key). Formats: date, shortdate, longdate, day, month, weekday, year, time, ago, number, percent, plus:n, times:n, upper, lower, slug, initials, short:n, host, url, count, first, last, take:n, join:sep, split:sep, unique:field, sort:field, where:field=value (or !=), sum:field, is:x, not:x, has:x, over:n, under:n, empty, or:text, yesno, markdown, lines, json; an argument that starts with $ is a value (where:topic=$state.topic). A Value is a name with formats, with ! in front for "not there" (result.flags|count|over:2). data-event="name" on anything makes a click on it the event (a form's submit, a field's change, a search box as it is typed in), and data-key on it names the item @key stands for. asset:<name> in a src, href or poster shows one of the app's own files. Scripts, frames and on… attributes are left out (a widget is the way to run code). Action: { do, confirm?: text — asked first; no stops the event } and one of: page (page, item?), back, set (name, value, keep?), toggle (name, keep?), say (text), copy (text), open (url: an address of this app, or https://…), ask (text: a follow-up to the run's agent; a one-task app's runs only), run (flow, inputs?, as?: its run as state.<as>, with working, status, result, error), again (inputs: the answers that change; a new run, its files along, which opens), start (app: an app's id or address, or tpl:<id> for a ready-made one; inputs; it opens), data (op: add|update|remove, collection, record?, id?). Their texts and values take names: {{item}} (what the element was filled with), {{event.x}} (the clicked element's data-x, a form's field x, a field's {{event.value}}, and {{event.text}}, what it says on screen, in the app's language) and every name a template takes; one {{name}} alone keeps its value as it is (a list stays a list). keep keeps a value with the run (or the app's own page). On a public page a view keeps only page, back, set, toggle, say, copy and open, and keeps nothing.

  • PUT /api/apps/runs/:runId/view — keeps one value of a run's view (what keep keeps: a tick, a value an event set). Body: { name: string — up to four parts of letters, digits, - and _, joined by dots ("done.3"), value: any JSON | null — null removes it }. Gives 400 for a name that cannot be one, 413 past 64 KB or 500 values. Returns: { view: { …what it keeps } }.
  • PUT /api/apps/:id/view — keeps one value of its own page's view (its pages with on: "app"), yours or a ready-made one (tpl:<id>, Explore's stories read). Body: as above. Gives 400 when its view has no page of its own. Returns: { view }.
  • PUT /api/apps/:id/home — the settings of an app that opens on its own page (yours, or tpl:<id> for a ready-made one such as Explore): the answers its runs from there take, and Update every day, a scheduled task (origin daily:<id>, listed with the others) that runs it each day at a time with them. Body: { inputs?: { question: answer } — only its form's questions are kept, daily?: { on?: boolean, time?: "HH:MM" } }. Gives 400 when it has no page of its own, for a time that is not one, or for turning it on while a question it needs is empty (or it needs a file each time); 503 without scheduled tasks. Returns: { home: Home }.

Apps: as a file#

An app travels as one file: a .json, or a .hydor-app (a zip of its app.json, data/ with its records, assets/ with its own files, and apps/ with the apps its steps call, their files in apps/

/). In a .json its files are base64 (files: { name: base64 }). Its screens go in as their HTML (screens: { name: { title, html } }) and come back as pages of the app installed. A ready-made app's file says which it is (builtin: id). Its folder, API keys and public page stay behind; the keys it needs travel by name only.

  • GET /api/apps/:id/export — an app as a file to keep or share (yours or tpl:<id>), with the apps its steps call. Query: as?: json (default: a .hydor-app zip), data?: 1 — its records too (zip). Returns: the file; as=json its package { naget: "app", format: 1, slug, app: Recipe, records?, apps?: [package], files?: { name: base64 }, builtin?: id }.
  • POST /api/apps/install/check — what an app from a file would do here, before anything is made. Body: { file: { name, data: base64 } } — a .json, .hydor-app or .zip (≤160 MB) or { package: a package or a bare Recipe }. Returns: { preview: { name, description, icon, color, category?, summary: { asks: [{ label, optional }], gets: [string] — its view's pages or its answer's fields, inView, how: [{ kind: one|tool|model|agent|code|parallel|repeat|decide|ask|app|data, tool? }], steps, sessions: boolean — made to be run by agents in sessions, ownPage: boolean }, steps, agents: [string], tools: string[] | null, apps: [called app's address], data: [{ name, records }], memory, limits, needs: { node: [string], python: [string], commands: [string], missing: [command not on this computer] }, models: [{ as, agent: claude|codex|custom, label, status: here — set up here as it is | matched — the same model here under another name | missing, use: what it becomes unless you pick ("" : the agent's default, or the helper model for a model step), options: [{ value, label }] — models here that could stand in }], bundled: [{ name, from — its address where it was made, installs: new|reuse }], secrets: [SecretState] — the keys it (and what comes with it) needs, files: [{ name, mime, bytes }] — its own files, view: { widgets: [name], libs: ["three 0.170.0"] } — what its view runs of its own, builtin?: { id, installed } — a ready-made app's file (installed: it installs as a copy), notes: [what changes on the way in: its folder left out, a mode that skips asking set to Auto, an assistant that is not here, its view's scripts, a ready-made app coming back], warnings: [string], problems: [why it cannot be installed] } }.
  • POST /api/apps/install — installs it as the check showed: the apps that come with it first (or the same one you have), then it, with its records; its steps call the others at the addresses they got here, and the models picked; its public page is closed and it has no keys. The packages each requires start installing. Body: as the check, plus models?: { "agent:as": a model here, or "" for the default }, agents?: boolean — agents in your sessions may run it (default true), secrets?: { NAME: value }, allow?: [NAME] — the user's own pages only: keys it needs, saved to the vault. Its own files are kept, under their names. Gives 400 with the problems. Returns: { app: App, notes: [string], records: number, skipped: number — records that did not fit, bundled: [{ name, slug, reused }] }; a ready-made app's file where that app was uninstalled installs it again instead: { builtin: id, notes, records: 0, skipped: 0, bundled: [] }.
  • GET /api/apps/:id/env — how what it needs stands on this computer. Returns: { env: Env, requires: Requires | null }.
  • POST /api/apps/:id/env — installs its packages again (after a failure, or afresh). Returns: { env: Env }; runs started meanwhile wait for it.

Apps: for each row of a CSV#

  • GET /api/apps/:id/estimate — a rough cost for a number of runs, from the median of the last 30 finished runs. Query: rows: number (1–500). Returns: { rows, perRun: number|null, basis: runs|none, total: number|null, cap: number|null, most: number|null — cap × rows, from: number — how many runs it is based on }.
  • POST /api/apps/:id/batches — runs your app once per row (your apps only, not ready-made). Body: { rows: [{ [fieldId]: value }] (1–500; a file field takes a path or web address), concurrency?: number (1–6, default 2), name?: string (≤120, e.g. the CSV's name) }. Rows that don't fit the form fail at once with the reason. Gives 429 if the rows would pass the app's runs a day. Returns: { batch: { id, app, status: running|done|stopped, total, done, failed, concurrency, name?, createdAt, endedAt? } }.
  • GET /api/apps/:id/batches — your app's last 30 batches. Returns: { batches: [Batch] }.
  • GET /api/apps/batches/:batchId — a batch and its runs, in row order. Returns: { batch: Batch, runs: [Run] }.
  • POST /api/apps/batches/:batchId/stop — stops a batch: waiting rows end and running ones are interrupted. Returns: { ok: true }.

Apps: API keys#

  • GET /api/apps/:id/keys — your app's API keys, never their values. Returns: { keys: [{ id, name, hint — first and last characters, createdAt, lastUsed? }] }.
  • DELETE /api/apps/:id/keys/:keyId — revokes a key; programs using it stop at once. Returns: { ok: true }.

App-wide switches (Settings → General → Resume after usage limits, Settings → Accounts → Switch accounts at a limit)#

  • GET /api/app-settings — whether sessions stopped by a plan's usage limit continue by themselves a minute after it resets, and whether a session whose account runs out of usage moves to another of its agent's accounts with usage left and carries on (both off by default). Returns: { autoResume: boolean, autoSwitch: boolean }.
  • PUT /api/app-settings — turns either on or off. Body: { autoResume?: boolean, autoSwitch?: boolean } (each left as is when absent). Returns: { autoResume: boolean, autoSwitch: boolean }.

Pages (the Library)#

Agents also have their own tools for pages: show_html shows a page in the conversation, save_page saves a new page or (with an id) a new version of one, save_slides, save_doc, save_sheet and save_latex do the same for slide decks, documents, spreadsheets and LaTeX documents, get_page lists them all or reads one (a page with its stored data, another kind as it is kept), export_page saves one as a file (PDF, Word, PowerPoint, Excel, CSV, Markdown, LaTeX or a web page). When the user changes one in its editor, the agent of that session (and of the session that made it) hears of it with the user's next message. Page: { id — 10 hex characters, title, version — the current one, versions — how many are kept, bytes — the current version's HTML (or a deck's text) size, storageBytes — the size of its stored data, createdAt, updatedAt, sessionKey? — the session it came from, space? — the project it is in (none: in no project; what an agent saves goes in its session's), by?: agent|user|restore|edit|made — who made the current version (edit: changed by the user in the editor; made: a page the user made), thumb? — set once its picture is ready, published?: true — it is published at a link, kind?: slides|doc|sheet|tex — another kind than a page of HTML }. Another kind keeps a model instead of HTML, the shape its save tool describes: slides { v: 1, ratio: "16:9"|"4:3", theme: { id?, bg, fg, head, muted, accent, accentFg, soft, colors: [6], font, headFont }, slides: [{ id, els: [part], anims?, transition?, notes?, hidden?, bg? }] }; a document { v: 1, page: { size: a4|letter, margin — mm }, font, size — pt, blocks: [{ id, type: p|li|table|image|hr|pagebreak, … }] }; a workbook { v: 1, sheets: [{ id, name, cells: { "B2": { v?: number|string|boolean, f?: string — a formula without its "=", s?: style } }, cols?: { [index]: { w } }, rows?: { [index]: { h } }, freeze?: { rows?, cols? } }] }; a LaTeX document { v: 1, engine: pdflatex|xelatex|lualatex, main — main.tex's text, files: [{ name, text }], pictures: [{ name — as \includegraphics names it, src }] }. Pictures in a model are naget-asset:<sha> sources.

  • GET /api/pages — the saved pages, the latest changed first. Returns: { pages: [Page] }.
  • GET /api/pages/:id — one page with its current HTML (another kind: its model), its versions and its own address. Returns: { page: Page, html?: string — a page's, model?: object — another kind's, versions: [{ version, title, bytes, createdAt, by: agent|user|restore|edit|made }] (newest first), link: "/pg/<token>" — the page's own address: it opens the page with its data without signing in, and anyone with it can change the data }. 404 when there is no such page.
  • GET /api/pages/:id/versions/:v — one version of a page (another kind: its own page, with its pictures in it). Query: model?: 1 — another kind's model instead. Returns: { title, html, version } (with model=1: { title, version, model }); 404 "That version is no longer kept" (a page keeps its newest 40 versions, ≤24 MB of them; the current one always stays).
  • GET /api/pages/kept?key= — which of a session's show_html pages were kept, and as which saved page. Query: key: string — the session's key. Returns: { items: { [timelineItemId]: pageId } }.
  • POST /api/pages/:id/storage — one call on the data a saved page keeps (what the page itself reads and writes with hydor.storage). Body: { op: get|set|remove|keys, key?: string — 1–200 characters, needed except for keys, value?: any JSON — for set; with its key ≤512 KB }. A page keeps ≤1,000 keys and ≤2 MB in all (413 past either). Returns: { value } (get: the value or null; keys: the key names; set and remove: null).
  • POST /api/pages — keeps a page in Pages (what Keep does for a page shown in the conversation), or makes a new page (without item: what New page does), or a new deck, document, spreadsheet or LaTeX document (with kind). Body: { title?: string — ≤120 characters (default "Page"), html: string — the whole page, non-empty, ≤2 MB, kind?: slides|doc|sheet|tex — instead of html, another kind, model?: object — its model (checked; the shorthand its save tool takes works too; left out: a new empty one), sessionKey?: string, item?: string — the show_html call's timeline item (if it was kept already, that page comes back instead), data?: { [key]: any JSON } — values to store with it, edited?: string — the user's changed HTML, kept on top as version 2 (**Keep with changes**; the session's agent hears of it), space?: string|null — the project it goes in (left out: the session's, when it is in one; null: none) }. 409 past 1,000 pages. Returns: { page: Page }.
  • POST /api/pages/:id/versions — saves the user's changed page as a new version (what Save does in each editor). Body: { html: string — the whole page, ≤2 MB (model?: object — another kind's model instead), title?: string, base?: number — the version that was changed, sessionKey?: string — the session it was changed beside, force?: boolean — save even when a newer version was saved since base }. The agents of sessionKey and of the session that made the page hear of the change with the user's next message. 409 "Version … was saved after the one you changed" when base is not the current version and force is not set; 400 when a page of HTML is sent a model or another kind HTML. Returns: { page: Page }.
  • POST /api/pages/import — makes a page of a file someone opens (what Open a Word or text file, Open an Excel or CSV file and Open a .tex or .zip file do): a .docx, .md, .txt or .html file becomes a document, an .xlsx, .ods, .csv or .tsv file a spreadsheet, a .tex file or a LaTeX project's .zip a LaTeX document. Body: { name: string — the file's name (its extension says what it is), data: string — its bytes as base64 (≤30 MB), title?: string — default: the name without its extension, sessionKey?: string, space?: string|null — the project it goes in (left out: the session's; null: none) }. 422 when the file cannot be read. Returns: { page: Page, notes: [string] — what could not come along (a picture of a kind LaTeX cannot use, say) }.
  • GET /api/pages/file?path= — a file of this computer as one of the editors' models, to change in place (what Files does when it opens a Word, Excel, CSV, TSV or LaTeX file). Query: path: string — absolute or from ~; a .docx, .xlsx, .csv, .tsv, .tex or .ltx file. Returns: { kind: doc|sheet|tex, path, mtime, model, loses: [string] — what of the file the editor does not keep, which saving over it loses (a Word file's headers, footnotes, comments, changes, equations, charts, diagrams, objects, boxes, columns, toc, layout; an Excel file's charts, merged, formulas, conditional, validation, comments, pivots, tables, links), cut?: true — it did not come in whole: to read only, notes: [string], tex?: { folder, main — its main file there, open — the model's name of the file asked for ("main.tex" for the main one), files: { [its path in the folder]: mtime }, inFolder: boolean — typeset among its folder's files } }. A LaTeX chapter comes as the document that reads it. 415 for another kind of file, or a .tex that is no whole document and that no document beside it reads; 422 when it cannot be read; 413 over 60 MB.
  • PUT /api/pages/file — writes an editor's model back to its file (Save in Files), in the file's own format: a CSV's rows and cells nobody changed keep their text, a LaTeX project writes only the files that changed and moves the ones the editor removed to the trash. Body: { path: string, model: object — as GET gave it, changed, mtime?: number — the file's time when it was read (409 when it changed since), force?: boolean — write over it anyway, backup?: boolean — put the file as it is in the trash first, copy?: boolean — write "name (edited).ext" beside it instead, tex?: object — a LaTeX file's tex as GET gave it, its files' times from the last save }. Returns: { path — where it was written, mtime, size, backup? — where the old file is in the trash, files? — a LaTeX project's times now, dropped: [string] — what a CSV could not keep: sheets, formulas (saved as their values), formatting }.
  • GET /api/pages/tex/setup — what this computer has for LaTeX. Returns: { engines: { pdflatex, xelatex, lualatex: boolean }, latexmk, bibtex, pictures — its pages can be drawn, sync — from a page to its source and back, sandbox — TeX runs shut in by bubblewrap: booleans, missing?: string — no TeX here: what to install, notes: { [engine]: why it cannot be used here } }.
  • POST /api/pages/tex/compile — typesets a LaTeX document, saved or not (what the LaTeX editor's preview does). Body: { model: object — the LaTeX document, base?: string — the key of the typesetting before, to build on, folder?: string — a file's document (opened from Files): typeset among the other files of its folder (pictures, styles, data), which TeX reads but cannot change; not the home folder or one holding it }. Returns: { key — its pages and PDF are at /api/pages/tex/<key>/…, ok — typeset with no errors, pdf: boolean — a PDF was made, pages, sizes?: [{ w, h }] — each page in points, errors: [{ file?, line?, message, hint? }], warnings: [the same], log — TeX's log, trimmed, engine, missing?: string — what to install, ms?, sandboxed? }.
  • GET /api/pages/tex/:key/sync — where a place on a typeset page comes from in the source. Query: page: number, x: number, y: number — in points from the page's top left. Returns: { file, line }, or {} when it is not known.
  • GET /api/pages/tex/:key/place — where a line of the source shows on the typeset pages. Query: file: string, line: number. Returns: { page, x, y, w, h } (points), or {} when it is not known.
  • POST /api/pages/tex/picture — keeps a picture for a LaTeX document (what adding one in its editor does); a PDF picture becomes a PNG of it. Body: { name: string — its file name, data: string — PNG, JPEG or PDF, base64 or a data: address (≤40 MB) }. Returns: { name — the name to use in \includegraphics, src: "naget-asset:<sha>" }; 415 when it is none of those, 422 when a PDF cannot be drawn.
  • POST /api/pages/assets — keeps a picture for slides or a document (what Picture does in the slides editor, and Insert in a document's). Body: { data?: string — a picture as a data: address (PNG, JPEG, GIF, WebP or SVG, ≤12 MB), url?: string — or a picture's public web address, fetched and kept }. Returns: { src: "naget-asset:<sha>" — the source to put in a deck's image part }; 415 when it is not a picture.
  • POST /api/pages/:id/copy — a copy of the page as it is now, as a new page "… (copy)", without its data. Returns: { page: Page }.
  • GET /api/pages/:id/data — everything the page keeps (its What it keeps view). Returns: { entries: [{ key, value — any JSON, bytes, updatedAt }] } (by key).
  • DELETE /api/pages/:id/data — forgets everything the page keeps; the page stays. Returns: { ok: true }.
  • GET /api/pages/:id/thumb — the page's picture in Pages (WebP), taken in the background when the page or its data changes; 404 until it is ready (Page.thumb is then set).
  • GET /api/pages/drafts?key= — the pages an agent of this session is writing right now (show_html, save_page, or an HTML file written whole), as far as they came. Query: key: string — the session's key. Returns: { drafts: [{ id — the tool call's id, tool: show_html|save_page|file, title?, path? — an HTML file's, pageId? — the saved page save_page changes, text — the HTML so far, done: boolean, startedAt }] } (a finished one stays two minutes).
  • POST /api/pages/file-edit — tells a session's agent that the user changed an HTML file in the page editor (the editor saves the file itself first). Body: { sessionKey: string, path: string — absolute, before: string, after: string — the file's text before and after }. The agent hears of it, with a diff, with the user's next message. Returns: { ok: true }.
  • PATCH /api/pages/:id — renames a page, and/or moves it to a project (neither is a new version). Body: { title?: string — ≤120 characters; empty becomes "Page", space?: string|null — a project's id, or null for none } (one of them). 400 for a project that is not one, or for an app's screen. Returns: { page: Page }.
  • POST /api/pages/:id/versions/:v/restore — brings an older version back as a new current version (its title and HTML); the stored data is not touched. Returns: { page: Page }; 404 when that version is no longer kept.
  • POST /api/pages/:id/link — a new own address for the page; the old one stops working at once, also on home screens it was added to. Returns: { link: "/pg/<token>" }.
  • DELETE /api/pages/:id — deletes the page with all its versions, its stored data and its published copies. Returns: { ok: true }.

Published sites#

Agents publish with their own publish_site tool (a built folder with an index.html, or a saved page by id, and a name): the user decides on its card and picks a private or a public link, and the agent then gets a message with the outcome and the link. Don't publish with POST /api/sites. Site: { id, name, visibility: private|public, kind: folder|page, folder?, pageId?, pageTitle?, path — "/site/<token or name>/", from the app's address, createdAt, sessionKey? }.

  • GET /api/sites — everything published, newest first. Returns: { sites: [Site] }.
  • GET /api/sites/requests/:id — a publish_site request and how the user decided. Returns: { request: { id, sessionKey, name, kind: folder|page, folder?, pageId?, pageTitle?, files, bytes, status: open|published|declined|failed, site?: Site, error?, createdAt, decidedAt? } }; 404 when unknown.
  • DELETE /api/sites/:id — unpublishes a site: its link stops working at once (publishing again makes a new private link). Returns: { ok: true }.

Scheduled tasks: read#

Agents in a conversation also have their own tools for this: schedule_task and update_task propose a task or a change on a card the user confirms, and list_tasks reads the tasks.

  • GET /api/tasks — every scheduled task, oldest first. Returns: { tasks: [{ id, name, prompt, schedule, agent, acpAgent?, cwd, model?, effort?, mode?, extra?, target?, enabled, nextAt?, lastAt?, lastStatus? ("ok" or why the last run could not start), lastKey? (the last run's session), hookPath? ("/hooks/<secret>", webhook tasks), failLimit, fails?, pausedReason?, recent?: [{ status, at }] (latest 6, newest first), check?: { at, ok, note } (a watcher's last look), origin?, app?: { id, inputs } (it runs this app), appName? (that app's name; unset when it is gone), createdAt }] }.
  • GET /api/automations/tasks/:id/runs — a task's runs, newest first (the latest 50; 100 are kept). Returns: { runs: [{ id, taskId, trigger: schedule|webhook|watch|manual|rerun|test, startedAt, endedAt?, status: running|done|partial|blocked|failed|silent|error, summary?, reply? (the agent's last reply, ≤20,000 chars), costUsd?, key? (its session), error?, rerunOf?, hasInput, appRun? (the app run it started: /apps/runs/<id>) }] }.
  • GET /api/automations/runs/:id — one run, to follow a test run or a re-run until it ends. Returns: { run } (fields as above); 404 when there is none.
  • GET /api/automations/tasks/:id/notes — the notepad the task's runs keep. Returns: { text }.
  • POST /api/automations/next — a schedule in words and its next runs in the user's time zone (saves nothing). Body: { schedule: object — as in POST /api/tasks }. Returns: { tz, text, next: number[] (up to 3, ms) }; 400 with the reason for a schedule that does not work.
  • POST /api/automations/watch/test — what a watcher sees now, as its Test button shows (saves nothing). Body: { watch: object — as schedule.watch in POST /api/tasks, cwd?: string — the folder a command runs in }. Returns: { ok, note, sample? }. A command watcher runs its command on this machine: only with the user's clear OK.

Scheduled tasks: changes#

  • POST /api/tasks — create a task, or replace one when id is given. Body: { id?: string — the task to replace; name: string ≤120; prompt: string ≤50,000 (a webhook task's may hold {{body.path}}, {{headers.name}}, {{query.name}}, {{body}}, {{request}}); agent: "claude"|"codex"|"acp"; acpAgent?: string — for acp, which agent ("console" is Custom); cwd: string — an existing folder, ~ allowed; model?: string; effort?: string; mode?: string — Claude Code default|acceptEdits|auto|plan|bypassPermissions, Codex plan|read-only|auto|auto-review, Custom ask|edits|auto|full|plan; schedule: { kind: "daily", time: "HH:MM", days?: number[] 0 Sun–6 Sat, omit for every day } | { kind: "every", minutes: ≥5 } | { kind: "once", at: ms } | { kind: "cron", expr: five fields or @hourly/@daily/@weekly/@monthly/@yearly, ≤120 chars } | { kind: "webhook", secret?: signing secret ≤300 chars, header?: the signature header, filter?: { path: string — "body.action", "headers.x-github-event", "query.kind", value: string, skip?: true — skip the run instead of running only then } } | { kind: "watch", minutes: 1–10080, watch: { kind: "feed", url } | { kind: "page", url, match?: text the watched lines contain, selector?: simple CSS selector } | { kind: "json", url, path: "data.price", op?: changed|eq|ne|gt|lt|contains, value?: needed unless changed } | { kind: "folder", path: absolute or ~/…, pattern?: "*.pdf" } | { kind: "command", command, on?: output|exit } (feed, page and json take local?: true to reach this computer's network: only when the user asks) }; target?: string — a session key: each run posts into it instead of starting a new session; extra?: { assistant?: id, memory?: false, capUsd?: number — spending cap per run, space?: id, swarm?, research?, connections? } — what each run's new session starts with (≤8,000 chars as JSON); failLimit?: 0–50 — failures in a row before it pauses (default 3; 0 never); enabled?: boolean — default true; origin?: string — kept only on create; app?: { id: string — an app's id or address, inputs: { [fieldId]: string } — its form's answers, which may hold a webhook's {{body.path}}, {{headers.name}}, {{query.name}} (as they are, no tags) and a watcher's {{change}} } — each run runs that app instead of starting a session (then prompt may be empty, and agent, cwd, model, target and extra go unused); 400 when there is no such app }. Replaces the whole task: a field left out is cleared and enabled becomes true, so send the task as GET /api/tasks gives it, with your changes. To pause or turn one on, send it with enabled false or true (turning on clears its failure count and pause reason). A webhook task keeps its address. Returns: { task }.
  • POST /api/tasks/:id/run — Run now: a run at once, even while paused. Returns: { key } (the session it started or posted into; none for a task that runs an app); 409 while it is starting already.
  • POST /api/automations/test-run — run a task once as given, without saving, in a session titled "Test: …"; never counts toward failures. Body: { task: object — the fields of POST /api/tasks; with id, it tests changes to that saved task (its notes, its history) }. Returns: { runId, key }.
  • POST /api/automations/runs/:id/rerun — run a task again with an earlier run's input (the same webhook call or watcher change). Returns: { runId, key }; 400 when that run's input was not kept.
  • PUT /api/automations/tasks/:id/notes — replace the task's notepad. Body: { text: string — ≤8,000 chars; past that the oldest lines go }. Returns: { text } (as saved).
  • POST /api/tasks/:id/rotate — a new webhook address; the old one stops working. Returns: { task } (new hookPath); 400 for a task that is not a webhook.
  • DELETE /api/tasks/:id — delete a task, its run history and notepad (the sessions it started stay). Returns: { ok: true }.
  • POST /api/automations/draft — "Make this an automation": the helper model drafts a task from a session; nothing is saved (save it with POST /api/tasks). Body: { key: string — the session }. Returns: { draft: { name, prompt, schedule, agent, acpAgent?, cwd, model?, effort?, mode?, extra?, origin: "session" }, why?: string }; can take up to two minutes; 400 for a session with no messages from the user.

Connections (connected accounts)#

  • GET /api/connections/catalog — the marketplace: every service that can be connected. Returns: { apps: [{ id, name, description, category, icon, color?, homepage, docs?, server: { transport: 'http'|'sse', url } | { transport: 'stdio', command, args?, env? }, auth: { type: 'none'|'oauth'|'api_key', … }, fields?: [{ name, label, secret?, optional?, placeholder?, default?, help?, url? }], keywords?, featured?, verified: { by, date } | null, notes? }], categories: { [id]: label } }.
  • GET /api/connections — the connected accounts and the vault's keys (names and masked hints, never values). Returns: { connections: [{ id, name, app — its catalog entry, enabled — on for every session, status: 'ok'|'untested'|'signin'|'error', error?, hint?, fields — non-secret values as entered, secretFields — names of secret fields that are set, tools?: [{ name, description?, on }] — from the last test, testedAt?, expiresAt?, catalogChanged?, createdAt, updatedAt }], secrets: [{ name, hint, domains, commands? — true when commands on this computer may use it, note, header?, prefix?, query?, createdAt, updatedAt, usedAt? }] }.
  • GET /api/connections/session/:key — the connected accounts as one session sees them. Returns: { apps: [{ id, name, on — on in this session, global — its every-session setting, remote — false when it runs on this computer }] }.
  • POST /api/connections/:id — connects a marketplace service that needs no browser sign-in, or changes its settings, then tests it in the background. Agents may use it only for services whose auth.type is none and that have no secret field. Body: { fields?: { NAME: string } — the service's fields by name (≤ 4000 chars each; needed unless optional or already saved; a remote service's address with fields filled in must be https), test?: boolean — also test a local service in the background (remote ones always are) }. The body can also carry key, but that is for the user only. Returns: { connection }. Returns 400 for a service with a browser sign-in or a missing field, and 404 if the id is not in the catalog.
  • POST /api/connections/:id/test — connects the way agents will and lists the tools; the outcome becomes the account's status. Returns: { ok: true, tools: number, connection } or { ok: false, error, connection }. Returns 404 if the service is not connected.
  • PATCH /api/connections/:id — turns the account on or off for every session, and turns single tools on or off. It merges: fields you leave out stay as they are, and tools is merged into the earlier per-tool choices. Body: { enabled?: boolean — on for every session, tools?: { [toolName]: boolean } — names as in connection.tools }. Returns: { connection }.
  • PUT /api/connections/session/:key — turns one connected account on or off for one session. Body: { id: string — a connected account's id, on?: boolean — on or off here; the same value as its every-session setting, or null/omitted, clears the session's own choice }. Returns: { apps } (as the GET). Returns 404 if the service is not connected.
  • DELETE /api/connections/:id — disconnects: deletes the account's sign-in or key here and revokes OAuth tokens where the service allows it. Returns: { ok: true }.

Keys (the vault)#

  • GET /api/connections/requests — the latest key or sign-in request in a session, and the saved key with that name. Query: { key: string — session key, name: string — the key's NAME, or what a sign-in's keys start with }. Returns: { request: { id, key, name, why, domains, fields?: [{ key — the key it is saved as, label, secret — false for a detail the agent gets as typed, type: 'password'|'text'|'email'|'url'|'number', help?, optional? }] — a sign-in's fields, have? — a sign-in's keys already saved: { KEY: hint }, commands? — the agent asked to use it in commands, commandsOn? — a key asked for may already go to commands, state: 'pending'|'saved'|'dismissed', createdAt, doneAt?, exists, hint? } | null, secret: { name, hint, domains, commands?, note, header?, prefix?, query?, createdAt, updatedAt, usedAt? } | null }.
  • DELETE /api/connections/secrets/:name — deletes a key for good (exact NAME). Returns: { ok: true }.
  • Agents ask for a key with their own request_secret tool: the user pastes it into a card. They use a key with http_request (auth: NAME). They never set or see a value.

MCP servers (Claude Code and Codex)#

  • GET /api/setup/mcp — Claude Code's servers (all folders, this folder only, and the folder's .mcp.json) and Codex's servers. Query: { cwd?: string — an existing folder, default home }. Env and header values are never returned, only their names. Returns: { servers: [{ agent: 'claude'|'codex', name, scope: 'user'|'local'|'project', transport: 'stdio'|'http'|'sse', command?, args?, url?, envKeys?, headerKeys?, bearerEnv?, enabled? — Codex only }] }.
  • POST /api/setup/mcp/status — starts each of one agent's servers the way a new session would. No model is used; it takes up to about 45 s for Claude Code and about 2 min for Codex. Body: { agent: 'claude'|'codex' — anything else counts as claude, cwd?: string — the folder (Claude Code) }. Returns: { servers: [{ name, state: 'connected'|'failed'|'auth'|'pending'|'off', error?, tools?: number }] }.
  • POST /api/setup/mcp — adds a server to one or both agents through their own CLIs. Body: { agents: ('claude'|'codex')[], name: string — letters, digits, . _ -, starting with a letter or digit, ≤ 64, transport: 'stdio'|'http'|'sse' — sse is Claude Code only, command?: string — stdio: the program alone, args?: string[] — stdio, env?: { KEY: value } — stdio, url?: string — http(s), headers?: { Name: value } — Claude Code only, bearerEnv?: string — Codex only: the env var holding the bearer token, scope?: 'user'|'local'|'project' — Claude Code only, default user, cwd?: string — the folder for local/project, default home }. Leave keys and tokens in env or headers to the user. Returns: { results: [{ agent, ok, output }] }; retry only the agents that failed.
  • POST /api/setup/mcp/remove — removes a server from one agent. Body: { agent: 'claude'|'codex', name: string, scope?: 'user'|'local'|'project' — Claude Code, as listed, default user, cwd?: string }. Returns: { ok, output }.

Plugins (for every agent)#

Settings → Agents → Plugins. A plugin is a folder in the Agent Plugins 1.0 format (plugin.json, skills/, mcp.json; Claude Code's layout is read too) with the app's own extension com.naget.agent for what it needs. Every agent the app starts gets the enabled ones whose setup is done. :name is a plugin's name (lowercase letters, digits, dashes and dots). A plugin view (plugin below) is { name, title, version?, description?, author?, homepage?, repository?, license?, keywords: string[], category?, icon?, format: 'agent-plugins'|'claude', source: { kind: 'builtin' } | { kind: 'git', url, ref?, subdir?, commit? } | { kind: 'file', file } | { kind: 'folder', path, mine?: true (made in the app's editor) }, installed: boolean, enabled: boolean, setup: { state: 'none'|'installing'|'ready'|'failed'|'outdated', error?, at? }, skills: [{ name, description, category?, shadowedBy? (the skill agents get in its place) }], servers: [{ name, type: 'stdio'|'streamable-http'|'sse', command?, args?, url?, problem? }], instructions: boolean, files: [{ name, bytes }] (its own files), editable: boolean, claudeOnly: string[] (Claude Code parts left out), requires: { plugins: [{ name, range }], npm: { name: version }, pip: string[], python?, commands: [{ name, hint?, optional? }], files: [{ url, path, sha256?, size? }], keys: [{ name, description?, optional?, url? }] }, missing: { commands, keys, plugins } (what is not here), dependents: string[] (installed plugins that need it), agents: ('claude'|'codex'|'custom'|'acp')[], problems: string[], root (its folder), data (its data folder), installedAt?, readme? (details only) }. A job (adding, setting up, updating) is { id, plugin, action: 'install'|'setup'|'update', state: 'running'|'done'|'failed', steps: [{ label, state: 'waiting'|'running'|'done'|'failed' }], log (the output's end), error?, startedAt, endedAt? }; the pages also get it as it runs.

  • GET /api/plugins — every plugin: installed and built in. Returns: { installed: [plugin], available: [plugin] (built-in ones not added yet), jobs: [job] (running, or ended in the last minute), folders: { installed, data, mine } }.
  • GET /api/plugins/:name — one plugin, installed or built in, with its README. Returns: plugin. 404 when there is none.
  • GET /api/plugins/jobs/:id — a job, while it runs and for 15 minutes after. Returns: { job }. 404 after that.
  • POST /api/plugins/inspect — looks at a plugin before adding it, from a Git repository, a .zip or a folder on this computer; nothing is installed. Body: { url: string (owner/repo, https://…, a GitHub link to a folder …/tree/<branch>/<folder>, or git@host:path), ref?: string (a branch or tag), subdir?: string (a folder inside the repository) }, or { file: { name, data: base64 } } (a .zip, up to 160 MB, its plugin.json at the top or in its one folder), or { path: string (a folder; ~ works) }. Returns: { token (for install, valid 30 minutes), view: plugin, brings: string[] (built-in plugins it needs that are added first), unavailable: string[] (plugins it needs that nobody has: it cannot be added), replaces?: string (a plugin of its name is installed) }. 400 with the reason when it is not a plugin, its manifest is invalid, or Git fails.
  • POST /api/plugins/install — adds a plugin, with the built-in plugins it needs first, sets each up (packages, downloads) and turns them on. Body: { name: string } (a built-in one) or { token: string } (one looked at). A plugin from a folder is used in place; from Git or a .zip it is copied into the app's data folder. Returns: { job }. 400 when it needs plugins nobody has; 409 when a git or file plugin takes a built-in one's name.
  • POST /api/plugins/:name/enable — turns it on, with the plugins it needs. No body. Returns: { on: string[] (every plugin turned on) }.
  • POST /api/plugins/:name/disable — turns it off, with the plugins that need it. No body. Returns: { off: string[] }.
  • POST /api/plugins/:name/setup — sets it up again (its packages and downloads, from scratch). No body. Returns: { job }. 409 while it is being set up.
  • POST /api/plugins/:name/update — a Git plugin: gets the latest from its repository; any plugin: sets it up when its needs changed. No body. Returns: { job }. 400 for one from a .zip (add the newer file instead).
  • POST /api/plugins/:name/remove — removes it and the plugins that need it, their packages and downloads (unless keepData); one made in the editor is deleted with its folder, one used from a folder of the user's stays there. Body: { keepData?: boolean }. Returns: { removed: string[] }.
  • POST /api/plugins/new — makes a plugin of the user's own, in the app's data folder (plugins/mine/), added and on. Body: { name: string (lowercase letters, digits, dashes and dots), title?: string, description?: string }. Returns: { plugin }. 409 when a plugin of that name is here.
  • GET /api/plugins/:name/draft — a plugin as its editor shows it (only ones made here or used from a folder, in the Agent Plugins layout). Returns: { name, title, version, description, category?, icon?, keywords, instructions (its com.naget.agent/instructions.md), skills: [{ name, text (the whole SKILL.md) }], servers: [{ name, type: 'stdio', command, args, env } | { name, type: 'streamable-http', url, headers }], requires (as in the plugin view), files: [{ name, bytes }] }. 400 for a plugin that cannot be changed here.
  • PUT /api/plugins/:name/draft — saves it from the editor: plugin.json, mcp.json, its skills (ones left out are deleted) and its instructions. Body: the draft's fields (title, version (three numbers), description, category?, icon?, keywords, instructions, skills, servers, requires: { plugins: { name: range } | ['name@range'], npm: { name: version }, pip: string[], python?, commands: (string | { name, hint?, optional? })[], files: [{ url (https), path, sha256? }], keys: (string | { name, description?, optional?, url? })[] }). Everything is checked first; 400 lists what to fix. Returns: { plugin, draft, job? (when its packages changed and it is on: set up again) }.
  • POST /api/plugins/:name/files — adds (or replaces) one of its own files, in its files/ folder. Body: { name: string (letters, digits, spaces, . _ - ( ) + , and / for folders), data: base64 } (up to 50 MB; 200 MB together). Returns: { files: [{ name, bytes }] }.
  • DELETE /api/plugins/:name/files?name= — removes one of its own files. Returns: { files }.
  • GET /api/plugins/:name/export — the plugin as a .zip (its folder under /, without setup or Git leftovers), to share; any plugin, installed or built in. Returns the file (application/zip).

Plugins (Claude Code's own)#

  • GET /api/setup/plugins — installed plugins, the ones available to install, and the marketplaces. The list is cached for 10 minutes; a fresh fetch can take up to 3 minutes. Query: { q?: string — all words must match name, description or marketplace, limit?: number — available ones to return, 1–200, default 60 }. Returns: { installed: [{ id — name@marketplace, name, description?, marketplace?, version?, scope?, enabled?, installed: true }], available: [{ id, name, description?, marketplace?, installCount?, version?, installed: false }] — most installed first, marketplaces: [{ name, source?, repo?, url? }], total — number of matching available plugins }.
  • POST /api/setup/plugins/:action — :action is install, uninstall, enable or disable. Body: { id: string — as listed, scope?: 'user'|'project'|'local' — install defaults to user }. The last three return 400 if the plugin is not installed. Returns: { ok, output }.
  • POST /api/setup/marketplaces/:action — :action is add or remove. Body: { target: string — add: a GitHub owner/repo, a git URL or a folder path; remove: the marketplace's name }. Removing a marketplace also uninstalls the plugins installed from it. Returns: { ok, output }.

Plugins (Codex's own)#

  • GET /api/setup/codex-plugins — Codex's added plugins, those its marketplaces offer (only ones with a readable name), and its marketplaces. Cached for 10 minutes; a fresh fetch can take up to 3 minutes. Query: { q?: string — all words must match, limit?: number — 1–200, default 60 }. Returns: { installed: [{ id — name@marketplace, name, marketplace?, version?, enabled?, installed: true }], available: [{ id, name, marketplace?, version?, installed: false }], marketplaces: [{ name, source? (its folder) }], total }.
  • POST /api/setup/codex-plugins/:action — :action is add or remove. Body: { id: string — as listed }. Returns: { ok, output }.
  • POST /api/setup/codex-marketplaces/:action — :action is add, remove or upgrade (refresh a Git marketplace). Body: { target: string — add: a GitHub owner/repo, a git URL or a folder; remove and upgrade: the marketplace's name }. Returns: { ok, output }.

Notifications#

  • POST /api/push/test — sends a test notification ("Notifications work on this device.") to every device that turned on Notify this device; it shows on the devices where the app is not in view. No body. Returns: { sent: number — how many devices it reached } (0: no device has notifications on).

Agents and their programs#

  • GET /api/clients — the agents' programs on this computer, and keeping them up to date. Returns: { clients: [{ id: "claude"|"codex"|"gemini"|"opencode"|"qwen"|"auggie", name, source: "yours"|"app"|"bundled"|"missing", version? — the one the sessions run, path?, installing?, latest? — a newer version out, failed? — a newer version that did not work here (the sessions keep version), yours?: { version?, path } — your own copy when the sessions run another (Codex: the one your terminal runs) }], updates: { auto: boolean — Update automatically, checkedAt? — when it last looked, error? — why that lookup failed, running?: "check"|"update", current? — the program it is updating, last?: { at, auto: boolean — it ran by itself, items: [{ id, name, yours?: boolean, from?, to?, ok: boolean, error?, kept? — the version kept in use because to did not work }] } } }.
  • POST /api/clients/check — Check now: looks up the newest version of each program, in the background (seconds); updates.running is "check" until it is done. Body: none. Returns: { clients, updates } as GET; 409 while a check or an update runs.
  • POST /api/clients/update — Update now: looks up the newest versions and updates every program that is behind: the app's copies with npm, your own with their own updater (claude update, codex update, opencode upgrade; npm for those installed with npm install -g). Each new version is checked to work the way the sessions use it, and undone when it does not. Runs in the background (it can take minutes): GET until updates.running is gone, then updates.last says what changed. Body: none. Returns: { clients, updates } as GET; 409 while a check or an update runs.
  • PUT /api/clients/auto — Update automatically (on by default: once a day; off: the daily run only looks). Body: { on: boolean }. Returns: { clients, updates } as GET.
  • POST /api/clients/:id/install — installs (or updates) a program with npm into the app's own folder; waits until done (up to 15 min). :id as above; 400 for others. Body: none. Returns: { clients, updates } as GET.

Accounts (Claude Code and Codex)#

  • GET /api/accounts — sign-in state of each CLI's own account and of every added account. Returns: { claude: AccountStatus, codex: AccountStatus, extra: [{ id, agent: "claude"|"codex", name, status: AccountStatus }] }, where AccountStatus is { agent, loggedIn: boolean, email?, plan?, method?, detail?, error? }.
  • POST /api/agent-accounts — adds another account for an agent (its own folder; settings, instructions, skills and plugins shared). It then needs signing in, which only the user can do (Settings → Accounts). Body: { agent: "claude"|"codex", name: string — 1–40 chars, unique per agent (409 if taken) }. Returns: { id, agent, name }.
  • PATCH /api/agent-accounts/:id — renames an added account. Body: { name: string — 1–40 chars }. Returns: { ok: true }.
  • DELETE /api/agent-accounts/:id — removes an added account; its sessions leave the list. Query: wipe?: "1" — also delete its sign-in and its sessions' history (cannot be undone). 409 while sessions run as it. Returns: { ok: true }.

More agents (ACP)#

  • GET /api/acp-agents — the agents in Settings → More agents. Returns: { agents: [{ id, name, command, args: string[], env?, hint?, builtin?, installed: boolean }], presets: [same, for known agents not added yet: gemini, opencode, qwen, goose, kimi, auggie] }. Custom comes first (id: "console", builtin: true).
  • POST /api/acp-agents — adds an agent, or replaces one with the same id. To add a preset, send its id, name, command and args from presets. The program installs in the background when the app knows its npm package (Gemini CLI, OpenCode, Qwen Code, Auggie). Body: { id?: string — [a-z0-9-]{1,40}, an existing id replaces that agent (new id made from the name when absent); name: string — required, cut to 40 chars; command: string — required, a command or full path; args?: string[]; env?: { NAME: value } — names must match [A-Za-z_][A-Za-z0-9_]* }. 400 for id "console". Returns: { id, name, command, args, env }.
  • DELETE /api/acp-agents/:id — removes an added agent (its sessions stay listed). 400 for "console". Returns: { ok: true }.

Models for Custom (Settings → Models)#

  • GET /api/models — everything on the Models page. Returns: { openrouter: { keyHint?, pinned: [OpenRouter model] }, ollama: { url, version? (absent: not running) }, vllm: { command?, path?, version? }, endpoints: [{ id, name, baseUrl, keyHint?, models: string[] }], hfTokenHint?, local: [{ id: "ollama/<name>"|"vllm/<org/name>", repo, runtime: "ollama"|"vllm", sizeBytes, detail?, vision?, supported?: boolean|null, reason?, server?: { state: "starting"|"ready"|"failed", gpu (-1 on a Mac), gpus? (every GPU it is split across, when more than one), port, error? } }], downloads: [{ key, name, target: "ollama"|"vllm", doneBytes, totalBytes, status: "running"|"done"|"failed"|"cancelled", error? }], machine: { gpus: [{ index, name, totalBytes, freeBytes }] (NVIDIA), ramBytes, ramFreeBytes, diskFreeBytes, cpus, apple?: { chip } (a Mac with Apple silicon, whose graphics share ramBytes) }, storage: { usedBytes, freeBytes } }.
  • GET /api/models/openrouter/picks — the OpenRouter models suggested for Custom before any search, in order, each with why: GPT-6 Luna (fast and cheap), GLM 5.3 (good, with fewer refusals), GPT-6.1 Sol (better), Claude Opus 5.5 (best overall); those OpenRouter no longer lists are left out. Returns: { models: [{ …the fields of GET /api/models/openrouter, note: string — why this one }] }. Pin one with PUT /api/models { pin }.
  • GET /api/models/openrouter — searches OpenRouter's catalog (kept an hour; works without a key, but pinned models only reach Custom once a key is set). Query: q?: string — words, all must match name, id or maker; sort?: "match"|"new"|"price"|"context" (default match with words, else new); f?: comma list of "tools","vision","reasoning","free". Returns: { models: [{ id: "openrouter/<slug>", label, org?, detail (slug), contextLength?, maxOutput?, price?: { input, output } ($/M tokens), vision?, reasoning?, tools: "native"|"text", created?, description? }] } (up to 50).
  • GET /api/models/hf — searches Hugging Face for text models, each with a first guess of how it runs here (kept 10 min). Query: q?: string — name words or org/name; sort?: "downloads"|"trending"|"likes"|"new" (default downloads); f?: comma list of "fits" (runs well here), "gguf" (Ollama), "safetensors" (vLLM), "vision". Returns: { models: [{ id: "org/name", kind: "gguf"|"safetensors"|"other", params?, downloads? (30 days), likes?, updated?, created?, vision?, gated?, fit?: ModelFit, fitGguf?: ModelFit (safetensors at 4-bit with Ollama) }] } (up to 40). ModelFit: { verdict: "good"|"slow"|"tight"|"no", where: "gpu"|"cpu"|"none" ("gpu" is an NVIDIA card or a Mac's Apple silicon), gpu?, gpus?: number[] (split across these GPUs, for vLLM, when no one has room), fp8?: true (a 16-bit model vLLM stores at 8-bit as it loads, when it does not fit as it is), needBytes, tokensPerSec?, downloadBytes, notes: string[] }.
  • GET /api/models/picks — the models the app suggests to run on this computer (Settings → Models, the welcome tour), smallest first, each as it would be here: LFM3-VL 2B, Gemma 4 12B, Flint (Macs with Apple silicon) and GLM-5.3 Flash (servers with several large GPUs). They run with vLLM by default, split across GPUs when needed; a computer with neither NVIDIA graphics nor Apple silicon gets a model's build for Ollama. Returns: { picks: [{ repo, name, maker, blurb, vision?, params (billions), active? (billions read per word), mac?, install?: { repo } | { ollama } (the body for POST /api/models/install; absent: it cannot run here), fit: ModelFit, runs: boolean (it runs here: suggested), best?: true (the strongest that runs well), sizeBytes (the download), have?: string (its local[].id, when installed), coming?: string (its download's key, while downloading), needs?: string (what this computer lacks, when it does not run here), ready: boolean (what runs it is set up: vLLM installed and able to run it, or Ollama running) }] }.
  • POST /api/models/inspect — "Check": exactly how a Hugging Face model would install and run here, before downloading. Body: { input: string — org/name or a huggingface.co / hf.co address }. Returns: { repo, kind: "gguf"|"safetensors"|"other", params?, contextLength?, vision?, arch?, gated?, quants: [{ tag, sizeBytes, ollamaName: "hf.co/<org/name>:<tag>", fit: ModelFit }] (GGUF, smallest first), downloadBytes, vllm?: { supported: boolean|null (null: vLLM not installed), reason?, fit: ModelFit }, ggufRepo?: string (a GGUF version to check instead) }.
  • PUT /api/models — changes Models settings; merges (only the fields sent change). Body: { pin?: string — an OpenRouter model id (with or without "openrouter/") to add to the pickers; unpin?: string — take one out; ollamaUrl?: string — Ollama's http(s) address (default http://127.0.0.1:11434); vllmCommand?: string|null — the vllm command's path (null or "": find it by itself); endpoint?: { id?: string, name: string, baseUrl: string — http(s)…/v1, apiKey?: string, remove?: boolean } — adds or replaces an OpenAI-compatible endpoint (id made from the name; same name replaces), or with { id, remove: true } removes it; apiKey left out keeps the old key; openrouterKey?: string|null; hfToken?: string|null }. openrouterKey, hfToken and endpoint.apiKey are key values: only the user types them, in Settings; agents leave them out (null removes a key, which the user should decide). Returns: the same object as GET /api/models.
  • POST /api/models/install — starts a download in the background; progress shows in downloads. Send one of the two. Body: { ollama?: string — for GGUF: a quant's ollamaName from inspect (or any Ollama model name); Ollama must be running (400); repo?: string — for vLLM: org/name of a safetensors model (400 if it has none) }. 409 if it is downloading already. Returns: the models state.
  • POST /api/models/cancel — cancels a running download, or clears a finished, failed or cancelled one from the list. Body: { key: string — the download's key, "ollama:<name>" or "vllm:<org/name>" }. Returns: the models state.
  • POST /api/models/remove — deletes a model from this computer (a running vLLM server stops first). Body: { id: string — "ollama/<name>" or "vllm/<org/name>" from local[].id }. Returns: the models state.
  • POST /api/models/start — starts a downloaded vLLM model's server (sessions also start it by themselves). Body: { repo: string — org/name; gpu?: number — GPU index (default: the one with the most free memory); maxLen?: number — context length (default 32768, at most the model's own) }. 400 when vLLM, an NVIDIA GPU or enough GPU memory is missing. A 16-bit model too big for one GPU starts there at 8-bit (FP8, on cards from the RTX 30 series and A100 on), else split across 2, 4 or 8 (unless gpu is given); on a Mac, vLLM runs on its Apple silicon. Returns: the models state (server.state "starting" until ready).
  • POST /api/models/stop — stops a vLLM model's server, freeing its GPU. Body: { repo: string — org/name }. Returns: the models state.

Helper model#

  • GET /api/helper — the model for small jobs beside sessions (Auto mode checks, goal checks, memory tidy-up, reading a page for one question, describing images for Custom). Returns: { model: "auto"|<Custom model id>, options: [{ value, label, detail?, vision?: boolean }] } ("auto" = Claude Haiku through Claude Code, else Codex; it cannot see images).
  • PUT /api/helper — picks the helper model. Body: { model: "auto" | one of options[].value } (400 for an unknown model). Returns: as GET.

Custom (Settings → Capabilities → Custom)#

  • GET /api/custom/settings — Custom's settings. Returns: { sandbox: boolean — commands in Auto and Full access run in the sandbox (default true); goalRounds: number — next steps a goal sends before it pauses (default 30); bwrap: boolean — the sandbox can run on this computer }.
  • PUT /api/custom/settings — changes them; merges. Body: { sandbox?: boolean; goalRounds?: integer 1–500 (the UI offers 10, 30, 100) }. Returns: as GET.

Usage and limits#

  • GET /api/usage — each plan's limits: the CLIs' own sign-ins and every added account. Read fresh when older than 2 minutes. Query: refresh?: "1" — read now. Returns: { claude: AgentUsage, codex: AgentUsage, accounts: { <accountId>: AgentUsage } }. AgentUsage: { agent, ok, error?, fetchedAt, account?: { email?, plan? }, windows: [{ id, label, usedPercent: number|null, resetsAt?: number|null }], limited?: { label, resetsAt? } | null (set while used up), resetCredits?: { available, items: [{ id, title, left?, expiresAt?, usableNow: boolean, note? }], nextAvailableAt?, cooldownUntil? }, extra?: { enabled, monthlyLimit?, used?, currency? }, credits?: { balance?, unlimited? }, breakdown?: [{ label, percent }], daily?: [{ date, tokens }], lifetimeTokens? }.
  • POST /api/usage/:agent/reset — spends one limit reset to clear the plan's limits now (cannot be undone; confirm with the user first). :agent = claude | codex. Body: { id?: string — a reset's id from resetCredits.items (required for claude; for codex, absent lets the provider pick); account?: string — an added account's id (absent: the CLI's own sign-in) }. Returns: { ok: boolean, outcome: "reset"|"nothing_to_reset"|"no_credit"|"already_used"|"cooldown"|"unavailable"|"ineligible"|"error", message: string, usage: AgentUsage }.

Activity, prices and keeping sessions#

  • GET /api/stats — activity over time (the Activity tab). Query: granularity?: "day"|"week"|"month"|"year" (default day); agent?: "all"|"claude"|"codex"|"acp"|"acp:<agent id>" — one ACP agent, "acp:console" is Custom (default all); model?: string — one model alone, as byModel names it; tz?: string — IANA time zone for the buckets (default UTC); count?: number — buckets, 1–400 (default 30, 26, 12, 10). Returns: { granularity, tz, agent, model?, buckets: [{ key, label, start, sessions, prompts, turns, toolCalls, commands, fileEdits, linesAdded, linesRemoved, tokensIn, tokensCached, tokensOut, genMs, cost? }], totals (same fields), previous (the same span just before), byAgent: { claude, codex, acp }, agents: [{ id: "claude"|"codex"|"acp:<id>", name: "Claude Code"|"Codex"|"Custom"|…, …totals }] (each agent with activity in the window), projects: [{ cwd, …totals }] (top 12 by lines changed), byModel: [{ model, sessions, prompts, tokensIn, tokensCached, tokensOut, cost: number|null (null: no known price), price: { input, output, cacheRead?, source: "custom"|"table" } | null }], firstAt?, firstByAgent? }. cost is the estimate at API prices. Sessions deleted as inactive keep their numbers here.
  • GET /api/prices — your own model prices (not the public table's). Returns: { prices: { <model id>: { input, output, cacheRead? } } }.
  • PUT /api/prices — saves your model prices. REPLACES the whole set: a model left out goes back to the public table, so GET first and send the merged map. Body: { prices: { <model id as Activity names it>: { input: number ≥ 0; output: number ≥ 0; cacheRead?: number ≥ 0 — cached input, defaults to input } } } (entries missing input or output are dropped). Returns: { prices }.
  • GET /api/retention — Settings → General → Delete inactive sessions. Returns: { on: boolean, days: number } (on, 365 at first).
  • PUT /api/retention — changes it. Body: { on?: boolean, days?: number — how long a session may stay inactive, 30 to 3650 }. Returns: { on, days }. Once a day the app deletes up to 200 sessions inactive for longer (never pinned ones, nor ones at work, waiting, queued, snoozed, due to resume, running elsewhere or with an open pull request); their numbers stay in Activity.
  • GET /api/claude-retention — whether Claude Code keeps its conversations (it deletes them after 30 days by default; the app tells every Claude Code config that says nothing to keep them, and keeps its own link to every conversation anyway). Returns: { days: number, keep: boolean }.
  • PUT /api/claude-retention — keep: writes cleanupPeriodDays 36500 to Claude Code's settings.json; not keep: removes it (back to 30 days). Body: { keep: boolean }. Returns: { days, keep }.

Machines (Settings → App → Machines)#

  • GET /api/machines — this machine and the other machines this app uses. Returns: { self: { host, version, home, platform, user }, machines: [{ id, name, via: 'ssh'|'url', url?, ssh?, port?, status: { state: 'connecting'|'online'|'offline', host?, version?, error?, waiting: number — sessions there that need the user, working: number, since? } }] }. A machine's pages are /m/<id>/….
  • GET /api/machine — this machine as another Hydor sees it. Returns: { host, version, home, platform, user }.
  • POST /api/machines — adds a machine; it must answer before it is kept (400 "Could not use …: …" otherwise). Body: { name: string — required, up to 40 chars; via?: 'ssh'|'url' — anything but 'ssh' means 'url'; ssh?: string — with via 'ssh': a host from ~/.ssh/config or user@host (letters, digits, _ . @ % : [ ] -, not starting with -); port?: number — with via 'ssh': 1–65535, the port Hydor listens on there, default 8790; url?: string — with via 'url': http:// or https:// address, no user or password in it; token: string — required, the access token made on that machine (starts with acm_) }. Returns: { machine } (as in the list). The token is a secret the user makes on the other machine: let the user add machines in Settings → Machines rather than asking for the token in chat.
  • PATCH /api/machines/:id — changes a machine; merges (fields left out keep their value, an empty or missing token keeps the current one). Body: the same fields as POST, all optional. A new name alone is saved at once; a change in how it is reached (via, url, ssh, port, token) is checked first and undone if the machine doesn't answer. Returns: { machine }. 404 for an unknown id.
  • POST /api/machines/:id/reconnect — tries a machine that isn't reachable again now. Returns: { ok: true }.
  • DELETE /api/machines/:id — stops using the machine (its sessions stay on it). Returns: { ok: true }.

Access tokens: other machines using this one (Settings → App → Machines → Access from other machines)#

  • GET /api/access-tokens — the tokens other machines use to reach this one (full access). Returns: { tokens: [{ id, name, createdAt, lastUsed? }] } (times in ms; no token values).
  • DELETE /api/access-tokens/:id — revokes one: the machine using it loses access at once. Returns: { ok: true }.

Tokens for other agents (Settings → App → Connect other agents)#

  • GET /api/mcp/tokens — tokens agents elsewhere use with Hydor's MCP server (POST /mcp, tools list_sessions, read_session, start_session, send_message, list_models, plus apps offered to other agents). Returns: { tokens: [{ id, name, createdAt, lastUsed? }] }.
  • DELETE /api/mcp/tokens/:id — revokes one: agents using it lose access at once. Returns: { ok: true }.

Source control accounts (Settings → App → Source control)#

  • GET /api/scm/accounts — the GitHub and Gitea accounts pull requests open with. Returns: { accounts: [{ id, provider: 'github'|'gitea', web — server address, login?, useGh? — signs in through the GitHub CLI }], gh: boolean — the GitHub CLI on this machine is signed in } (no tokens).
  • POST /api/scm/accounts — adds an account, after asking the server who the credentials belong to (400 with the server's message when they don't work); an account for the same server is replaced. Body: { provider: 'github'|'gitea'; web?: string — server address: for GitHub only for GitHub Enterprise (default https://github.com), required for Gitea; useGh?: boolean — GitHub only: use the GitHub CLI's sign-in, no token; token?: string — access token, required unless useGh }. Returns: { account: { id, provider, web, login, useGh } }. Agents: use useGh: true when gh is true; a token is a secret the user pastes in Settings → Source control.
  • DELETE /api/scm/accounts/:id — removes it; pull requests already linked stay linked but stop updating. Returns: { ok: true }.

Pull requests and branch reviews (a session's)#

:key is the session's key. They work in the session's folder (its worktree's, when it has one); 404 "That session has no folder" otherwise.

Themes (Settings → App → General → Theme)#

  • GET /api/themes — the themes the user made (the built-in ones, ids naget-dark, naget-light, midnight and sage, are not listed). Returns: { themes: [{ id, name, base: 'dark'|'light', colors: { <colour>: '#rrggbb' } }] }.
  • PUT /api/themes — REPLACES the whole list of the user's themes (read it first, then send every theme to keep, changed or added). Body: { themes: [{ id: string — 1–48 lowercase letters, digits and hyphens, unique, not a built-in id; name: string — up to 40 chars; base: 'dark'|'light' — sets code and terminal colours and fills colours left out; colors: { <colour>: hex — '#abc', 'aabbcc' or '#aabbcc' } }] — up to 40 }. Colours: bg (page), sidebar, panel (cards), elevated (menus and dialogs), bg-1 (fields), bg-2 (hover), bg-3 (selected), fg, fg-2, fg-3 (text, secondary, muted), line, line-2 (borders, strong borders), accent, accent-fg (text on accent), busy, warn, ok, err, info, claude, codex, code-bg, term-bg, term-fg; unknown keys and bad values are dropped. Returns: { themes }; open pages update at once. Which theme shows (dark/light/system and the pick for each) is chosen in each browser, not through the API: tell the user to pick it in Settings → General.

The sidebar's tools and groups#

  • GET /api/sidebar — the sidebar's orders, as the user dragged them: the same on every device, whichever machine the page shows. Returns: { tools: string[] (every tool at the bottom of the sidebar, first to last; the usual order is pages (the Library), spaces (Projects), apps, usage, system, settings, search, help), places: string[] (the groups of projects and folders among the sessions that were dragged into an order, first to last: "space:<project id>" or "dir:<folder>"; a group it does not name comes before them, the one used last first), names: { [folder]: name } (names the user gave folders, which the app shows for the folders' own) }.
  • PUT /api/sidebar — sets either order or both; open pages change at once. Body: { tools?: string[] — tools first to last, by the names above; the ones left out follow in their usual order, so to move some and keep the rest where the user put them, send the whole list as GET gave it, changed; [] puts every tool back in its usual place (files, git, terminal and preview, which moved into System, are passed over), places?: string[] — the groups first to last, as "space:<project id>" (ids from GET /api/spaces) or "dir:<folder>" (its full path); send the whole list as GET gave it, changed; [] puts them back in their usual order, the one used last first }. Returns: { tools, places }. A tool or a group it does not know how to read is refused (400).
  • PUT /api/sidebar/folder-name — gives a folder a name the app shows for it (in the sidebar, under sessions, in the folder menus), on every device; the folder on disk keeps its own. Body: { path: string — the folder's full path, name: string — up to 80 characters; "" gives it its own name back }. Returns: { names: { [folder]: name } }. To rename a project, PATCH /api/spaces/:id with { name }.

Language (Settings → App → General → Language)#

  • GET /api/me/language — the language agents are asked to write in. Returns: { lang: 'en'|'zh'|'el' }.
  • PUT /api/me/language — sets it. Body: { lang: 'en'|'zh'|'el' } (other values are ignored). Returns: { lang }. It reaches agents as they start or resume. The language the pages show is chosen in each browser, and every page sends its own when it opens, which replaces this: to change the app's language, the user picks it in Settings → General.

Updates, startup and the app's switches (Settings → App → General)#

  • GET /api/health — no sign-in needed. Returns: { ok: true, version }.
  • POST /api/update-check — asks the upstream for a newer version now (copies installed from git; the Mac app asks GitHub's newest release, and with Update automatically on starts downloading a newer one at once). Returns: { update: UpdateState | null } (null: not installed from git with an upstream).
  • POST /api/update — Update now: gets the new version, installs packages when they changed, builds, and restarts when the app can restart itself; running agents carry on. The Mac app downloads the new version, checks it and restarts into it (a version already downloaded and waiting installs at once). Returns: { update } at once (progress in update.running.phase: 'pull'|'download'|'install'|'build'|'restart'; read it again later). 409 while an update runs, 400 when not installed from git.
  • PUT /api/update/auto — Update automatically (on by default, only when the app can restart itself: from git after 5 hours without a message from the user, at most once a day; the Mac app downloads a new version as soon as it is out and restarts into it a minute after saying so). Body: { on: boolean }. Returns: { update }. Turning it off while a version waits stops its countdown (it waits for Restart now); turning it on starts one.
  • POST /api/update/install — the Mac app's Restart now: the version downloaded and waiting (update.ready) installs at once; the app restarts into it and running agents carry on. Returns: { update } (running.phase 'restart'). 409 when no version is waiting or an update runs.
  • POST /api/update/later — the Mac app's Later: the waiting version's countdown stops, and it asks again in 4 hours (update.ready.later); Restart now and the menu bar's Restart to Update still install it meanwhile. Returns: { update }. 409 when no version is waiting.
  • GET /api/startup — Start when this computer starts. Returns: { on: boolean, how: 'user-service'|'system-service'|'docker'|'none', unit?, linger? — a user service starts before anyone logs in, can: boolean — whether it can be changed from here }.
  • PUT /api/startup — turns it on or off. Body: { on: boolean }. Returns: the same as GET. 400 in Docker (its restart policy decides) or without systemd for the user; 403 for a system service without passwordless sudo.
  • GET /api/releases — the release notes of this copy. Returns: { current: string — the version running, releases: [{ version, date?, summary — the first paragraph, notes — all of it, Markdown }] } (newest first).
  • POST /api/tour — marks the welcome tour as finished up to a version, so it doesn't open by itself on any device (the current tour is version 2). Body: { version: integer 0–1000 }. Returns: { ok: true }. Starting the tour happens in the browser (Settings → General → Take the tour).
  • GET /api/tips — the tips after the tour, which point out a part of the app the first time it is on screen (Settings → General → Tips). Returns: { on: boolean, seen: string[] — the tips shown already, in order, tour: number — the tour version taken (tips wait for the tour) }.
  • PUT /api/tips — turns tips on or off, marks some as shown, or shows them all again, on every device. Body: { on?: boolean, seen?: string[] — tips to mark as shown: search, inbox, session-menu, details, queue, limit, memory-note, sources, files-made, page, browser, skill, goal, children, research, budget, context, pane, groups, dictate, handsfree, machines, files, git, terminal, preview, review, library, apps, spaces, knowledge, usage, ask, tasks, memory, guidelines, assistants, connections, skills, reset?: true — show every tip again (before seen) }. Returns: the same as GET. 400 for a tip it doesn't know. UpdateState: { kind: 'git'|'app' — from git commit by commit, or the Mac app release by release, behind: number — commits behind the upstream (the Mac app: 1 when a newer release is out), latest? — the newest one's subject (the Mac app: its version), checkedAt, error? — why the check failed, auto: boolean, canRestart: boolean, running?: { phase, since, auto }, ready?: { version, at, installIn? — ms until it installs by itself (the pages count down), later? — put off with Later until then (ms since 1970); neither: it waits for Restart now } — the Mac app: a new version downloaded and checked, waiting, last?: { ok, at, auto, from?, to?, needsRestart?, error? } }.

This computer and previews (System and Preview pages)#

  • GET /api/system — the computer now and the last 10 minutes. Returns: { info: { hostname, os, kernel, uptime — seconds, cpuModel, cores, mounts: [{ mount, device, fstype, size, used, avail }] — bytes, processes: { byCpu, byMem, gpu }: [{ pid, name, cmd, user, cpu — %, rss — bytes, gpuMem? — bytes }] — the 12 busiest each }, history: [{ t, cpu — %, cores — % each, load: [1, 5, 15 min], mem: { total, used, available, cached, buffers }, swap: { total, used }, gpus: [{ index, name, util — %, memUtil, memUsed, memTotal, temp — °C, power, powerLimit — W, fan, clock — MHz, pstate }], net: { rx, tx, ifaces } — bytes/s, disk: { read, write } — bytes/s, temps: [{ label, value — °C }] }] } (samples 2 s apart, newest last, up to 300; a call starts sampling for 30 s when nobody watches, so the first may take a second).
  • GET /api/preview — servers listening on this machine that Preview can open (ports from 1024, not the app's own). Returns: { servers: [{ port, program, command, cwd?, pid?, web?: boolean — it answers with a web page (Preview's Web apps), title? — that page's title, project? — its package's name, else its folder's, framework? — Vite, Next.js… when it can be told }], port: number | null — the preview port (null: previews are off), origin: string | null — the preview's own address when set }.
  • GET /api/preview/shot?port=&path=&device=&live= — a picture (PNG) of a dev server's page, taken on this computer (Preview's Show it as a picture, and Save a picture of it). Query: port: number, path?: string — starts with / (default /), device?: desktop|tablet|phone, live?: 1 — the page as its live stream in Preview shows it now (when one is open), instead of opened afresh. 502 when nothing answers.
  • POST /api/preview/shot — the same picture kept as a file for a week, to start a session with (Ask an agent about this screen). Body: { port: number, path?: string, device?: desktop|tablet|phone, live?: boolean }. Returns: { path — the PNG's path on this computer, size }.

Before and after screenshots#

Agents take them with their own ui_screenshot tool when they change how a web app looks (phase before, then after, with the same label); the user reviews each pair in the conversation. Shot: { id — 12 hex characters, key — the session's, label — which screen, phase: before|after|check, viewport: desktop|phone, url, title — the page's own, width, height — the picture's, in pixels, createdAt }.

  • GET /api/shots?key= — a session's screenshots, oldest first. Query: key: string — the session's key. Returns: { shots: [Shot] }.
  • GET /api/shots/:id/pair — a screenshot and, for an after one, the before one it answers (the session's latest before with the same label and viewport, taken earlier). Returns: { shot: Shot, before: Shot | null }; 404 when it is no longer kept.
  • GET /api/shots/:id — the picture itself (WebP). 404 when it is no longer kept (the pictures go with their session, and the oldest go past 500 MB in all).

Not for agents#

These routes serve the app’s own pages, need the user’s browser, or are the user’s alone; app_api refuses the ones an agent must never use.

  • POST /api/sessions/:key/approvals/:id — answers an approval, question or form ({ decision, message?, answers?, form? }): the user's own decision; agents never answer another session's approvals.
  • POST /api/uploads — saves files for an answer to a question; it only serves the approval cards.
  • GET /api/sessions/:key/image — serves a message's picture as raw bytes.
  • GET /api/sessions/:key/queue/:id/attachment — serves a queued message's attachment as a raw file.
  • GET /api/find/usage, POST /api/find/usage — the search box's own record of what the user picks and visits; writing to it would skew what it suggests.
  • /ws — the live socket the pages listen on (it streams).
  • GET /guest-api/:token and the /g/<token> page — the public view of a read-only link, for guests.
  • GET /agent/run/:token/assistant — Custom's own runtime asks it before each turn which tools its session's assistant allows; it needs the running agent's token.
  • GET /api/memory/export — downloads every active note to the browser as a file (?format=json with histories, or ?format=md); GET /api/memory/entries gives the same notes as JSON.
  • POST /api/skillpack/proposals/:id/approve — the user's own answer to a skill an agent proposed in a session (like an approval), given on the card, in Settings → Skill library or from the inbox; it saves a file every agent then reads. (Body: { text?: string, scope?: 'library'|'space' }, the whole SKILL.md as edited, and for a session in a project where to keep it.)
  • POST /api/skillpack/proposals/:id/dismiss — the same answer as a no. (Body: { note?: string }, told to the proposing agent.)
  • POST /api/browser/profiles/:id/open — opens a profile's browser so the user can sign in to sites through the live view: it needs the user at the screen.
  • GET /api/browser/sessions/:key and PATCH /api/browser/sessions/:key/options — a session's browser view, profile and site lists, for the page. Agents have their own browser tools.
  • POST /api/browser/sessions/:key/open, /close, /watch — the page opening or closing a session's browser, and asking for live-view frames.
  • POST /api/browser/sessions/:key/takeover, /input, /navigate, /nav, /tab, /dialog — the user driving the browser by hand.
  • POST /api/browser/sessions/:key/asks/:id — the user's own answer to what the browser waits for them on (Done, Can't, allowing a local address).
  • GET /api/browser/sessions/:key/steps/:n — a step's picture as a raw JPEG.
  • GET /api/documents/preview?path= — reads a file's contents for the file viewer.
  • POST /api/documents/pages — has LibreOffice lay an Office file out as pages for the viewer.
  • GET /api/documents/pages.pdf — sends that PDF file itself (raw file).
  • GET /api/pages/:id/export — sends a page, deck, document, spreadsheet or LaTeX document as a file itself (PDF, Word, PowerPoint, Excel, CSV, Markdown, LaTeX or a web page; the Export menu). Agents make the file with their export_page tool, which writes it where they can hand it over with present_files.
  • GET /api/pages/assets/:sha, GET /api/pages/picture, GET /api/pages/fonts.css, GET /api/pages/fonts/* — the pictures and Google fonts the slides and document editors show, and the pictures from the web in agents' replies (/api/pages/picture?url=: fetched by this server, public addresses only, and remembered for a while), served as they are (raw files).
  • GET /api/pages/tex/:key/:page, GET /api/pages/tex/:key/pdf — a typeset LaTeX document's pages as pictures and its PDF, served as they are (raw files).
  • POST /api/voice/transcribe — the page's microphone: the body is a raw recording.
  • POST /api/voice/command — the hands-free page: it acts at once (starts sessions, answers approvals) with no card for the user to OK first.
  • Deep research, child sessions, swarm, second opinion, model arena and spending caps: none left out; all their routes are listed above.
  • GET /api/search (no path after it) is the sidebar's search through sessions, not web search: it belongs to Sessions.
  • POST /api/apps/:id/keys — makes an API key whose value is shown only once; the person makes keys in the app's API & sharing tab.
  • POST /api/apps/:slug/run and GET /api/apps/:slug/run/:runId — for programs outside Hydor, with the app's key (Authorization: Bearer napp_…, 30 calls a minute). Body { inputs | answers at the top level, files?, wait? (≤900 s) }; the answer has steps: [{ step, title, status, error? }] for an app with steps. These calls can wait up to 900 s. Inside the app, use POST /api/apps/:id/runs and GET /api/apps/runs/:runId.
  • GET /api/apps/batches/:batchId/csv — a CSV file download; GET /api/apps/batches/:batchId has the same data as JSON.
  • GET /a-api/:slug, POST /a-api/:slug/run, GET /a-api/:slug/run/:id — a public page's own calls for its visitors (told apart by a cookie; runs start only from the page itself).
  • POST /api/sites — publishes at once, without asking. Agents publish with their own publish_site tool, whose card lets the user decide and pick a private or public link.
  • POST /api/sites/requests/:id — the user's answer on a publish card (Publish or Not now, and the kind of link).
  • GET /pg/:token, GET /pg/:token/html, GET /pg/:token/manifest.webmanifest, POST /pg/:token/storage — a saved page's own address, for the browser that opens it (no sign-in; its storage calls are taken only from that page). Agents read and change a page's data with POST /api/pages/:id/storage.
  • GET /site/:token, GET /site/:token/* — the published sites themselves, served to visitors.
  • GET /htmlframe — the sandboxed frame every page renders in.
  • POST /hooks/:secret — a webhook task's address, for outside services: no sign-in (the secret in the path is the permission), and it may require a signature. An agent starts a run with POST /api/tasks/:id/run.
  • GET /api/automations/proposals/:id, POST /api/automations/proposals/:id/confirm, POST /api/automations/proposals/:id/decline — the card for a task an agent proposed with schedule_task or update_task: only the user answers it, and the agent hears the answer as a message.
  • POST /api/connections/secrets, PATCH /api/connections/secrets/:name — these set a key's value, hosts, whether commands may use it, or how it is sent. Agents never set a key's value or where it may go: the user does, in Settings → Connections → Keys or on the key card.
  • POST /api/connections/:id with key or a secret field — this sets a key's value.
  • POST /api/connections/requests/:id — the user's answer to a key or sign-in card; it carries the values.
  • POST /api/connections/requests/:id/dismiss — the user's "Not now" on another session's card, which tells that session's agent.
  • POST /api/connections/:id/oauth, GET /api/connections/flows/:flow — a browser sign-in; it needs the user's browser.
  • GET /connections/callback — the OAuth callback.
  • POST|GET|DELETE /connections/mcp/:token/:app, GET /connections/launch/:token/:app, POST /connections/elicit/:token/:app — how running agents reach their connected accounts. These use run-token addresses and work from this machine only.
  • POST /api/setup/mcp/login — opens a terminal for the server's sign-in.
  • GET /api/push, POST /api/push/subscribe, POST /api/push/unsubscribe — they need the browser's own push subscription: only the device itself turns Notify this device on or off.
  • POST /api/accounts/:agent/login — starts a Claude Code or Codex sign-in; the user must open the link and approve in their browser.
  • POST /api/accounts/:agent/login/:flow — finishes a sign-in with the code or localhost address the user copies from their browser.
  • DELETE /api/accounts/:agent/login/:flow — cancels a sign-in in progress (part of the user's sign-in dialog).
  • POST /api/accounts/:agent/logout — signs a CLI (or an added account, ?account=) out for every terminal and editor; the user's own call, from Settings → Accounts.
  • PUT /api/models with openrouterKey, hfToken or endpoint.apiKey — key values; the user types them in Settings.
  • POST /agent/run/:token/ensure, /describe, /review, GET /agent/run/:token/custom, /context, /assistant — Custom's own calls during a run (start its vLLM model, describe images, Auto mode's check, settings, instructions), allowed only with that run's token.
  • /api/fs/* (list, read, stat, raw, view-link, write, mkdir, rename, delete, exists, search, find, diff, recent, upload) and /view/:token/* — files: agents read and change files with their own tools, with the user's approvals; raw and view serve files to the browser.
  • /api/git/* (repos, clone, clone/:id, repo, fetch, sync, pull, push, fetch-all, remote, diff, show, log, commit, message, discard, discard-hunk, ignore, revert, undo-commit, continue, abort, branches, switch, branch-delete, branch-rename, merge, compare, branch-only, tag, push-tags, stash, stash/show, stash/:action) — the Git page's own calls; agents use git through their own command tool, with the user's approvals.
  • /api/terminals* (list, create, rename, close, context, complete, ask), /ws/term/:id and /api/terminal-history — the user's shells and their typed history; the socket streams.
  • /api/kernels* and /nbframe — Jupyter kernels, run from the notebook page.
  • /api/uploads — attachments sent with messages.
  • POST /api/access-tokens and POST /api/mcp/tokens — each returns a secret token, shown once to the user ({ id, token }); the user makes them in Settings.
  • POST /api/preview/link — a one-time link (valid 2 minutes) that signs the user's browser in to the preview port.
  • POST /api/preview/live — opens a dev server's page in the app's own browser and streams it to the Preview page, where the user's browser can't reach the preview port: the user drives it from there (its frames and input go through the browser's live-view calls above, under the key it returns).
  • POST /api/auth/password and POST /api/auth/logout — the password, and this browser's sign-in.
  • POST /mcp and POST /mcp/run/:token — the MCP endpoints themselves (agents elsewhere with a token; each session's own tools).
  • /g/:token and /guest-api/:token — what a guest's browser reads through a read-only link.
  • /m/:id/* — the app's pages forwarding to another machine's API and sockets.

Change this page on GitHub · the app's Help shows the same text