# 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](../todos/using-todos.md), and [scratchpads](../scratchpads/using-scratchpads.md) in the running app. The same API powers the [Solo CLI](../cli/overview.md).

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:

```json
{
  "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:

```bash
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:

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

Errors look like:

```json
{
  "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](../account/free-tier.md)) |
| `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:

```bash
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](../prompt-templates/overview.md). 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.

```json
{
  "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](../workspaces/overview.md) 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](../agents/setting-up-tools.md). `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:

```bash
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:

```bash
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:

```bash
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](../cli/overview.md) — a ready-made command-line client for this API.
- [Deep links](./deep-links.md) — `solo://` URLs for jumping to projects, processes, todos, and scratchpads.
- [MCP server integration](./mcp-server.md) — the MCP counterpart for AI assistants.

---

Are you a human? Read this doc on the web: https://soloterm.com/docs/integrations/http-api
