HTTP API

Solo can run a local HTTP server that exposes the app over a REST API, so scripts, editors, and other local tools can control projects, processes, workspaces, todos, and scratchpads in the running app. The same API powers the Solo CLI.

The server listens on 127.0.0.1 only — it is never reachable from other machines — and ordinary endpoint requests require a bearer token that Solo generates fresh each time the server starts. Published OPTIONS requests are the exception: they are unauthenticated for route introspection and CORS preflight.

Enabling the API

  1. Open Settings and choose the MCP tab. The default shortcut is Cmd+, on macOS and Ctrl+, elsewhere, but you can rebind or disable it in Hotkeys settings.
  2. Turn on HTTP API.

The default port is 24678. You can change it in the same settings section while the API is toggled off; the preferred port must be an integer from 1024 through 65535. If the configured port is busy when Solo starts the server, Solo falls back to a random free port — so always read the actual port from the discovery file rather than hard-coding it.

Authentication and the discovery file

When the server starts, Solo writes a discovery file named http-api.json into its app data directory:

  • macOS and Linux: ~/.config/soloterm/http-api.json
  • Windows: %USERPROFILE%\.config\soloterm\http-api.json

If you run Solo with a custom app data directory via SOLOTERM_APP_DATA_DIR, the file lives there instead. Settings also displays the exact path under the HTTP API section.

The file is the bootstrap source for connecting: it contains the bearer token, the bound port, base URLs, the app's process ID, and a full machine-readable catalog of every endpoint with request and response schemas. An abridged example:

{
"schemaVersion": 3,
"apiVersion": "1",
"appVersion": "1.2.3",
"origin": "http://127.0.0.1:24678",
"baseUrl": "http://127.0.0.1:24678",
"apiBaseUrl": "http://127.0.0.1:24678/api",
"endpointBaseUrl": "http://127.0.0.1:24678",
"port": 24678,
"token": "…",
"pid": 12345,
"capabilities": ["project_list", "process_spawn", "todo_create", "…"],
"endpoints": [
{
"method": "GET",
"path": "/api/projects",
"authentication": "bearer",
"url": "http://127.0.0.1:24678/api/projects",
"queryParameters": ["…"],
"responseSchema": {}
}
]
}

Things to know about the file:

  • It is written with 0600 permissions on Unix, so only your user can read the token.
  • It is rewritten with a fresh token every time the server starts, and removed when the API is turned off or the app quits. If a request starts failing with 401, re-read the file.
  • The token and pid fields exist only in the file. The authenticated GET /api/discovery endpoint returns the same live catalog without them.

Send the token on every ordinary endpoint request using standard bearer authentication:

TOKEN=$(jq -r .token ~/.config/soloterm/http-api.json)
PORT=$(jq -r .port ~/.config/soloterm/http-api.json)
 
curl -s "http://127.0.0.1:$PORT/api/status" \
-H "Authorization: Bearer $TOKEN"

Ordinary endpoint requests without a valid token receive 401 unauthorized with a WWW-Authenticate: Bearer header. This includes GET /api/version; published OPTIONS requests do not require a token.

Response envelope

Every ordinary JSON response wraps its payload in a consistent envelope. Successful responses look like:

{
"ok": true,
"requestId": "req-…",
"data": { }
}

Errors look like:

{
"ok": false,
"requestId": "req-…",
"error": {
"code": "bad_request",
"message": "Project create requires valid name and path",
"details": { "name": ["cannot_be_empty"] }
}
}

HEAD responses are bodyless. Successful published OPTIONS responses are also bodyless, so those responses do not use this envelope; OPTIONS errors use the normal JSON error envelope. For JSON errors, error.code and error.details are the stable contract for programmatic handling; error.message is human-readable and may change. Validation details map field names to arrays of stable reason strings (for example ["required"], ["must_be_positive_integer"], ["too_long"]). The reserved key $body describes failures with the request body as a whole, such as ["invalid_json"].

Error codes

Code HTTP status Meaning
bad_request 400 Invalid body, query parameter, or path parameter
unauthorized 401 Missing or invalid bearer token
forbidden 403 Not permitted (for example, license limits)
not_found 404 Unknown route or resource, including scope mismatches
method_not_allowed 405 Method not supported on this route
conflict 409 State conflict, such as deleting a project with running processes without confirmation
precondition_failed 412 Optimistic concurrency check failed (expectedRevision mismatch)
unsupported_media_type 415 Body sent without Content-Type: application/json
payload_too_large 413 JSON body exceeds the size cap
version_mismatch 426 Client/API version incompatibility
service_unavailable 503 App is still starting up
internal 500 Unexpected server failure

Requesting a nested resource through the wrong parent (for example, a todo under a project it doesn't belong to) returns 404 not_found with scope_mismatch details on both the resource ID and the parent scope field.

Request IDs and actor attribution

Every response carries an x-solo-request-id header. Every JSON response echoes the same value as requestId in the body; HEAD responses and successful published OPTIONS responses have no body. You can supply your own ID for log correlation by sending x-solo-request-id on the request — ASCII letters, digits, hyphen, underscore, dot, or colon, up to 128 bytes. Invalid or oversized values are ignored and replaced with a generated ID.

Todo and scratchpad mutation requests from non-browser clients may also send two optional headers to identify the tool making the change:

Header Purpose
x-solo-actor-id Stable machine ID for your tool on todo and scratchpad mutations (shown in audit metadata)
x-solo-actor-name Human-readable name for todo and scratchpad mutations (shown in the app, e.g. on todo comments and scratchpad edits)

Values must be valid visible-ASCII HTTP header strings. They are trimmed and capped at 120 bytes; invalid headers are ignored, and todo or scratchpad changes fall back to a default HTTP API actor. Project, workspace, and process handlers do not use these headers. The current browser CORS policy also omits both actor headers from its allow-list, so browser clients cannot send them.

Requests, limits, and pagination

  • Request bodies must be JSON with Content-Type: application/json. Bodies are capped at 2 MB (payload_too_large beyond that).
  • Endpoints that don't accept a body reject non-empty bodies. An empty {} counts as a body.
  • List endpoints accept offset and limit. The default limit is 50 and the maximum is 500; larger requests are clamped and flagged with limitClamped: true.
  • List responses include totalCount, offset, limit, limitClamped, hasMore, and nextOffset, plus a scope object describing what the collection was scoped to. The one exception is the workspace list, which always returns every workspace without pagination.
  • Human labels (project names, display names, process names, todo titles) are trimmed, must not contain control characters, and are capped at 200 characters. Scratchpad names are capped at 120 characters. Todo bodies and comments are capped at 64 KB.
  • Tags are trimmed, lowercased, deduplicated, and sorted; arrays are capped at 32 tags of up to 48 characters each.
  • Boolean query parameters accept true/false, 1/0, yes/no, y/n, and on/off.
  • HEAD is supported on every GET endpoint, and OPTIONS returns the allowed methods for any published route. Browser requests are allowed from loopback origins only (localhost, 127.0.0.1, [::1]).

Version, status, and discovery

Method Path Purpose
GET /api/version Compact probe: apiVersion and appVersion
GET /api/status Readiness, app data dir, port, project count, process counts by state
GET /api/discovery Full live endpoint catalog (same as the discovery file, minus token and pid)

Use /api/version as a cheap "is the app up and is my token valid" check, /api/status for a health snapshot, and /api/discovery when you need schemas, capabilities, or stable error-code metadata.

Projects

Method Path Purpose
GET /api/projects List projects. Query: workspaceId, offset, limit
POST /api/projects Create (import) a project
GET /api/projects/{projectId} Get one project
PATCH /api/projects/{projectId} Set or clear the display name
DELETE /api/projects/{projectId} Delete a project. Query: confirmStopRunning, promptTemplatePolicy
POST /api/projects/{projectId}/move-to-workspace Move a project to another workspace

Create requires a name and an absolute path to an existing directory; workspaceId is optional and defaults to the default workspace:

curl -s -X POST "http://127.0.0.1:$PORT/api/projects" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "My app", "path": "/Users/me/code/my-app"}'

Creation is idempotent on path: a new import returns 201 with created: true, while a path Solo already knows returns 200 with created: false and the existing project (the submitted name is ignored and nameIgnored is set). The stored path is canonicalized; the response echoes requestedName and requestedPath.

PATCH takes {"displayName": "New name"} to set a display name or {"displayName": null} to clear it back to the original name.

DELETE refuses with 409 conflict if the project has running processes unless you pass confirmStopRunning=true. A primary project also cannot be deleted while linked checkout projects reference it; remove those linked checkouts first. The optional promptTemplatePolicy query parameter (delete or convert_to_global) controls what happens to the project's prompt templates. The response reports counts of removed processes, scratchpads, todos, and comments.

Move-to-workspace takes a required workspaceId plus at most one placement hint, beforeProjectId or afterProjectId, referencing a project already in the destination workspace.

Workspaces

Method Path Purpose
GET /api/workspaces List workspaces with project counts
POST /api/workspaces Create a workspace
PATCH /api/workspaces/{workspaceId} Update name, icon, or color
DELETE /api/workspaces/{workspaceId} Delete a workspace. Query: destinationWorkspaceId
POST /api/workspaces/reorder Reorder all workspaces

Create requires a non-empty name; icon and color are optional strings. Update accepts any subset of name, icon, and color (at least one). Workspace names are trimmed, reject control characters, and are capped at 200 characters. A supplied non-empty icon is trimmed and capped at 12 characters; a supplied non-empty color is trimmed and capped at 40 characters. Delete accepts a destinationWorkspaceId query parameter naming the workspace that receives the deleted workspace's projects — it is required when the workspace still contains projects. Reorder takes {"workspaceIds": [3, 1, 2]} listing every workspace exactly once in the desired order.

{
"ok": true,
"requestId": "req-…",
"data": {
"id": 2,
"workspace": {
"id": 2,
"uuid": "…",
"name": "Client work",
"icon": "briefcase",
"color": "blue",
"position": 1,
"createdAt": "2026-07-01T12:00:00Z",
"updatedAt": "2026-07-01T12:00:00Z",
"projectCount": 0
}
}
}

See Workspaces for how workspaces behave in the app.

Processes

Method Path Purpose
GET /api/processes List processes. Query: projectId, status, offset, limit
GET /api/processes/{processId} Get one process
GET /api/processes/{processId}/output Read output. Query: lines, kind (rendered or raw), projectId
DELETE /api/processes/{processId} Close a process. Query: projectId, confirmStopRunning
POST /api/projects/{projectId}/processes/spawn Spawn a terminal or agent
POST /api/processes/{processId}/start Start a stopped process
POST /api/processes/{processId}/stop Stop a running process
POST /api/processes/{processId}/restart Restart a process
POST /api/processes/{processId}/rename Rename a process
POST /api/projects/{projectId}/start-all Start all commands in a project
POST /api/projects/{projectId}/stop-all Stop all commands in a project
POST /api/projects/{projectId}/restart-all Restart all commands in a project

Each process reports a kind, including command, terminal, and agent; /api/discovery publishes the complete observable enum. Each process also reports a status (stopped, starting, running, stopping, exited, or failed), plus its pid and uptimeSeconds while running. The status query parameter on the list endpoint filters by those same values.

Spawn creates a new tab in the project. kind must be terminal or agent; agents also need agentToolId (or agentToolInstallationId) identifying a configured agent tool. name is optional for either kind. extraArgs is optional only for agent and is rejected for terminal. You cannot supply a command — Solo chooses the command for the process kind:

curl -s -X POST "http://127.0.0.1:$PORT/api/projects/1/processes/spawn" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"kind": "agent", "agentToolId": 2, "name": "Reviewer"}'

Rename takes {"name": "web server"} (newName is accepted as an alias) and responds with oldName and newName. Lifecycle responses include changed, the resulting status, and the full process object; the process endpoints accept an optional projectId query parameter to assert the process belongs to that project.

The bulk endpoints operate on a project's command processes and return action-named arrays — started, stopped, restarted, skipped, and errors — with one entry per process, so partial failures are visible instead of failing the whole request.

Output reads return the last lines of either the rendered terminal output (what you see on screen) or raw retained output. Omitting lines returns the last 50 lines. Omitting kind selects rendered; raw=true is a compatibility shorthand for kind=raw. Raw mode bypasses rendered terminal rows, but it is not a byte-preserving stream: Solo lossy-decodes the retained bytes as UTF-8, strips ANSI CSI/OSC sequences, tails the result by line, and rejoins the selected lines with newline characters.

Todos

Method Path Purpose
GET /api/projects/{projectId}/todos List todos with filters
POST /api/projects/{projectId}/todos Create a todo
GET /api/projects/{projectId}/todos/{todoId} Get a todo. Query: includeComments
PATCH /api/projects/{projectId}/todos/{todoId} Update a todo
DELETE /api/projects/{projectId}/todos/{todoId} Delete a todo
POST /api/projects/{projectId}/todos/{todoId}/complete Complete or reopen a todo
GET /api/projects/{projectId}/todos/{todoId}/comments List comments. Query: offset, limit
POST /api/projects/{projectId}/todos/{todoId}/comments Add a comment
PATCH /api/projects/{projectId}/todos/{todoId}/comments/{commentId} Edit a comment
DELETE /api/projects/{projectId}/todos/{todoId}/comments/{commentId} Delete a comment

The list endpoint supports completed and isBlocked booleans, status (open, in_progress, backlog, completed), priority (high, medium, low), a free-text query, comma-separated tags, and sort (priority, created-desc, created-asc, updated-desc, updated-asc, completed-desc, completed-asc).

Create requires a title; body, priority (default medium), and tags are optional:

curl -s -X POST "http://127.0.0.1:$PORT/api/projects/1/todos" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title": "Fix login flow", "body": "Repro steps in #1234", "priority": "high", "tags": ["auth"]}'

Update accepts any subset of title, body, priority, status, completed, and tags (at least one); status and completed are mutually exclusive. The complete endpoint accepts an empty body (marks completed), an explicit {"completed": false} to reopen, or {"status": "completed"}/{"status": "open"} as an alias. It releases the caller's todo coordination lock by default; pass {"releaseLock": false} to keep it. Completion responses include affectedTodoIds, since completing a todo can unblock others.

Todo objects include workflow fields you can read but not write directly over HTTP: lockedBy, blockerIds, isBlocked, and unresolvedBlockerCount. Comments record which actor created and last edited them — this is where the actor headers described above show up.

Scratchpads

Method Path Purpose
GET /api/projects/{projectId}/scratchpads List scratchpads. Query: query, tags, includeArchived, offset, limit
POST /api/projects/{projectId}/scratchpads Create a scratchpad
GET /api/projects/{projectId}/scratchpads/{scratchpadId} Read a scratchpad
PUT /api/projects/{projectId}/scratchpads/{scratchpadId} Replace content, name, or tags
DELETE /api/projects/{projectId}/scratchpads/{scratchpadId} Delete a scratchpad
POST /api/projects/{projectId}/scratchpads/{scratchpadId}/append Append content
POST /api/projects/{projectId}/scratchpads/{scratchpadId}/rename Rename a scratchpad
POST /api/projects/{projectId}/scratchpads/{scratchpadId}/archive Archive
POST /api/projects/{projectId}/scratchpads/{scratchpadId}/unarchive Unarchive

Create requires a name (title is accepted as an alias); content and tags are optional. List results omit content; reads return the selected scratchpad projection plus a meta block describing what was read.

Reads support windowing and structured access via query parameters. mode selects full, content, headings (just the Markdown headings), or section with a sectionHeading parameter to read one section of the document. full is the nominal default, but when mode, offset, and limit are all omitted, content over 20,000 bytes or 400 lines automatically switches to headings. Explicit full or content, or line pagination, opts out of that automatic switch.

offset and limit select a line range only with full or content; Solo rejects them with headings or section. A supplied limit must be positive, and values above 500 are silently clamped to 500. The meta object reports totalLines, the effective mode, and hasMore/nextOffset for paging through long documents.

Optimistic concurrency

Scratchpads are shared between you, agents, and API clients, so destructive writes are guarded by a revision check. Every scratchpad carries an integer revision that increments on each change. Updates, renames, and deletes require you to send the revision you last read as expectedRevision (revision is accepted as an alias); appends accept it optionally:

curl -s -X PUT "http://127.0.0.1:$PORT/api/projects/1/scratchpads/7" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"content": "# Notes\n\nUpdated plan", "expectedRevision": 4}'

If someone else changed the scratchpad since you read it, the request fails with 412 precondition_failed and details {"expectedRevision": ["mismatch"]}. Re-read the scratchpad, reconcile, and retry with the new revision. For deletes, expectedRevision may be sent in the JSON body (preferred) or as a query parameter.

Are you an agent? Read this doc via API: /api/v1/docs/integrations/http-api