# simple-server.pilely.app — build and run a server binary for your app

Base URL: `https://simple-server.pilely.app` (the `@simple_server` service). Build a server binary from source (CodeBuild) and run it on managed boxes (`t4g.nano`). One app may run several servers at once; root picks one at random per request when your app is `app_type = backend`. Every operation is a `POST` to a route below. This README is the only manual: `GET https://simple-server.pilely.app/README.md`, public, no credential needed. The platform directory of every service README is `https://pilely.app/README.md`.

## When to use

- The user's app is a backend app (`app_type = backend`) and needs its own server process: upload its source, build it, and run it on one or more managed boxes.
- Watching a build or a box: list builds and servers, read a build's compiler output or a box's own log stream, and check whether root currently has a live target for the app.
- Keeping a backend up, and rolling out a new version or a changed env set: `server/scale`. Say how many servers (`count`), which build and which env set; the platform keeps that many healthy servers running, replaces a crashed one, and rolls an update one server at a time without dropping capacity. Watch it with `status`.
- Managing servers by hand instead (no desired state): `server/add` and `server/{server_id}/remove`. Nothing replaces a crashed server then; the platform only swaps your servers onto a new server image (`### When the platform moves servers to a new server image`).

## Auth

- Every route needs a signed-in caller — send `Authorization: Bearer <token>`: your own token for this service, or your app's own token. A signed-out caller, an agent and every caller a route does not serve get the uniform `404 not_found`. How to sign in and get a token: `https://pilely.app/README.md`; the sign-in page is `https://pilely.app/~/login`.
- Every route is scoped to one app, `<app_uuid>` in the path. You must own `<app_uuid>` and it must be `app_type = backend`. Every other caller, every unknown app, and every app that is not a backend get the same `404 not_found`.
- A request through a DIFFERENT app than `<app_uuid>` — even the true owner's, and that app's own box — gets the same `404 not_found`; only your own token for this service, or `<app_uuid>`'s own token, is admitted.
- An app's anonymous visitor (an anonymous token through an app) also gets the uniform `404 not_found`: no route here is anonymous-readable.
- A box of `<app_uuid>`, calling with its own backend token (`PILELY_BACKEND_TOKEN`, see `### The box's backend token`), is the app acting as itself, never its owner. It may call only `server/list`, `server/{server_id}` and `status`; every other route answers it the same `404 not_found` an unowned app gets. A box of another app gets `404 not_found` on every route.
- A version, server or other id in the path that is unparseable, unknown, or belongs to another app answers the same `404 not_found` as a denied caller.

## Limits

| Limit | Value |
|---|---|
| Source zip | at most 50 MiB compressed, at most 1024 files, at most 200 MiB unzipped, with a `Cargo.toml` at the zip's root |
| Builds in flight | one per app at a time (`409 conflict`) |
| Live servers | 3 per app (`409 conflict` on `server/add`); for the length of one swap an app may have 4: a `scale`-managed app during a roll, a hand-added app while the platform swaps a server onto a new server image. The swap's extra server counts toward the 3, so `server/add` answers `409 conflict` ("already at 3") while a swap holds the app at 3 or 4 live servers |
| Live servers per account | 10 across all your apps (`409 server_limit_reached` on `server/add` and `server/scale`), counted as servers are billed: a `server/scale` app counts its `count`, any other app its live servers |
| Balance to start a server or a build | above $0 (`402 out_of_traffic_credits`) and at least 10 server-hours: 10 × the server-hour price (`402 balance_too_low`) |
| Desired `count` | `0`–`3` |
| Automatic replacements | 3 per app per rolling hour, then `degraded` until the next `scale` call |
| Kept build binaries | the 3 most recent succeeded builds, plus the desired state's version if it is older; an older binary is pruned and can no longer be launched |
| Log reads | `since` unix-ms `>= 0`; `limit` `1`–`10000` lines |
| Health history | the last 20 probe results per server |
| Rate limits | none on this service |

## Errors

JSON errors are `{"ok": false, "code": "…", "reason": "…"}`; markdown errors are a `Request failed (<status>)` page with the reason, carrying the same status. Codes shared by every route:

| Status | `code` | When |
|---|---|---|
| 401 | `bad_forwarded_identity` | the forwarded identity headers reached this service missing or malformed (always JSON) |
| 402 | `storage_over_quota` | your stored data is over your storage allowance and this route is not a list or a delete: `server/add`, `server/scale` and `binary/start-build` are refused; delete data or buy storage |
| 404 | `not_found` | no such app, you are not its owner, it is not `app_type = backend`, it was called through a different app, or the version/server in the path is unknown or not this app's |
| 500 | `internal` | the operation failed; retry in a few seconds |
| 503 | `storage_over_quota` | the app's own backend calling while the app's owner is over their storage allowance |

**Over the storage allowance.** When your stored data goes over your storage allowance (you get an email each day for 7 days), every route here answers your app's backend `503 storage_over_quota`, and you may still list (`binary/list`, `server/list`, `status`, `binary/{version}`, `server/{server_id}`, both `logs`) and delete (`server/{server_id}/remove`, `binary/{version}/cancel`); `server/add`, `server/scale` and `binary/start-build` answer `402 storage_over_quota`. Nothing is deleted; it all clears as soon as you are back within the allowance.

Route-specific codes: `400 bad_request` on `binary/start-build`, `server/add`, `server/scale` and both `logs` routes; `409 conflict` on `binary/start-build`, `binary/{version}/cancel` and `server/add`; `409 managed_by_scale` on `server/add` and `server/{server_id}/remove` while the app has a desired state; `503 scale_unavailable` on `server/scale`.

Starting paid work — `binary/start-build`, `server/add`, and `server/scale` with `count` above `0` — is checked against your balance first, and refused with nothing started:

| Status | `code` | When |
|---|---|---|
| 402 | `out_of_traffic_credits` | your balance is at or below $0; top up, then retry |
| 402 | `balance_too_low` | your balance is above $0 but below 10 server-hours (10 × the server-hour price); top up before starting a server or a build |
| 402 | `storage_exceeded` | `binary/start-build` only: the source zip does not fit your storage quota; free space or buy storage |
| 409 | `server_limit_reached` | `server/add` and `server/scale` only: the start would take you past 10 live servers across your apps |
| 503 | `quota_unavailable` | the billing check could not be made; nothing was started; retry in a few seconds |

A `GET` (or any method other than `POST`) on a route below, and any unknown path, answers an empty-body `404`.

## Response format

Make these calls with an HTTP client (`curl` or your runtime's equivalent), never a browser. Every route answers `POST` only. Markdown comes back by default; send `Accept: application/json` for the JSON envelope. Both forms carry the same status code.

JSON success bodies are `{ "ok": true, … }` with the route's fields at the top level. A **build** object is:

```json
{ "version": 3, "status": "succeeded", "classification": "succeeded",
  "codebuild_build_id": "<project>:<uuid>", "sha256": "<hex>", "size_bytes": 1234567,
  "started_at": "<timestamp>", "ended_at": "<timestamp>" }
```

`status` is `in_progress`, `succeeded`, `failed` or `cancelled`. `classification` is `null` while the build runs, then `succeeded`, `compile_failed`, `timed_out`, `cancelled` or `fault` (`timed_out` and `fault` settle `status = failed`).

A **server** object is:

```json
{ "id": "<uuid>", "version": 3, "private_ip": "10.0.0.12", "port": 8080, "state": "running",
  "launched_at": "<timestamp>", "last_health_at": "<timestamp>", "terminated_at": null, "env_id": "aB3dE5gH" }
```

`state` is `launching`, `running`, `unhealthy`, `crashed` or `terminated`. `env_id` is the `@simple_env` set the box was launched with (`SIMPLE_ENV_ID`), or `null`.

A **price** object (what the app's servers cost, on `status` and on the `server/add` and `server/scale` answers) is:

```json
{ "servers": 2, "server_hour_usd": <number>, "server_day_usd": <number>, "balance_usd": 4.2,
  "owner_live_servers": 3, "balance_covers_days": <number>,
  "line": "2 servers × $<hour price>/hour (about $<day price>/day each), your balance of $4.20 covers about <N> days at your 3 live servers across all apps" }
```

`servers` is this app's servers, `owner_live_servers` your live servers across all your apps. `server_hour_usd` is the price of one server for one hour and `server_day_usd` of one server for a day. `balance_covers_days` is how long your balance pays for all your live servers (`0` at a balance of $0 or below; `null` when you run none). The figures come from the platform's billing service; when it cannot be reached, `price` is `null` and the markdown page has no `Price` section.

A **desired** object (the app's desired state, set by `server/scale`) is:

```json
{ "count": 2, "version": 5, "env_id": "aB3dE5gH", "roll": false, "set_at": "<timestamp>" }
```

`version` and `env_id` are `null` for `count: 0`; `env_id` is `null` when the desired state names no env set.

Markdown responses carry the same facts as a short page, with the follow-up routes written out as full URLs with your `<app_uuid>` already in place.

## Routes

| Route | Purpose | Who may call |
|---|---|---|
| `POST /{app_uuid}/binary/start-build` | multipart source zip → a new build | the app's owner, through this service or the app |
| `POST /{app_uuid}/binary/list` | every build | the app's owner, through this service or the app |
| `POST /{app_uuid}/binary/{version}` | one build, plus which servers run it | the app's owner, through this service or the app |
| `POST /{app_uuid}/binary/{version}/cancel` | stop an in-flight build | the app's owner, through this service or the app |
| `POST /{app_uuid}/binary/{version}/logs` | the build's compiler output | the app's owner, through this service or the app |
| `POST /{app_uuid}/server/add` | launch a box running a build (default: latest succeeded) | the app's owner, through this service or the app |
| `POST /{app_uuid}/server/scale` | set the desired state: how many servers, on which build and env set | the app's owner, through this service or the app |
| `POST /{app_uuid}/server/list` | every server | the app's owner, through this service or the app; the app's own box |
| `POST /{app_uuid}/server/{server_id}` | one server plus its last health results | the app's owner, through this service or the app; the app's own box |
| `POST /{app_uuid}/server/{server_id}/remove` | drop it from the live target set and terminate it | the app's owner, through this service or the app |
| `POST /{app_uuid}/server/{server_id}/logs` | the box's own log stream | the app's owner, through this service or the app |
| `POST /{app_uuid}/status` | latest build, servers by state, whether root has a live target | the app's owner, through this service or the app; the app's own box |

`{app_uuid}` is your backend app's id. `{version}` is a build's integer version, returned by `binary/start-build` and `binary/list`. `{server_id}` is a server's uuid, returned by `server/add` and `server/list`.

### POST /{app_uuid}/binary/start-build

Start a build from a zip of your source tree.

#### Auth

The app's owner, with their own token for this service or the app's own token; a box, an agent, a signed-out visitor and any other app get `404` (see `## Auth`).

#### Request

Multipart body, one part named `source_bundle`: a zip of your source tree, at most 50 MiB, with a `Cargo.toml` at the zip's root (not nested under a wrapping directory).

#### Example

```bash
curl -X POST https://simple-server.pilely.app/<app_uuid>/binary/start-build \
  -H "Authorization: Bearer <your-token>" \
  -H "Accept: application/json" \
  -F "source_bundle=@source.zip"
```

#### Response

`202 Accepted`. JSON: `{ "ok": true, "version": 3 }`. Markdown: a `Build started (202)` page with the version and the routes to watch it (`binary/list`, `binary/<version>/logs`, `binary/<version>/cancel`).

A build is charged to your balance by the minute (see `### What it costs`), so it starts only while your balance is at least 10 server-hours (10 × the server-hour price), and only when the source zip fits your storage quota. Each is checked before anything is uploaded.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | missing/wrong multipart part, over 50 MiB compressed, over 1024 files, over 200 MiB unzipped, or no root `Cargo.toml` |
| 402 | `out_of_traffic_credits` | your balance is at or below $0; nothing was uploaded |
| 402 | `balance_too_low` | your balance is below 10 server-hours; nothing was uploaded |
| 402 | `storage_exceeded` | the source zip does not fit your storage quota; nothing was uploaded. Free space or buy storage |
| 409 | `conflict` | a build is already in flight for this app |
| 500 | `internal` | the build could not be started; retry in a few seconds |
| 503 | `quota_unavailable` | the billing check could not be made; nothing was uploaded; retry in a few seconds |

#### When to use

The user changed the server's source and wants a new binary. Poll `binary/list` or `binary/{version}` until it settles, then `server/add` it.

### POST /{app_uuid}/binary/list

Every build of this app: version, status, classification, started/ended, sha256, size.

#### Auth

The app's owner, with their own token for this service or the app's own token; a box, an agent, a signed-out visitor and any other app get `404` (see `## Auth`).

#### Request

No fields; send no body.

#### Example

```bash
curl -X POST https://simple-server.pilely.app/<app_uuid>/binary/list \
  -H "Authorization: Bearer <your-token>" \
  -H "Accept: application/json"
```

#### Response

`200`. JSON: `{ "ok": true, "builds": [ <build>, ... ] }`, newest version first (build object in `## Response format`). Markdown: a `Builds for <app_uuid>` page with one line per build.

#### Errors

No route-specific codes.

#### When to use

Watching a build settle, or finding the version to launch.

### POST /{app_uuid}/binary/{version}

One build's status plus every server currently running it.

#### Auth

The app's owner, with their own token for this service or the app's own token; a box, an agent, a signed-out visitor and any other app get `404` (see `## Auth`). An unparseable or unknown `{version}` answers `404` like a denied caller.

#### Request

No fields; send no body. `{version}` in the URL.

#### Example

```bash
curl -X POST https://simple-server.pilely.app/<app_uuid>/binary/3 \
  -H "Authorization: Bearer <your-token>" \
  -H "Accept: application/json"
```

#### Response

`200`. JSON: `{ "ok": true, "build": <build>, "running_on": [ { "id", "version", "private_ip", "port", "state", … }, ... ] }` — `running_on` holds server objects. Markdown: a `Build v<version>` page with a `Running on` list.

#### Errors

No route-specific codes.

#### When to use

Checking one build, or which servers still run it before removing them.

### POST /{app_uuid}/binary/{version}/cancel

Stops the CodeBuild run and marks the build `cancelled`.

#### Auth

The app's owner, with their own token for this service or the app's own token; a box, an agent, a signed-out visitor and any other app get `404` (see `## Auth`). An unknown `{version}` answers `404`.

#### Request

No fields; send no body.

#### Example

```bash
curl -X POST https://simple-server.pilely.app/<app_uuid>/binary/3/cancel \
  -H "Authorization: Bearer <your-token>" \
  -H "Accept: application/json"
```

#### Response

`200`. JSON: `{ "ok": true, "version": 3, "status": "cancelled" }`. Markdown: a `Build cancelled` page.

#### Errors

| Status | `code` | When |
|---|---|---|
| 409 | `conflict` | the build has already settled (not `in_progress`) — nothing to cancel |
| 500 | `internal` | `StopBuild` or the row update failed; retry in a few seconds |

#### When to use

A build is running from the wrong source, or a newer build should replace it.

### POST /{app_uuid}/binary/{version}/logs

The build's compiler output, newest last, from the app's own build log group (a build started before per-app build groups reads the shared one it wrote to). Build logs are kept 7 days; an older build answers no lines.

#### Auth

The app's owner, with their own token for this service or the app's own token; a box, an agent, a signed-out visitor and any other app get `404` (see `## Auth`). An unknown `{version}` answers `404`.

#### Request

Query params, both optional:

- `since` — unix-ms, `>= 0`; only events at/after this time.
- `limit` — max number of lines, `1`–`10000`.

A value outside these bounds is a `400 bad_request`. No body.

#### Example

```bash
curl -X POST "https://simple-server.pilely.app/<app_uuid>/binary/3/logs?limit=200" \
  -H "Authorization: Bearer <your-token>" \
  -H "Accept: application/json"
```

#### Response

`200`. JSON: `{ "ok": true, "lines": [ { "timestamp_ms", "message" }, ... ] }`. Markdown: a log page with the lines in a code block, newest last.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | `since`/`limit` are not numbers, `since` is negative, or `limit` is outside `1`-`10000` |
| 500 | `internal` | the log read failed; retry in a few seconds |

#### When to use

A build settled `compile_failed` or `timed_out` and the user needs the compiler's error.

### POST /{app_uuid}/server/add

Launch a new box running a build.

#### Auth

The app's owner, with their own token for this service or the app's own token; a box, an agent, a signed-out visitor and any other app get `404` (see `## Auth`). A named `version` that doesn't exist answers `404`.

#### Request

JSON body, or none: `{ "version"?: <int>, "env_id"?: "<id>" }`.

- `version` — the build to run; omit it for the latest `succeeded` build.
- `env_id` — an `@simple_env` set id (8 characters, `a-zA-Z0-9`); omit it for a box with no env set. See `### The box's env set — SIMPLE_ENV_ID` under `## Notes`.

#### Example

```bash
curl -X POST https://simple-server.pilely.app/<app_uuid>/server/add \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"version": 3, "env_id": "aB3dE5gH"}'
```

#### Response

`202 Accepted`. JSON: `{ "ok": true, "server_id": "<uuid>", "price": <price> }` (price object in `## Response format`: this app's servers with the new one, and how long your balance lasts). Markdown: a `Server launching (202)` page with the routes to watch it and a `Price` section with the price line. It usually takes under two minutes to reach `running` — poll `server/list` or `server/{server_id}`. The response never carries the box's backend token or its `jti`.

A server is charged to your balance every hour it runs (see `### What it costs`), so it starts only while your balance is at least 10 server-hours (10 × the server-hour price) and you run fewer than 10 live servers across your apps.

#### Errors

| Status | `code` | When |
|---|---|---|
| 404 | `not_found` | the named version doesn't exist (plus every case in `## Errors`) |
| 400 | `bad_request` | the body isn't `{ "version"?: <int>, "env_id"?: "<8-char id>" }`, the named version has not succeeded, there is no succeeded build at all, or the named version's binary has been pruned (it fell out of the 3 most recent kept builds) — start a new build instead |
| 402 | `out_of_traffic_credits` | your balance is at or below $0; nothing was launched |
| 402 | `balance_too_low` | your balance is below 10 server-hours; nothing was launched |
| 409 | `conflict` | already at 3 live servers |
| 409 | `server_limit_reached` | you already run 10 live servers across your apps; nothing was launched |
| 409 | `managed_by_scale` | this app's boxes are managed by `server/scale`; change the desired state with `server/scale`, or call it with `count: 0` to go back to `server/add` and `server/{server_id}/remove`. Nothing changed |
| 500 | `internal` | the launch failed; retry in a few seconds |
| 503 | `quota_unavailable` | the billing check could not be made; nothing was launched; retry in a few seconds |

#### When to use

An app managed by hand (no desired state): running a built version, rolling out a new version, or picking up a changed env set — add the new server, wait for `running`, then remove the old one. To have the platform do this, and replace crashed servers, use `server/scale` instead.

### POST /{app_uuid}/server/scale

Set the app's desired state: how many servers, on which build and which env set. The platform then launches and removes servers until the app matches it, and keeps it there. See `### How scale converges` under `## Notes`.

#### Auth

The app's owner, with their own token for this service or the app's own token; a box, an agent, a signed-out visitor and any other app get `404` (see `## Auth`). A named `version` that doesn't exist answers `404`. A call with `count` above `0` starts paid servers and is refused, with nothing stored, while your balance is at or below $0, while it is below 10 server-hours, or when it would take you past 10 live servers across your apps (a call that does not raise your total — the same `count` on a new version, `roll: true` — is never refused by the 10-server limit). `count: 0` is never refused. If your balance runs out after the call, the platform's launches wait for credit (`rollout.state: "blocked"`).

#### Request

JSON body: `{ "count": <0-3>, "version"?: <int>, "env_id"?: "<id>", "roll"?: <bool> }`.

- `count` — required, `0`–`3`: how many servers to keep running. `0` removes every server and clears the desired state.
- `version` — the build to run; omit it for the latest `succeeded` build at the time of the call. It is stored as a number: the desired state never follows newer builds by itself. Ignored for `count: 0`.
- `env_id` — an `@simple_env` set id (8 characters, `a-zA-Z0-9`), passed through and never looked up, as on `server/add`; omit it for servers with no env set. Ignored for `count: 0`.
- `roll` — default `false`. `true` replaces every live server once, one at a time, even if it already runs the desired version and env set: this is how servers pick up changed values in the same env set.

Every call replaces the whole desired state, resets the crash budget and clears a `degraded` state. A call that repeats the current desired state (same `count`, `version`, `env_id`, no `roll`) changes no server. The first call on an app adopts the servers `server/add` launched: live servers on the desired version and env set count toward `count`, the others are rolled out.

#### Example

```bash
curl -X POST https://simple-server.pilely.app/<app_uuid>/server/scale \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"count": 2, "version": 5, "env_id": "aB3dE5gH"}'
```

#### Response

`202 Accepted`. JSON: `{ "ok": true, "desired": <desired> | null, "price": <price> | null }` (desired and price objects in `## Response format`). `desired` is `null` only for a `count: 0` call that left no live server before it answered. `price` prices the `count` servers asked for among your live servers across all apps, and is `null` for `count: 0`. Markdown: a `Scale set (202)` page with the desired state, the full `status` URL to watch it and, for `count` above `0`, a `Price` section with the price line. The call launches nothing itself; watch `status` (`desired`, `actual`, `rollout`).

With `count: 0`, every live server is out of root's target set before the answer (the app answers hosted-offline from then on); the servers are then terminated and the desired state cleared, after which `server/add` and `server/{server_id}/remove` work again.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | the body is missing or isn't the shape above, `count` is outside `0`–`3`, `env_id` is malformed, the named version has not succeeded, there is no succeeded build at all, or the named version's binary has been pruned |
| 402 | `out_of_traffic_credits` | `count` above `0` while your balance is at or below $0; nothing changed |
| 402 | `balance_too_low` | `count` above `0` while your balance is below 10 server-hours; nothing changed |
| 404 | `not_found` | the named version doesn't exist (plus every case in `## Errors`) |
| 409 | `server_limit_reached` | the `count` would take you past 10 live servers across your apps; nothing changed |
| 500 | `internal` | the desired state could not be stored; nothing changed; retry |
| 503 | `quota_unavailable` | `count` above `0` and the billing check could not be made; nothing changed; retry in a few seconds |
| 503 | `scale_unavailable` | `server/scale` is not available on this service yet; nothing changed. `server/add` and `server/{server_id}/remove` still work |

#### When to use

Putting a backend on its servers and keeping it up: the first launch (`count` 1–3), every later new version or env set (one call), picking up changed values in the same env set (`roll: true`), and taking the app offline or back to manual servers (`count: 0`).

### POST /{app_uuid}/server/list

Every server of this app: id, version, private ip, state, launched, last health, terminated, env set.

#### Auth

The app's owner, with their own token for this service or the app's own token, or a box of this app with its backend token; anyone else, another app's box included, gets `404` (see `## Auth`).

#### Request

No fields; send no body.

#### Example

```bash
curl -X POST https://simple-server.pilely.app/<app_uuid>/server/list \
  -H "Authorization: Bearer <your-token>" \
  -H "Accept: application/json"
```

#### Response

`200`. JSON: `{ "ok": true, "servers": [ <server>, ... ] }`, newest launch first (server object in `## Response format`; `env_id` is the set the box was launched with, or `null`). Markdown: a `Servers for <app_uuid>` page with one line per server. Never returns a box's backend token or its `jti`.

#### Errors

No route-specific codes.

#### When to use

Watching a new box reach `running`, or finding a `server_id` to inspect or remove.

### POST /{app_uuid}/server/{server_id}

One server's state plus its last 20 health-probe results.

#### Auth

The app's owner, with their own token for this service or the app's own token, or a box of this app with its backend token; anyone else, another app's box included, gets `404` (see `## Auth`). An unparseable `{server_id}`, an unknown one, or one belonging to another app answer `404` alike.

#### Request

No fields; send no body. `{server_id}` in the URL.

#### Example

```bash
curl -X POST https://simple-server.pilely.app/<app_uuid>/server/<server_id> \
  -H "Authorization: Bearer <your-token>" \
  -H "Accept: application/json"
```

#### Response

`200`. JSON: `{ "ok": true, "server": <server>, "health": { "probes": [ { "ok", "at", "fail_count" }, ... ] } | null }`. `health` is `null` before the first probe; `probes` holds up to the last 20 results, oldest first. `server` carries the same fields as one `server/list` item, `env_id` included. Markdown: a `Server <id>` page with a `Health probes` list. Never returns the box's backend token or its `jti`.

#### Errors

No route-specific codes.

#### When to use

A box is `unhealthy` or slow to reach `running` and the user needs its probe history.

### POST /{app_uuid}/server/{server_id}/remove

Drops the server from the live target set, terminates the instance, and marks the row `terminated`. A server the platform is swapping onto a new server image is removed together with the other server of that swap: removing the old server also removes its replacement, and removing the replacement (`server/list` shows both while the swap runs) also removes the old server. Either way you end up with one server fewer.

#### Auth

The app's owner, with their own token for this service or the app's own token; a box, an agent, a signed-out visitor and any other app get `404` (see `## Auth`). An unknown `{server_id}`, or one belonging to another app, answers `404`, before any `409 managed_by_scale`.

#### Request

No fields; send no body.

#### Example

```bash
curl -X POST https://simple-server.pilely.app/<app_uuid>/server/<server_id>/remove \
  -H "Authorization: Bearer <your-token>" \
  -H "Accept: application/json"
```

#### Response

`200`. JSON: `{ "ok": true, "server_id": "<uuid>", "state": "terminated" }`. Markdown: a `Server removed` page. The box's backend token is revoked immediately.

#### Errors

| Status | `code` | When |
|---|---|---|
| 409 | `managed_by_scale` | this app's boxes are managed by `server/scale`; change the desired state with `server/scale`, or call it with `count: 0` to go back to `server/add` and `server/{server_id}/remove`. Nothing changed |
| 500 | `internal` | the terminate or row update failed; retry in a few seconds |

#### When to use

An app managed by hand (no desired state): retiring an old version after its replacement is `running`, or stopping the app's server entirely.

### POST /{app_uuid}/server/{server_id}/logs

The box's own log stream in the app's log group, newest last. Server logs are kept 7 days.

#### Auth

The app's owner, with their own token for this service or the app's own token; a box, an agent, a signed-out visitor and any other app get `404` (see `## Auth`). An unknown `{server_id}`, or one belonging to another app, answers `404`.

#### Request

Query params, both optional:

- `since` — unix-ms, `>= 0`.
- `limit` — max lines, `1`–`10000`.

A value outside these bounds is a `400 bad_request`. No body.

#### Example

```bash
curl -X POST "https://simple-server.pilely.app/<app_uuid>/server/<server_id>/logs?since=1734112233445" \
  -H "Authorization: Bearer <your-token>" \
  -H "Accept: application/json"
```

#### Response

`200`. JSON: `{ "ok": true, "lines": [ { "timestamp_ms", "message" }, ... ] }`. Markdown: a log page with the lines in a code block, newest last.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | `since`/`limit` are not numbers, `since` is negative, or `limit` is outside `1`-`10000` |
| 500 | `internal` | the log read failed; retry in a few seconds |

#### When to use

The user's server misbehaves or crashed and they need its own output.

### POST /{app_uuid}/status

The latest build, every server with its own state, whether root currently has at least one live target for this app, and the desired state with how far the app is from it.

#### Auth

The app's owner, with their own token for this service or the app's own token, or a box of this app with its backend token; anyone else, another app's box included, gets `404` (see `## Auth`).

#### Request

No fields; send no body.

#### Example

```bash
curl -X POST https://simple-server.pilely.app/<app_uuid>/status \
  -H "Authorization: Bearer <your-token>" \
  -H "Accept: application/json"
```

#### Response

`200`. JSON:

```json
{ "ok": true, "latest_build": <build> | null, "servers": [ <server>, ... ], "has_target": true,
  "desired": <desired> | null,
  "actual": { "live": 3, "running": 2, "on_spec_running": 2 },
  "rollout": { "state": "converging", "reason": null, "replacements_last_hour": 1 } | null,
  "price": <price> | null }
```

- `latest_build` is the highest version whatever its status. `has_target: false` means a request to this app right now would answer the hosted-offline envelope.
- `desired` is the app's desired state (the same object `server/scale` answers with), or `null` when it has none. Its `count` is `0` only between a `count: 0` call and the removal of its last server.
- `actual` is always present: `live` counts `launching`, `running` and `unhealthy` servers, `running` counts `running` ones, `on_spec_running` counts running servers on the desired version and env set (`0` without a desired state).
- `rollout` is `null` without a desired state. `state` is `steady` (`count` servers running on spec, no other server live), `converging` (the platform is still launching, rolling, replacing or removing), `degraded` (the crash budget is spent; nothing launches until the next `server/scale` call) or `blocked` (a server needs launching but the owner is out of traffic credit). `degraded` wins over `blocked`. `reason` is `null` except `{ "code": "crash_loop", "message": "…" }` when `degraded` and `{ "code": "out_of_credit", "message": "…" }` when `blocked`. `replacements_last_hour` is the number of automatic replacements in the last 60 minutes (`0`–`3`).
- `price` is what this app's servers cost and how long your balance lasts at all your live servers (price object in `## Response format`), or `null` when the platform's billing service cannot be reached. `servers` counts the servers you are charged for: one per server asked for, so a crash replacement or the extra server of a roll does not add one.

Markdown: a `Status for <app_uuid>` page with `Latest build`, `Servers`, `Desired state`, `Price` (omitted when the billing service cannot be reached) and `Root target` sections.

#### Errors

| Status | `code` | When |
|---|---|---|
| 500 | `internal` | a read failed; retry in a few seconds |

#### When to use

The first call when the user asks whether their backend is up, or why their app answers hosted-offline; and the call to watch after `server/scale` — poll it until `rollout.state` is `steady`, and treat `degraded` as a build to fix, not only `has_target`.

## Notes

### Other GETs on this host

- `GET /README.md` — this page; public, the same bytes for every caller and every `Accept`.
- `GET /` — `302` to `/README.md`.
- `GET /healthz` — liveness probe.

### What a box runs

- The box runs your build's binary with `PORT` (`8080`) and `SIMPLE_SERVER_ID` (its server id) in its environment, plus `PILELY_BACKEND_TOKEN` and, when `server/add` or `server/scale` named one, `SIMPLE_ENV_ID`. The process gets at most 384 MiB of memory.
- Your binary must listen on `PORT` and answer `GET /healthz` with a `2xx`. Every box is probed every 10 seconds (2-second timeout): the first pass moves it to `running` and adds it to root's target set; 3 consecutive failures make it `unhealthy` and remove it from the target set; 6 consecutive failures make it `crashed` and terminate it. A box still `launching` after 5 minutes with no pass is `crashed` too.
- Root sends each request for a backend app to one live target picked at random; with no live target the app answers the hosted-offline envelope (`has_target: false` on `status`).
- With a desired state (`server/scale`), a `crashed` server, or one whose instance disappears, is replaced by a new server on the desired build and env set. After 3 replacements within one hour the platform launches nothing more for the app: `status` shows `rollout.state: "degraded"` (`crash_loop`), the servers still live are left as they are, and the next `server/scale` call resumes. Without a desired state nothing replaces a crashed server.
- A box reaches the internet on TCP ports 80 and 443 only (IPv4 and IPv6): box egress is locked to HTTP(S), and every other port and protocol is closed (raw SMTP, a database port, SSH, any UDP). DNS lookups work. Call a third party through its HTTPS API; for a service with no HTTPS endpoint, put a relay that listens on 443 in front of it and call the relay.

### How scale converges

- A server is live while `launching`, `running` or `unhealthy`. It is on spec when it runs the desired `version` with the desired `env_id` on the platform's current server image (and, after a `roll: true` call, was launched after that call). So when the platform moves to a new server image, every `server/scale` app is rolled onto it the way `roll: true` rolls it: one server at a time, none lost, no replacement counted.
- While fewer than `count` live servers are on spec, the platform launches on-spec servers. It never launches while the app has `count + 1` live servers, and launches one beyond `count` only to replace an off-spec server, one swap at a time.
- It removes off-spec servers and servers beyond `count`: off-spec first, then servers that are not running, then the oldest. It never removes a `running` server if that would leave fewer than `count` running, so an update adds a new server and removes an old one only after the new one is `running`; a new build that never reaches `running` never removes a healthy old server. A removal is the same as `server/{server_id}/remove`: out of root's target set, terminated, its backend token revoked.
- Each step starts within 2 minutes of the change that allows it (the platform checks every 30 seconds): a launch after a `scale` call, a removal after the new server reaches `running`, a replacement after a server leaves the live set (6 failed probes, or its instance gone).
- A launch the cloud provider refuses is retried on the next check; it is not a replacement and uses no budget.
- While the app owner is out of traffic credit (the state in which the app answers `503 out_of_traffic_credits`), the platform launches nothing for the app and `status` shows `rollout.state: "blocked"` (`out_of_credit`). Removals that need no launch still happen. Launches resume on their own within minutes of a top-up; a crashed server is replaced then. A skipped launch is not a replacement.
- The desired version's binary is never pruned while the desired state names it.
- Deleting the app removes its servers and its desired state; nothing relaunches.

### When the platform moves servers to a new server image

- The platform occasionally moves every server to a new server image (the system a box runs your binary on). Your build, its version and its env set never change; nothing is needed from you.
- A `server/scale` app is rolled onto it like `roll: true` (`### How scale converges`).
- A server added by hand (`server/add`) is swapped: the platform launches a replacement on the new image with the same version and env set, waits for it to reach `running`, then removes the old server. One server per app at a time, so the app has at most one server more than you added while it happens, and never fewer running. A replacement that never reaches `running` is stopped, the old server keeps serving, and the swap is tried again an hour later. Removing either server of a swap meanwhile removes the other too.
- Nothing is swapped while the owner is out of traffic credit; the swap resumes after a top-up.
- `server/list` shows both servers while a swap runs. A swap is never charged as an extra server.

### What it costs

Everything an app's servers and builds use is charged to the app's owner, out of the owner's prepaid balance (`https://pilely.app/README.md`), and what they store counts against the owner's storage quota. Prices are set by the platform's billing service; this service sends what was used, never a price. What each item costs: `https://pam.pilely.app/pricing.md`; the live server price is in the `price` object.

- A running server is charged one server-hour at the start of each hour it runs: the first hour when it starts, then one every hour. You pay for the number of servers you asked for: a crash replacement, the extra server of a rolling update and the replacement of a server image swap are never charged again. Removing a server mid-hour refunds nothing.
- `status` and the `server/add` and `server/scale` answers show the price line: "N servers × $<hour price>/hour (about $<day price>/day each), your balance of $B covers about X days", where X counts every live server you run across your apps (the `price` object in `## Response format`).
- Starting a server or a build needs a balance of at least 10 server-hours (10 × the server-hour price); you run at most 10 live servers across your apps, and a build's source zip must fit your storage quota (`## Errors`).
- When the owner's balance is at $0 or below at the end of a server's paid hour, that server is stopped instead and no new hour is charged. A `server/scale` app keeps its desired state, and its servers come back on their own after a top-up.
- A server's own internet traffic (what it downloads and sends, not its answers to your app's users, which are charged as app traffic) is charged every hour, by direction and address type.
- A build is charged by the minute, rounded up, whatever its outcome except `fault` (the platform's own failure), plus its internet traffic and the storage operations on its source zip.
- Server logs and build logs are charged by the GB written, every hour.
- Each running server is also charged, every hour, for its share of the platform's record of server network traffic (the server network's flow log): what that log took in that hour, split across the running servers by how much of it each one's connections wrote. A server with very many connections pays a larger share.
- Source zips, built binaries and the build cache count against the storage quota, and stop counting when they are evicted, pruned or expire, or when the app is deleted.

### What is kept

- Each app has two log groups, created by the platform with its first build: its servers' logs and its builds' logs. Both keep 7 days of lines. A server cannot create log groups of its own.
- A build that does not end `succeeded` (`compile_failed`, `timed_out`, `cancelled`, `fault`) keeps no binary: what it uploaded is deleted when it ends. Its source zip stays among the app's newest 3.
- The build cache expires 7 days after its last write. Anything in the build cache that is not an app's own cache is deleted daily.
- Deleting the app deletes, within a day, its source zips, binaries, build cache and both log groups.

### The box's backend token

Every launched box carries `PILELY_BACKEND_TOKEN` in its environment — a credential that identifies the request as coming from THIS box of THIS app, never a user. The box's own process presents it as a Bearer at the platform's `simple-*` hosts (e.g. `POST https://simple-env.pilely.app/<SIMPLE_ENV_ID>/get`), and root forwards it as the app acting as itself (`X-Pile-Requester-Entity: app:<app uuid>` on `X-Pile-Requester-App: <app uuid>`), never as the app's owner: the box reaches a platform resource only where its app is an `app` member of that resource's group. Neither `server/add` nor `server/{server_id}` nor `server/list` ever return the token or its `jti`. On this service the token reads its own app's server state only: `server/list`, `server/{server_id}` and `status`. Every other route here answers it `404 not_found`; a box can no longer add or remove servers or start or cancel builds. Removing the server (or the box crashing) revokes it immediately — the token carries no expiry, so the row it lives in IS the whole of its validity.

A box calls `### POST /{app_uuid}/server/list` or `### POST /{app_uuid}/status` to see which servers of its app are live; another app's id answers `404 not_found`. Every box of the app runs the same code and reads the same list, so a loop that should run once still needs its own guard, such as a claim row.

Because the token is the app acting as itself, every record the box creates at `simple-db` carries the APP's own id as `_submitter_user_id` (`https://simple-db.pilely.app/README.md` → `### Submitter fields on every record`), whoever the box is acting for. When a box writes on behalf of a user, store that user's id (from the forwarded `X-Pile-Requester-Entity: user:<uuid>`) in the table's own columns and show it as the author, never `_submitter_*`.

### The box's env set — `SIMPLE_ENV_ID`

When the `server/add` body names an `env_id`, the box's environment carries it as `SIMPLE_ENV_ID`, beside `PILELY_BACKEND_TOKEN`; without one the var is not set at all. The box reads its set at boot with `POST https://simple-env.pilely.app/<SIMPLE_ENV_ID>/get`, presenting `PILELY_BACKEND_TOKEN` as the Bearer (`https://simple-env.pilely.app/README.md`). This service never calls simple-env and checks no access: the id is passed through as given, and the box's own read decides — it succeeds only while this app is an `app` member of the set's `read_group`. A changed set takes effect on a NEW box: call `server/scale` with the new `env_id`, or with `roll: true` to pick up changed values in the same set (an app managed by hand: add one, then remove the old one). `server/list` and `server/{server_id}` show the `env_id` each box was launched with.