# S2A - Serverless Agent Service API

> S2A hosts agents and services as scale-to-zero containers. You give it a GitHub repository, an inline Dockerfile or a pre-built image; it builds the image, finds the cheapest cloud and region among a set of supported clouds that fits the requested size, deploys it, waits for the healthcheck, and serves it at a stable URL. Idle containers sleep; the first request wakes them.

This file is served by the API itself at `/llms.txt`. Every path below is relative to the base URL that served it (for example `http://localhost:8080`). The machine-readable contract is the OpenAPI 3 document at `/openapi.json`; fetch it when you need exact field names, enums, defaults or response schemas. All request and response bodies are JSON.

## Discovery

- [OpenAPI 3 document](/openapi.json): generated from the route table and Go types; the source of truth for schemas
- [Swagger UI](/docs): interactive documentation for humans
- [Liveness](/healthz): `{"status":"ok","version":"...","time":"..."}`; no auth required
- [This file](/llms.txt): agent-oriented guide

## Authentication

Every `/v1/*` request must send the API key in the `x-api-key` header. `Authorization: Bearer <key>` is accepted as a fallback. Keys are Swarms API keys and start with `sk-`. `/healthz`, `/openapi.json`, `/docs` and `/llms.txt` are public.

- 401 `{"error":"missing x-api-key header"}`: the header is absent
- 400 `{"error":"..."}`: the key does not have the required prefix
- 401 `{"error":"invalid or deleted API key"}`: the key is unknown or revoked
- 503 `{"error":"authentication backend unavailable"}`: retry with backoff

Verify a key and learn who you are with `GET /v1/me`. It returns `{"authenticated":true,"principal":{"user_id":"...","key_id":"...","key_name":"...","credit_limit_usd":...}}`. When the server runs without auth (development only) `authenticated` is false.

Tenancy: you only see deployments created with your own user's keys. A deployment owned by someone else returns 404, not 403, so treat 404 as "not yours or does not exist".

## Endpoints

| Method | Path | Purpose | Success |
| --- | --- | --- | --- |
| GET | /v1/me | Caller behind the API key | 200 |
| GET | /v1/providers | Enabled clouds, their limits and capabilities | 200 |
| GET | /v1/regions?cloud=auto&scope=any | Region catalog; `enabled` marks regions this server can deploy to | 200 |
| GET | /v1/pricing/rates?cloud= | Current rate table and where each rate came from | 200 |
| POST | /v1/pricing/quote | Rank regions by monthly cost for a size, without deploying | 200 (422 if nothing fits) |
| POST | /v1/pricing/refresh | Pull live prices from the public cloud price APIs now | 200 |
| POST | /v1/deployments | Create a deployment; build and rollout run asynchronously | 202 |
| GET | /v1/deployments?include_destroyed=false | List your deployments (events omitted) | 200 |
| GET | /v1/deployments/{id_or_name} | Full record: status, url, quote, events | 200 |
| GET | /v1/deployments/{id_or_name}/events | Timeline only | 200 |
| GET | /v1/deployments/{id_or_name}/logs?tail=200&since=10m&follow=false | Container stdout/stderr as plain text, oldest first, RFC 3339 timestamp per line; `follow=true` streams until you disconnect | 200 (501 if the cloud has no logs) |
| POST | /v1/deployments/{id_or_name}/redeploy | Rebuild and roll out a new generation; optional `source` / `env` patch | 202 |
| POST | /v1/deployments/{id_or_name}/wake | Scale a sleeping deployment up now | 200 |
| POST | /v1/deployments/{id_or_name}/sleep | Scale a live deployment to zero now | 200 |
| DELETE | /v1/deployments/{id_or_name}?purge=false | Destroy cloud resources; `purge=true` also deletes the record | 200 (204 with purge) |
| ANY | /apps/{name}/... | Traffic to the deployed application through the gateway | whatever the app returns |

Errors are always `{"error":"<message>"}`. Deployment endpoints accept either the id (`dep_...`) or the name.

## Recommended workflow for an agent

1. `GET /v1/me` to confirm the key works.
2. `GET /v1/providers` to learn which clouds are available. Every cloud is listed; one with `available: false` carries the reason in `error`, is skipped by automatic placement, and is rejected with 400 if named explicitly.
3. Optional: `POST /v1/pricing/quote` with the intended size to preview cost and placement before committing.
4. `POST /v1/deployments` with a spec. Read `id`, `url`, `cloud`, `region` and `quote` from the 202 response.
5. Poll `GET /v1/deployments/{id}` every 5 to 10 seconds until `status` is `live` or `failed`. A build plus deploy typically takes one to several minutes. On `failed`, read `error` and `events` for the stage that failed, then `GET /v1/deployments/{id}/logs?tail=200` for the container's own output (a healthcheck failure almost always shows its cause there: wrong port, crash on start, missing env). Logs stay readable for failed deployments until they are destroyed.
6. Send traffic to `url` (or `/apps/{name}/...` on this server). Requests are proxied to the container.
7. Later: `POST .../redeploy` to ship a new version, `DELETE` to tear it down.

## Deployment spec

Minimal examples. Unknown fields are rejected with 400, so send only fields listed here or in the OpenAPI document. Bodies are limited to 4 MB.

From a GitHub repository, cheapest region anywhere:

```json
{
  "name": "my-agent",
  "source": {"github_repo": "acme/my-agent", "branch": "main"},
  "env": {"OPENAI_API_KEY": "..."}
}
```

From an inline Dockerfile, restricted to Europe:

```json
{
  "name": "hello",
  "source": {"dockerfile": "FROM python:3.12-slim\nWORKDIR /app\nCOPY . .\nCMD [\"python\",\"-m\",\"http.server\",\"8080\"]"},
  "placement": {"cloud": "auto", "scope": "europe"},
  "resources": {"cpu": 2, "memory_gb": 4, "volume_gb": -1}
}
```

From a pre-built image (no build step), cheapest region in the Americas:

```json
{
  "name": "api",
  "source": {"image": "ghcr.io/acme/api:1.2"},
  "placement": {"cloud": "auto", "region": "auto", "scope": "america"},
  "port": 3000,
  "healthcheck": {"path": "/ready", "timeout_seconds": 120}
}
```

Field reference:

- `name` (required): `^[a-z][a-z0-9-]{1,38}[a-z0-9]$`, globally unique among non-destroyed deployments. It becomes the URL path or subdomain. 409 if taken.
- `source`: at least one of `github_repo` (`owner/repo` or https URL), `dockerfile` (inline text) or `image`. `dockerfile` overrides the repository's Dockerfile; `image` skips the build. `branch` defaults to `main`, `dockerfile_path` to `Dockerfile`.
- `resources.cpu`: vCPU per instance, default 8. `resources.memory_gb`: default 2 GB per vCPU. `resources.volume_gb`: persistent volume, default 20; use `-1` for no volume. `volume_mount_path` defaults to `/data`.
- `placement.cloud`: `auto` (default) lets S2A pick the cheapest available cloud; otherwise one of the cloud ids returned by `GET /v1/providers`. `placement.region`: a region id from `GET /v1/regions`, or `auto` for the cheapest region in scope. `placement.scope`: `america`, `europe` or `any`; only used when region is `auto`.
- `healthcheck.path` (default `/health`) must answer 2xx or 3xx within `healthcheck.timeout_seconds` (default 300) or the deployment fails. The same probe gates every wake.
- `scaling.min_instances` (default 0, scale to zero), `max_instances` (10), `idle_timeout_seconds` (300, seconds without traffic before sleeping), `concurrency` (80).
- `port`: the port the container listens on, default 8080. The app must bind `0.0.0.0`.
- `env`: environment variables passed to the container.
- `active_hours_per_month` (default 730) and `requests_per_month` (default 0): assumptions used only for the cost estimate.

Each cloud has a maximum instance size; `GET /v1/providers` reports `max_cpu` and `max_memory_gb` per cloud. A cloud that cannot host the requested size is skipped and listed in `rejected`. If no enabled cloud fits, creation returns 422.

Create-response status codes: 202 accepted; 400 invalid body, validation error or cloud not available; 409 name in use; 422 no region can host the requested size; 503 the work queue is full (retry later).

## Deployment record

`GET /v1/deployments/{id}` returns:

- `id`, `spec` (with defaults applied), `status`, `error` (when failed), `created_at`, `updated_at`, `generation` (incremented on every redeploy)
- `cloud`, `region`: the resolved placement
- `image`: the image that was deployed
- `url`: the stable public URL to call the app; use this, not `endpoint`
- `endpoint`: the raw cloud endpoint behind the gateway
- `quote`: `total_usd` per month at `active_hours_per_month`, plus `compute_usd`, `storage_usd`, `requests_usd`, `hourly_usd` while active, and `rate_source`
- `events[]`: `{time, stage, message}` timeline; `stage` is one of `placement`, `queued`, `build`, `deploy`, `healthcheck`, `live`, `sleep`, `wake`, `destroy`. A failure is recorded on the stage that failed with a message starting `failed:`
- `last_request_at`: used by the idle scaler

Status lifecycle: `queued` -> `building` -> `deploying` -> `healthchecking` -> `live`. A live deployment moves between `live`, `sleeping` and `waking`. Terminal or exceptional states: `failed`, `destroying`, `destroyed`. Poll until `live` or `failed`; never assume the 202 means the app is reachable.

## Calling the deployed application

The gateway serves the app at `url`, which is `<base>/apps/<name>/...`, or `https://<name>.<domain>/...` when host-based routing is configured. Any HTTP method, path, query and body are forwarded unchanged. The gateway itself needs no API key; your application decides its own auth.

Gateway responses that come from the platform rather than the app:

- 404 `{"error":"unknown application"}`: no such name
- 503 with `Retry-After: 10` and `{"error":"deployment in progress","status":"building"}`: still rolling out; wait and retry
- 503 `{"error":"deployment is not running","status":"failed"}`: the deployment is failed or destroyed
- 504 `{"error":"timed out waiting for container to wake"}`: the wake exceeded the healthcheck timeout; retry once
- 502: wake failed, no endpoint, or the upstream errored; check `GET /v1/deployments/{id}` and its events

Sleeping containers: after `idle_timeout_seconds` without traffic the deployment goes to `sleeping`. The next request is held by the gateway while the container starts and its healthcheck passes, then answered normally. Expect the first request after idle to take longer (seconds to a minute or two depending on the cloud and image). Call `POST .../wake` ahead of time if you want a warm container before a burst of traffic, and `POST .../sleep` to stop paying immediately. Both return 409 `{"error":"deployment is <status>"}` when the deployment is not in a state that allows the transition.

## Logs

`GET /v1/deployments/{id}/logs` returns the container's stdout and stderr as `text/plain`, one line per log line, oldest first, each prefixed with an RFC 3339 timestamp. Parameters: `tail=N` (default 200, max 10000), `since=<RFC 3339 time or duration such as 10m, 2h>`, `follow=true` to keep the connection open and receive new lines as they arrive (stop by closing the connection). Logs are available in every status including `failed`; a deployment that failed before its container started returns an empty body. On Cloud Run lines appear a few seconds after they are written. 501 means the cloud offers no logs.

## Redeploy

`POST /v1/deployments/{id}/redeploy` with an empty body rebuilds from the same source. Send a body to change only the source or env before rebuilding; other spec fields cannot be patched:

```json
{"source": {"github_repo": "acme/my-agent", "branch": "release"}, "env": {"LOG_LEVEL": "debug"}}
```

Returns 202 with the record back in `queued` and `generation` incremented. 409 if the deployment is busy (queued, building, deploying, healthchecking, destroying) or destroyed. While the new generation rolls out the gateway answers 503 `deployment in progress` with `Retry-After: 10`, so poll for `live` again before resuming traffic.

## Delete

`DELETE /v1/deployments/{id}` removes the cloud resources and sets `status` to `destroyed`; the record stays visible with `?include_destroyed=true` on the list endpoint. `DELETE .../{id}?purge=true` also removes the record and returns 204. A 502 `destroy failed: ...` means the cloud refused; the resources may be partly removed, so retry the DELETE. Destroy can take up to ten minutes on real clouds.

## Pricing and placement without deploying

`POST /v1/pricing/quote` accepts `{"cpu": 8, "memory_gb": 16, "volume_gb": 20, "cloud": "auto", "region": "auto", "scope": "europe", "active_hours_per_month": 730, "requests_per_month": 0}`. All fields are optional and default like a deployment spec. The response has `cheapest`, `quotes[]` sorted cheapest first (each with `enabled` telling you whether this server can deploy there) and `rejected[]` with reasons. Only quotes with `enabled: true` are deployable here.

`GET /v1/regions?scope=america` lists candidate regions with their continent; `GET /v1/pricing/rates` shows the per-vCPU-hour, per-GB-hour and storage rates in use and whether they came from a live fetch or the embedded baseline.

## Practical guidance

- Deployment names are global on the server. Prefix them with something unique to your project to avoid 409s.
- Send `Content-Type: application/json`. The decoder rejects unknown fields; do not invent keys.
- Always poll for `live` before sending traffic. Use `events` and `logs` to explain failures to the user; quote the last log lines back.
- With `min_instances: 0` the monthly `quote.total_usd` assumes `active_hours_per_month`; the real cost scales with the time the container is awake.
- Omitted sizes are large by default (8 vCPU, 16 GB, 20 GB volume). Set `resources` explicitly for small services.
- `volume_gb: -1` disables the persistent volume, which is the right choice for stateless services and removes the storage line from the quote.
- Ship images for `linux/amd64`; that is the platform the builder targets.
- For private GitHub repositories the server operator must set `GITHUB_TOKEN`; the API has no per-request credential for cloning.
- Which clouds are usable differs per server. Always read `GET /v1/providers` and check `available` instead of assuming. The `local` backend (containers on the control plane host) is reserved for the operator's root key and never appears for user keys; `cloud: local` from a user key is rejected with 400. Some cloud providers are newer than others; the `local` backend is the one most thoroughly exercised.

## curl cheat sheet

```bash
BASE=http://localhost:8080
KEY=sk-...

curl -s $BASE/v1/me -H "x-api-key: $KEY"

curl -s $BASE/v1/deployments -X POST -H "x-api-key: $KEY" -H 'content-type: application/json' \
  -d '{"name":"my-agent","source":{"github_repo":"acme/my-agent"},"resources":{"cpu":2,"memory_gb":4,"volume_gb":-1}}'

curl -s $BASE/v1/deployments/my-agent -H "x-api-key: $KEY"           # poll until status == live
curl -s "$BASE/v1/deployments/my-agent/logs?tail=100" -H "x-api-key: $KEY"   # why did it fail?
curl -N "$BASE/v1/deployments/my-agent/logs?follow=true" -H "x-api-key: $KEY" # live tail
curl -s $BASE/apps/my-agent/health                                    # traffic through the gateway
curl -s $BASE/v1/deployments/my-agent/redeploy -X POST -H "x-api-key: $KEY"
curl -s "$BASE/v1/deployments/my-agent?purge=true" -X DELETE -H "x-api-key: $KEY"
```

## Optional

- [Deployment guide](https://github.com/The-Swarm-Corporation/agent-hosting/blob/main/docs/deployment.md): running the server locally and on each cloud
- [Authentication guide](https://github.com/The-Swarm-Corporation/agent-hosting/blob/main/docs/auth.md): API keys and the Supabase table
- [Source repository](https://github.com/The-Swarm-Corporation/agent-hosting)
