Skip to content

Latest commit

 

History

History
580 lines (477 loc) · 192 KB

http-api.md

File metadata and controls

580 lines (477 loc) · 192 KB

OpenAI-compatible HTTP API

The HTTP surface ships only when the binary is built with go build -tags=http (make build TAGS=http). It exposes OpenAI-shaped routes plus /coddy/* helpers, backed by the same session manager and ReAct agent as coddy acp.

It is not a command of its own. coddy serve runs it whenever httpserver.enable is true, which it is unless the file says otherwise, alongside every other subsystem the configuration enables - the Telegram gateway, the swarm relay, the cron scheduler. They share one session manager, so a conversation a bot started is a session this API lists, streams and can continue.

The bundled SPA is included only when you also set the ui tag (for example make build TAGS="http ui" or go build -tags=http,ui). With http only, GET / returns 404 with a plain-text hint.

OpenAPI and Swagger UI

Specs are regenerated on each request so they stay aligned with handlers.

  • GET /openapi.yaml - OpenAPI 3.0 (YAML); GET /openapi.json mirrors it in JSON (both Content-Disposition: inline).
  • GET /docs/ - Swagger UI (static assets bundled in the binary, no CDN). GET /docs redirects to /docs/.

Authentication (optional)

Authentication is off by default (historical behavior). There are two credentials, and either one closes the same gate:

  • a bearer token for API clients (coddy --remote, coddy acp --remote, a swarm relay, scripts);
  • a password sign-in form for browsers, described under Web UI sign-in below.

Set a bearer token to protect the API when exposing it beyond loopback:

  • httpserver.auth_token in config.yaml (supports ${ENV}), or
  • --auth-token <token> on coddy serve, or
  • the CODDY_HTTP_TOKEN environment variable.

When a token is configured, every /v1/* and /coddy/* route requires Authorization: Bearer <token> and returns 401 with a WWW-Authenticate: Bearer challenge otherwise. The SPA shell and static assets stay public so a client can load and prompt for the token; /docs and /openapi.* are protected unless httpserver.public_docs: true. The token is never returned by GET /coddy/config (redacted; the DTO only reports auth_configured). Tokens passed via --auth-token / CODDY_HTTP_TOKEN are never written to config.yaml and survive PUT /coddy/config hot reloads. Prefer TLS (or a TLS-terminating proxy) when using auth over a network. Binding a non-loopback address without a token logs a startup warning unless httpserver.allow_insecure: true.

Without a token the surface is unauthenticated; run behind appropriate network controls.

Web UI sign-in (optional)

A bearer token protects the API but gives a browser no way in: the SPA shows Unauthorized (401) and has no field to type a token into. The sign-in form is the browser's credential for the same gate. It is off by default, and a configuration that does not mention it behaves exactly as before.

httpserver:
  host: 0.0.0.0
  auth_token: "${CODDY_HTTP_TOKEN}"   # unchanged: for API clients
  login:
    enable: true
    user: "pasha"
    password_hash: "$$argon2id$$v=19$$..."   # written by `coddy serve set-password`
    session_ttl_hours: 720                   # 0 = until the browser closes

Write the account with the command rather than by hand:

coddy serve set-password --user pasha

It asks for the password twice (once, without a prompt, when standard input is a pipe), stores an argon2id hash and switches the form on, editing config.yaml in place so comments and key order survive. A hash typed in by hand needs every $ doubled - $$argon2id$$v=19$$... - because this file expands $NAME as an environment reference when it loads; coddy -t says so when it finds a hash that no longer parses.

The account can also live entirely in the environment, which is the route for a container or a systemd unit: CODDY_HTTP_USER and CODDY_HTTP_PASSWORD (plaintext, hashed as the server starts, never written to the file) enable the form on their own, the way CODDY_HTTP_TOKEN enables the bearer gate without auth_token. $CODDY_HOME/.env is their natural home. When both the file and the environment carry an account the environment wins, and an explicit login.enable: false in the file switches the form off with the variables still set. There is no --login-password flag: a password on a command line is visible in ps.

Three routes serve the form, and all three are reachable without a credential, because they are the way through the gate:

Route What it does
GET /coddy/auth/me login_required, auth_required, authenticated, and user / expires_at for a signed-in browser. The SPA calls it on boot to choose between the sign-in screen and the app.
POST /coddy/auth/login {user, password}. On success sets an HttpOnly, SameSite=Strict coddy_session_<host digest> cookie (Secure when the request arrived over TLS or through a proxy sending X-Forwarded-Proto: https), living for session_ttl_hours; with 0 the browser drops it on close and the server expires its own record after 30 days. The name carries a digest of the host the request was addressed to, because cookies are not scoped by port: without it two Coddy servers on one machine - a relay and the node behind it, say - would sign each other out at every login.
POST /coddy/auth/telegram {init_data}: the launch data of the web UI opened as the Telegram bot's Mini App. The signature is checked against the bot's token (launch data at most an hour old, each launch once), and only a user in gateways.telegram.admins is signed in: an HttpOnly, SameSite=Strict coddy_tg_session_<host digest> cookie that opens what the form's cookie opens, also behind a token-only gate, and ends when the token changes, the bot is disabled or the person stops being an admin. 403 for anybody else. GET /coddy/auth/me says telegram_login: true when the route is available.
POST /coddy/auth/logout Drops the session on the server and expires the cookie (the Mini App's too). Idempotent.

Once signed in, the cookie opens every /v1/* and /coddy/* route, the SSE streams included - a same-origin EventSource sends cookies, so no ?access_token= is needed. The cookie is SameSite=Strict, which costs nothing here (the page itself is public and every call the loaded page makes is same-origin) and means the browser never attaches it to a request another site caused. On top of that, cookie-authenticated requests that change state are refused with 403 unless Sec-Fetch-Site says same-origin (or none) or Origin matches the request host - so a script driving this API with a cookie has to send an Origin header, or, better, present a bearer token. Bearer requests are never subject to that check, which is what keeps coddy --remote, an editor and a relay working unchanged.

A wrong password and an unknown user get the same 401 and the same body, compared in constant time. Repeated failures from one address are answered progressively more slowly, doubling from 250 ms to a cap of five seconds and forgotten after fifteen quiet minutes; the attempt is counted before it is judged, so a burst arriving together pays the growing wait rather than each member reading a clean slate. Loopback is never throttled, so the machine cannot lock itself out of its own agent - and because that exemption would otherwise cover the whole internet behind a reverse proxy, a request that arrives from loopback is counted against the address in X-Forwarded-For (or X-Real-IP) when one is present. A request that arrives from anywhere else is counted by where it actually came from, headers ignored. Sessions live in memory: a configuration reload (saving settings from the page, for instance) keeps them, and restarting the process ends them. Rotating the password or renaming the account ends every session it opened, on the next request, with nothing to invalidate by hand. The store is bounded at 512 live sessions, oldest first.

GET /coddy/config never returns password_hash; it reports httpserver.login_configured and httpserver.login_source (config or env), and a save from the settings screen preserves the hash the way it preserves auth_token. The neighbouring auth_configured means what it says - a bearer token is set - so a server closed with a password and no token reports auth_configured: false next to login_configured: true; ask GET /coddy/auth/me when the question is "does this server want a credential at all". The document keeps describing the file, so an account that came from the environment is never written into config.yaml by a save.

The sign-in form is for this origin. The environment selector still reaches a remote server with a bearer token, because a cookie of this origin does not travel cross-origin.

One note on ${VAR} references. login.user and login.password_hash take them like every other value in config.yaml, and like every other value they are expanded when the file loads - so a save from the settings screen writes the expanded value back, turning user: "${CODDY_HTTP_USER}" into user: pasha. That is how this file has always treated ${VAR}; if the point is to keep the credential out of the document entirely, use CODDY_HTTP_USER and CODDY_HTTP_PASSWORD, which never enter it.

Cross-origin access and the remote UI

The bundled UI can point at a remote coddy serve server (an environment selector switches the API base URL and sends the remote's bearer token). Because that is a cross-origin call, the remote server must opt into CORS:

httpserver:
  auth_token: "${CODDY_HTTP_TOKEN}"
  cors:
    enable: true
    allow_loopback: true          # the laptop's own coddy serve, on any loopback address and port
    allowed_origins: ["https://my-ui.example"]   # exact origins elsewhere; or ["*"]
  remotes:                       # optional: offered in the UI environment selector
    - name: "prod box"
      url: "https://box.example:12345"
      token: "${PROD_BOX_TOKEN}"   # optional: the bearer the UI and the console present

When cors.enable is true, preflight OPTIONS requests for an allowed origin return 204 with Access-Control-Allow-Origin (echoed origin, or * when configured), Vary: Origin and Access-Control-Allow-Headers: Authorization, Content-Type, X-Coddy-Session-ID; disallowed origins receive no CORS headers. A request whose Origin is null (a sandboxed page, a file:// page) is refused with 403 on every route but a canvas frame's address (Canvas). An origin is allowed when allowed_origins names it exactly, when allowed_origins holds "*", or when allow_loopback is on and the origin is http or https with a loopback host - localhost, a *.localhost name, 127.0.0.0/8 or [::1] - on any port. The list is read first and in order, so a "*" in it answers * to every origin no entry names before it, loopback ones included; an origin admitted by allow_loopback alone is echoed, never widened to *. Bearer auth still applies to the actual request whichever of the three admits the page, and allow_loopback and "*" are only as safe as that credential. A remote's token is either the token of its httpserver.remotes entry, which GET /coddy/config hands to every page that reads it, or one typed into the UI's Connect to… form, which the UI keeps client-side per remote. A swarm relay answers a cross-origin page through swarm.cors instead (same shape), and the UI recognises it by its public GET /swarm/info rather than by /v1/models, which a relay does not serve.

Because EventSource cannot send an Authorization header cross-origin, the two SSE subscription routes - GET /coddy/sessions/{id}/composer-stream and GET /coddy/events - also accept the token as a ?access_token= query parameter. No other route does; prefer the header where the client can set one, since a query token reaches proxy access logs.

Embedded web UI (-tags=http,ui)

  • GET / serves index.html, styles.css, app.js and events-worker.js (the SharedWorker the tabs of one environment share GET /coddy/events through) from external/ui/ (go:embed) when ui is set at link time with http, and with them what makes the web UI an installable app: the web app manifest /manifest.webmanifest (served as application/manifest+json), its icons /icon-192.png, /icon-512.png and /icon-maskable-512.png, and the service worker /sw.js, which caches nothing and shows the web UI's notifications (Installing the web UI as an app). Like the rest of the shell they are public behind httpserver.auth_token and the sign-in form, because a browser asks for them before anyone signs in. Responses for /, /index.html, /app.js, /events-worker.js, /styles.css and these files include Cache-Control: no-cache so a normal reload picks up assets after you rebuild the binary (fixed URLs, no fingerprint). The renderers the page loads on demand (Mermaid, KaTeX and its fonts) are served from /chunks/ under content-hashed names with Cache-Control: public, max-age=31536000, immutable.

  • API-only mode: set ui.enable: false in config to run the server without the SPA even when the ui tag is compiled in. GET / then returns a short plaintext notice (HTTP 404) instead of the UI; every /v1/* and /coddy/* route keeps working and keeps requiring httpserver.auth_token when set. Useful for a headless remote box other UIs connect to.

  • Recommended session URL pattern: #/s/<sessionId> (client-only history). For POST /v1/responses and POST /v1/chat/completions, send X-Coddy-Session-ID with a sess_<hex> id that satisfies server-side validation (internal/session/validate.go).

  • X-Coddy-Session-Origin: print on POST /v1/responses and POST /v1/chat/completions marks the session the request creates as a run of one-shot print mode (origin: print in session.json), which the session listings and pickers leave out unless asked (include_print, origin=print on GET /coddy/sessions). It is what coddy --remote <server> -p sends with the prompt that creates its session. The server records it only when the request creates the session, never on one that exists already, and answers any other value with 400 before anything is created (Sessions).

  • GET /coddy/sessions, PATCH /coddy/sessions/{id}, and other /coddy/sessions/{id}/... routes identify the session only by the {id} path segment (no duplicate header in OpenAPI; clients may still send X-Coddy-Session-ID, it is not used to pick the resource for those URLs).

  • The bundled UI calls POST /v1/responses with stream: true. On session open and reconnect, it reads GET /coddy/sessions/{id}/activity and GET /coddy/sessions/{id}/queue without requiring a row in the filtered or paginated GET /coddy/sessions listing. Server activity drives Stop and queue controls separately from the local stream reader; a lost reader does not prove the turn ended. The existing GET /coddy/sessions/{id}/composer-stream relay (SSE) lets the UI replay bytes already emitted for a running turn and continue receiving live chunks until data: [DONE] or an error event. Starting a new POST /v1/responses cancels that relay subscription client-side. For ReAct (model agent, plan, or ask), it sends optional metadata.model with the selected YAML models[].model id and optional metadata.runPlanSlug to implement a saved design plan (coddy.dev/runPlanSlug on ACP session/prompt; answered with 409 before the turn starts when model is ask) (from GET /v1/models, every row but the agent, plan and ask profiles); default is its remembered pick in coddy_llm_model, else the alphabetically first row. Every turn also carries metadata.surface: "webui", which gives that turn a system prompt block telling the model the web UI draws mermaid and svg fences as pictures and typesets LaTeX (external/httpserver/webui_prompt.go; on POST /v1/chat/completions too, and only for that value, so an API client or the remote console keeps the plain prompt). It also carries metadata.lang, the interface's language (en, ru): the documentation that the turn's @coddy: mentions attach and the agent's documentation tools read is in it; the remote console sends its terminal's. When the selected model exposes reasoning_levels, it also sends optional metadata.reasoning with the chosen level; default follows the model's reasoning_default, persisted choice in coddy_llm_reasoning. It sends the @ mentions of input as typed: the server resolves them when the turn starts - a file anywhere on disk, a line range (@Dockerfile:21-31, reaching the model as <coddy_attachment ... lines="21-31"> holding only those lines), a folder listing, another session, a rule, a subagent, a page of the built-in documentation, a web page - the way it resolves every surface's prompt (see docs/features/mentions.md). GET /coddy/mentions powers the @ picker, POST /coddy/mentions/check tells the composer which typed mentions resolve so only those are highlighted, and GET /coddy/workspace/file powers the line-range picker that opens after the :. SSE default chunks follow chat.completion.chunk deltas (delta.content for assistant text, delta.reasoning_content for streamed model reasoning so the UI can keep the thinking foldout separate). Named event: lines (tool_call, which carries _meta.coddy.agentTool for a call the agent behind an acp model ran on its own side, tool_call_update, plan, token_usage, turn_progress, available_commands, message_queue, user_message) expose tool progress and incremental token totals between LLM rounds (token_usage carries reasoningTokens when the provider reports what the call's reasoning cost); message_queue carries the whole message queue of the session on every change and user_message carries a queued follow-up the agent has just read, or an after-turn message whose own prompt starts, at the point it entered the conversation (see docs/features/message-queue.md); the turn stream reserves provider_usage for a snapshot emitted mid-turn (nothing emits one today; the account usage of a finished turn is read from GET /coddy/providers/{name}/usage or arrives on GET /coddy/events); when memory.enable is true, the server also emits memory_run for the memory subagent of the turn (started with the task and child session ids, finished with the task status, the duration, whether the report was delivered to the model in this turn and, for a failed run, the error it ended with as reason, or skipped with a reason); no text travels on it, and the run's record is the task row with agent.system true under GET /coddy/sessions/{id}/background-tasks plus the child transcript it names (see docs/features/memory.md). A turn the server started on its own because background tasks the model started ended with their wake on (notify_on_finish, on by default) opens with background_wake: tasks names each with id, kind, label, agent, status, exitCode, durationMs and error, and the turn's first message is persisted with the same tasks, so no client shows the instruction as a user bubble, live or after a reload; the bundled UI shows nothing in its place, and the task row says woke_agent (see docs/features/background-tasks.md). coddy_meta JSON follows the usual rule immediately before data: [DONE]; for agent/plan/ask turns it also carries stop_reason (end_turn, cancelled, max_turns, ...) so remote clients recover the ACP stop reason. A prompt that starts with settings commands has them applied before the turn (Session settings); a prompt of commands only runs no turn, streams the notice of each change as the answer, and ends with coddy_meta settings_only: "true", so a client can drop the rows it drew for the exchange: nothing of it is kept in the transcript, and the change shows in the session's settings. event: session_settings carries the settings snapshot whenever the session's settings change during the stream (a command, the permission dialog's session switch, the model's switch_model), with a notice only when the agent made the change itself. event: session_goal carries the session goal (the shape of GET /coddy/sessions/{id}/goal) whenever it changes during the stream - set by /goal, checked, continued, paused, cleared - and event: goal_turn opens every turn the session supervisor starts (kind kickoff, continue, recover, resume or wrapup, index of limit, objective, reason, remaining); that turn's first message is persisted with the same marker as goal_turn, so the bundled UI shows a one-line goal row instead of the instruction text. Stop generation in the composer calls POST /coddy/sessions/{id}/cancel with X-Coddy-Session-ID matching {id}. Right after sending it, the UI triggers AbortSignal on its own streamed fetch of that turn (the POST or the relay): a browser keeps six HTTP/1.1 connections per host across all of its tabs, and the cancel could otherwise wait for the one that stream holds. The turn runs on without the stream; a failed cancel shows an error, keeps the running controls available for a retry and rejoins the turn through GET /coddy/sessions/{id}/composer-stream. Success acknowledges cooperative cancellation, while turn_ended or a fresh activity snapshot reporting idle confirms completion. That maps to ACP session/cancel for the agent turn (TurnCtx propagation for direct models[].model completions uses the same cancel hook). Cancel mid-stream still persists accumulated assistant content for that turn; GET /coddy/sessions/{id}/messages can briefly trail that write, so the SPA merges fetched rows with its per-session shadow or on-screen transcript (mergeTranscriptPreferLocalSuffix in external/ui/src/ui/chat/transcriptServerSnapshot.ts). More than one session may stream in parallel (pickStreamMutationBase in streamMutationBase.ts keeps SSE mutations scoped per X-Coddy-Session-ID).

  • On the first user message of a brand-new chat (no session in the rail yet), the UI **POST**s /coddy/describe in parallel with /v1/responses, with the new chat's X-Coddy-Session-ID and its folder as cwd, so the slash commands of that workspace are described to the model. Until the call settles (an answer, an empty short, a failure, or 30 seconds, after which a late name still lands) the header and the chat's History row show a shimmering placeholder instead of the first message. When short arrives, the header and session list titles update immediately; PATCH /coddy/sessions/{id} still runs once headers expose the canonical session id (retries PATCH briefly if persistence lags describe), carrying the tags the same answer proposed, so naming and filing a new chat cost one model call between them. That PATCH carries titleIfUnpinned, which makes the title a suggestion: a session already named during the first turn - in the chat header, or by the agent's session_describe tool - keeps its name, and only the tags land.

  • The bundled UI exposes Settings at #/settings. It loads GET /coddy/config/schema (JSON Schema for the editor) and GET /coddy/config (current values, including API keys, optional per-provider proxy URLs, and the gateways.telegram.token bot token), then POST /coddy/config/validate and PUT /coddy/config on save. The provider form's Models list and the logical-model form's Context window field call POST /coddy/providers/models with the provider row as the form holds it (an automatic fetch leaves api_key_command out; only the refresh control sends it), so a provider that has not been saved yet (for example right after an OAuth device sign-in) lists its models too; the logical-model form additionally calls GET /coddy/config/reasoning-levels behind Fetch reasoning levels, which fills models[].reasoning_levels with the tiers detected for the model id being edited (the type of the provider row it points at is sent as provider_type, so an unsaved provider is honoured). The gateways block round-trips through this API, so editing settings in the UI no longer drops the Telegram configuration. There is no separate auth on these routes - expose coddy serve only on trusted networks.

  • Turn progress is the named SSE event turn_progress with startedAt (RFC3339, when the turn was admitted), elapsedMs (the same fact as a duration, so a client whose clock disagrees with the server's counts from its own now minus it), outputTokens, estimated and, on the first one only, phase: "preparing". That first one is sent as soon as the turn is taken, before it brings in its MCP servers and waits for its model's context window - seconds for a cold server - and the bundled UI reads it as "Preparing the session…"; the loop's own reports carry no phase. It is sent again when the loop starts - zero tokens, which a client renders as the clock and "waiting for the model" - at most once a second while a model call streams, and after every call. outputTokens sums what the provider reported for the turn's completed calls and an estimate (characters / 4) of what the call in flight has streamed, tool-call argument fragments included as they arrive (or once the call completes for providers without fragments); estimated is true while an estimate is part of the number, and stays true for a provider that reports no usage. The composer relay does not replay a frame the client's transcript snapshot already covers, so a client that joins late reads the same numbers from GET /coddy/sessions/{id}/activity. The OpenAI-strict POST /v1/chat/completions stream replaces it with an SSE comment like every named event.

  • File input progress: tool_call_update with status: "pending" may carry _meta.coddy.toolInputProgress for write, edit, and apply_patch. Fields: path (bounded to 512 bytes, empty until received), bytes and lines (decoded content received), argumentBytes (raw JSON bytes received), and preview (latest six lines, at most 1 KiB, possibly a partial line). This is a draft, never executable arguments or a tool result. Updates are throttled to five per second per call plus a final flush. A terminal status supersedes the draft. Clients must sanitize terminal controls before displaying model-generated paths or previews. The OpenAI-strict stream suppresses this named event like other tool updates.

  • Pictures a call returned: the completed tool_call_update of a call that returned a picture (read on a PNG, JPEG, GIF or WebP file, or an MCP tool whose result carried image blocks) carries _meta.coddy.images, one entry per picture: name (the file's name, <server>-<tool>-<n> for an MCP tool's picture), mime_type (the type the model is sent), asset (the copy kept in the session's assets/), url (GET /coddy/sessions/{id}/assets/{name}) and, when a thumbnail was made, preview_url. After a reload the tool row of GET /coddy/sessions/{id}/messages carries the same pictures as files. The bundled UI previews them under the call's row; the picture's bytes never travel in the event.

  • Current context usage is a separate named SSE event, usage_update, with used and size. It is re-emitted after manual or automatic compaction. token_usage remains the completed model-call counter and is not used as the context-window numerator.

Endpoint summary

Method Path Notes
GET / Embedded web UI (-tags=http,ui) or 404 with a hint (http only). Cache-Control: no-cache on /, /index.html, /app.js, /events-worker.js, /styles.css, /manifest.webmanifest, /sw.js and the app icons when the UI module is linked; /chunks/* (content-hashed) is immutable for a year.
GET /openapi.yaml, /openapi.json Spec.
GET /docs, /docs/ Swagger UI.
GET /v1/models Merged list: agent, plan, ask (each owned_by: coddy), then every YAML models[].model row (id is the selector; owned_by is the provider prefix). A client tells a profile by its id: a provider row may itself be named coddy (a coddy acp agent served as a model), and its rows carry owned_by coddy too. Ordering is agent, plan, ask, then models in configuration order (same ordering the server emits). Use any returned id as model on POST. The row a session that selects no model runs on carries default: true: the configured agent.model, or the first models[] row when agent.model names a model the configuration does not list; while agent.model is empty no row carries it. The remote console shows that model for a session with none of its own; the bundled UI picks its default from its remembered choice (coddy_llm_model) or the alphabetically first row instead. Every row carries max_context_tokens, the context window a session on that model measures its usage and its automatic compaction threshold against: the model's max_context_tokens, else the window its provider's model listing reports, else 128000 (the profile rows carry the window of agent.model; the bundled UI draws its context ring against it). Each models[].model row also carries multimodal (true when models[].multimodal: true in YAML; the bundled UI shows a file attachment button in the composer when the selected model has multimodal: true). Reasoning models also carry reasoning_levels (array, e.g. ["minimal","low","medium","high"]; absent for non-reasoning models) and optional reasoning_default (level pre-selected for new chats); off joins reasoning_levels only when that logical model sets allow_reasoning_off: true. The bundled UI shows a reasoning-level selector in the composer when reasoning_levels is non-empty. Models served by a type: codex provider report none where the model id alone would imply minimal, because the Codex backend rejects that tier.
POST /v1/chat/completions stream, messages (last user), optional stream_options.include_usage. With stream: true the body is the strict OpenAI chat.completion.chunk stream described under Two stream dialects. 409 when another turn, a direct completion as well as an agent, plan or ask turn, already holds the session turn lock (exclusive per persisted bundle), and 409 when X-Coddy-Session-ID names a child session spawned by spawn_agent: those transcripts are read-only for every model kind, and the error names the parent session to prompt instead.
POST /v1/responses model, input, optional stream, optional attachments ( path workspace-relative under session cwd - except with source.literal, where it only names the body and may be an editor's file:// URI or an absolute path - agent / plan / ask only; file bodies are hydrated as text - a file stored in another detected encoding, such as Windows-1251, is converted to UTF-8; server rejects traversal, oversized, non-text (binary or undetectable encoding), and folder path; optional source.startLine / source.endLine narrow a file to a 1-based inclusive line range that the model sees as lines="N-M", and a range the file cannot honour is a 400; optional kind: "stdin" with a source.literal marks the body as data piped into a one-shot run - what coddy -p --remote sends for its stdin - which the model reads as a kind="stdin" attachment that no mention or command is read from, and any other kind is a 400), optional inline_files (array of {name, data_url}; data_url is a data:<mime>;base64,... URI; works for all modes — direct YAML model and agent / plan / ask). For agent / plan / ask: each file is saved to the session assets/ directory (~/.coddy/sessions/<id>/assets/) with 0o444 (read-only) permissions; the model receives a <coddy_session_assets> annotation in the user message so it can read or cp the files without re-transmitting their bytes over the API. For direct YAML model: each entry becomes an image content part on the user message so multimodal models receive the file inline. input remains the full composer text, and its @ mentions are resolved by the server when the turn starts (an attachment already sent for the same path and range is not read twice). Keeps history between turns when using headers. 409 when another turn, a direct completion as well as an agent, plan or ask turn, already holds the session turn lock, for streaming and non-streaming calls alike, and 409 when X-Coddy-Session-ID names a child session spawned by spawn_agent: those transcripts are read-only for every model kind, and the error names the parent session to prompt instead. Every agent / plan / ask turn is published to the session's composer relay whatever stream is set to, so other clients can watch it over GET /coddy/sessions/{id}/composer-stream; with stream: false the response body is unchanged. A stream: false turn is cancelled when its HTTP request is dropped, a streamed one keeps running. A streamed response that has produced no frame for 15s sends an SSE comment keepalive, so a turn on a YAML model with stream: false - silent on the wire until the whole answer is generated - is not dropped by an idle-timeout proxy. Note the two unrelated meanings of stream: this request field selects the response shape for the client, while models[].stream in config.yaml selects the transport coddy uses to reach the LLM.
GET /v1/responses/{id} Session metadata snapshot.
GET /coddy/sessions Pagination via limit/cursor (cursor is numeric offset token). A session that holds no message - opened and left without a prompt - is not listed unless it is pinned or its first turn is running, so a new chat is in the list from its first send; a print run is listed under include_print=true even without a message; the all and archived scopes of POST /coddy/sessions/bulk-delete remove such a bundle all the same. The envelope always carries active_count: the global count of turns currently active in History's normal eligible scope, independent of the returned page and any search, archive, tag, origin or sort filter. It excludes archived sessions, scheduler job sessions, subagent sessions and print runs. A git session row carries repoRoot, the path of the main checkout shared by its worktrees, while cwd remains the session's actual directory. Optional cwd keeps only the sessions of that workspace, matched as a folder (cleaned, symlinks resolved, case-insensitive on Windows and macOS) rather than as the stored string; a parent or a child folder does not match. Optional q substring filter (case-insensitive) over the session.json title, the session's working directory, its tags, OR the first persisted user message content in messages.json order (system, assistant, tool, etc. before that first user are ignored). q is not full-text search over the transcript. Pagination applies after filtering. Rows sort by session.json updatedAt (newest first), then id for stable ties; updatedAt moves forward on persistence (new turns, title pin, etc.), not when the server only loads a bundle to serve GET requests. include_scheduler=true includes the job sessions of the scheduler (they set schedulerRun with schedulerJobId in session.json, hold the job's runs as children and are omitted from the default composer list). include_subagents=true includes child sessions spawned by spawn_agent (subagentRun in session.json, stored inside the parent's bundle and hidden by default); each such row carries subagent {parentSessionId, name, taskId} read from that bundle, and the default listing never descends into one. include_print=true includes the runs of one-shot print mode (coddy -p, origin: print), hidden by default together with the children they spawned. include_activity=true adds turnActive, activitySeq, readActivitySeq, lastErrorSeq, unreadComplete, permissionPending, questionPending and backgroundRunning to each row for multi-tab UI. questionPending is process-local to the Coddy server process that owns the interactive wait. lastErrorSeq is the activity generation of the latest real turn failure, or zero when the latest outcome was not an error; a client compares it with readActivitySeq to distinguish an unseen failure from one already viewed. backgroundRunning counts the session's background tasks still in flight - the model's own work, nothing finished and no system errand such as the per-turn memory run - and detached work outlives the turn that started it, so it can be above zero while turnActive is false. include_stats=true adds what the session management table renders: messageCount (persisted transcript rows of every role), tokenUsage {inputTokens, outputTokens, totalTokens} read from the bundle's stats.json (zeroes for a session that never completed a model call), createdAt and model. createdAt is omitted for a bundle stored before the field existed - when a session was started is not recoverable and is not invented - and model is omitted for a session that never overrode agent.model, so a client shows those as unknown instead of as a value. Optional archived selects which side of the archive is listed: exclude (the default working list), only or all; a row that is archived carries archived: true and archivedAt. Optional tags is a comma separated list and keeps the sessions carrying any of them, normalized the way stored tags are, so Backend finds backend; a row carries its tags when it has any. Optional origin keeps the sessions of one surface: local for the ones a person opened on this host, gateway for the chats a messenger gateway is holding, print for the runs of one-shot print mode (listed without include_print; local never lists them); a row started by a gateway carries origin (gateway:telegram), a print run print. Optional sort (updated by default, or created, title, messages, tokens) and order (desc by default, or asc) replace the default ordering; they are applied to the whole filtered listing before paging, so page two of a sorted listing continues page one. A pinned session leads the listing whatever sort says, and the pins are ordered among themselves by pinnedRank - the order the operator dragged them into - newest pin first until one is dragged. A session the sort column says nothing about (no creation stamp, no title) sorts last in both directions, ties break by id, and tokens reads each bundle's stats.json, so it costs one extra small file read per session and only when it is the column asked for. An unknown archived, origin, sort or order value is a 400 rather than a silent default.
POST /coddy/describe JSON {"text"} to get a short description phrase and the tags the model proposed for filing the conversation. The tags ride on the call that already names a new chat, so a session is filed without a second request to the model; a model that ignores the instruction answers the phrase alone and tags is an empty array. Every text is asked about, however short: a first message of two words is the one that needs the labels most. A /name word that names a skill or a built-in action of the workspace is described to the model by its catalog row, so /rpa-init is named by what it does; an answer that is only a command name falls back to the words around the command, or to the command as typed when it was typed alone. Settings commands at the start of the text (/model, /plan) are taken off it, and a text of nothing else answers an empty short without asking the model. The workspace is the session in X-Coddy-Session-ID when the server holds it, else the folder in the cwd query (a new chat's pick), else the server default; an unknown session or an unusable folder falls back instead of failing.
POST /coddy/enhance-prompt JSON {"text"} — rewrites the user's draft into a clearer, more specific prompt through one direct LLM completion without tools. The draft is source text only: it is never answered or executed. Optional X-Coddy-Session-ID selects that session's effective model; otherwise the endpoint falls back to agent.model, then the first configured models[] row. JSON {"object":"coddy.enhance_prompt","text"}; 400 for a blank draft, 502 for an LLM failure, and 503 when no model is available. The composer replaces the draft with the result and Ctrl+Z/⌘Z restores the original.
GET /coddy/slash-commands Paginated slash commands derived from configured skills (page, page_size required). Optional prefix filters by name (case-insensitive). Optional X-Coddy-Session-ID resolves ${CWD} expansion for skills.dirs like other session-scoped lookups. Optional cwd (an absolute path to an existing folder, else 400) names the workspace when there is no session behind the request: the folder a new chat picked before its first message. It answers whenever X-Coddy-Session-ID is absent or names a session the server does not have yet, and it is ignored next to a session the server has. JSON items[].name and items[].description, total, has_more. Built-in commands (/compact, /goal, /export, /plugin) are not listed here; they are served separately by /coddy/commands, advertised to editors via the ACP available-commands update, and run deterministically when sent as a prompt (e.g. POST /v1/responses with input:"/plugin marketplace list").
GET /coddy/commands Lists the built-in slash commands that run without an LLM turn, so the composer can render a Commands group beside skills: the settings commands (/model, /reasoning, /think, /nothink, /agent, /plan, /ask, /permissions; Session settings) and the actions (/compact, /goal, /export, /plugin). Optional prefix filters by name (case-insensitive); optional X-Coddy-Session-ID fills the values for that session. JSON {"object":"coddy.commands","items":[{"name","description","kind","setting","hint","aliases","choices","value","duringTurn"}]}: kind is setting or action; a settings row names the setting it changes, its argument hint, its aliases, the choices its argument takes for the session (configured models, the model's reasoning levels, the modes) or the fixed value of a command without one, and duringTurn: true because it applies while a turn runs. compact is included only while compaction.enable is true; export and plugin are always present. /export sent as a prompt writes the session transcript to a file in the session workspace (see docs/features/session-export.md).
GET /coddy/skills Lists every skill the workspace reads: the four default folders, then the extra directories of skills.dirs (Skills). Optional X-Coddy-Session-ID resolves ${CWD} (the project folders and a ${CWD} or relative entry of skills.dirs) against that session's workspace, like /coddy/slash-commands (server default cwd without the header; 400 for a malformed id, 404 for an unknown session), and takes the same optional cwd query for a folder picked before a session exists (the web UI's Settings → Skills sends the chat's workspace this way). JSON {"object":"coddy.skills_list","items":[...]}; each item has name, description, file_path, enabled, version (installed version — the marketplace-declared version for synced skills, else sha256: and the first 12 hex digits of the archive for a plugin installed from a zip archive, else the SKILL.md frontmatter version; absent when unknown), source (the source or marketplace address the skill was installed from; absent for local/bundled skills), and readonly (true for bundled skills, which cannot be deleted). Uses same directory scan and last-wins deduplication as the agent loader.
POST /coddy/skills/{name}/enable Removes {name} from the disabled list in ManagedDir (~/.coddy/skills/.disabled). 200 {"ok":true}; 400 when name contains path separators or is blank. Invalidates the slash-command cache so the next /coddy/slash-commands call reflects the change.
POST /coddy/skills/{name}/disable Adds {name} to the disabled list. Same response codes and cache invalidation as enable.
POST /coddy/skills/sync Fetches the sources and marketplaces in effect for the workspace (the session in X-Coddy-Session-ID, else the server default cwd) and materializes skills into ManagedDir. Without source, syncs every source installed whole - the built-in one, those of <home>/marketplaces.json and those of the workspace's .coddy/marketplaces.json the trust gate admits - then refreshes every marketplace in effect and reinstalls only the plugins installed from it. With ?source=<key> only that entry: a marketplace by name or a source by address (also one no file declares), and for a marketplace only the plugins installed from it. A project entry the gate holds back (skills.project_trust) is not fetched: a full sync lists it under held, and naming it in source is a 400 that says how to approve it. Manual only. 200 {"ok":true,"added":[...],"updated":[...],"failed":[{"source","error"}],"held":[<entry>]}, each held row shaped like an entry of GET /coddy/skills/sources. Invalidates the slash-command cache.
GET /coddy/skills/updates For every installed remote skill, fetches its marketplace source and compares the installed version against the latest declared upstream (network / git access). A source a project of the workspace declares (the session in X-Coddy-Session-ID, else the optional cwd query, else the server default cwd) that the trust gate holds back is not contacted and reports no update. For a zip-archive plugin without a declared version the upstream value is the archive sha256 its entry declares, and any change of it is an update. 200 {"object":"coddy.skills_updates","items":[{"name","source","version","latest","update_available"}]}.
GET /coddy/skills/available Fetches the manifest of every source and every marketplace in effect for the workspace (network / git; a held project entry offers nothing) and lists the plugins they advertise for install. 200 {"object":"coddy.skills_available","items":[{"name","description","version","source","installed"}]}.
POST /coddy/skills/install JSON {"source","plugin"}: installs a single named plugin from a marketplace source (not the whole source). 200 {"ok":true,"added":[...],"updated":[...],"failed":[...]}; 400 when the plugin or source is unknown, and when nothing could be installed (an archive refused, a plugin without skills), with the reason in the error message. Invalidates the slash-command cache.
POST /coddy/skills/{name}/update Re-syncs the marketplace source that provides {name}, installing the version it currently declares; a skill installed from a marketplace (a catalogue) reinstalls only its own plugin, not every plugin that marketplace lists. 200 {"ok":true,"added":[...],"updated":[...],"failed":[...]}; 400 when {name} was not installed from a remote source, and when that source is a project entry of the session workspace the trust gate holds back (approval withdrawn, or skills.project_trust: deny), with how to approve it. A source no file of the workspace declares any more stays updatable. Invalidates the slash-command cache.
GET /coddy/skills/sources Lists what is declared for the workspace (the session in X-Coddy-Session-ID, else the server default cwd, as the MCP tab does), in the order it takes effect: the built-in source, the entries of <home>/marketplaces.json, then those of the workspace's .coddy/marketplaces.json, each once (Skills). 200 {"object":"coddy.skills_sources","workspace","project_trust","entries":[...],"items":["owner/repo", ...],"system":["EvilFreelancer/rpa-skills"],"errors":[...]}. Each entry carries kind (source: installed whole; marketplace: a catalogue, plugins installed one by one), name (a marketplace's), source, origin (system, home, project), source_path (the declaring file; absent for the built-in source), gated (a project entry, the kind the workspace trust gate decides on), trusted, status (ready, needs_approval, denied) and fingerprint (the digest an approval binds to). items are the sources installed whole that are in effect, system the built-in ones (no remove control), errors a marketplaces.json that could not be read.
POST /coddy/skills/sources JSON {"source","sync","scope","kind"}: declares source - kind source (default, installed whole) or marketplace (its marketplace.json is read for the name it is kept under) - in the file scope names: global (default) <home>/marketplaces.json, local the workspace's .coddy/marketplaces.json, which also approves it for that workspace. Declaring downloads nothing; sync:true fetches a source at once (with kind: marketplace it is a 400: a marketplace installs nothing on its own). 200 {"ok":true,"added":bool,"sync":{...}}; 400 on an unrecognized source, scope or kind. The built-in source is in effect already and is written nowhere (added:false).
POST /coddy/skills/sources/trust Approves a project entry of the workspace's .coddy/marketplaces.json for that workspace, so it is synced and its plugins offered. Body {"key","fingerprint"}: key names the entry (a marketplace by name, a source by address); the optional fingerprint is the one GET /coddy/skills/sources showed, and an entry the checkout rewrote since is refused with 409. The approval is bound to the workspace and to the entry (kind, name, source) and recorded in <home>/skills-trust.json. 200 {"ok":true,"fingerprint"}; 400 for a missing key, an entry nothing declares, one that needs no approval (built-in or yours) or under skills.project_trust: deny.
POST /coddy/skills/sources/untrust Withdraws that approval (body {"key"}). Skills the entry installed stay. 200 {"ok":true,"removed":bool}.
DELETE /coddy/skills/sources?source=<key> Takes every source and marketplace key names (a marketplace by name, either kind by source, matched case-insensitively) out of <home>/marketplaces.json and the workspace's .coddy/marketplaces.json, or only out of the one the optional origin names (home or project, the origin of the row a client showed; Settings sends it); a marketplace removed by name takes a source of the same address with it, and a project entry its approval. Installed skills remain. 200 {"ok":true,"removed":bool}; 400 when the source query parameter is missing, for an unknown origin, and for the built-in source, which no file declares (a copy a file carries is taken out, and the answer says the source itself stays).
DELETE /coddy/skills/{name} Deletes any on-disk skill by name (its directory, plus its remote provenance entry when synced). Bundled (read-only) skills, and skills outside the configured skill directories, return 400. The skill is looked up in the workspace GET /coddy/skills lists it for: the session in X-Coddy-Session-ID, else the optional cwd query, else the server default cwd (Settings → Skills sends the chat's folder). A skill linked into a skills directory from elsewhere is removed as the link; what it points at stays on disk. 200 {"ok":true}. Invalidates the slash-command cache.
GET /coddy/mcp Merged MCP server list from two files: the user-global <home>/mcp.json (scope global) and the project-local .coddy/mcp.json (scope local), which overrides a name; both are Cursor-compatible, and config.yaml declares no server. JSON {"object":"coddy.mcp_list","workspace":"...","project_trust":"ask","items":[...]}, where workspace names the project the approvals are recorded for (for a linked git worktree, the same folder of its main checkout); each item has name, source (global / local), origin (home / project - the owning file), transport, command, args, url (all three as written), env and headers (the names, each value replaced by "<redacted>": no value of either file ever leaves the server), reads (the variables of the process its values name as ${NAME}, by name), source_path, enabled, status (connected / error / disabled / unsupported / needs_approval / denied), error (a probe error shows the URL as written and <redacted> for a value the declaration resolves to), trusted, gated, fingerprint, tools ([{name, description, enabled}]), disabled_tools. Enabled servers are probed for tools over their transport (stdio spawn, http streamable with legacy-SSE fallback, sse; connect, tools/list, close) with results cached until the definition changes; ?refresh=1 re-probes. Unknown transport types report unsupported without probing. A project-local entry that the workspace trust gate holds back is never probed: it reports needs_approval (or denied) with trusted:false, no tools, and the declaration an approval would cover.
POST /coddy/mcp/{name}/enable Clears the server's disabled flag. Global entries persist in their defining file; project entries persist in <home>/mcp-overrides.json, leaving the checkout unchanged. 200 {"ok":true}; 400 for an unknown server. Live sessions connect the server where the trust gate admits it, and keep their other servers running; a session in the middle of a turn connects it when its next turn starts.
POST /coddy/mcp/{name}/disable Sets the server's disabled flag under the same scope rule as enable. New sessions skip connecting the server; live sessions close it and keep their other servers running (a turn in flight keeps it until it ends).
POST /coddy/mcp/{name}/tools/{tool}/enable Enables {tool} in the server's effective tool list. Global switches persist in <home>/mcp.json; project switches persist in <home>/mcp-overrides.json. 200 {"ok":true}; 400 for an unknown server. Nothing reconnects: live sessions offer the tool again on their next turn.
POST /coddy/mcp/{name}/tools/{tool}/disable Disables {tool} under the same scope rule. The tool is hidden from the agent's tool list and rejected at dispatch. Same responses as the tool enable.
POST /coddy/mcp/{name}/trust Approves the declaration of a project-local server for the server's workspace, so sessions may start it, and connects it in live sessions. Optional body {"fingerprint":"sha256:…"} names the declaration the operator was shown, by the fingerprint of GET /coddy/mcp: when the checkout rewrote the entry since, the answer is 409 and nothing is recorded; without a body the current declaration is approved. The approval is bound to the workspace and to a digest of the command-bearing declaration (transport, command, args, env, url, headers) and is recorded in <home>/mcp-trust.json with a receipt (env and header names only). 200 {"ok":true,"fingerprint":"sha256:…"}; 400 for an unknown server, for a <home>/mcp.json entry (it needs no approval), or under mcp.project_trust: deny. Rewriting the entry withdraws the approval.
POST /coddy/mcp/{name}/untrust Withdraws the workspace approval. 200 {"ok":true,"removed":<bool>}. Live sessions close the server and keep their other servers running; a turn already in progress keeps it until it ends.
POST /coddy/mcp/project-trust Sets mcp.project_trust in config.yaml and reloads it. Body {"policy":"ask"|"allow"|"deny"}. 200 {"ok":true,"project_trust":"…"}; 400 for an unknown value. If the file is saved but the reload fails, 500 reports the failure and the running policy stays unchanged. The bundled UI edits this in the MCP servers tab, next to the servers it governs.
GET /coddy/subagents Subagent definitions visible from a workspace (?cwd=<absolute path>, default: the server's default cwd; a relative path is 400): the embedded built-ins (general, explore, and the hidden orchestrator crossreview), user-scope files under ~/.agents/agents and ${CODDY_HOME}/agents, project-scope files under the workspace's .agents/agents and .coddy/agents, then the extra directories of subagents.dirs (later directories win by name). {"object":"coddy.subagent_list","workspace","policy","items":[...]} where workspace is the canonical path receipts are keyed by (for a linked git worktree, the same folder of its main checkout), policy the effective subagents.project_trust, and each item carries name, description, scope (builtin, user, project), path, digest (SHA-256 of the definition bytes, the embedded bytes for built-ins), model, mode, builtin, hidden, the bounds the definition declares - tools, disallowed_tools, permission_mode, timeout_seconds, max_turns, background, isolation (worktree for a child that works in a git worktree of its own), spawns, and role_bytes (the size of the role body; the body itself is never served, since a client would render it before anyone approved the file) - and the trust decision for that workspace: trust (trusted or needs_approval) mirrored as the booleans trusted and needs_approval. A bound a definition does not declare is absent rather than empty: absent means "inherits". Under deny project directories are not read at all. Errors on this route and on the trust routes are {"error":{"message"}} typed application/json. The bundled UI lists this catalog under Settings → Subagents, where a project definition under ask carries a shield that calls the trust routes below. See docs/features/subagents.md.
POST /coddy/subagents/{name}/trust Approves the current content of a project-scope definition for a workspace: records a receipt in <home>/subagents-trust.json bound to the canonical workspace, the name and the file digest, so spawn_agent may run it under project_trust: ask. Rewriting the file withdraws the approval. Optional body {"cwd": ..., "digest": ...} (default cwd: the server's default cwd); digest is the one GET /coddy/subagents showed, and a file rewritten since is refused with 409 instead of approving content nobody saw. cwd must be the session's server-side workspace, the cwd field of the GET /coddy/sessions rows or GET /coddy/workspace/context with the session header, since receipts are keyed by that path. This route is the approval path for a remote console or ACP client, whose local coddy agents trust writes a receipt on the wrong machine. 200 {"object":"coddy.subagent","item":<catalog entry>} with the refreshed entry; 404 when no definition of that name is visible from the workspace; 409 when digest no longer matches the file; 400 for a built-in or user-scope definition (nothing to approve), a malformed body, or a relative cwd.
POST /coddy/subagents/{name}/untrust Withdraws the receipt of a definition for a workspace (same optional body). Withdrawing an approval that was never on file changes nothing. 200 with the refreshed entry; 404 unknown name; 400 malformed body or relative cwd.
GET /coddy/hooks Hook definition files a session with this workspace would load (?cwd=<absolute path>, default: the server's default cwd; a relative path is 400), in load order (hooks.files: the operator's ${CODDY_HOME}/hooks.json, then the workspace's .claude/settings.json, .claude/settings.local.json and .coddy/hooks.json). {"object":"coddy.hook_list","workspace","policy","items":[...]} where workspace is the canonical path receipts are keyed by (for a linked git worktree, the same folder of its main checkout), policy the effective hooks.project_trust, and each item carries file (the name receipts and the CLI use: the workspace-relative path for project scope, the absolute path otherwise), path, scope (user, project), digest (SHA-256 of the file), the trust decision for that workspace (trust, mirrored as trusted and needs_approval), error for a file that does not parse, warnings, and hooks: one row per handler (event, matcher, type, command, args, timeout_seconds, async, fail_closed, unsupported). Under deny project files are not read at all. See docs/features/hooks.md.
POST /coddy/hooks/trust Approves the current content of a project-scope hooks file for a workspace: records a receipt in <home>/hooks-trust.json bound to the canonical workspace, the file and its digest, so its hooks run under hooks.project_trust: ask from the next turn. Rewriting the file withdraws the approval. Body {"cwd": ..., "file": ...}: cwd must be the session's server-side workspace (default: the server's default cwd), file the name GET /coddy/hooks lists. This route is the approval path for a remote console or ACP client, whose local coddy hooks trust writes a receipt on the wrong machine. 200 {"object":"coddy.hook_source","item":<catalog entry>} with the refreshed entry; 404 when no such file is visible from the workspace; 400 for a user-scope file (nothing to approve), a file that does not parse, a missing file, a malformed body, or a relative cwd.
POST /coddy/hooks/untrust Withdraws the receipt of a file for a workspace (same body). Withdrawing an approval that was never on file changes nothing. 200 with the refreshed entry; 404 unknown file; 400 missing file, malformed body or relative cwd.
PUT /coddy/mcp/{name} Upserts one named entry in an mcp.json file. ?scope=local (default) targets the project .coddy/mcp.json of the workspace (the session in X-Coddy-Session-ID, else the server's default cwd); ?scope=global targets the user-global <home>/mcp.json. JSON body is the Cursor-format entry: command, args, env (object), url, headers (object), disabled, disabledTools; it replaces the stored entry whole. An env or headers value of "<redacted>", the placeholder the list shows, keeps the value the target file stores for that name, read in the same write. ?fingerprint= names the declaration the client was shown, by the fingerprint of GET /coddy/mcp: when the file holds another one (or none) by then, the answer is 409 and nothing is written or approved. A project entry keeps a stored value only with it. Requires command or url; 400 when the name contains __, spaces, or path separators, the scope is unknown, the body is invalid, a "<redacted>" names a key the file stores no value for, or a project entry keeps a value without fingerprint. 200 {"ok":true}. A project entry written under mcp.project_trust: ask is approved as written. Live sessions start the server, or start it again from the edited declaration, and keep their other servers running. config.yaml declares no server.
DELETE /coddy/mcp/{name} Removes the named entry from the mcp.json file that defines it (project or global); a project entry's switches in <home>/mcp-overrides.json go with it, unless it is deleted from a linked git worktree's file (they stay with the project). Live sessions close the server. 400 for an unknown server. 200 {"ok":true}.
GET /coddy/workspace/files Paginated workspace listing under session cwd (page, page_size required). Non-blank case-insensitive substring prefix on path_rel; omit or blank prefix yields empty items. dirs=true adds directory rows (kind dir, path_rel trailing /). JSON items[].name, items[].path_rel, items[].kind (file or dir), total, has_more.
POST /coddy/mentions/check Which @ mentions of a draft a sent prompt would attach, for the composer's highlight. Body {"text": "..."} (at most 256 KiB, else 413); X-Coddy-Session-ID names the session it would be sent to, and the optional cwd query the folder of a new chat, as on /coddy/slash-commands. The resolver runs dry - a file is looked at and never read, a folder is not listed, a session is found and not summarised, a page is not fetched. JSON mentions[], one per @ token in document order (a token in a fenced block, an inline code span or a quoted line is prose and left out): token, the whole token; typed, the part that resolves (@src/a.go of @src/a.go b.go); kind (file, directory, session, rule, agent, plan, url, doc). typed and kind are absent for a token that names nothing, such as @google/genai in npm install @google/genai. 400 for a broken body.
GET /coddy/info Which build serves this API, and where: {"object":"coddy.info","version":...,"hostname":...,"permissionMode":...,"toolsets":[...]}. toolsets names the toolsets[] presets of the configuration, empty without one: the start screen offers a toolset chip only while there is one and sends a pick with the first message as metadata.toolset (Toolsets). version is the string coddy -v prints; hostname is the machine's host name (empty when the system does not say); permissionMode is the permission mode a new session starts under: the one chosen last on any surface, kept in ${CODDY_HOME}/permission-mode.json (ask, accept_edits or bypass; ask until one is chosen), the same value every settings snapshot reports as configuredPermissionMode. The web UI shows the version at the right of the start screen's footer, for the server the page is talking to (the local one, a remote, a node reached through a relay), and names the machine the page runs on by its host name on the swarm map, where the connection to a relay starts. Its start screen, which has no session to read a snapshot from, shows permissionMode on the permission chip and sends a mode picked there with the first message as /permissions <mode> whenever it differs from that value; it reads the route again after every config_reloaded event and every reconnect of GET /coddy/events, and takes configuredPermissionMode from every newer event: session_settings, whichever session it is about.
GET /coddy/status What this coddy serve process is and what needs attention, as one document built from the running process on each request: the build, how it was started, the subsystems and their addresses, turns, waiting prompts and background tasks, the sessions disk, attention, and with the metrics tag the last hour of the provider series. It backs Settings → System (System); the members, units and codes are under System status. 404 from a server older than the route.
GET /coddy/docs The contents of the documentation built into the binary (Built-in documentation): version (the binary's) and groups[] of docs/nav.yaml, each with id, title, summary and pages[] (slug, title, summary). A slug is the page's path under docs/ without .md, the same in every language. No site is contacted. Optional lang on this route and the two below (ru, any spelling of a locale such as ru_RU.UTF-8) answers from the Russian translation; none, or a language without a translation, is English, and lang in the response names the language served.
GET /coddy/docs/page One page. ref (required) takes a slug, a path with or without docs/ and .md, a coddy: link, an @coddy: mention, a coddy.dev/docs address, a file name one page has or a title, optionally with #section. JSON slug, title, summary, group (id, title), anchor (the section ref named, else empty), markdown (links to other pages rewritten to coddy:<slug>#<anchor>, images and repository files to GitHub at the release tag), headings[] (level, text, anchor), prev and next (slug, title, or null), url (the public address, coddy.dev/ru/docs/<slug> for a Russian page), lang (the language of the text: English for a page the translation lacks). A section's anchor is the one GitHub gives its English heading, in both languages: a Russian page answers to it (and ref may also name the anchor of its Russian heading), and its headings[] carry the Russian text under the shared anchor. 400 without ref; 404 for a page or a section that does not exist, naming the closest pages or the page's sections.
GET /coddy/docs/search Sections of the documentation in lang ranked against q with BM25 over the page title, the section heading and the text, after stemming (English, and Snowball for Russian words, ё read as е); a word of three letters or more also finds the words it begins; at most three sections per page. Optional limit (1 to 50, default 10). JSON query, version, lang, hits[] (slug, title, group, anchor and heading unless the hit is the part above the first section, snippet[] of text fragments with hit on the matched words). An empty q answers no hits; 400 for a bad limit.
GET /coddy/mentions Candidates for an @ mention (the composer's picker, a console in remote mode). Optional q (the text after @; a leading " opens a quoted path), limit (1 to 200, default 50), refresh=1 (rebuild the workspace index first), lang (the language coddy: pages and sections are named in; without it, the language of q; the inserted address is the same in both); X-Coddy-Session-ID names the session whose cwd, rules and plans answer; the optional cwd query names the folder of a new chat that has no session yet, as on /coddy/slash-commands, so agent: lists the subagents that folder defines. A scheme-less q ranks the files and folders of the workspace (inside a git checkout the index follows .gitignore and keeps dotfiles) and merges in matching rules, subagents and plans; q starting with /, ~, ./, ../ or a drive letter browses that folder; session:, rule:, agent: list that kind (rule: puts first, as continue steps, the folders one level below the folder typed - folders of a rule folder and subfolders that keep a .coddy/rules/ - then every rule whose address starts with it); coddy: lists the pages of the built-in documentation, finds pages by slug or title and sections by their words, and after <page># the sections of that page; an empty q offers the four scheme hints and the top of the workspace, and a q that starts a scheme's name (ag) puts that scheme's hint first. JSON items[] (kind: file, directory, session, rule, agent, plan, doc or scheme; insert, the text that replaces @ plus the query; label; detail; continue, true when choosing it keeps the picker open), total (every match before the cut), indexing, index_truncated. 400 for a bad limit.
GET /coddy/workspace/file One workspace text file as display lines, for the composer's line-range picker. Required path_rel (workspace-relative, never escapes session cwd); optional cwd for a new chat's folder, as on /coddy/slash-commands; optional max_lines (1 to 5000, default 2000). JSON path_rel, mime_type, lines[] (no terminators; a trailing newline adds no empty last line and CRLF loses the trailing carriage return), total_lines (whole file), truncated. Legacy encodings decode to UTF-8; files over 512 KiB are refused. 400 for a directory, undecodable content, a traversing path or a bad max_lines; 404 for a missing file.
GET /coddy/workspace/context Workspace context for the composer chips: path, name, is_git_repo, is_worktree, plus repo_root (main checkout), base_branch (from origin/HEAD), branch, branches (local), remote_branches (branches that exist on a configured remote and not locally, named as a local branch would be - feature/x, not origin/feature/x - each once, as the last fetch left them), worktrees[] (path, branch, main) inside a git repository. shell carries the interpreter run_command executes through on the server host (/usr/bin/bash, cmd.exe, ...) - a host fact rather than a folder one, served here because this is the call the SPA already makes; the tool card names it instead of the word Shell. Uses the session cwd from X-Coddy-Session-ID (server default cwd without the header). Optional path query describes an explicit folder instead (pre-session preview); a missing folder yields 400. Git data degrades gracefully when the git binary is absent (is_git_repo:false).
POST /coddy/workspace/fetch Refreshes the remote branches when the web UI's branch list opens: fetches every configured remote of the workspace's repository the way git fetch --all --prune does and answers with the workspace context read afterwards plus fetch. Only remote-tracking refs move: the working tree, the index, HEAD, local branches and tags are never touched (tags are not fetched; a remote whose fetch refspec writes outside refs/remotes/ is left out and reported; remotes with skipFetchAll are left out). Credentials come from the repository's own git setup (credential helpers, the ssh agent) and nothing is prompted for, no askpass program included; without the git binary (the go-git fallback) only remotes that need no credentials, or take ssh keys from the agent, can be refreshed. Concurrent calls for one repository share one fetch, and the whole refresh is bounded to 20 seconds. fetch.status: ok (remotes, fetched_at), failed (remotes it set out to fetch, some of them possibly not tried after a timeout, and error with credentials in URLs masked; the branches in the answer are the cached ones, and a client must not present them as fresh), or skipped (reason: not a git repository, or no remotes to fetch when there is none or every one is marked skipFetchAll). A failed fetch is still 200. The folder is chosen as for GET /coddy/workspace/context: path query (a new chat before its session exists), else the session in X-Coddy-Session-ID, else the server default cwd; a missing folder yields 400.
GET /coddy/workspace/folders Direct subfolders of path (default: session cwd) for the workspace folder picker: {"path","parent","folders":[{"name","path"}]}. Hidden folders and node_modules are skipped; rows sorted by name. Missing folder yields 400. path=:drives: returns the machine's drive roots instead ({"drives":true,"folders":[{"name":"C:","path":"C:\\"}]}, path = parent = :drives:); on a host with no drive letters (Linux, macOS) the sentinel is just an unknown folder (400). A drive root reports "parent":":drives:", which is how the picker walks up out of a volume — everywhere else parent stays the real parent directory.
POST /coddy/workspace/folders Creates one subfolder for the folder picker: {"path","name"} makes name inside path (default: session cwd) and answers with the listing of the new folder ({"path","parent","folders":[]}), so the picker steps into it and Open picks it. name is a single entry: empty, ., .., a volume name, or anything containing / or \ yields 400. A parent that does not exist yields 400 and the chain is never created; path=:drives: yields 400 because the volume level has no directory behind it. A name already taken yields 409.
GET /coddy/config/schema JSON Schema (draft 2020-12) for the settings editor. Describes the same logical fields as ConfigJSON with description, default, pattern on providers[].name, optional x-coddy-* hints (for example x-coddy-provider-api-key-env-placeholder on providers[].api_key for dynamic placeholders), and x-coddy-property-order for section order. httpserver is omitted (listen bind stays on the coddy serve CLI), and so is metrics in a binary built without the metrics tag. GET / PUT /coddy/config still return the full document including httpserver and metrics when present in YAML. A swarm relay serves the same four /coddy/config routes behind its client token, scoped to swarm and logger: its schema describes only those, marked x-coddy-relay: true at the root, its GET /coddy/config serves only those and the revision, and a save naming any other section is 400 (The relay's own Settings).
GET /coddy/config/reasoning-levels Reasoning levels the ?model= logical id offers when models[].reasoning_levels is not overridden, for the settings form's Fetch reasoning levels button. Detection is model-id based (gpt-5* and gpt-6* -> minimal,low,medium,high; OpenAI o-series, gpt-oss*, qwen3*, Claude extended-thinking -> low,medium,high), the vendor an aggregator puts in front of the id left out (qwen/qwen3.8-flash reads as qwen3.8-flash). The provider type decides the Codex remap (minimal becomes none): optional ?provider_type= carries the type of the provider row as it stands in the (unsaved) form and wins over the saved config, so a provider that is not saved yet, or whose type was just changed, resolves the way it will after Save; without it the provider prefix is looked up in the active config, and an unknown prefix gets the un-remapped list. The id need not exist in models[], and a saved override is ignored - the button reports what the id offers, not what is configured. 200 {"ok":true,"model":...,"levels":[...],"detected":true}; a model with no reasoning support is {"ok":true,"levels":[],"detected":false}, not an error; a malformed id is 200 with {"ok":false,"error":...} so the form can show it inline; a missing model parameter is 400 with the same flat {"ok":false,"error":...} body the other /coddy/config routes use.
GET /coddy/config Current process configuration as JSON (same logical fields as config.yaml, including secrets: a provider's api_key, and the token of an httpserver.remotes entry that carries one, which the web UI presents to that remote; the credentials that open this server - httpserver.auth_token, the sign-in hash, the swarm tokens - are write-only and reported as set or not). Per-session path fields (skills.dirs, subagents.dirs, hooks.files, prompts.dir) are returned as written in the file, including a ${CWD} placeholder; ${CODDY_HOME} and the process-scoped directories are returned expanded. The document carries revision, which names the configuration it was read from: send it back unchanged with the PUT.
POST /coddy/config/validate Body is ConfigJSON. 200 with {"ok":true} when valid, 400 with {"ok":false,"error":"..."} otherwise.
PUT /coddy/config Body is ConfigJSON. Validates, writes config.yaml atomically, reloads in-process config for HTTP handlers and new agent turns. agent.model is optional: an empty value is stored as-is, and calls that need a default model (coddy -p, coddy acp, POST /v1/responses without metadata.model) report a missing model when they resolve one. The file is rewritten over its current content: comments (including commented-out keys), the existing key order and the spelling of every value the request did not change survive the save - a ${VAR} reference (so a key kept in the environment is not written into the file), ${CODDY_HOME}, ~, quotes, a list written on one line - and a file that carries no # yaml-language-server: $schema= header gets the published one (https://coddy.dev/config.schema.json) so editors keep validating it; a header pointing somewhere else is left as it is. A value sent back as the client read it keeps what the file says now: what the process runs differently from the file (a command-line flag, the relay address coddy serve fills in, a pairing token from the environment) is not written into it by an unrelated save, and a value another save changed after the client's GET is not put back. "As the client read it" is measured against the configuration the body's revision names (the last 32 this process served), else against the one live when the PUT arrives; a list is one value, and an edited list is written as sent. Keys the file never had appear only when their value differs from the built-in defaults and from what the file loads them as - unset optional fields are omitted rather than written as null, so commented-out sections stay out of the file, and an entry of a list (a provider, a model) keeps only the fields it named plus the ones the request set. MCP servers are not part of this document (they live in the mcp.json files, see /coddy/mcp); a changed mcp.project_trust reconnects them in active sessions, re-running the workspace trust gate so an unapproved project declaration is not started; a session with a turn in flight is reconnected when that turn ends, not mid-turn; ACP client-provided per-session servers remain connected. 200 {"ok":true}; 400 on validation failure; 500 if the file was written but reload failed (server restores config.yaml.bak over the primary path when possible).
GET /coddy/providers/{name}/models Fetches the provider model list. OpenAI, Anthropic, and NeuralDeep use their documented model endpoints (NeuralDeep at the deployment its api_base selects); Codex uses the fixed official Codex backend with the saved ChatGPT OAuth token and lists every model the account is offered, asking as Codex release 0.999.0 so that a model the backend reserves for a recent Codex release is not left out. A type: acp row starts its saved agent when it is not running and lists the models the agent offers (default first, the agent's own model). Credentials and proxy apply server-side. 200 {"ok":true,"models":[{"id","name","context_window"}]} on success (context_window only when the provider's listing reports one - spellings: limit.context, context_length, max_model_len, max_context_length, context_window); 200 {"ok":false,"error":"...","models":[]} when upstream fails; 404 for an unknown saved provider.
POST /coddy/providers/models Fetches the model list for a provider row posted in the body ({name, type, api_base, api_key, api_key_command, proxy} - the providers[] shape). Unlike the GET route, the row need not be saved in config.yaml: the settings form sends the entry being edited, so a provider that only exists in the form (for example right after an OAuth device sign-in) lists its models the same way a stored one does. Fields the body leaves empty are inherited from the saved provider of the same name when there is one, so a sparse {"name":"..."} post resolves the stored credentials without secrets travelling over the wire. The credential pair (api_key / api_key_command) is one slot: it is inherited only when the body posts neither field, and only while the resolved api_base and proxy still match the saved row, so an overridden route never receives stored credentials. The body must be application/json: any other content type (the kinds a cross-site page can send without a preflight) gets 415. A posted api_key_command is executed server-side, exactly as it would be for a saved provider. A row of type: acp lists the models its agent offers (default first, the agent's own model) from the saved row only: its acp block names a program, and a body cannot bring one of its own - an acp row that is not saved yet is 400. Same 200 {"ok":true,"models":[...]} / {"ok":false,"error":"...","models":[]} answer; 400 on a malformed body or an invalid provider row.
GET /coddy/providers/{name}/codex-auth Non-secret OAuth status for a Codex provider: connected, optional source (coddy or codex_cli), and optional account_id. The Codex CLI login counts only for the row it serves - the only codex row, or the row named codex among several; another row without a login of its own is not connected, and cli_login_row names the row the CLI login serves. Accepts a valid unsaved provider name so Settings can sign in before saving the row, and a saved row of another type that Settings is switching to codex; such a row lends the sign-in only its proxy. Never returns tokens.
POST /coddy/providers/{name}/codex-auth/device Starts ChatGPT device authorization and returns login_id, official verification_url, user_code, and status:"pending". The request must be Content-Type: application/json (the body is ignored, send {}); any other type is refused with 415 before the issuer is contacted, since a page on another site can send the other types without a preflight. A new start supersedes the provider's previous pending attempt, including one still waiting for the issuer to answer (that one returns 409); a sign-out cancels a pending start the same way. The saved provider's proxy applies. The server completes the exchange in the background and stores tokens at $CODDY_HOME/providers/<name>/codex-auth.json with restrictive file permissions.
GET /coddy/providers/{name}/codex-auth/device/{loginID} Polls the server-side exchange. Returns status pending, completed, or failed, connected, and optional error. Never returns tokens.
DELETE /coddy/providers/{name}/codex-auth Cancels the provider's pending device sign-in, so a confirmation that arrives afterwards cannot store the token again, then removes only the Coddy-managed token file. Returns the resulting status; source:codex_cli may remain when the server user has a separate Codex CLI login.
GET /coddy/providers/{name}/neuraldeep-auth Non-secret NeuralDeep sign-in status: connected, display masked key, optional key_name, and source - the credential requests actually use (oauth, api_key, api_key_command, env, or none; an explicit key wins over a stored login). hub names the hub that issued the stored login and endpoint_hub the hub a sign-in for the endpoint in the optional ?api_base= query (default: the saved row's, falling back to the default deployment) would use; Settings passes the endpoint picked in the form and warns when the two differ, since a key minted by one deployment is not honored by the other. Accepts a valid unsaved provider name, and a saved row of another type that Settings is switching to neuraldeep (the openai row of config.example.yaml, say); such a row lends the sign-in routes only its proxy, and its api_key, api_key_command and api_base do not count toward source and endpoint_hub. Never returns the key.
POST /coddy/providers/{name}/neuraldeep-auth/device Starts the hub's RFC 8628 device flow for client coddy and returns login_id, verification_url (the portal URL with the pre-filled code when the hub provides one, otherwise the plain verification URI), user_code, and status:"pending". The request must be Content-Type: application/json, the body optional; any other type is refused with 415 before the hub is contacted, since a page on another site can send the other types without a preflight. The hub is the one paired with the deployment: api_base in the optional JSON body (the endpoint picked in Settings, possibly unsaved), else the saved row's api_base (none for a row still saved as another type, so the default deployment); a body value that is not one of the two official endpoints is refused with 400 before the hub is contacted. A new start supersedes the provider's previous pending attempt, including one still waiting for the hub to answer (that one returns 409); a sign-out cancels a pending start the same way. The saved provider's proxy applies. The server polls the hub in the background (bounded by the login's expires_in) and stores the key at $CODDY_HOME/providers/<name>/neuraldeep-auth.json with restrictive file permissions.
GET /coddy/providers/{name}/neuraldeep-auth/device/{loginID} Polls the server-side wait. Returns status pending, completed, or failed, connected, and optional error. Never returns the key.
DELETE /coddy/providers/{name}/neuraldeep-auth Signs out: best-effort revokes the key on the hub (POST /api/cli/revoke), then removes the local credential file. Returns the resulting status.
GET /coddy/providers/{name}/opencode-auth Non-secret stored login status: connected, account, optional organization and masked, and the active credential source (none, oauth, stored_key, api_key, api_key_command, env). Accepts valid unsaved provider names and rows being switched to OpenCode.
POST /coddy/providers/{name}/opencode-auth/device Starts Console account login. Requires application/json; an optional proxy comes from the unsaved form. The request names no gateway: the login serves both, and the row's api_base decides where its requests go. A missing proxy retains the saved route, an empty value or inherit follows the process proxy, none connects directly. Returns login_id, verification_url, user_code, status and connected. The server polls Console, discovers the selected organization and stores tokens privately. A new start or logout cancels older attempts; shutdown cancels every wait. Invalid settings return 400, a superseded start 409, a non-JSON request 415, an upstream failure 502.
GET /coddy/providers/{name}/opencode-auth/device/{loginID} Returns pending, completed or failed, connected and optional error. Another provider cannot read the attempt (404). No token is returned.
DELETE /coddy/providers/{name}/opencode-auth Cancels pending attempts and removes the local credential, then returns status. Revoke the session or service account key in Console to end server-side access.
GET /coddy/providers/{name}/usage Account usage behind a provider row, for the status bar of every surface (design record docs/plans/neuraldeep-usage.md). The rows with a source today are type: neuraldeep (the hub's read-only GET /v1/limits), type: codex (the Codex backend's usage endpoint, read with the saved ChatGPT OAuth token) and type: devin (the seat-management status RPC of the Devin API server, read with the session token alone - a user JWT makes the server withhold the quota fields) - each read with the row's own credential and proxy, and a credential never leaves the server. 200 {"ok":true,"usage":{...}} with the provider_usage update: plan, keyName, windows[] (id, label, optional used/limit/remaining, usedPercent, exhausted, resetsAt on the source's clock, resetInSec age-corrected), rate (the live minute), cooldownSec, wallet (balanceRub, spentRub30d, the account's own rubles, possibly negative), blocked with blockers and retryAt/retryInSec, unlimited and unlimitedModels (upstream ids that bypass the windows), blockedModels (upstream ids the key may not call now, each with blocker, retryAt and retryInSec - a gate over part of the catalogue, so blocked stays false and only these models are refused), observedAt, fetchedAt, refreshPending/refreshInSec when pacing deferred a refresh, resuming only on the frames the agent itself sends on the turn stream while a turn waits for a hit limit to lift (agent.wait_for_limit_reset; never on this route). A source reports only the fields it has: NeuralDeep fills windows session/week/day plus rate, cooldownSec, wallet and the model gates; Codex fills windows labelled by their upstream durations (5h, week, plus feature-scoped entries such as Fast model · week) and the blocked state; Devin fills windows day/week (its reported remaining percents shown as used) for quota plans, acu for ACU plans, and plan. No request counts, RPM or cooldown are ever invented for a source that reports none. 200 {"ok":false,"unsupported":true} for a provider type without a source, plus "disabled":true when the row's usage limits panel is switched off (providers[].usage_limits_panel: false; nothing is read for that row and nothing is published for it); 200 **`{"ok":false,"error":"unauthorized
GET /coddy/sessions/{id}/activity Composer activity snapshot turnActive (a turn running in this server process, or the exclusive turn lock held by another one), activitySeq, readActivitySeq, lastErrorSeq, unreadComplete and questionPending. questionPending is true while an interactive question tool waits for an answer. It is process-local: only the Coddy server process that owns the interactive wait reports it. lastErrorSeq is the activity generation of the latest real failure, or zero when the latest outcome was not an error. While this process runs the turn the answer also carries its progress: turnStartedAt, turnElapsedMs, turnOutputTokens, turnTokensEstimated - what the turn_progress event of the turn stream reports, for a client that joined late. A turn finished background tasks started also carries backgroundWake ({tasks}, the tasks in the shape of the background_wake stream frame), so a client resuming the session mid-turn - a console over --remote - learns that nobody typed it and follows it. A turn held by another process leaves these out.
GET /coddy/sessions/{id}/tool-calls Tool calls timeline; each resultPreview is capped at 19 content lines plus a final ... row when truncated (20 visible preview rows total; see resultPreviewTruncated, resultTotalLines). Todo mutations (coddy_todo_item_update, coddy_todo_plan_replace) also include their final planSnapshot so historical cards retain the state they produced. ?from=K&to=E lists only the calls issued by messages K to E (exclusive, to defaults to the end), so a client holding one page of GET .../messages reads the files of that page's calls only; a value that is not a non-negative integer is 400.
GET /coddy/sessions/{id}/tool-calls/{toolCallId} Always full persisted result plus meta and args. The bundled SPA requests this when the user activates More… under the tool result after the streamed or list preview was truncated (not when merely expanding the disclosure).
GET /coddy/sessions/{id}/goal The session goal: {"object":"coddy.session_goal","sessionId","goal","version"}, goal null without one, else id, objective, status (active, paused, blocked, complete, limited), statusReason, setAt, updatedAt, continuations of maxContinuations, checks, activeMs, tokensUsed and tokenBudget, lastCheck (verdict met / not_met / needs_user / impossible, reason, remaining, verified, at, model), checklist (text, status met / not_met / unverified, evidence), and model / reasoning, the checker /goal --model and --reasoning chose (absent when the goal follows the configuration), and checkModel / checkReasoning, what the next check runs on: model, else supervisor.model, else the session's model, at reasoning, else that model's reasoning_default (default when the model offers levels but configures none, absent when it offers none). A goal of a chain also carries thenFile, the follow-up file /goal --then-file named, chainIndex, its place in the chain (0 for the goal the operator set, N in the N-th session the chain opened), and continuedFrom / continuedIn, the sessions on either side. version orders it against the session_goal frames. Setting a goal and starting work is a prompt, /goal <objective>, through POST /v1/responses; so is /goal resume. See Session goal and supervisor.
PATCH /coddy/sessions/{id}/goal Exactly one of {"status":"paused"} (stops the automatic continuations, keeps the goal; no goal answers 409 no_goal, any other status 400 invalid_request) or {"objective":"..."} (replaces the goal with a fresh active one without starting a turn, its follow-up file and its place in a chain dropped with the rest; empty or over 4000 characters is 400). Answers like GET.
DELETE /coddy/sessions/{id}/goal Clears the goal; a turn working on it finishes its step and gets no continuation. Answers like GET with goal: null.
GET /coddy/sessions/{id}/queue Waiting rows carry id, text, mode (steer or after_turn), optional imageParts (each image's name, mimeType and sizeBytes, never its bytes), and createdAt, plus the queue version. After-turn rows remain visible after Stop while the session is idle. See Message queue.
POST /coddy/sessions/{id}/queue Queue {"text":"...","mode":"steer","inline_files":[{"name":"image.png","data_url":"data:image/png;base64,..."}]}. Mode defaults to steer; after_turn runs after the current answer. Text may be empty when an image is supplied. Each data_url must be a base64 data:image/... URI, or the answer is 400 invalid_request; for a session model without multimodal: true the images are left out, as POST /v1/responses leaves them out of a prompt. 201 returns the stored message and queue. No active turn answers 409 no_active_turn, a full queue 409 queue_full, and a child session 409 subagent_read_only. Settings commands at the start apply immediately; a command-only request without images returns 200, and one with images queues the images. Turn-scoped commands followed by a prompt answer 409 turn_scoped_follow_up. Stop drops unread steer rows and retains after-turn rows without starting them.
PATCH /coddy/sessions/{id}/queue/{message_id} Change a waiting row's mode with {"mode":"steer"} or {"mode":"after_turn"} and answer with the queue. 400 invalid_request for another mode, 404 not_found if the agent already read it.
DELETE /coddy/sessions/{id}/queue/{message_id} Takes one queued message back and answers with the rest, plus message: the message taken back, with its images in full under inline_files ([{name, data_url}], the shape POST takes) so a client can put it back into its draft. 404 with code not_found when the agent read it first - losing that race is ordinary, and the message is already in the conversation.
DELETE /coddy/sessions/{id}/queue Drops everything waiting and answers with the empty list.
GET /coddy/sessions/{id}/background-tasks The session's tasks: commands the agent started with run_command background: true (kind command) and subagent runs started with spawn_agent (kind agent; such rows carry agent {name, session_id, model, reasoning, input_tokens, output_tokens}: the definition name, the child session whose transcript GET /coddy/sessions/{session_id}/messages serves, the model the child runs on, the reasoning level it calls that model with (left out for a model that offers none), and what its model calls have spent so far - the input every call sent, summed, and the output generated, the call in flight estimated until the provider reports it; a system run such as the memory subagent also has system: true, and a child with isolation has worktree and branch, the git worktree it works in and its branch, the worktree gone once a run that left nothing there ended) and preview servers started with preview_server (kind server, with url: the address the static file server answers at; such a row has no process, and timeout_seconds 0 means it runs until it is stopped, see docs/features/preview-server.md). Each row carries id, kind, label, command, status (queued, running, succeeded, failed, timed_out, stopped, orphaned), started_at, finished_at, exit_code, expected_seconds (the model's own estimate), timeout_seconds (the hard limit), notify_on_finish (the task wakes the agent when it ends; recorded only where a wake can happen - never by coddy -p, a subagent or a scheduled run), woke_agent (the task's end started a turn: set when that turn begins and kept in the record, it is what the bell on a finished card stands for), plus server-computed elapsed_seconds, overdue, running; the payload also has a running count. Tasks recorded by an earlier coddy process are merged in from the session bundle as orphaned. Poll this for status: a background task outlives the SSE stream of the turn that started it. A running agent row also carries pending_permission while a detached child - one whose spawning turn has ended - is blocked on a permission prompt: the payload of the SSE permission event (sessionId is the child session, toolCall, options) plus parent_session_id, task_id, agent_name and asked_at. The bundled UI shows it at the end of the parent session's chat; it is announced on GET /coddy/events as subagent_permission, answered through POST /coddy/sessions/{child}/permission, and never persisted, so a prompt cannot outlive the process that raised it. See docs/features/background-tasks.md.
DELETE /coddy/sessions/{id}/background-tasks Clears the finished tasks of the session, in memory and from the session bundle, and answers with cleared. Running tasks are untouched. History is deleted with the session anyway; this is the operator's way to throw it away early.
GET /coddy/sessions/{id}/background-tasks/{task_id} One task plus output, the combined stdout and stderr captured so far (works while it still runs). Optional ?tail=N returns only the last N lines; a non-integer or negative value is a 400. A task the pool no longer holds is answered from the session bundle log, capped at its last 256 KiB with output_truncated set. 404 when the id is unknown.
POST /coddy/sessions/{id}/background-tasks/{task_id}/stop Terminates the task and the whole process group it started, then returns the final row and its output. Stopping an already finished task changes nothing and still returns 200. An unknown id is a 404; a task that exists but could not be terminated is a 500, so "not found" never masks a process that is still alive.
GET /coddy/sessions/{id}/stats Session stats: completed model-call token totals (and optional per-turn list) plus the current contextBreakdown. Compaction rewrites the conversation category and estimated total immediately while preserving provider counters. The envelope also carries contextWindow {"model","tokens","source"} - the window this session measures against, resolved as GET /v1/models resolves it (source: config / provider / default), named with the model it belongs to. A provider listing can answer after the model list was served, and a client that was viewing another session missed the usage_update carrying the correction; reading it here is how that client catches up without waiting for the next turn.
GET /coddy/sessions/{id}/messages OpenAI-shaped messages, uiLog, the session's settings snapshot ({sessionId, version, model, reasoning, reasoningChoices, mode, permissionMode, configuredPermissionMode, overrides}, Session settings), and messagesRev - the revision of the history the list was read at, which a client attaching to a running turn passes to composer-stream as ?since_rev=. An archived session carries archived: true here, which is where a client learns it - the session listing skips the archive, so the conversation on screen may be in no page it holds. For a child session spawned by spawn_agent the payload also carries readOnly: true and subagent {parentSessionId, name, taskId}: the transcript is served from the live child while it runs and from its bundle afterwards, and no route accepts a prompt for it (409), so the bundled UI replaces the composer with a notice linking to the parent chat. User rows with uploaded files include files: [{name, mime_type, preview_url?, url?}], and a tool row carries the same files for the pictures its call returned (read on an image file, an MCP tool's image blocks), which the bundled UI previews on the call's row. An assistant row carries the model's reasoning and reasoning_duration_ms when it reasoned, and reasoning_tokens when the provider reported what the reasoning cost - a row whose reasoning text the provider kept to itself included. An assistant row whose text stops partway because the model's output limit cut it off (or a provider failure cut off a part of an answer the model was continuing after that) carries truncated: true: the assistant row after it goes on from its last character, content is stored as streamed with the whitespace at the join, and the bundled UI draws the two as one text, as the stream did. A user row nobody typed - the first message of a turn finished background tasks started - carries background_wake {tasks: [{id, kind, label, agent, status, exit_code, duration_ms, error}]}; its content is the instruction the model read, and the bundled UI shows nothing for the row: the turn reads as the agent carrying on. An assistant row a model of type acp wrote carries agent_tools [{id, title, kind, status, input, output, output_lines}], the tool calls the agent behind it ran on its own side, in the order it announced them: output holds the first lines of a call's output and output_lines how many it had when there were more, the whole of it is GET /coddy/sessions/{id}/tool-calls/{toolCallId} with the row's id, and the tool-calls list carries them as rows too; the bundled UI draws them as tool rows before the row's text (ACP agent as a model). They are never tool calls of Coddy's. A persisted decodable image receives a session-scoped preview_url (the bounded thumbnail) and, while the asset is still in the bundle, a url for the original bytes a preview card opens enlarged; original data URLs and absolute asset paths are not returned. uiLog holds the rows a transcript shows besides the history: errors, the notice a turn stopped short ends with, hook messages, and the notice of a settings change the agent made itself (switch_model, a skill's frontmatter). A change the user made leaves no row, and the notices of such changes that a session saved by an earlier version holds are not returned. After POST .../cancel, the last assistant row may appear on disk one persistence step later than the in-flight stream; clients that already showed partial text should merge with local state (bundled UI uses mergeTranscriptPreferLocalSuffix in external/ui/src/ui/chat/transcriptServerSnapshot.ts). Paged reads: without limit, before or from the whole history comes back. ?limit=N is a page of about N messages (1..1000) ending at before (default: the end), ?limit=N&before=K the page before a window starting at message K, ?from=K a window from K to before or the end (not with limit; before alone is refused, since it would read the whole prefix). A page never splits a tool step or an answer the model continued after its output limit (a truncated row and the assistant rows that go on from it) and, with limit, starts at the prompt of its turn when one lies within half a page, so pages join without a gap or an overlap; positions past the history are clamped and any other bad value is 400. Every read carries window {offset, total, turnsBefore, userRowsBefore}: the index of the first returned message, the length of the history, the prompts before the page that are not compaction summaries (a prompt's userMessageIndex for POST .../rewind is turnsBefore plus its position among the page's prompts) and every user-role message before it (the numbering of uiLog userTurnIndex). uiLog holds only the page's rows; a row on the boundary of two pages opens the newer one, so the newest page still shows what ended the turn before it. The bundled UI reads a session this way (Web UI). rewindUndo {userMessageIndex} is present while the last rewind can be taken back with POST .../rewind/undo; the index names the prompt that was edited.
GET /coddy/sessions/{id}/assets/{name} The uploaded asset at its original size - what a preview card opens enlarged, the thumbnail beside it being bounded to a 160px edge. The URL is supplied by messages[].files[].url, present only while the asset is still in the session bundle. Only images leave the bundle, and the file name never decides that: the first 512 bytes are sniffed, and anything that is not an image/* media type is 404, so a text file called photo.png is not served. Answers with the sniffed Content-Type, Cache-Control: private, max-age=31536000, immutable and X-Content-Type-Options: nosniff.
GET /coddy/sessions/{id}/assets/{name}/thumbnail Bounded PNG preview persisted for an uploaded session image or a picture a tool call returned. The URL is supplied by messages[].files[].preview_url.
GET /coddy/sessions/{id}/artifacts/{artifactID} Downloads one immutable file explicitly published by the agent through share_file. The identifier is resolved only through that session's artifact manifest; guessed IDs, traversal, files from another session, symlinks, a digest mismatch, HEAD, and requests carrying Range are refused. The response is always application/octet-stream with an attachment disposition, Content-Security-Policy: sandbox; default-src 'none', X-Content-Type-Options: nosniff, and private immutable cache headers. Published descriptors appear as metadata-only artifacts arrays on the completed tool update, its persisted role-tool message, and the tool-call list/detail responses; they contain id, name, sha256, size, url, the sourcePath and relativePath the file was copied from and sourceState, never file bytes. sourceState says where that file is now: present while it is still at relativePath in the session's workspace, deleted once it was removed from there (or replaced by something that is not a regular file), unavailable when it is not a file of this workspace at all (the session moved to another folder, a link stands at the path or on the way to it, or the descriptor records no source). The web UI's card opens the source in the Files window only while it is present.
GET /coddy/sessions/{id}/artifacts/{artifactID}/preview Inline original bytes for a shared image artifact. The server resolves the artifact only through its session manifest and sniffs its first 512 bytes, returning it only when the content type is image/*. A filename does not grant image treatment, so text called preview.png remains 404. The descriptor's optional previewUrl points here for image-named artifacts; clients should use it for thumbnails and the artifact url for a download.
GET /coddy/events text/event-stream of activity that is not tied to one session: event: turn_started and event: turn_ended with {object, sessionId, phase, at}, for every turn in this server process whichever surface started it; event: provider_usage with {object:"coddy.provider_usage", sessionId, usage} whenever the manager built a fresh account-usage snapshot outside a request (a finished turn, a deferred refresh; sessionId names the turn that caused it, the snapshot itself is account-wide); event: session_question_pending with {object:"coddy.session_question_pending", sessionId} when an interactive question wait is registered or settled (both lifecycle edges use the same notification and carry no question text; the event and questionPending are process-local to the Coddy server process that owns the wait; clients re-read GET /coddy/sessions or GET /coddy/sessions/{id}/activity for questionPending); event: message_queue with {object:"coddy.message_queue", sessionId, messages, version} on every change of a session's message queue, whoever made it and whichever client is watching - a session is shared, so what one browser queues onto the running turn appears in the others and in a console attached over --remote (the same change also travels down that turn's own stream, so ordinary deliveries keep the higher version; browser restart recovery follows the snapshot rules); event: session_settings with {object:"coddy.session_settings", sessionId, settings, notice, source} whenever a session's model, reasoning level, mode or permission mode changes, from whichever surface - a command, PATCH, the permission dialog, the model's switch_model, a console or an editor - with the whole snapshot and its process-wide version, so a client keeps the highest it has seen, and a notice of the change only when the agent made it itself (its switch_model, a skill's frontmatter); event: session_goal with {object:"coddy.session_goal", sessionId, goal, version, notice} whenever a session's goal changes from whichever surface or from the supervisor (the shape of GET /coddy/sessions/{id}/goal, goal null once cleared); and event: config_reloaded with {object:"coddy.config_reloaded", at} after every swap of the live configuration - a PUT /coddy/config save, the agent's config_commit or config_rollback, a skill install. The reload event carries nothing but the fact: what moved is already behind GET /v1/models and GET /coddy/slash-commands, so a client re-reads whichever of those it renders. It is published only once the new configuration is live, so that re-read cannot catch the outgoing one. event: session_changes with {object:"coddy.session_changes", sessionId, at} is published after POST /coddy/sessions/{id}/changes/revert discarded changes of the session's folder, so every Edits view of the session reads GET /coddy/sessions/{id}/changes again; after a turn, which may have edited the folder too, turn_ended is that signal. event: background_wake with {object:"coddy.background_wake", sessionId, phase:"woken", at, tasks} says the turn now holding a session was started by finished background tasks, not typed; a console attached over --remote, which otherwise reads only the turns it starts, follows that turn on GET /coddy/sessions/{id}/composer-stream, prompt and all. event: subagent_permission tracks the permission prompt of a detached subagent (one whose spawning turn has ended): phase asked with {object:"coddy.subagent_permission", phase, parentSessionId, childSessionId, taskId, toolCallId, agentName, askedAt, request} (request is the payload of the SSE permission event under the child's id) when it starts waiting, and phase settled (the ids, no request) once it is answered anywhere, withdrawn or its run ends, so a client that did not answer takes its copy down; the answer goes to POST /coddy/sessions/{childSessionId}/permission and the first answer from any surface wins. On connect it replays one turn_started per turn already running - at is when that turn started, not when the client connected - followed, for a turn finished background tasks started, by its background_wake with the same at (a live background_wake is dated at the turn's start as well, so a client that hears of one wake twice knows it is the same turn), and one subagent_permission per prompt still waiting, then event: ready; an idle stream sends SSE comment keepalives. Lets a client attach to a turn it did not start, and keep a model picker honest, without polling GET /coddy/sessions. The bundled UI holds one such connection for all the tabs of an environment rather than one per tab, since a browser keeps only six HTTP/1.1 connections per host (Server events shared across tabs).
GET /coddy/sessions/{id}/composer-stream text/event-stream relay of the live agent/plan/ask turn (same SSE as POST /v1/responses with stream: true). Replays bytes captured so far, then live tail until the turn ends. With no turn running, answers at once with event: error and error.code no_active_stream so the client can fall back to GET .../messages. While a turn runs but has not attached its relay yet, emits the SSE comment : composer stream pending (about 30s max), then the same error event if none appears. Replayed frames carry an id: sequence; a reconnecting client sends it back as Last-Event-ID (or ?last_event_id=) to resume after that frame. A client that has just loaded GET .../messages passes its messagesRev as ?since_rev= instead and is replayed only the frames that transcript does not hold - the step still streaming, not the finished steps already persisted, which a full replay put on screen a second time. If the frames a client needs were already trimmed from the buffer, the stream leads with event: desync ({object, lastEventId, resumedAt}) so the client reloads GET .../messages rather than rendering a gap. A frame older than 250ms when it reaches the subscriber carries an age: field (milliseconds) so the client dates it when it happened: a replayed reasoning block keeps its start and a replayed tool call its duration. The bytes of the primary POST /v1/responses stream are unchanged.
POST /coddy/sessions/{id}/permission Resolves a tool permission prompt while a streamed ReAct turn is blocked - a turn a client posted, or a turn the server woke on its own, whose prompt goes to the composer relay and waits, persisted, for whichever client shows it first. Body {"toolCallId","optionId"} where toolCallId matches event: permission on the composer SSE (toolCall.toolCallId). optionId is allow, allow_always (remembers this exact command), allow_always_program (offered for run_command only, and only when the command is a single plain invocation; remembers the program, or the program plus its subcommand for multiplexers like git), allow_always_url / allow_always_origin (offered for http_request instead of allow_always; remember the request's address or its whole origin together with the files, proxy, unchecked certificate and output path it carried), or reject (or send outcome allow / cancelled). reject is a person's refusal and outcome cancelled a prompt dismissed: for a command the decisions check stopped, a refusal ends the call as permission denied by user and a dismissal while its turn runs with the check's own refusal. 204 when accepted. A prompt persisted by a turn that no longer runs (a restart of the server, a stream that ended) is answered here too: if no newer user message made it stale, the answer resumes that turn in the background, published on the composer relay; if it is stale, the gate is cleared, the tool is not run, and the newer history is preserved. A fresh prompt the resumed turn raises is asked the way a woken turn's is - an http_request whose tools.http_request.default_headers changed since its prompt is asked again with the request it would send now. 404 when nothing is waiting. 409 for a child session spawned by spawn_agent that is not waiting: its prompts are normally relayed to the parent chat, so it holds none of its own and nothing may resume a turn on it. The exception is a detached child whose spawning turn has ended: its prompt is published as pending_permission on the parent's background task row and as event: subagent_permission on GET /coddy/events, and is answered here under the child's id, the one its sessionId names. Optional X-Coddy-Session-ID must match {id} when set.
POST /coddy/sessions/{id}/question Answers the interactive question tool while a streamed ReAct turn is blocked. Body {"requestId","answers"} where answers is an array of string arrays (one row per question, entries are option labels or custom text). requestId must match event: question on the composer SSE. 204 when accepted. 404 when nothing is waiting. Optional X-Coddy-Session-ID must match {id} when set.
PATCH /coddy/sessions/{id} {"title"} pins session.json.titlePinned. {"tags":[...]} replaces the session's labels wholesale rather than merging into them, so an empty array clears them and omitting the field leaves them alone; values are normalized (lower case, inner whitespace as a hyphen, duplicates dropped, at most 8 of at most 32 characters) and the answer carries what was stored. {"archived":true} puts the session in the archive and stamps archivedAt; false takes it back out and drops the stamp. {"pinned":true} holds the session at the top of every listing and stamps pinnedAt; false lets it back into the order. Setting either to the value it already has changes nothing and writes nothing, so the stamp keeps saying when it actually happened. {"markActivityRead":true} advances readActivitySeq to the current activitySeq (clears the unread-completion marker and acknowledges a matching lastErrorSeq) without changing session.json.updatedAt, so the session does not jump in the history list. It does not change lastErrorSeq; the PATCH response includes all three counters. The settings: selectedModelId (a configured model; "" goes back to agent.model), selectedReasoning (a level the model offers, off only when that logical model sets allow_reasoning_off: true, "" or default for the model's own), mode (agent, plan, ask) and permissionMode (ask, accept_edits, bypass; persisted as session.json.sessionPermissionMode, so it survives a restart; a change for the session also becomes the mode new sessions start in), with turns (1 to 50) to change them for that many turns instead of for the session; they go through the session manager's setter, which validates them (400 naming the offered values, 409 for a child session), announces event: session_settings and answers with the whole settings snapshot. At least one field is required.
POST /coddy/sessions/{id}/workspace Switches the session workspace. {"path":dir} moves the session cwd to an existing folder — validated before the session exists, so a fresh session is created straight in the target workspace — and re-derives everything the workspace decides: skills, project rules, slash commands, SessionStart hooks (re-fired with source workspace, so the stored hook context follows the new folder), and the configured MCP servers, which are re-dialed for the new cwd through the trust gate (the new workspace's .coddy/mcp.json is merged and gated fresh, a project declaration staying cold under mcp.project_trust: ask until approved) while the previous workspace's configured clients are closed; ACP client-supplied servers are preserved. A failed dial warns and continues rather than failing the switch. The new cwd persists in session.json. {"branch":b} checks the branch out in place and runs the same workspace-scoped reload (a checkout can change .coddy/mcp.json, skills, rules or hooks under the same cwd); if the branch is already checked out in another worktree (including the main one) the session cwd jumps there instead. A b that exists only on a remote (an entry of remote_branches) creates the local branch tracking the remote one - origin's when origin has it, else that of the first remote by name. A b written as a remote-tracking branch (<remote>/<branch>) with no local branch of that name means the local <branch> tracking that remote: created when it does not exist, the existing one when it already tracks that remote branch, and 409 when an unrelated local branch of that name is in the way. The in-place and worktree rules then apply to that local branch. {"branch":b,"worktree":true} fetches origin and ensures a feature worktree (created under <repo>/.coddy/worktrees/<branch>/, below a folder that carries its own .gitignore, so the worktrees never show up as untracked in the main checkout) and moves the session cwd into it. A new local branch starts from the fresh origin default branch, a branch that exists only on origin is checked out from origin/<branch> with tracking, an existing local branch is reused at its own tip, and a worktree already holding the branch is reused - reuse works offline, while creating requires a reachable origin. The default branch itself, a local branch tracking it, and a branch checked out in the main checkout (so worktree:true on the current branch) are all refused with 409. Session is created on demand (draft flow). The workspace is chosen once per session: as soon as the conversation has messages, or a turn is in flight, any switch yields 409. Responds with the fresh workspace context (same shape as GET /coddy/workspace/context plus id). 400 for a missing folder or branch switch outside git; 409 on git checkout/worktree failures or a locked workspace. 409 also for a subagent child session, whose transcript is read-only and whose workspace belongs to its parent's turn.
POST /coddy/sessions/{id}/cancel Best-effort cancel of an in-flight ReAct turn or streamed direct completion (ACP session/cancel). Success acknowledges the cancellation request, not completion; turn_ended or a fresh GET /coddy/sessions/{id}/activity reporting idle confirms release. For persisted bundles, also writes a cross-process cancel marker so another coddy process holding the turn can stop cooperatively. When the LLM had already streamed assistant tokens, the agent persists that partial assistant row before ending the turn (StopReasonCancelled). Optional X-Coddy-Session-ID must match {id} when present. 404 when no live or persisted bundle exists under that id (session store required for cold loads).
POST /coddy/sessions/{id}/compact Compacts (summarizes) the session history since the last summary row into one summary row inserted into the transcript after it; later LLM prompts replay the chain of summary rows plus the kept tail (the earliest rows are merged into one when the chain outgrows a third of the window), while GET .../messages keeps every original row. This is a manual trigger, so it forces compaction: it folds whatever exists even when the conversation is shorter than the keep-recent boundary (compaction.keep_recent_turns, default 2 user turns), reducing the kept tail as needed. Equivalent to the built-in /compact prompt command (which additionally accepts trailing summarizer instructions, e.g. /compact focus on file paths). Optional body {"instructions":"...","model":"...","reasoning":"..."}: model is the command's --model, the summarizer for this one compaction - a models[].model in any letter case, its name without the provider, or a part of one that matches exactly one configured model; the configured chain stays behind it as the fallback. reasoning is the command's --reasoning, the level the summary is written at: one the summarizer offers (the model model names, else compaction.model, else the session's), or default for its own; fallback models that do not offer it run at their own. It runs as a turn of the session: GET /coddy/events carries turn_started / turn_ended for it, so another client viewing the session reloads its transcript and the smaller context stats. A history larger than the summarizer's own context window is folded in several passes instead of being refused, each pass carrying the summary so far, and the progress reaches every client of the session as a compact_context tool-call row. 200 {"compacted":true,"summary","compacted_messages","kept_messages","model","steps"} (steps is the number of summarization calls: 1 while the history fits one request); 200 {"compacted":false,"reason":"nothing_to_compact"} only when there is no prior conversation to fold, or nothing since the last summary row; 400 when compaction.enable is false, the body is malformed, model names no configured model or more than one, or reasoning is a level the summarizer does not offer (checked before the session is admitted, so nothing is compacted and no turn starts); 404 unknown session; 409 while another agent turn holds the composer lock or the session is being deleted, and 409 for a child session spawned by spawn_agent (read-only transcript).
DELETE /coddy/sessions/{id} Removes the session tree: the session plus every child session spawned by spawn_agent (found by parentSessionId, nested descendants included). The task representing each child run is stopped and awaited first, root to leaf, then every remaining background task of every node, and only then are the bundles removed deepest first (the requested session last), so nothing writes into a removed directory. Each bundle goes entirely (including tool_calls/, stats.json, and task logs) together with its in-memory MCP clients. Deleting a child session directly removes that child and its own descendants and stops its task in the parent's tasks drawer. An active turn of any node is cancelled and awaited before anything is removed, and a turn that arrives during the deletion is refused. 200 {"object":"coddy.session_deleted","id"}, also for an id with no bundle on disk; 400 for a malformed id, 409 when a cancelled turn is still running after the settle timeout or when descendants kept appearing while the tree was being marked (nothing was removed in either case, retry once the tree is quiet), 500 when the directory cannot be removed.
POST /coddy/sessions/pins/reorder {"ids":[...]}, the pinned sessions top first, rewrites pinnedRank so the listing reads in that order. The whole order travels rather than the id that moved: a list rewritten from what the client was looking at cannot interleave with a concurrent change into an order nobody asked for. Every id is checked before anything is written - valid, stored, pinned, listed once - so a 400 leaves every pin where it was. A session pinned afterwards goes above them all, and unpinning forgets the placement, so pinning again is a new pin rather than a return to an old seat.
POST /coddy/sessions/bulk-delete Removes several session trees in one request, each with the semantics of DELETE /coddy/sessions/{id} (background tasks and subagent children stopped, bundles removed deepest first). Body is either {"ids":[...]} (scope ids, the default), {"scope":"all"} or {"scope":"archived"}, either scope with an optional "except":[...] list of ids to keep. Both scopes are resolved server side against the session listing (scheduler runs excluded, subagent children going with their parents), so they mean the whole stored history rather than the page the client has loaded - which is what makes "delete everything but the conversation I am in" expressible at all. all reaches into the archive as well, since a scope that left sessions behind because they were put aside would not be the whole history; archived empties the archive and touches nothing else. An except entry is a promise that the session survives, so it is checked before anything is removed: a malformed id, or one with no bundle on disk, is a 400 and nothing is deleted - a misspelt exception would otherwise turn keep this one into delete everything. Sparing a session spares its ancestors too, because the delete takes a whole tree: excepting a child keeps the parent it hangs from (the listing names the parent, not the child). One id that cannot be removed does not abandon the rest: 200 {"object":"coddy.sessions_bulk_deleted","requested","deleted":[...],"failed":[{"id","error"}]} lists both, so a table drops the rows that went and keeps the others with their reason; a failed entry carries the retryable conflict verbatim and a generic delete failed for anything else, which is logged rather than returned. A duplicate id is deleted once (requested counts what was attempted), and an id with no bundle on disk counts as deleted, as the single-session route also answers 200 for it. 400 for a malformed body, an empty ids list, an unknown scope, an invalid session id, ids sent together with a scope, except sent beside an ids list, or an except entry that names no stored session. The bundled UI sends ids for the rows an operator ticked - the conversation on screen is never one of them - and scope: "archived" with that conversation in except for its empty the archive action, so the protection of the open chat holds for a scope the server resolves; scope: "all" is there for a client that wants the whole stored history without paging through it.
POST /coddy/sessions/{id}/rewind Truncates the session's history at one user message, in place. Body {"userMessageIndex":N} (0-based over user rows): the messages after that user message and their ui_log rows leave the history, their tool_calls/ records move into the bundle's undo snapshot, and a pending permission prompt of a removed call is dropped, messagesRev bumps, and a session_rewound event is published to GET /coddy/events so every surface holding the session reloads the kept prefix. 200 {"object":"coddy.session_rewound","sessionId","messagesRev"}; 400 for a malformed id, an invalid JSON body or an out-of-range index, 404 for an unknown session, 409 for a read-only transcript (a subagent child or a scheduler job's session) or a turn in flight - the answer names which. The cut tail is kept in the bundle until POST .../rewind/undo takes it back or it stops applying.
POST /coddy/sessions/{id}/rewind/undo Takes the last rewind back. A turn running in the session (the edited prompt) is cancelled first and awaited, whatever was appended after the cut is dropped, and the cut messages, their ui_log rows and tool_calls/ records come back; messagesRev bumps and session_rewound is published as for a rewind. Available while GET .../messages carries rewindUndo: until a second prompt follows the edited one, another rewind replaces it, or the kept prefix changes (a compaction). File changes the removed turns made are not reverted. No body. 200 {"object":"coddy.session_rewind_undone","sessionId","messagesRev"}; 400 for a malformed id, 404 for an unknown session, 409 when there is nothing to undo, the turn did not stop, or the transcript is read-only.
GET /coddy/sessions/{id}/changes What git reports for the session's folder: every tracked file that differs from HEAD, staged or not, and every new file git does not ignore, whoever made the change. Nothing is recorded per turn. When the session runs in a subfolder of a repository only that subfolder is reported, by paths relative to it. Without the git binary on PATH the server answers through its built-in implementation (go-git), which detects no renames: a staged rename reads as a deletion plus an addition. JSON {"object":"coddy.session_changes","sessionId","vcs","files":[{"path","status","additions","deletions","binary","truncated"}],"totals":{"files","additions","deletions"},"skipped"} with status one of added / modified / deleted. vcs is git, or empty when the folder is in no repository, and the set is then empty. New files are read up to 500 of them and 2 MB each, regular files only; skipped counts the ones left out, a symbolic link and a repository nested in the folder (one entry, never read) included. In a repository with no commit yet, every file of the index is an addition. A binary file carries no line counts and no patch. ?include=patch adds the unified diff, ?include=content the decoded before (at HEAD) / after (in the working copy) sides, and the two combine comma-separated. A patch cut short at 256 KB sets truncated while the line counts still describe the whole file.
GET /coddy/sessions/{id}/changes/file?path= One file of the same set, always with patch, before and after; only that file is read. path is matched against what git reports, never resolved on disk, so an unchanged, ignored, unknown or traversing path is 404; either separator names the same file. A missing path is 400.
POST /coddy/sessions/{id}/changes/revert Discards uncommitted changes of the session's folder through git. The body says what it means: {"paths":[...]} (at least one path, as the list gives them) or {"all":true}; an empty, malformed or ambiguous body - both keys, an empty or null list, an unknown key, anything after the object - is 400 and nothing is discarded. all also clears index entries that differ from HEAD where the disk does not (a staged edit written back, a staged new file deleted since). A tracked file gets its content and index entry from HEAD back; a file HEAD does not hold (new, or only added to the index) is deleted, with any folder it leaves empty; a rename brings the old file back and deletes the new one. Files git ignores and nested repositories are never touched. A path git does not report (committed or put back meanwhile) is 409 and nothing of the request is applied; a folder in no repository is 400; a turn running in the session, or in any other session whose folder is this one or lies inside or around it, is 409, and the discard holds the session turn lock. A session_changes event follows on GET /coddy/events. JSON {"object":"coddy.session_changes_reverted","sessionId","at"}.
GET /coddy/sessions/{id}/worktree The linked git worktree the session works in and what removing it would touch, which the web UI reads before it asks whether to keep the worktree or remove it; changes nothing. JSON {"object":"coddy.session_worktree","sessionId","in_worktree","path","branch","main_checkout","managed","uncommitted","unpushed","loose_commit","locked","submodules","nested","other_sessions":[{"id","title"}],"running_tasks","removable","needs_discard","refusal"}. in_worktree is false in the main checkout and outside git. uncommitted counts changed, staged and untracked entries, unpushed the commits no remote branch holds, loose_commit names a commit no branch, remote branch or tag holds. removable says whether the worktree can be removed now, needs_discard whether only by discarding its uncommitted work (kept as a snapshot under refs/coddy/worktree-snapshots/), and refusal why not: a worktree Coddy did not open, a lock, loose commits, initialised submodules, nested git repositories of their own among its untracked folders, other_sessions of this process working in it, or running_tasks of any session there. The same checks refuse worktree_exit with action: remove and /worktree exit --remove.
GET/PUT /coddy/sessions/{id}/plan Read or overwrite todo entries (ACP shape).
POST /coddy/sessions/{id}/plan/archive Archives active todos like coddy_todo_plan_archive.
GET/POST /coddy/sessions/{id}/plans List or create design plan files plans/<slug>.plan.md (bundled UI; not core ACP).
GET/PUT/DELETE /coddy/sessions/{id}/plans/{slug} Read, autosave, or discard a design plan file.
PATCH /coddy/sessions/{id}/plans/{slug} Body {"runPlan":true} runs RunPlan (sync), which switches the session to agent mode. Prefer POST /v1/responses with metadata.runPlanSlug for streaming. 409 while the session is in ask mode (read-only: switch the mode first, then run); the same request through metadata.runPlanSlug or an @plans/... mention is refused or read-only the same way. 409 also for a child session spawned by spawn_agent (read-only transcript).

After PUT /coddy/config, the next agent, plan, or ask turn uses the updated models, tools, and other fields from the swapped in-memory config. A changed mcp.project_trust is applied to idle active sessions before the save response completes and to future sessions; a session running a turn is reconnected when that turn ends. A reconnect that runs past the save's deadline leaves that session on the servers it already has and retries on its next turn, so one hanging server cannot strip the others.

System status

GET /coddy/status answers 200 application/json with one document the server builds from the running process on each request. It sits behind the /coddy gate like every route (Authentication): the API's credential opens it, the exporter's metrics token does not, and the API's credential does not open /metrics (Monitoring). Reading it starts no outbound request: no provider, bot API or MCP server is asked. A relay mount carries it unchanged (/swarm/nodes/<id>/coddy/status), and 404 means a server older than the route.

{"object": "coddy.status", "generatedAt": "2026-10-15T10:00:00Z",
 "build": {"version": "1.3.12", "revision": "050fbbbf", "goVersion": "go1.26.9",
           "os": "linux", "arch": "arm64", "tags": ["cli", "gateway", "http", "metrics"]},
 "process": {"startedAt": "2026-10-15T09:00:00Z", "startedBy": "systemd", "configFile": "~/.coddy/config.yaml"},
 "subsystems": [
   {"kind": "httpserver", "state": "running", "address": "127.0.0.1:12345", "pendingAddress": "0.0.0.0:12345"},
   {"kind": "metrics", "state": "running", "address": "127.0.0.1:12347", "credential": "token"}],
 "restartRequired": true,
 "turns": {"running": 2, "oldestSeconds": 340},
 "prompts": {"permission": 1, "question": 2},
 "tasks": {"agent": {"running": 1}, "command": {"queued": 1}},
 "disk": {"availableBytes": 398458880, "sizeBytes": 250000000000},
 "attention": [{"code": "restart_required"}, {"code": "disk_low"},
               {"code": "swarm_join_down", "relay": "relay.example:12346", "offlineSince": "2026-10-15T09:48:00Z"}],
 "metrics": {
   "providers": [{"name": "local-mlx", "attempts": 1204, "failures": 4, "meanSeconds": 6.1,
                  "lastFailureAt": "2026-10-15T09:58:00Z", "lastOkAt": "2026-10-15T09:59:45Z"}],
   "series": {"stepSeconds": 15, "end": "2026-10-15T10:00:00Z", "attemptsOk": [null, 3.0],
              "providerErrors": [null, 0.0], "attemptSecondsMean": [null, 5.8], "tokensIn": [null, 38000.0],
              "tokensOut": [null, 2100.0], "turnsRunning": [1, 2], "tasksUnfinished": [3, 3]}}}

Each series holds 240 points in the real document, shortened here. Members are camelCase, as in /coddy/info. An optional member is left out when it is empty, false or zero, never sent as zero, so a missing member means none or not measured.

Member Present Meaning
object, generatedAt always coddy.status, and when the server built the document (RFC 3339, UTC).
build always version (what coddy -v prints), goVersion, os, arch; revision and tags (the build tags) when the binary knows them.
process always startedAt; startedBy: systemd (CODDY_SERVE_ROLE=service), daemon (the worker of coddy serve --daemon) or foreground, read once at start; configFile, the loaded file with the OS home folder (not CODDY_HOME) written ~.
subsystems always, possibly empty A row for each subsystem the configuration enables or the process still runs, in declaration order: kind; state (running, starting, stopped, retained); address of a listener that runs; pendingAddress when the saved address differs; credential (token or none) on the metrics row only, never the token.
restartRequired only true A saved change waits for a restart nothing will do by itself (the condition behind coddy_restart_required): a listen address that moved, which the row's pendingAddress names, or a surface that runs turns enabled in a process started without a session store.
restartRefused when set Why a supervised process would not restart into the saved configuration: the message of the check a fresh process runs, which refuses it.
turns always running, the sessions with a turn running; oldestSeconds, the age of the longest, only while one runs.
prompts when not empty Prompts waiting for a person to answer: permission and question, each present only above zero; absent when both are zero.
tasks when not empty Unfinished background tasks of the model by kind (command, agent, server) and status (queued, running).
disk when read availableBytes and sizeBytes of the disk that holds the sessions.
unavailable when not empty Blocks that could not be read: today "disk", when the read failed or the platform has none while a session store exists.
attention when not empty What needs a person, in the order of the table below.
metrics with the metrics tag Whether or not metrics.enable is set; absent from a binary built without the tag. providers: name, attempts and failures (the four outcomes CoddyProviderFailing counts) since start, meanSeconds only once there is an attempt, lastFailureAt and lastOkAt (the 15 second sample in which the totals last rose). series: stepSeconds (15), end, and seven arrays of exactly 240 points, oldest first, the last at end; null is a gap. attemptsOk, providerErrors, tokensIn and tokensOut are per minute, attemptSecondsMean is in seconds, turnsRunning and tasksUnfinished are gauges. A sampler fills them every 15 seconds from the exporter's own counters; they live in memory for one hour and start empty after a restart.

An attention item is {"code": ...}, plus subsystem, provider or relay with offlineSince where it names one:

Code Raised when
restart_required restartRequired is set.
disk_low The sessions disk has under 512 MiB free (a fixed value, the one CoddyDiskLow uses); the web UI words zero as Disk full.
subsystem_down A row is stopped (never while starting), the exporter included when it failed alone; carries subsystem.
provider_failing Only with metrics: in the last 15 minutes a sample in which the provider's failures rose, and no success since; carries provider.
swarm_join_down A registration of this process into a parent relay (swarm.join) has been offline for 5 minutes, the condition of CoddySwarmJoinDown; one item for each relay host, with relay and offlineSince (RFC 3339, UTC). The time a registration yields to a relay of the same process does not count.

The list is ordered restart_required, disk_low, subsystem_down (row order), provider_failing (provider name order), swarm_join_down (relay host order). The number on the Settings badge is its length: the browser applies no rule of its own.

The document carries one path, process.configFile. It carries no session id or title, workspace folder, prompt, tool argument, error text, chat id, user name or token: only build facts, counts, durations, the addresses the process listens on and provider names and relay hosts from the configuration, which are the ones /metrics admits as labels.

Stop and queue control snapshots

On session open and reconnect, clients read GET /coddy/sessions/{id}/activity and GET /coddy/sessions/{id}/queue for the selected id. These reads do not depend on a matching row in a filtered or paginated History listing, a live turn reader, or another queue mutation. The UI and interactive remote console track server activity separately from their own prompt/stream lifetime. The browser skips both hydration reads during its own POST admission and confirms a turn_ended overlapping a pending or admitted POST through fresh REST before declaring idle.

The session manager's QueueSnapshot reads the queue list and its version under one lock. Queue HTTP responses (GET, POST, PATCH and DELETE) and message_queue events use that pair, so a response cannot label an older list with a newer version. Ordinary deliveries keep the highest version per session. For browser restart recovery, a fresh queue GET captures the delivery revision and recovery epoch before the request. The browser rejects a snapshot crossed by any queue delivery; otherwise it may establish a lower version, including 0. That recovery advances the local epoch and rejects mutation responses and own/relay stream queue frames captured in earlier epochs. Replayed turn_started notifications do not clear the waiting list or reset its version.

These controls keep non-interactive prompt handling and permission/question ownership unchanged, and do not attach the remote console to another client's live transcript. Shared queues require clients of the same coddy serve process; separate local console or ACP processes sharing only a sessions directory retain separate queues and answer channels. Cross-process cancellation still uses the bundle's cancel marker.

Two stream dialects

A POST /v1/chat/completions request with stream: false returns JSON with usage.prompt_tokens, usage.completion_tokens and usage.total_tokens when the provider reports token counts. An agent, plan or ask turn sums the model calls of its own agent loop; the calls of a subagent it spawned (counted on that subagent's task row), of a compaction and of the memory run are not included. When the provider reports cached input tokens, usage.prompt_tokens_details.cached_tokens carries them; Anthropic's separate uncached, cache-write and cache-read counts are all included in prompt_tokens. If the provider supplies no counts, the JSON omits usage so clients can distinguish unknown usage from zero usage.

The two streaming endpoints carry the same turn in two shapes, chosen by who reads them. POST /v1/chat/completions with stream: true is the surface a third-party client points at (VS Code Copilot through chatLanguageModels.json, the openai SDKs, anything that parses chat.completion.chunk literally), so it is the strict OpenAI contract and nothing else: a first chunk with delta.role assistant; delta.content, delta.reasoning_content and delta.tool_calls deltas, each choice with finish_reason: null; a final chunk whose finish_reason is stop, length when the turn ended on max_turns / max_tokens, tool_calls when a direct model called one of the client's tools, or content_filter when the provider's content filter cut a direct answer short; with stream_options.include_usage: true one more chunk with an empty choices array and the turn's usage (prompt_tokens, completion_tokens, total_tokens, summed over the model calls of the turn from the provider's own counters by the same rule as the JSON answer, zero when the provider reported none, and prompt_tokens_details.cached_tokens only when it reported cached input); then data: [DONE]. No named event: frame appears on it - such a client would read one as a chunk without choices - though each leaves an SSE comment in its place, so the connection stays as busy as the turn through a tool phase; a turn that produced no text still opens and finishes one choice, because a stream that never finishes a choice is what VS Code reports as "Response contained no choices." POST /v1/responses is coddy's own channel, read by the SPA, the remote console (--remote, coddy acp --remote) and the swarm relay: the same chunks plus every named event below, coddy_meta last.

A direct models[].model id on POST /v1/chat/completions is coddy standing in for the provider, and the client brings what it would bring to the provider. Its tools (OpenAI function tools) are offered to the model as they are; tool_choice none withholds them, and any other value leaves the choice to the model, because the providers take no forcing parameter. A call the model makes comes back as delta.tool_calls chunks on the stream - one chunk per call, arguments complete, index counting the calls - and as message.tool_calls on a JSON answer, with finish_reason tool_calls; the client runs the tool, replays the assistant message with its tool_calls and answers with a tool message under the tool_call_id, and that request may end on the tool message. Direct completions sharing an existing session ID use the same exclusive turn lock as profile turns: a busy session returns 409 before its transcript is changed or the provider is contacted. Message content may be a string, null or an array of parts: text parts are joined, image_url parts (a data URL or an https address) reach a model whose models[].multimodal is true as images and are dropped for any other model, never flattened into text; they are accepted on user messages only, as at OpenAI, and refused with 400 on any other role, as are other part types. What a client may hand over is bounded: at most 128 tools, a tool's parameters up to 256 KiB, 16 images per message and 20 MiB per image URL string (a data URL carries the picture itself), past which the request is refused with 400; an https image address is handed to the provider as it is, coddy never fetches it. A JSON answer made of tool calls has content null, as OpenAI renders it. The agent / plan / ask profiles run coddy's own tools and never read the client's, and take no trailing tool message, though the pictures of the last user message reach a multimodal profile model. An agent / plan / ask turn started over either endpoint is published to the composer relay in the rich shape whatever the caller received, so a browser watching the session sees its tool calls either way.

Session memory REST (-tags=http,memory)

These /coddy/sessions/{id}/memory/* routes register only when coddy is built with memory (external/httpserver/memory_http.go). A plain http build without memory returns 404 (memory_http_stub.go).

Method Path Notes
GET /coddy/sessions/{id}/memory/tree Without root, lists global and workspace. Otherwise lists allowed .md / .txt children (traversal guarded).
GET /coddy/sessions/{id}/memory/file Query root + path. UTF-8 content.
PUT /coddy/sessions/{id}/memory/file JSON {"root","path","content"}.
POST /coddy/sessions/{id}/memory/dir JSON {"root","path"} for new subdirectory.
DELETE /coddy/sessions/{id}/memory/file Query root + path. Removes a .md / .txt file or deletes a directory tree recursively. 400 when path targets the memory root itself.

Scheduler REST (-tags=http,scheduler)

Paths are missing from a plain http build and from OpenAPI when scheduler is not linked (client 404). When linked, responses use the same ErrorEnvelope JSON as other /coddy routes. 503 when scheduler.enable is false for that process. Handlers plus merged scheduler OpenAPI fragments live in external/httpserver/scheduler_http.go (http,scheduler); scheduler_http_stub.go registers nothing when scheduler is omitted.

Method Path Notes
GET /coddy/scheduler/jobs JSON { scheduler, jobs[] }. Envelope scheduler includes enabled, dir (the user jobs folder, ${CODDY_HOME}/scheduler), project_dir (.coddy/scheduler), project_trust, workspace (the one the request named), timeout, max_queue, runs_active, retain_sessions. Optional query include_body=true embeds each job instruction body. Without X-Coddy-Session-ID and without cwd only the user jobs are listed; with either (cwd as in the other listings, read-only), the project jobs of every workspace the scheduler scans and of that workspace follow, and a request with a session registers its workspace (for a linked git worktree, the same folder of its main checkout) with the scheduler (${CODDY_HOME}/scheduler-workspaces.json). scope=user narrows to the user jobs. Each row carries scope (user / project), workspace (of a project job), trust (trusted, needs_approval, denied, conflict, invalid; only a trusted job runs), trust_reason, digest (sha256 of the file, what an approval binds), scheduled (false for a project job of a workspace the scheduler does not scan), running (true while this process has a run of the job in flight), session_id (the job session every run is a child of, empty until the first run) and last_run (the newest run row, see …/runs), besides the frontmatter fields including agent and permission_mode.
POST /coddy/scheduler/jobs Create job. JSON body optional scope (user default; project writes <workspace>/.coddy/scheduler/<job_id>.md in the workspace of the session in X-Coddy-Session-ID, the server's cwd without the header, never a cwd query, and approves it at once for the bytes written), job_id, description, schedule (5-field UTC cron), optional paused, cwd, model, mode, agent (a subagent definition name), permission_mode (ask, accept_edits, bypass), body. 201 + Location, body { object, job_id, scope, workspace? }. 409 when job_id already exists in the scope, when a project job would take the id of a user job, or a user job the id of a project job of a workspace the scheduler knows; 400 for a permission mode or an agent name that cannot be, or a project job's cwd that leaves the workspace.
GET /coddy/scheduler/jobs/{job_id} Full SchedulerJob including body, and raw (the file text) for a project job. Every route under /coddy/scheduler/jobs/{job_id} addresses the user job of that id unless the query says scope=project: then the project job of the workspace query (accepted only for a workspace the scheduler already scans, or the session's own; 403 otherwise) or of the session in X-Coddy-Session-ID.
PUT /coddy/scheduler/jobs/{job_id} Replace entire job file.
PATCH /coddy/scheduler/jobs/{job_id} Merge fields (e.g. paused, schedule, body, agent, permission_mode). Optional job_id in the body renames the on-disk job (moves .md and .state; the run history follows, since the sidecar names the job session). Response job_id is the effective id after the patch. 409 if the target id exists or a run of the job is in flight.
DELETE /coddy/scheduler/jobs/{job_id} Remove .md, basename.state and the job session with every run under it. 409 while a run is in flight.
POST /coddy/scheduler/jobs/{job_id}/pause Sets YAML paused: true.
POST /coddy/scheduler/jobs/{job_id}/resume Sets paused: false.
POST /coddy/scheduler/jobs/{job_id}/run Starts one run now, the way the cron tick does: a background task of kind agent under the job session, backed by a run session. 202 with task_id, session_id (the job session) and run_session_id (the transcript). Does not advance cron .state. 409 when paused, while a run of the job is in flight, when scheduler.max_queue runs are already going, when a project job is not trusted (the answer names the state and how to approve it), or when the job's definition is not trusted for its workspace.
POST /coddy/scheduler/jobs/{job_id}/trust scope=project only. Approves the project job for its workspace: body { digest }, the digest the client showed; the receipt in ${CODDY_HOME}/scheduler-trust.json binds the workspace, the job id and that digest. 409 when the file changed since (the digest no longer matches) or the job is a conflict or invalid; 400 for a user job or under scheduler.project_trust: deny.
POST /coddy/scheduler/jobs/{job_id}/untrust scope=project only. Withdraws the approval: { trusted: false, removed }.
POST /coddy/scheduler/jobs/{job_id}/cancel Stops the run in flight through the pool; the run is recorded as stopped. JSON { cancelled: bool }, false when the job was not running.
GET /coddy/scheduler/jobs/{job_id}/runs The runs of the job, newest first: { object, job_id, session_id, runs[] } where session_id is the job session and each row carries task_id (the task under the job session), session_id (the run session, read-only; GET /coddy/sessions/{session_id}/messages is its transcript and names the job in its subagent.scheduler block), job_session_id, trigger (cron / manual), status (running, succeeded, failed, timed_out, stopped, orphaned), running, started_at, ended_at, elapsed_seconds, error, label. limit query (default 50, max 100). The same rows are the kind: "agent" tasks of GET /coddy/sessions/{job session}/background-tasks, which is what the runs panel polls (and whose …/stop is the panel's Stop).
DELETE /coddy/scheduler/jobs/{job_id}/runs Clears every finished run of the job, task records and transcripts: { cleared: n }. A run in flight stays. The session's own DELETE …/background-tasks would leave the transcripts behind, so the panel's Clear comes here.

Process logs for scheduler stay short; a run's full trace is its run session under the job session's bundle (<job session>/subagents/<run session>/), and the job session itself answers 409 to any prompt.

Inline-file capability: POST /v1/responses accepts inline_files only when the effective YAML model has multimodal: true. Entries supplied for a non-multimodal model are ignored and are neither forwarded to its provider nor persisted in session assets/history.

model, profiles, and direct completion

  • POST /v1/chat/completions and POST /v1/responses require model to be exactly one id from GET /v1/models (session operating profiles agent / plan / ask or a models[].model entry).
  • agent / plan / ask selects the Coddy agent (ReAct, tools). Optional JSON metadata.model (models[].model selector) updates the session SelectedModelID. Omit metadata or omit metadata.model to follow EffectiveModelID (session selectedModelId then agent.model). metadata.model set to null, "", or an unknown selector is 400. Optional metadata.settingsVersion is the version of the last settings snapshot the client applied: when the server has published a newer one since, the request's metadata.model, metadata.reasoning and mode are ignored and the session's own settings stand, so a tab that has not seen a change made elsewhere cannot undo it. Optional metadata.reasoning sets the session reasoning level (resolved after any metadata.model change); it must be one of the effective model's reasoning_levels (null / "" clears it; any other unsupported value is 400). The agent maps the level to OpenAI reasoning_effort, Anthropic extended-thinking budget_tokens or, for the Claude models with adaptive thinking, Anthropic output_config.effort; qwen3* models on OpenAI-compatible providers (including neuraldeep) also send chat_template_kwargs {"enable_thinking": true} so the chat-template thinking switch stays on; codex providers send it as Codex reasoning.effort (minimal becomes none) together with summary: auto and encrypted reasoning replay.
  • model listing a models[].model YAML selector runs one direct LLM request per call (no ReAct); metadata with a model key on that branch is rejected with 400 (use model as the routing field only).
  • A model served by a type: acp provider (a local ACP agent, ACP agent as a model) answers through the agent / plan / ask profiles only: as a direct model of POST /v1/chat/completions or POST /v1/responses it is refused with 400 (invalid_request_error), since the agent runs its own tools and keeps its own conversation rather than taking the caller's messages and tools.
  • On POST /v1/chat/completions a direct model takes the generation options of the request for that one request. max_tokens, or its newer OpenAI name max_completion_tokens (a request may carry both only when they agree), replaces the model's max_tokens, and temperature replaces its temperature, 0 included. Without them the configured values apply, and no request changes the configuration or leaks into another request. A value the provider cannot send as asked is refused with 400 before a session is created or the provider is contacted: a cap below 1, a temperature outside 0-2 (0-1 for an anthropic provider), and either option on a codex model, since the Codex backend rejects both. A requested temperature is sent next to a reasoning level as well, where a configured one is left out: whether the model takes both is the backend's answer (Qwen3 thinking on vLLM does, OpenAI's reasoning models refuse).
  • reasoning_effort on the same request picks the reasoning level, and it must be one of the reasoning_levels GET /v1/models lists for that id; any other value, or any value for a model that lists none, is 400, never a quiet downgrade. Omitted, null or "", the model's reasoning_default applies, and a model without a default gets no reasoning level at all (a Claude model that thinks by default still thinks, at its own default effort) - not the same request as the level none, which a codex model offers and receives as such. The level travels as OpenAI reasoning_effort, as Anthropic extended thinking (where a max_tokens of the request that leaves no room above the thinking budget is 400 rather than silently raised), as Anthropic output_config.effort for the Claude models with adaptive thinking (they take no budget, so any positive max_tokens is kept; see Configuration) or as Codex reasoning.effort, and the JSON answer reports it as metadata.reasoning_effort.
  • The agent / plan / ask profiles read none of these fields: a profile turn is a series of LLM calls, each on the profile model's configured values, and its reasoning level comes from metadata.reasoning.
  • A direct answer is finished only by the provider's own terminal signal: a finish_reason or [DONE] from an OpenAI-compatible server, a stop_reason from Anthropic, response.completed or response.incomplete from Codex. Codex's response.incomplete keeps the text written so far and reports its reason (max_output_tokens as finish_reason length, content_filter as content_filter; any other reason is an error), and response.failed is an error carrying the backend's code and message. A stream that closes before any terminal signal, or whose event ends inside its JSON, is an error naming the truncation (500 on a JSON answer, an error frame and no finished choice on a stream), never a stop, and a failure after text reached the client is not retried into a second copy of it.
  • A failure the model's provider answered with an HTTP status keeps that status on its way to the client, on a direct completion and on an agent / plan / ask turn alike, so a client can tell a request the provider will never accept from an outage. A provider 4xx is answered with the same status: a request over the plan's input limit is 400, a rejected key 401. 429 carries Retry-After when the provider named a pause, or when Coddy chose not to wait for a usage-limit reset. A provider 5xx becomes 502 Bad Gateway, with the provider's Retry-After when a 503 named one, and a provider 504 or a request timeout becomes 504 Gateway Timeout. The error object names the provider's status: {"error":{"message":"...","type":"upstream_error","upstream_status":400}}, and a streamed request carries the same object in its error frame. 500 is left for failures of Coddy itself, a stream cut before its terminal signal among them.

Non-stream replies include metadata with model set to the effective YAML backend selector (api_model may expose the REST model id).

Sessions and headers

Without X-Coddy-Session-ID

POST /v1/chat/completions / POST /v1/responses allocate a random session bundle for that flow (ACP session/new).

With X-Coddy-Session-ID

  1. Active in-memory bundle wins.
  2. Otherwise load from disk when session.json exists.
  3. Otherwise pin session/new with the supplied identifier (fresh empty bundle folders).

Malformed ids (HTTP 400). Dedicated /coddy/* helpers return 503 if persistence is unavailable (Manager lacked a FileStore, primarily in tests).

Per-session model (bundled UI)

  • GET /coddy/sessions/{id}/messages returns model (effective YAML backend for the session), selectedModelId (stored override in session.json, may be empty), selectedReasoning (effective reasoning level for the session, empty when the model has none), and mode (session profile agent, plan, or ask; remote clients restore it on load).
  • PATCH /coddy/sessions/{id} accepts selectedModelId to set the YAML models[].model selector for that session (unknown ids 400), selectedReasoning to set the reasoning level (must be one of the model's reasoning_levels; empty clears it; unsupported value 400) and selectedToolset to pick a toolsets[] preset for the session, or default for every tool of the mode (an unknown name 400; kept in session.json, Toolsets). GET /v1/models names the toolset of each model (toolset, absent for none), and the settings snapshot carries toolset and toolsetChoices while the configuration has a preset.
  • The SPA restores Model and the reasoning level from the settings snapshot when opening a chat: the session's own values, while model and selectedReasoning name what a running turn holds. Changing Model writes cookie coddy_llm_model (default for the next New chat) and **PATCH**es the active session.

Memory roots

Matches external/memory/README.md: global uses configured memory.dir (fallback $CODDY_HOME/memory). workspace resolves to $CWD/.coddy/memory for that session bundle. agentMemory placeholders remain agent-only (session.json), not REST-editable here.

Interactive tool permission prompts are bypassed when the session runs in bypass, except a forced one: a PreToolUse hook's ask and a command the decisions check did not clear. Otherwise the bundled UI shows event: permission during POST /v1/responses (stream: true) and completes the turn via POST /coddy/sessions/{id}/permission (same pattern as question).

CLI flags

Equivalent to coddy acp: --config, --home, --cwd, --sessions-dir, --session-id, --log-*, optional --scheduler and --mcp-project-trust ask|allow|deny (overrides mcp.project_trust for this process; see mcp-integration.md).

Networking flags: -H / --host, -P / --port, falling back to YAML httpserver.host / httpserver.port, and from there to 127.0.0.1:12345. The bind default is loopback because one process now starts every enabled subsystem: an operator who asked for a Telegram bot must not find the agent API on every interface as a side effect. Pass -H 0.0.0.0 (or set httpserver.host) to accept connections from other machines, and expect the "reachable without authentication" warning if you do so without a token.

Auth: --auth-token <token> (also CODDY_HTTP_TOKEN) enables bearer authentication without writing the secret to config.yaml.

Subsystem switches: --http=false runs everything else without the API, --gateway, --swarm and --scheduler override their enable keys for one run, and the relay's own settings are --swarm-host, --swarm-port, --swarm-auth-token, --swarm-pairing-token and --swarm-allow-insecure.

Build

# HTTP API only (no npm, no embedded SPA root)
make build TAGS=http

# HTTP API plus embedded SPA (**make ui-build** runs first)
make build TAGS="http ui"

go test ./... skips external/httpserver unless go test -tags=http. SPA-specific tests compile under go test -tags=http,ui (Makefile make test runs ui-build once, then a single go test with every tag; the per-combination matrix is make test-matrix, which CI runs).

For a manual gateway check against a disposable coddy serve process, examples/test_httpserver.sh runs the Python demos under examples/httpserver/ (see examples/README.md). Steps that call chat or responses expect a working models backend.

Session workspace previews

These routes use the workspace of {id}, loaded from persistence when needed. They never select a root from a query or a session header. Paths are workspace-relative and every filesystem operation is confined by os.Root; raw/text accept only regular files. An escaping symlink or special file is unavailable.

Method Route Contract
GET /coddy/sessions/{id}/workspace/tree One directory level: folders first, then files, each group sorted by name. path_rel is empty for the root. limit defaults to 200 and is capped at 1000; pass next_cursor back as cursor, as it is (an opaque position; a malformed one is a 400). Returns entries (name, path_rel, kind, size_bytes, mod_time), next_cursor and has_more. Kinds are file, directory, symlink and special; directory size is zero. Rows contain no MIME sniffing. include_hidden=1 shows hidden names, node_modules and vendor; filtering is navigation-only.
GET / HEAD /coddy/sessions/{id}/workspace/raw path_rel requires a regular file. A fixed extension-plus-sniff allowlist permits inline raster images, audio, video, PDF and .txt; HTML, SVG and unknown types always use an attachment and application/octet-stream. download=1 forces an attachment. Supports Range (206/416), weak metadata ETag and conditional 304. Sets private, no-cache, nosniff, no-referrer and sandbox CSP.
GET /coddy/sessions/{id}/workspace/text Streaming decoding from a bounded encoding sample, including legacy text. offset is zero-based (default 0, maximum 10000000), max_lines defaults to 300 and is capped at 1000. Returns lines without terminators, offset, next_offset, has_more, total_lines_known (EOF reached), charset, etag, mod_time and size_bytes. File size is not capped at 512 KiB; each line and page is bounded to 1 MiB. etag pins a later page to its version; a mismatch or concurrent replacement is 409. Undecodable input is 415, an oversized line is 413.
POST /coddy/sessions/{id}/workspace/media-token Normal API authentication is required. Body: {"path_rel":"clip.mp4","download":false}. Returns token and expires_at, with Cache-Control: no-store. Add the token as access_token to raw with the same path and download mode.
POST /coddy/sessions/{id}/workspace/stat Tells which workspace-relative paths exist, and as what. Body: {"paths":["internal/foo.go","docs"]}, at most 256 paths. Returns object (coddy.workspace_stat) and entries, one {"path_rel","kind"} per path that exists, path_rel as it was asked and kind one of file, directory, symlink (never followed) and special. A path that is malformed, absolute, leaves the workspace or names nothing is left out of entries rather than failing the request, and a path asked twice is reported once. Cache-Control: no-store. The web UI asks it which paths an answer names are files it can open.

A media capability substitutes for bearer/cookie authentication only on the raw route, including HEAD and subsequent ranges. Its versioned HMAC binds session, workspace, path, view/download mode and expiry of at most one hour. An altered, expired or mismatched capability returns 401 even if a bearer header accompanies it. Tree, text and token minting still use the ordinary auth gate.

The signing key is domain-separated from the configured API credential (httpserver.auth_token, CLI/env tokens), or the password-login hash when no bearer credential is configured. Matching replicas and restarts therefore retain URLs; rotating that credential invalidates outstanding URLs. An unauthenticated server uses a random per-process key. No API credential is placed in a media URL. CORS allows HEAD, Range and If-None-Match and exposes Content-Range, Accept-Ranges, ETag and Content-Disposition.

Canvas

The routes of a chat's canvases: .canvas files of the session's workspace (JSON Canvas 1.0, the format of Obsidian's canvases) whose file nodes naming HTML pages run as live frames. A subagent's id acts on the canvas of the top session of its spawn tree. Every route names its file with the file query, relative to the workspace, a .canvas file outside dot-named folders (400 otherwise); GET .../canvas and POST .../canvas/frames take none and act on the chat's current canvas: the file a page was last shown on or opened. Every route answers 404 while tools.canvas.enable is false, and 409 for a session that is not saved.

Method Route Contract
GET /coddy/sessions/{id}/canvas A canvas as the board: session_id, file, current (whether it is the chat's current canvas), revision, stamp (moves when the file changes, whoever changed it; missing when it is gone), error (why it cannot be read; nothing of it is shown then), frames[], items[], edges[]. A frame carries id (its node's), path, root, title, device, width, height, x, y, added_by, gen, stamp (a digest of the names, sizes and times of the files under its root, which moves when they change), url (its address relative to the API base, signed now) with expires_at, and report, the counts of the newest browser report. items[] are its other nodes (id, type - text, file, link or group - x, y, width, height, color, text, file, url, label, and note for a page that does not run), edges[] its lines (id, fromNode, fromSide, fromEnd, toNode, toSide, toEnd, color, label), read from the file on every request. Without file the current canvas, or an answer with an empty file while the chat has none. Reading writes nothing. Cache-Control: no-store.
POST /coddy/sessions/{id}/canvas/open Makes the file the chat's current canvas, the one pages shown without a file go on; the web UI calls it when the person opens the file. Writes nothing to the file; 400 for one that is missing or does not parse. Answers the board.
POST /coddy/sessions/{id}/canvas/frames Puts a page on a canvas: {"path","root","title","device","width","height"}, the page relative to the workspace, as a file node of the file, or of the current canvas, or of a new canvas/<page folder>.canvas (numbered when the name is taken) while the chat has none. The same page at the same size is one frame (reason: shown); past 24 frames 409. Answers reason, file, created (the file was made for it), frame, revision.
DELETE /coddy/sessions/{id}/canvas/frames/{frame} Takes a frame off the canvas: its node and the edges at it leave the file, the page's files stay, every address of the frame stops working.
POST /coddy/sessions/{id}/canvas/frames/{frame}/rotate Moves the frame to its next generation, so every address handed out before stops working; the web UI calls it when the page navigated away from the canvas. Answers the frame with its new address. The file is not written.
POST /coddy/sessions/{id}/canvas/frames/{frame}/report What the browser saw: stamp (the files it loaded), loaded, closed, error, title, console[] (level error or warning, message, source, line, column). Kept in memory, the newest per frame of a file, at most 20 lines of at most 1,000 characters; canvas_inspect reads it. 204.
PATCH /coddy/sessions/{id}/canvas/nodes Moves or resizes nodes in one change: {"nodes":[{"id","x","y","width","height","device"}]}, frames (by a size or a device) and cards, links and groups, at most 500, written to the file once. All of them or none: an id the file does not hold is 404.
GET / HEAD /coddy/canvas/{capability}/{path} A file of a frame, path relative to its root; .coddy/bridge.js is the bridge script.

Writes keep every node, edge and key they do not change, the keys Coddy does not know and their order included, and write the file byte for byte as Obsidian 1.14 does: a node of plain values on one line, one holding an object one key a line, a node Coddy changed in Obsidian's key order, characters escaped as JSON.stringify escapes them, the file ending where it ended. A frame's device and title go under a coddy key of its node; a node Coddy did not move keeps its own numbers. A root wider than the page's folder, the generation behind a frame's address and the current canvas are kept in the session bundle (canvas.json), never in the file, so a file cannot widen what its pages read. One file's read-modify-write is one turn across chats, and a node deleted in the file since it was read stays deleted.

Every change made through the canvas is announced on GET /coddy/events as event: canvas_changed with {"object":"coddy.canvas_changed","sessionId","file","revision","frameId","reason","at"}; reason is added, shown, updated, removed or opened.

A frame's address. /coddy/canvas/<capability>/<page> is authenticated by the capability in its path alone: no bearer, no cookie, so it loads in an iframe of any origin, through a remote server and through a swarm relay alike. The capability is an HMAC over the session, the .canvas file, the frame, its workspace, its root, its generation and an expiry of 12 hours, signed with a key derived for the canvas from the secret behind media tokens, so a media token never passes for a frame address. It is valid while the file holds the frame at that generation with that root, in the session's working directory; a removed, repointed or rotated frame, and a session that moved to another folder, answer 401. Files are served through os.Root with no symbolic link on the way and the opened file checked to be the one walked to: GET and HEAD only, no dot-named path, a folder's index.html (with a 301 to add a missing slash), never a listing.

Every answer of the route carries:

  • Content-Security-Policy: sandbox allow-scripts allow-forms allow-modals; default-src 'self' data: blob: 'unsafe-inline' 'unsafe-eval' <sources>; connect-src 'self' <sources>; form-action 'none'; base-uri 'self'; frame-ancestors *, the sources being the CDNs of tools.canvas.allow_cdn and tools.canvas.allowed_sources;
  • X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, Cache-Control: no-store;
  • Access-Control-Allow-Origin: *, since the page's own module scripts and fetches come from an opaque origin; a preflight OPTIONS of the route is answered the same way.

An HTML page gets the bridge script and a <meta name="referrer" content="no-referrer"> at the start of its head. A page is at most 10 MB: canvas_show and POST .../canvas/frames refuse a larger one, and one that grew past it is answered with 413 and a short page, with the bridge, that says so.

Opaque origins. Every other route refuses a request whose Origin header is null with 403, before authentication and whatever credential it carries: a sandboxed page sends Origin: null, and neither a bearer it read from a file nor a relay's substituted credential may let it drive the server. A client that sends Origin: null by itself (a file:// page) is refused the same way; one that sends no Origin is not affected.