# Sandbox Services

Sandbox Services expose public HTTP entrypoints for workloads inside a sandbox. A service can point to a long-running listener on a sandbox port, start a command on first public access, or route to a [Sandbox Function](/docs/sandbox/functions).

Use services for:

- public HTTP routes into a sandbox
- route-level method filtering, auth, CORS, rate limits, timeout, and path rewrite
- public URL auto-resume for paused sandboxes
- public entrypoints for listeners, command-backed services, and sandbox functions

## Mental Model

A sandbox service has three layers:

| Layer | Purpose |
|-------|---------|
| Service | Names the public entrypoint and, for listener-backed services, the sandbox port |
| Runtime | Describes who owns the backing process: user, command, or function |
| Route | Matches public HTTP requests and applies route policy before proxying or executing |

The service response includes a `public_url` when `ingress.public` is true and the deployment has an exposure domain. Treat `public_url` as the service base URL. Append a route path when calling it.

```bash
curl "$PUBLIC_URL/api/health"
```

## Runtime Behavior

When `runtime` is omitted, Sandbox0 treats the service as `manual`.

| Runtime | `port` | Public access behavior |
|---------|--------|------------------------|
| `manual` | Required | Sandbox0 proxies traffic to `port`. The template, user, or agent must keep the listener running |
| `cmd` | Required | On public access, Sandbox0 creates the configured procd `cmd` context when it is missing, waits for readiness, then proxies traffic to `port` |
| `function` | Omit | Sandbox0 routes the request to procd and executes the configured inline function source. No user-managed listener is required |

For `cmd` services, `health_check.path` controls readiness. If it is omitted, Sandbox0 falls back to a TCP connect check on the service port.

## Public URL Shape

For listener-backed services, the public URL is derived from the sandbox ID, service port, region ID, and root domain:

```text
https://<sandbox_id>--p<port>.<region_id>.<root_domain>
```

Function services also return a service-level `public_url`, but they use Sandbox0's reserved function routing label. See [Sandbox Functions](/docs/sandbox/functions) for function-specific URL and config details.

The URL itself does not include a route path. If a route has `path_prefix: /webhook`, call:

```bash
curl -X POST "$PUBLIC_URL/webhook/github"
```

## Routes And Paths

Routes are evaluated before proxying or function execution.

| Field | Behavior |
|-------|----------|
| `path_prefix` | Matches request paths. Defaults to `/` |
| `methods` | Allows only listed HTTP methods. Empty allows all methods |
| `rewrite_prefix` | Rewrites the matched path prefix before proxying or function execution |
| `auth` | Applies bearer or header authentication before the request reaches the sandbox |
| `cors` | Handles CORS preflight and response headers at the gateway |
| `rate_limit` | Applies per-route rate limiting at the gateway |
| `timeout_seconds` | Sets the upstream or function execution timeout |
| `resume` | Allows this route to resume a paused sandbox when sandbox `auto_resume` is also true. Resume-enabled public routes require `runtime.type: cmd` or `runtime.type: function` |

`path_prefix` is not stripped automatically. Without `rewrite_prefix`, the upstream service or function handler sees the original request path.

```yaml
routes:
    - id: webhook
      path_prefix: /webhook
      resume: true
```

A request to `$PUBLIC_URL/webhook/github` reaches the backing service or function as `/webhook/github`.

To strip `/webhook`, set `rewrite_prefix: /`:

```yaml
routes:
    - id: webhook
      path_prefix: /webhook
      rewrite_prefix: /
      resume: true
```

The same request reaches the backing service or function as `/github`.

## Sandbox Functions

Use [Sandbox Functions](/docs/sandbox/functions) when you want a public HTTP handler without running a long-lived listener inside the sandbox. A function is still configured as a service with `runtime.type: function`, so it uses the same public URL, route policy, auth, CORS, rate limit, timeout, rewrite, and auto-resume model.

Function services omit `port`, execute inline source through procd, and can return normal HTTP responses, SSE streams, or WebSocket messages. Set `runtime.function.max_concurrency` when one function service must limit simultaneous handler executions inside a sandbox runtime.

## Cmd Services

A `cmd` service starts a procd command on first matching public access when the service command is not already running. Sandbox0 injects these environment variables into the command:

| Environment variable | Value |
|----------------------|-------|
| `SANDBOX0_SERVICE_ID` | Service ID |
| `SANDBOX0_SERVICE_PORT` | Configured service port |
| `SANDBOX0_SERVICE_RUNTIME` | `cmd` |

```yaml
services:
    - id: api
      port: 8080
      runtime:
          type: cmd
          command:
              - python3
              - -c
              - |
                  import http.server
                  import json
                  import os
                  import socketserver

                  port = int(os.environ["SANDBOX0_SERVICE_PORT"])

                  class Handler(http.server.BaseHTTPRequestHandler):
                      def do_GET(self):
                          if self.path == "/healthz":
                              self.send_response(204)
                              self.end_headers()
                              return
                          self.send_response(200)
                          self.send_header("content-type", "application/json")
                          self.end_headers()
                          self.wfile.write(json.dumps({
                              "service_id": os.environ["SANDBOX0_SERVICE_ID"],
                              "path": self.path,
                          }).encode())

                  socketserver.TCPServer(("", port), Handler).serve_forever()
      health_check:
          path: /healthz
      ingress:
          public: true
          routes:
              - id: api
                path_prefix: /api
                rewrite_prefix: /
                methods: [GET]
                timeout_seconds: 30
                resume: true
```

After applying this service, the first request starts the command, waits for `/healthz`, rewrites `/api/hello` to `/hello`, and proxies to the command.

```bash
curl "$PUBLIC_URL/api/hello"
```

## Manual Services

Use `manual` when another actor starts and owns the listener.

```yaml
services:
    - id: web
      port: 3000
      runtime:
          type: manual
      ingress:
          public: true
          routes:
              - id: web
                path_prefix: /
                resume: false
```

Manual services can expose a listener while the sandbox is running, but they cannot be restarted by public auto-resume. Use a `cmd` service or a function service when a public route needs `resume: true`.

## API Reference

### List Services

<Endpoint method="GET">
/api/v1/sandboxes/{'{id}'}/services
</Endpoint>

The response includes the normalized services, `publishable`, `publish_blockers`, `public_url`, and `exposure_domain`.

<Tabs
  tabs={[
    {
      label: "Go",
      language: "go",
      code: `resp, err := sandbox.GetServices(ctx)
if err != nil {
    log.Fatal(err)
}
for _, service := range resp.Services {
    fmt.Printf("%s url=%s port=%d\\n", service.ID, service.PublicURL.Or(""), service.Port.Or(0))
}`
    },
    {
      label: "Python",
      language: "python",
      code: `resp = sandbox.get_services()
for service in resp.services:
    print(f"{service.id} url={service.public_url} port={service.port}")`
    },
    {
      label: "TypeScript",
      language: "typescript",
      code: `const resp = await sandbox.getServices();
for (const service of resp.services ?? []) {
    console.log(\`\${service.id} url=\${service.publicUrl ?? ''} port=\${service.port ?? ''}\`);
}`
    },
    {
      label: "CLI",
      language: "bash",
      code: `s0 sandbox service get -s sb_abc123`
    }
  ]}
/>

### Replace Services

<Endpoint method="PUT">
/api/v1/sandboxes/{'{id}'}/services
</Endpoint>

`PUT` replaces the full service list. Include every service that should remain active.

<Tabs
  tabs={[
    {
      label: "Go",
      language: "go",
      code: `tokenHash := sha256.Sum256([]byte(os.Getenv("PUBLIC_API_TOKEN")))

resp, err := sandbox.UpdateServices(ctx, []apispec.SandboxAppService{
    {
        ID:   "api",
        Port: apispec.NewOptInt32(8080),
        Runtime: apispec.NewOptSandboxAppServiceRuntime(apispec.SandboxAppServiceRuntime{
            Type:    apispec.SandboxAppServiceRuntimeTypeCmd,
            Command: []string{"python3", "-m", "http.server", "8080"},
            Cwd:     apispec.NewOptString("/workspace"),
        }),
        Ingress: apispec.SandboxAppServiceIngress{
            Public: true,
            Routes: []apispec.SandboxAppServiceRoute{
                {
                    ID:         "api",
                    PathPrefix: apispec.NewOptString("/api"),
                    Methods:    []string{"GET", "POST"},
                    Auth: apispec.NewOptSandboxAppServiceRouteAuth(apispec.SandboxAppServiceRouteAuth{
                        Mode:              apispec.SandboxAppServiceRouteAuthModeBearer,
                        BearerTokenSHA256: apispec.NewOptString(hex.EncodeToString(tokenHash[:])),
                    }),
                    TimeoutSeconds: apispec.NewOptInt32(30),
                    Resume:         true,
                },
            },
        },
    },
})
if err != nil {
    log.Fatal(err)
}
fmt.Printf("services: %d\\n", len(resp.Services))`
    },
    {
      label: "Python",
      language: "python",
      code: `import hashlib
import os

from sandbox0.apispec.models.sandbox_app_service import SandboxAppService
from sandbox0.apispec.models.sandbox_app_service_ingress import SandboxAppServiceIngress
from sandbox0.apispec.models.sandbox_app_service_route import SandboxAppServiceRoute
from sandbox0.apispec.models.sandbox_app_service_route_auth import SandboxAppServiceRouteAuth
from sandbox0.apispec.models.sandbox_app_service_route_auth_mode import SandboxAppServiceRouteAuthMode
from sandbox0.apispec.models.sandbox_app_service_runtime import SandboxAppServiceRuntime

token_hash = hashlib.sha256(os.environ["PUBLIC_API_TOKEN"].encode()).hexdigest()

resp = sandbox.update_services([
    SandboxAppService(
        id="api",
        port=8080,
        runtime=SandboxAppServiceRuntime(
            type="cmd",
            command=["python3", "-m", "http.server", "8080"],
            cwd="/workspace",
        ),
        ingress=SandboxAppServiceIngress(
            public=True,
            routes=[
                SandboxAppServiceRoute(
                    id="api",
                    path_prefix="/api",
                    methods=["GET", "POST"],
                    auth=SandboxAppServiceRouteAuth(
                        mode=SandboxAppServiceRouteAuthMode.BEARER,
                        bearer_token_sha256=token_hash,
                    ),
                    timeout_seconds=30,
                    resume=True,
                ),
            ],
        ),
    ),
])
print(f"services: {len(resp.services)}")`
    },
    {
      label: "TypeScript",
      language: "typescript",
      code: `import { createHash } from 'node:crypto';

const tokenHash = createHash('sha256')
    .update(process.env.PUBLIC_API_TOKEN!)
    .digest('hex');

const resp = await sandbox.updateServices([
    {
        id: 'api',
        port: 8080,
        runtime: {
            type: 'cmd',
            command: ['python3', '-m', 'http.server', '8080'],
            cwd: '/workspace',
        },
        ingress: {
            _public: true,
            routes: [
                {
                    id: 'api',
                    pathPrefix: '/api',
                    methods: ['GET', 'POST'],
                    auth: {
                        mode: 'bearer',
                        bearerTokenSha256: tokenHash,
                    },
                    timeoutSeconds: 30,
                    resume: true,
                },
            ],
        },
    },
]);
console.log('services:', resp.services?.length ?? 0);`
    },
    {
      label: "CLI",
      language: "bash",
      code: `s0 sandbox service update --services-file services.yaml -s sb_abc123`
    }
  ]}
/>

### Delete One Service

The public API replaces the full service list; the CLI wraps that flow for deleting one service by ID.

<Tabs
  tabs={[
    {
      label: "CLI",
      language: "bash",
      code: `s0 sandbox service delete api -s sb_abc123`
    }
  ]}
/>

### Clear Services

Clearing services removes public HTTP exposure from the sandbox.

<Tabs
  tabs={[
    {
      label: "Go",
      language: "go",
      code: `_, err := sandbox.ClearServices(ctx)
if err != nil {
    log.Fatal(err)
}`
    },
    {
      label: "Python",
      language: "python",
      code: `sandbox.clear_services()`
    },
    {
      label: "TypeScript",
      language: "typescript",
      code: `await sandbox.clearServices();`
    },
    {
      label: "CLI",
      language: "bash",
      code: `s0 sandbox service clear -s sb_abc123`
    }
  ]}
/>

## Set Services At Claim Time

You can attach services when claiming a sandbox. The same service schema is accepted at claim time and by `PUT /services`.

```yaml
template: default
config:
    auto_resume: true
    services:
        - id: api
          port: 8080
          runtime:
              type: cmd
              command:
                  - python3
                  - -m
                  - http.server
                  - "8080"
              cwd: /workspace
          ingress:
              public: true
              routes:
                  - id: api
                    path_prefix: /api
                    rewrite_prefix: /
                    methods: [GET, POST]
                    resume: true
```

<Tabs
  tabs={[
    {
      label: "Go",
      language: "go",
      code: `sandbox, err := client.ClaimSandbox(ctx, "default",
    sandbox0.WithSandboxAutoResume(true),
    sandbox0.WithSandboxServices([]apispec.SandboxAppService{
        {
            ID:   "api",
            Port: apispec.NewOptInt32(8080),
            Runtime: apispec.NewOptSandboxAppServiceRuntime(apispec.SandboxAppServiceRuntime{
                Type:    apispec.SandboxAppServiceRuntimeTypeCmd,
                Command: []string{"python3", "-m", "http.server", "8080"},
                Cwd:     apispec.NewOptString("/workspace"),
            }),
            Ingress: apispec.SandboxAppServiceIngress{
                Public: true,
                Routes: []apispec.SandboxAppServiceRoute{
                    {
                        ID:            "api",
                        PathPrefix:    apispec.NewOptString("/api"),
                        RewritePrefix: apispec.NewOptString("/"),
                        Methods:       []string{"GET", "POST"},
                        Resume:        true,
                    },
                },
            },
        },
    }),
)
if err != nil {
    log.Fatal(err)
}`
    },
    {
      label: "Python",
      language: "python",
      code: `from sandbox0.apispec.models.sandbox_config import SandboxConfig

sandbox = client.sandboxes.claim(
    "default",
    config=SandboxConfig.from_dict({
        "auto_resume": True,
        "services": [
            {
                "id": "api",
                "port": 8080,
                "runtime": {
                    "type": "cmd",
                    "command": ["python3", "-m", "http.server", "8080"],
                    "cwd": "/workspace",
                },
                "ingress": {
                    "public": True,
                    "routes": [
                        {
                            "id": "api",
                            "path_prefix": "/api",
                            "rewrite_prefix": "/",
                            "methods": ["GET", "POST"],
                            "resume": True,
                        },
                    ],
                },
            },
        ],
    }),
)`
    },
    {
      label: "TypeScript",
      language: "typescript",
      code: `const sandbox = await client.sandboxes.claim('default', {
    autoResume: true,
    services: [
        {
            id: 'api',
            port: 8080,
            runtime: {
                type: 'cmd',
                command: ['python3', '-m', 'http.server', '8080'],
                cwd: '/workspace',
            },
            ingress: {
                _public: true,
                routes: [
                    {
                        id: 'api',
                        pathPrefix: '/api',
                        rewritePrefix: '/',
                        methods: ['GET', 'POST'],
                        resume: true,
                    },
                ],
            },
        },
    ],
});`
    },
    {
      label: "CLI",
      language: "bash",
      code: `s0 sandbox create -f sandbox.yaml`
    }
  ]}
/>

## Auto-Resume

Public service auto-resume has three requirements:

- sandbox `auto_resume` must be true
- matched route `resume` must be true
- the matched service must be restartable with `runtime.type: cmd` or `runtime.type: function`

All three requirements are needed for public URL requests to wake a paused sandbox and make the backing service available.

For a paused sandbox, Sandbox0 claims a fresh runtime allocation for the same
sandbox identity and restores the latest RootFS generation. The sandbox ID and
service `public_url` stay unchanged until the sandbox is deleted or `hard_ttl`
expires. For `cmd` services, the command starts again in the new runtime
generation. For `function` services, there is no long-running listener; each
request executes the handler after the sandbox runtime is available.

## Notes

- `PUT /services` replaces the entire service list.
- Public traffic must match a route on a public service.
- If `methods` is empty, all methods are allowed.
- Route auth stores SHA-256 digests, not plaintext tokens.
- Route rate limits use the gateway rate-limit backend. Self-hosted deployments
  default to process-local memory; configure `rate_limit_backend: redis` and
  one shared `rate_limit_redis_url`/key prefix across gateway replicas.

## Next Steps

<CardGroup>
  <Card title="Sandbox Functions" href="/docs/sandbox/functions" cta="Continue">
    Expose public handlers without running long-lived HTTP listeners.
  </Card>

  <Card title="Contexts" href="/docs/sandbox/contexts" cta="Continue">
    Create and inspect the command processes that can back services.
  </Card>
</CardGroup>
