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
- Open Settings and choose the MCP tab. The default shortcut is
Cmd+,on macOS andCtrl+,elsewhere, but you can rebind or disable it in Hotkeys settings. - 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
0600permissions 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
tokenandpidfields exist only in the file. The authenticatedGET /api/discoveryendpoint 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_largebeyond that). - Endpoints that don't accept a body reject non-empty bodies. An empty
{}counts as a body. - List endpoints accept
offsetandlimit. The default limit is 50 and the maximum is 500; larger requests are clamped and flagged withlimitClamped: true. - List responses include
totalCount,offset,limit,limitClamped,hasMore, andnextOffset, plus ascopeobject 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, andon/off. HEADis supported on everyGETendpoint, andOPTIONSreturns 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.
Related pages
- Solo CLI overview — a ready-made command-line client for this API.
- Deep links —
solo://URLs for jumping to projects, processes, todos, and scratchpads. - MCP server integration — the MCP counterpart for AI assistants.