> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sevalla.com/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> These are the docs for Sevalla, a platform for deploying applications, databases, static sites, object storage and load balancers. The dashboard is at https://app.sevalla.com.
> Bold text in steps is an exact dashboard label.
> To act on a Sevalla account, prefer the Sevalla MCP server (https://mcp.sevalla.com/mcp, see https://docs.sevalla.com/ai-agents/overview.md), then the Sevalla CLI (https://docs.sevalla.com/developer-tools/cli.md), then the REST API (https://api-docs.sevalla.com, OpenAPI spec at https://api.sevalla.com/v3/openapi.json).
> Every page index is at https://docs.sevalla.com/llms.txt.

# Deploy an agent with the OpenAI Agents SDK

> Deploy an OpenAI Agents SDK agent with tools and a handoff to Sevalla: FastAPI streams its answers, PostgreSQL keeps its sessions and a background worker runs long tasks.

An agent built with the [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) runs on Sevalla as an application: FastAPI serves it from the web process and streams each answer as Server-Sent Events, a Sevalla PostgreSQL database keeps every conversation, and a background worker runs agent tasks that no client waits for. [Ingot](https://docs.sevalla.com/applications/builds/ingot.md), Sevalla's builder, installs the project from `uv.lock` and starts FastAPI with Uvicorn.

This guide builds a support agent for a store. The **Storefront assistant** looks up orders with a tool and hands returns off to the **Returns agent**, which starts them with a tool of its own.

## At a glance

| | |
| - | - |
| Source | A Git repository |
| Build | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) (Python 3.14, uv), or the [Dockerfile](#deploy-with-a-dockerfile) |
| Start command | `uvicorn app.main:app --host 0.0.0.0 --port "$PORT"`, which Ingot detects from `app/main.py` |
| Port | The `PORT` environment variable, `8080` by default |
| Processes | Web process; `Migrate` job (`python -m app.db`, **Before deployment**); `Worker` background worker (`exec python -m app.worker`) |
| Services | PostgreSQL 18 over an internal connection; the OpenAI API over HTTPS |
| Disk | None |
| Size | **H1** for the web process, the worker and the job; **S1** for more memory headroom |

### Environment variables

| Variable | Required | Available during | Secret | Value |
| - | - | - | - | - |
| `DB_URL` | Yes | Runtime | Yes | Added by the internal connection to the database: `postgres://<user>:<password>@<host>:5432/<database>` |
| `OPENAI_API_KEY` | Yes | Runtime | Yes | Your OpenAI API key |
| `API_TOKEN` | Yes | Runtime | Yes | Generate with `openssl rand -hex 32`. Clients send it as `Authorization: Bearer <token>`. |
| `OPENAI_AGENTS_DISABLE_TRACING` | No | Runtime | No | `1` stops the SDK from sending traces to OpenAI. See [Tracing](#tracing). |
| `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` | No | Runtime | No | `false` keeps traces without the inputs and outputs of model and tool calls |
| `PORT` | No | Runtime | No | Set by Sevalla to the web process port |

The dashboard makes the connection's variables available during the build too; the build doesn't need them.

## Before you begin

* A Sevalla company with a payment method, and a GitHub, GitLab or Bitbucket account for the repository. See [Git providers](https://docs.sevalla.com/git/overview.md).
* An [OpenAI API key](https://platform.openai.com/api-keys) for a project with credits. Set a budget on the project, so an agent stuck in a loop can't run up the bill.
* Python 3.14 and [uv](https://docs.astral.sh/uv/) 0.13 or later on your computer.
* Docker, to run PostgreSQL on your computer while you develop.

## Create the project

Create the project with uv and add the SDK and FastAPI:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
uv init --app --no-package --python 3.14 support-agent
cd support-agent
uv add "openai-agents[sqlalchemy]" "fastapi[standard]"
rm main.py
mkdir app && touch app/__init__.py
```

`uv init` writes `pyproject.toml`, a `.python-version` with `3.14`, a `.gitignore` that keeps out `.venv`, and a Git repository. `uv add` installs the packages into `.venv` and pins every version in `uv.lock`:

| Package | What it does |
| - | - |
| `openai-agents[sqlalchemy]` | The OpenAI Agents SDK 0.24, with SQLAlchemy and the asyncpg driver, so sessions can live in PostgreSQL |
| `fastapi[standard]` | FastAPI 0.162 with Uvicorn, the server Ingot starts, and the `fastapi dev` command |

### Define the agents

```python app/assistant.py theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
from agents import Agent, function_tool

MODEL = "gpt-6-luna"

# Stand-in data: replace with calls to your order system.
ORDERS = {"1001": "shipped", "1002": "delivered"}


@function_tool
def get_order_status(order_id: str) -> str:
    """Look up an order's status by its order number."""
    return ORDERS.get(order_id, "not found")


@function_tool
def start_return(order_id: str) -> str:
    """Start a return for a delivered order."""
    if ORDERS.get(order_id) != "delivered":
        return "Only delivered orders can be returned."
    return f"Return R-{order_id} started. The customer gets a prepaid label by email."


returns_agent = Agent(
    name="Returns agent",
    handoff_description="Handles returns of delivered orders.",
    instructions="You start returns for delivered orders and tell the customer the return number.",
    model=MODEL,
    tools=[start_return],
)

assistant = Agent(
    name="Storefront assistant",
    instructions=(
        "You answer questions about Acme Storefront orders. "
        "Hand off to the returns agent when a customer wants to return an order."
    ),
    model=MODEL,
    tools=[get_order_status],
    handoffs=[returns_agent],
)
```

* `@function_tool` turns a function into a tool. The SDK builds the tool's schema from the type hints and the docstring, which the model reads to decide when to call it.
* `handoffs=[returns_agent]` gives the assistant a tool named `transfer_to_returns_agent`. When the model calls it, the returns agent takes over the conversation.
* `MODEL` sets the model of both agents. `gpt-6-luna` is fast and costs $0.10 per 1M input tokens and $0.50 per 1M output tokens: a question that calls one tool uses about 300 input and 30 output tokens, and a return with a handoff about 440 and 100. Change `MODEL` to use another model, such as `gpt-6.1-sol` for harder tasks.

### Keep sessions in PostgreSQL

A session is the conversation the SDK remembers between runs. `SQLAlchemySession` keeps it in PostgreSQL, so every instance of the application sees the same conversations:

```python app/db.py theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import asyncio
import os

from agents.extensions.memory import SQLAlchemySession
from sqlalchemy import Column, DateTime, MetaData, String, Table, Text, func
from sqlalchemy.ext.asyncio import create_async_engine

# SQLAlchemy needs the driver's name in the URL's scheme, so swap postgres:// or postgresql:// for it.
engine = create_async_engine("postgresql+asyncpg://" + os.environ["DB_URL"].split("://", 1)[1])

metadata = MetaData()
runs = Table(
    "agent_runs",
    metadata,
    Column("id", String, primary_key=True),
    Column("session_id", String, nullable=False),
    Column("message", Text, nullable=False),
    Column("status", String, nullable=False, default="queued"),
    Column("output", Text),
    Column("created_at", DateTime(timezone=True), server_default=func.now()),
)


def session_for(session_id: str) -> SQLAlchemySession:
    return SQLAlchemySession(session_id, engine=engine)


async def create_tables() -> None:
    async with engine.begin() as conn:
        await conn.run_sync(metadata.create_all)
    # The SDK creates its own tables, agent_sessions and agent_messages, on a session's first use.
    await SQLAlchemySession("setup", engine=engine, create_tables=True).get_items()
    await engine.dispose()


if __name__ == "__main__":
    asyncio.run(create_tables())
```

* `DB_URL` is the variable Sevalla adds when you connect the database. It starts with `postgres://`, which SQLAlchemy doesn't accept, so the code puts the asyncpg driver's scheme in its place.
* A run with `session=session_for(...)` loads the session's earlier messages before it calls the model and saves the new ones when it ends.
* `agent_runs` is the queue of [background runs](#run-long-tasks-in-a-background-worker).
* `python -m app.db` creates the tables, and does nothing to tables that exist. On Sevalla, a job runs it before each deployment, so 2 instances never race to create them.

### Serve the agent

```python app/main.py theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import os
import secrets
import uuid

from agents import Runner
from fastapi import Depends, FastAPI, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi.sse import EventSourceResponse, ServerSentEvent
from openai.types.responses import ResponseTextDeltaEvent
from pydantic import BaseModel
from sqlalchemy import insert, select

from app.assistant import assistant
from app.db import engine, runs, session_for

API_TOKEN = os.environ["API_TOKEN"].encode()


def check_token(credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer())):
    if not secrets.compare_digest(credentials.credentials.encode(), API_TOKEN):
        raise HTTPException(status_code=401, detail="Invalid token")


app = FastAPI(dependencies=[Depends(check_token)])


class Message(BaseModel):
    session_id: str
    message: str


@app.post("/chat", response_class=EventSourceResponse)
async def chat(body: Message):
    result = Runner.run_streamed(assistant, body.message, session=session_for(body.session_id))
    async for event in result.stream_events():
        if event.type == "raw_response_event" and isinstance(event.data, ResponseTextDeltaEvent):
            yield ServerSentEvent(event="delta", data=event.data.delta)
        elif event.type == "run_item_stream_event" and event.name == "tool_called":
            yield ServerSentEvent(event="tool", data=event.item.raw_item.name)
        elif event.type == "agent_updated_stream_event":
            yield ServerSentEvent(event="agent", data=event.new_agent.name)
    yield ServerSentEvent(event="done", data=result.final_output)


@app.post("/runs", status_code=202)
async def create_run(body: Message):
    run_id = str(uuid.uuid4())
    async with engine.begin() as conn:
        await conn.execute(insert(runs).values(id=run_id, **body.model_dump()))
    return {"id": run_id, "status": "queued"}


@app.get("/runs/{run_id}")
async def get_run(run_id: str):
    async with engine.connect() as conn:
        run = (await conn.execute(select(runs.c.status, runs.c.output).where(runs.c.id == run_id))).first()
    if run is None:
        raise HTTPException(status_code=404, detail="Run not found")
    return {"id": run_id, **run._asdict()}
```

Every route needs the header `Authorization: Bearer` followed by the value of the `API_TOKEN` environment variable. Without the header, FastAPI answers `401` with `{"detail":"Not authenticated"}`; with another token, `401` with `{"detail":"Invalid token"}`. The agent spends money on every request, so never leave it open.

| Route | What it does |
| - | - |
| `POST /chat` | Runs the agent on `message` in the session `session_id`, and streams the run as Server-Sent Events |
| `POST /runs` | Queues the same run for the background worker and returns its `id` at once, with status `202` |
| `GET /runs/{id}` | Returns the run's `status` (`queued`, `done` or `failed`) and its `output` |

`Runner.run_streamed` starts the run and `stream_events()` yields what happens as it happens. The route turns 4 kinds of events into Server-Sent Events, each with its data encoded as JSON:

| Event | Data |
| - | - |
| `agent` | The name of the agent that answers: when the run starts, and after a handoff |
| `tool` | The name of a tool the agent calls |
| `delta` | The next piece of the answer's text |
| `done` | The whole answer, as the last event of the stream |

`EventSourceResponse` sets `Content-Type: text/event-stream` and `Cache-Control: no-cache`, and while the agent sends nothing, it sends a `: ping` comment every 15 seconds to keep the connection open.

### Run long tasks in a background worker

The worker takes queued runs from `agent_runs`, oldest first, runs each one to the end and saves its output:

```python app/worker.py theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import asyncio
import signal

from agents import Runner
from sqlalchemy import select, update

from app.assistant import assistant
from app.db import engine, runs, session_for


async def run_next() -> bool:
    # The row stays locked until the run ends. If the worker stops mid-run, the
    # transaction rolls back and the run goes back to the queue.
    async with engine.begin() as conn:
        run = (
            await conn.execute(
                select(runs)
                .where(runs.c.status == "queued")
                .order_by(runs.c.created_at)
                .limit(1)
                .with_for_update(skip_locked=True)
            )
        ).first()
        if run is None:
            return False
        print(f"Run {run.id} started", flush=True)
        try:
            result = await Runner.run(assistant, run.message, session=session_for(run.session_id))
            values = {"status": "done", "output": result.final_output}
        except Exception as exc:
            values = {"status": "failed", "output": str(exc)}
        await conn.execute(update(runs).where(runs.c.id == run.id).values(**values))
        print(f"Run {run.id} {values['status']}", flush=True)
        return True


async def main() -> None:
    stopping = asyncio.Event()
    asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stopping.set)
    while not stopping.is_set():
        if not await run_next():
            try:
                await asyncio.wait_for(stopping.wait(), timeout=2)
            except TimeoutError:
                pass
    print("Worker stopped", flush=True)


if __name__ == "__main__":
    asyncio.run(main())
```

* `with_for_update(skip_locked=True)` locks the run's row, and other workers skip locked rows, so each run happens once however many workers you start. While a run is in progress, its row reads `queued`; it changes to `done` or `failed` when the run ends.
* If the model call fails, the run's status is `failed` and its `output` holds the error, for example `Error code: 401 - Incorrect API key provided`.
* On `SIGTERM`, the worker finishes the run in progress, takes no new one, and exits with `Worker stopped`. See [Shut down without losing work](#shut-down-without-losing-work).

### Run the agent on your computer

Start PostgreSQL in Docker, set the variables the code reads, and create the tables:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
docker run -d --name support-db -e POSTGRES_USER=support -e POSTGRES_PASSWORD=support -e POSTGRES_DB=support -p 5432:5432 postgres:18
export DB_URL=postgres://support:support@localhost:5432/support
export API_TOKEN=local-token
export OPENAI_API_KEY=sk-proj-...
uv run python -m app.db
uv run fastapi dev
```

Set `OPENAI_API_KEY` to your key. `fastapi dev` finds the application in `app/main.py` and serves it on `http://127.0.0.1:8000`. In a second terminal, ask about an order:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -N http://127.0.0.1:8000/chat \
  -H "Authorization: Bearer local-token" \
  -H "Content-Type: application/json" \
  -d '{"session_id": "dana", "message": "What is the status of order 1001?"}'
```

`-N` turns off curl's buffering, so the events print as they arrive:

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
event: agent
data: "Storefront assistant"

event: tool
data: "get_order_status"

event: delta
data: "Order"

event: delta
data: " "

...

event: done
data: "Order 1001 has shipped."
```

The model writes the answer, so its wording can differ. To try the worker, run `uv run python -m app.worker` in a third terminal with the same variables. Press `Ctrl+C` to stop each one.

## Prepare the project for Sevalla

The project already has what Ingot needs:

| File | Why Sevalla needs it |
| - | - |
| `.python-version` | Ingot builds on the Python line it names, 3.14. Without it, Ingot uses its default line, which a new Ingot release can move. See [Pin the Python version](https://docs.sevalla.com/guides/python/overview.md#pin-the-python-version). |
| `uv.lock` | Ingot installs the exact versions in it with `uv sync --locked`, into a virtual environment at `/app/.venv` that's on the `PATH`. Commit it. |
| `app/main.py` | Ingot finds `app = FastAPI(...)` and starts the web process with `uvicorn app.main:app --host 0.0.0.0 --port "$PORT"`. |

Uvicorn listens on all interfaces, on the port in the `PORT` environment variable that Sevalla sets (`8080` by default), and runs one worker process. The background worker and the job run their own commands in the same image, which you set when you create them. See [Run workers and jobs](https://docs.sevalla.com/applications/builds/ingot.md#run-workers-and-jobs).

Commit everything and push it to your Git provider. `uv init` already created the repository:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
git add .
git commit -m "Prepare for Sevalla"
git branch -M main
git remote add origin https://github.com/acme/support-agent.git
git push -u origin main
```

## Deploy to Sevalla

These steps build with [Ingot](https://docs.sevalla.com/applications/builds/ingot.md). To build from your own Dockerfile instead, see [Deploy with a Dockerfile](#deploy-with-a-dockerfile).

**1. Create the database**

In the sidebar, click **Databases**, then click **Create database**. Under **Database**, choose **PostgreSQL** and the newest version. Enter `support-db` as the **Display name**, choose a **Data center** and a **Size**, then click **Create database**. See [Create a database](https://docs.sevalla.com/databases/create.md) for every field.

**2. Create the application**

In the sidebar, click **Applications**, then click **Create** > **Application**. Under **Source**, click **Private repository**, then choose the **Git provider**, the **Repository** and the **Branch**. Under **Location**, choose the same **Data center** as the database. Keep the **Size** at **H1** or choose a larger one, then click **Create application**. The application opens on its **Overview** tab with the status **Not deployed yet**: creating an application doesn't deploy it.

**3. Connect the database**

Click the **Networking** tab. Under **Connected services**, click **Add internal connection**. Choose `support-db` as the **Service**, turn on **Add environment variables to the application**, and click **Add internal connection**. Sevalla adds `DB_URL` and the other connection variables to the application. See [Connect an application](https://docs.sevalla.com/databases/connect.md#connect-an-application).

**4. Add the OpenAI key and the API token**

Click the **Environment variables** tab, then click **Add environment variables**. Add `OPENAI_API_KEY` with your OpenAI API key, click **Add another**, and add `API_TOKEN` with a long random value, for example the output of `openssl rand -hex 32`. Under **Availability**, keep **Available during runtime** selected and clear **Available during buildtime**, so the build never sees the secrets. Click **Add environment variables**.

**5. Create the migration job**

Click the **Processes** tab, then click **Create process**. Choose **Job**, enter `Migrate` as the **Name** and `python -m app.db` as the **Custom start command**. Keep the **Start policy** at **Before deployment** and the **Resource** at **H1**, then click **Create process**.

**6. Create the background worker**

Click **Create process** again. Choose **Background worker**, enter `Worker` as the **Name** and `exec python -m app.worker` as the **Custom start command**. Keep the **Resource** at **H1** and click **Create process**.

**7. Deploy**

Click the **Overview** tab, then click **Deploy now** under **Recent deployments**. Check the **Branch** and click **Deploy**.

**8. Turn on auto-deploy**

To deploy every push to the branch automatically, click the **Settings** tab, then click **Update auto-deploy** in the **Auto-deploy** section. Turn on **Auto-deploy** and click **Update auto-deploy**. See [Deployments](https://docs.sevalla.com/applications/deployments.md#deploy-automatically-on-push).

The worker's command starts with `exec`, so Python replaces the shell that Sevalla runs the command in and receives `SIGTERM` itself. The web process, the worker and the job all run in **H1** (0.3 vCPU, 0.3 GB of memory), the smallest size: in testing, each instance used 240 to 290 MB of memory and never restarted. For real traffic, choose **S1** (1 GB) for headroom. See [Instance sizes](https://docs.sevalla.com/applications/scaling.md#instance-sizes).

![The Create process sheet with Background worker selected, the Name and Custom start command fields, the resource and the scaling settings](https://assets.sevalla.com/screenshots/light/app-processes-worker-create.png)

## Verify the deployment

1. On the **Deployments** tab, click the deployment. Its build log shows Ingot's [build plan](https://docs.sevalla.com/applications/builds/ingot.md#read-the-build-plan):

   ```text Build log theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   ╭─ ■ FastAPI · Python 3.14 ───────────────────────────────────────────────────────────────────╮
   │ Python           3.14                                                .python-version "3.14" │
   │ Package manager  uv                                                  uv.lock                │
   │ uv               0.13                                                default                │
   │ Framework        FastAPI                                             app/main.py            │
   │ Start            uvicorn app.main:app --host 0.0.0.0 --port "$PORT"  FastAPI                │
   ├─ Image ─────────────────────────────────────────────────────────────────────────────────────┤
   │ Build            python:3.14-slim-trixie                                                    │
   │ Runtime          python:3.14-slim-trixie                                                    │
   │ User             1000:1000                                                                  │
   │ Init             tini                                                                       │
   │ Port             $PORT, default 8080                                                        │
   ╰────────────────────────────────────────────────────────────────────────────── Ingot v0.4.1 ─╯
   ```

   After the build, the log shows `Migrate has been successful`, `Web has been deployed successfully` and `Worker has been deployed successfully`.
2. Wait for the deployment's status to change to **Deployed**. The application's status next to its name changes to **Running**. With the API, [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) returns the status, and [Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`) searches the build log, with the IDs from [Deploy with the API](#deploy-with-the-api):

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s "https://api.sevalla.com/v3/applications/$APP_ID/deployments/$DEPLOYMENT_ID" \
     -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r .status
   curl -s "https://api.sevalla.com/v3/applications/$APP_ID/deployments/$DEPLOYMENT_ID/logs?search=Migrate%20has%20been%20successful" \
     -H "Authorization: Bearer $SEVALLA_API_KEY" | jq length
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   success
   1
   ```
3. Copy the domain under the application's name, for example `support-agent-x7k2m.sevalla.app`. A request without the token gets no further than FastAPI:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s -X POST https://support-agent-x7k2m.sevalla.app/chat
   ```

   ```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   {"detail":"Not authenticated"}
   ```
4. Ask the agent about an order, with your `API_TOKEN`. The `grep` commands keep the names of the agents and tools from the stream:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -sN https://support-agent-x7k2m.sevalla.app/chat \
     -H "Authorization: Bearer $API_TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"session_id": "dana", "message": "What is the status of order 1001?"}' \
     | grep -A1 -E '^event: (agent|tool)$' | grep '^data:'
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   data: "Storefront assistant"
   data: "get_order_status"
   ```

   Without the `grep` commands, the whole stream prints: the `agent` event at once, `tool` 2 to 3 seconds later, then the answer's `delta` events and `done` with `"Order 1001 has shipped."`.
5. Ask for a return in the same session:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -sN https://support-agent-x7k2m.sevalla.app/chat \
     -H "Authorization: Bearer $API_TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"session_id": "dana", "message": "I want to return order 1002."}' \
     | grep -A1 -E '^event: (agent|tool)$' | grep '^data:'
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   data: "Storefront assistant"
   data: "Returns agent"
   data: "start_return"
   ```

   The second `agent` event is the handoff, and the answer gives the return number `R-1002`. Ask about the first order in the same session; the answer names order 1001, which the agent read from PostgreSQL:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -sN https://support-agent-x7k2m.sevalla.app/chat \
     -H "Authorization: Bearer $API_TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"session_id": "dana", "message": "Which order did I ask about first?"}' \
     | grep -A1 '^event: done$' | grep -o 1001
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   1001
   ```
6. Queue a run for the worker, wait, and read its status:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   RUN_ID=$(curl -s https://support-agent-x7k2m.sevalla.app/runs \
     -H "Authorization: Bearer $API_TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"session_id": "lee", "message": "What is the status of order 1002?"}' | jq -r .id)
   sleep 15
   curl -s https://support-agent-x7k2m.sevalla.app/runs/$RUN_ID -H "Authorization: Bearer $API_TOKEN" | jq -r .status
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   done
   ```

   The run's `output` holds the answer. The worker's [runtime logs](https://docs.sevalla.com/applications/logs.md) show `Run <id> started` and `Run <id> done`. With the API, [Get application runtime logs](https://api-docs.sevalla.com/v3/applications/get-application-runtime-logs) (`getApplicationRuntimeLogsV3`) finds the line; logs take a few seconds to arrive, so run it again if it prints `0`:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s "https://api.sevalla.com/v3/applications/$APP_ID/runtime-logs?search=Run%20$RUN_ID%20done" \
     -H "Authorization: Bearer $SEVALLA_API_KEY" | jq length
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   1
   ```

![The application header with its name, Running status, repository, branch and the domain that opens the application](https://assets.sevalla.com/screenshots/light/app-overview-address.png)

## How streaming works on Sevalla

Cloudflare and Sevalla's proxy pass each event to the client as the application writes it, with no buffering and no configuration: in testing, the 130 `delta` events of a 120-word answer arrived spread over 2 seconds, as the model wrote them.

* **Long pauses.** Cloudflare ends a request that sends no bytes for too long with error `524` (see [504 Gateway Timeout and Cloudflare error 524](https://docs.sevalla.com/applications/troubleshooting/http-errors.md#504-gateway-timeout-and-cloudflare-error-524)). The `: ping` comment that FastAPI sends every 15 seconds keeps a stream open while the model thinks, and a stream that keeps sending stays open: a 5-minute test stream arrived complete.
* **Disconnects.** When the client closes the connection, FastAPI stops the stream and the run with it. The session keeps the question but no answer. Queue work that must finish with `POST /runs`.
* **Deployments.** A deployment stops the instance that serves a stream, so a stream can end without its `done` event. A client that doesn't get `done` should send the question again: the session keeps every earlier turn. See [Shut down without losing work](#shut-down-without-losing-work).
* **Caching.** Sevalla never caches a request with an `Authorization` header, even with [edge caching](https://docs.sevalla.com/applications/caching.md) on, so every request reaches the agent.

Use `POST /chat` when a person waits for the answer, and `POST /runs` when no one does: a webhook, a scheduled task, or a run longer than a client stays connected.

## Shut down without losing work

When a deployment, a restart or a scale-down stops an instance, Sevalla waits 5 seconds, sends `SIGTERM`, and stops the instance 30 seconds after the shutdown began. See [How processes run](https://docs.sevalla.com/applications/processes.md#how-processes-run).

* **Web process.** Uvicorn stops accepting connections and waits for the streams in progress. New requests already go to the new instances. In testing, streams in progress kept running for 25 seconds after `SIGTERM`, then ended when Sevalla stopped the instance. The answers in this guide take 2 to 5 seconds.
* **Background worker.** The worker finishes the run in progress, takes no new one, and logs `Worker stopped`. In testing, the old worker finished its run during a deployment while the new worker took the next runs from the queue. If a run lasts longer than Sevalla waits, Sevalla stops the worker, PostgreSQL rolls the transaction back, and the run goes back to `queued` for another worker to run again from the start. Tools that change data run again too, so make them safe to repeat: `start_return` returns the same return number for the same order.

## Run more instances

Sevalla doesn't keep a client's requests on one instance, and the agent keeps nothing in memory between requests: sessions and runs live in PostgreSQL. So you can run 2 or more instances of the web process and of the worker. In testing, requests in the same session reached both of 2 web instances, and both remembered the customer's name; 2 workers ran 4 queued runs between them, each run once. To add instances, update the process's **Instance count**. See [Set the number of instances](https://docs.sevalla.com/applications/scaling.md#set-the-number-of-instances).

## Tracing

The SDK traces every run (each model call, tool call and handoff, with their inputs and outputs) and sends the traces to OpenAI with your `OPENAI_API_KEY`, in batches from a background thread. View them on the [Traces dashboard](https://platform.openai.com/traces). The SDK sends what's left when the process exits, so a graceful shutdown keeps the last traces. Tracing isn't available to organizations that use OpenAI's APIs under a Zero Data Retention policy.

To change it, add an environment variable on the **Environment variables** tab and deploy:

| Variable | Value | Effect |
| - | - | - |
| `OPENAI_AGENTS_DISABLE_TRACING` | `1` | The SDK sends no traces. |
| `OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA` | `false` | The SDK still sends traces, without the inputs and outputs of model and tool calls. |

## Keep the agent safe

* **Secrets stay out of the image.** `OPENAI_API_KEY` and `API_TOKEN` are runtime-only variables, so builds and build logs never see them.
* **Every route needs the token.** Bots scan new domains: in testing, requests for `/.env` and `/server-status` reached the agent within a minute of its first deployment. A request without the token never reaches the model.
* **Tools get the least access they need.** The agent decides which tool to call from text that customers write, and text it reads (order notes, emails, web pages) can carry instructions meant to take it over. Give each tool only the data and actions it needs, and let your code, not the model, take actions you can't undo, such as refunds. For an approval step that waits for a person, see [Deploy a LangGraph agent](https://docs.sevalla.com/guides/ai/langgraph.md#approve-a-refund).
* **Spending has a limit.** Set a budget on the OpenAI project, and watch the application's requests on its **Analytics** tab.

## Deploy with a Dockerfile

Ingot needs no Dockerfile. Write your own when you want full control of the image: the base image, the system packages and every layer. Add a `Dockerfile` and a `.dockerignore` to the root of the repository:

```dockerfile Dockerfile theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
FROM python:3.14-slim-trixie AS build
COPY --from=ghcr.io/astral-sh/uv:0.13 /uv /usr/local/bin/
ENV UV_COMPILE_BYTECODE=1 \
    UV_PYTHON_DOWNLOADS=0
WORKDIR /app
COPY pyproject.toml uv.lock .python-version ./
RUN uv sync --locked --no-dev

FROM python:3.14-slim-trixie
RUN useradd --uid 1000 --create-home app
ENV PATH="/app/.venv/bin:$PATH" \
    PYTHONUNBUFFERED=1 \
    PORT=8080 \
    FORWARDED_ALLOW_IPS="*"
WORKDIR /app
COPY --from=build /app/.venv .venv
COPY . .
USER 1000:1000
EXPOSE 8080
CMD ["sh", "-c", "exec uvicorn app.main:app --host 0.0.0.0 --port \"$PORT\""]
```

| Line or choice | Why |
| - | - |
| `python:3.14-slim-trixie` | The Python line that `.python-version` pins, on Debian 13, as Ingot builds it. A line tag picks up each patch release and security fix when you deploy again; a digest would stay on one release. |
| Two stages | The first stage installs the packages with uv. The final image gets only the virtual environment and the code, not uv. |
| `ghcr.io/astral-sh/uv:0.13` | uv's official image, which Ingot uses too. `COPY --from` takes its `uv` binary without a download script. |
| `uv sync --locked --no-dev` | Installs exactly what `uv.lock` pins, and fails if the lock file doesn't match `pyproject.toml`. `UV_COMPILE_BYTECODE=1` compiles the packages at build time, so instances start faster. `UV_PYTHON_DOWNLOADS=0` makes uv use the image's Python rather than download one. |
| Lock files before the code | Docker reuses the installed packages until `pyproject.toml`, `uv.lock` or `.python-version` change, so a code change rebuilds only the last layers. |
| `useradd` and `USER 1000:1000` | Runs the agent as an unprivileged user with a home directory. The code and packages stay owned by root, so the agent can't change them. |
| `FORWARDED_ALLOW_IPS="*"` | Uvicorn trusts the `X-Forwarded-Proto` and `X-Forwarded-For` headers from Sevalla's proxy. Ingot sets the same. |
| `sh -c "exec uvicorn ..."` | Uvicorn doesn't read `PORT` itself, so a shell puts it on the command line. `exec` replaces the shell with Uvicorn, which runs as PID 1, handles `SIGTERM` and finishes the streams in progress before it exits. |

```text .dockerignore theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
.git
.venv
**/__pycache__
.env*
Dockerfile
.dockerignore
```

Commit and push both files. Ingot doesn't use the `Dockerfile`, so you can keep it in a repository that builds with Ingot, but it adds the `.dockerignore` patterns to its own: its build plan shows an **Ignored** row with `6 patterns from .dockerignore`. Then switch the application to the Dockerfile build strategy:

**1. Choose the Dockerfile build strategy**

On the application's **Settings** tab, in the **Build strategy** section, click **Update build strategy**. Click **Dockerfile**, keep the **Dockerfile path** `Dockerfile` and the **Docker context** `.`, then click **Update build strategy**.

**2. Deploy**

Click the **Overview** tab, then click **Deploy now** under **Recent deployments**. Check the **Branch** and click **Deploy**.

![The Update build strategy sheet with Dockerfile selected, the Dockerfile path, the Docker context and the Docker registry credential](https://assets.sevalla.com/screenshots/light/app-settings-build-strategy-dockerfile.png)

The build log shows `📦 Building and pushing docker image using Dockerfile...` where Ingot's build plan was, then `Migrate has been successful`: the job runs `python -m app.db` in the new image. With the API, for the new deployment's ID:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s "https://api.sevalla.com/v3/applications/$APP_ID/deployments/$DEPLOYMENT_ID/logs?search=using%20Dockerfile" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" | jq length
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
1
```

The agent answers the same `curl` requests, the session `dana` still remembers order 1001, and queued runs reach `done`. The deployment's **Dockerfile** tab shows your Dockerfile. See [Dockerfile](https://docs.sevalla.com/applications/builds/dockerfile.md).

## Deploy changes

With **Auto-deploy** on, every push to the branch starts a deployment; otherwise, click **Deploy now** for each change. Each deployment builds the image, runs the `Migrate` job, then replaces the instances of the web process and the worker. After you add a package with `uv add`, commit `pyproject.toml` and `uv.lock` together. To store more in PostgreSQL, add tables to `app/db.py`: `python -m app.db` creates new tables but doesn't change existing ones, so use a migration tool such as Alembic for changes to them. See [Deployments](https://docs.sevalla.com/applications/deployments.md).

## Add a custom domain

To serve the agent from your own domain, such as `agent.example.com`, add the domain on the application's **Domains** tab and create the DNS records that Sevalla shows. Custom domains need a web process larger than **H1**, so [scale](https://docs.sevalla.com/applications/scaling.md) the web process first. See [Custom domains](https://docs.sevalla.com/domains/overview.md).

## Troubleshoot

| Symptom | Cause | Fix |
| - | - | - |
| `401` with `{"detail":"Not authenticated"}` | The request has no `Authorization` header. | Send `Authorization: Bearer` and the value of `API_TOKEN`. |
| `401` with `{"detail":"Invalid token"}` | The token doesn't match `API_TOKEN`. | Check the variable on the **Environment variables** tab. A change applies at the next deployment. |
| The `Migrate` job fails with `KeyError: 'DB_URL'` | The application has no internal connection to the database. | Add the connection with **Add environment variables to the application** turned on, then deploy again. |
| A run's `output` is `Error code: 401 - Incorrect API key provided` | `OPENAI_API_KEY` is wrong or revoked. | Create a key in the OpenAI dashboard, update the variable and deploy. |
| A stream ends without a `done` event | A deployment stopped the instance, the client disconnected, or the model call failed. | Read the web process's [runtime logs](https://docs.sevalla.com/applications/logs.md) for a traceback, then send the question again. |
| Runs stay `queued` | No worker is running. | Check that the **Worker** process exists, runs `exec python -m app.worker` and shows **Running** on the **Processes** tab. |
| [Create application](https://api-docs.sevalla.com/v3/applications/create-application) answers `400` with `Cannot access repository <url>: Repository not found or you don't have access.` | No Git account connected to Sevalla can read the repository, or Sevalla's GitHub App doesn't have access to it. | Connect the Git account under **My account**, and give the GitHub App access to the repository; the message links to both pages. |

For more causes, see [Troubleshoot builds and deployments](https://docs.sevalla.com/applications/troubleshooting/builds-and-deployments.md).

## Deploy with the API

This sequence makes the same deployment as [Deploy to Sevalla](#deploy-to-sevalla) with the [Sevalla API](https://docs.sevalla.com/developer-tools/api.md), for a script or an AI agent: each step is one call, and later steps use the IDs that earlier ones save in shell variables. It needs `curl`, `jq` and `openssl`, and:

* An [API key](https://docs.sevalla.com/developer-tools/api-keys.md) with the **Company developer** role, or the `APP:CREATE`, `APP:READ`, `APP:UPDATE`, `DATABASE:CREATE` and `DATABASE:READ` permissions.
* The repository pushed to GitHub, with the Git account that can read it connected to Sevalla. If Sevalla can't read the repository, the **Create the application** step fails with `400` and a message that starts `Cannot access repository` and says where to grant access.
* Your OpenAI API key.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
export SEVALLA_API_KEY=svl_...
export OPENAI_API_KEY=sk-proj-...
API=https://api.sevalla.com/v3
AUTH="Authorization: Bearer $SEVALLA_API_KEY"
JSON="Content-Type: application/json"
REPO=https://github.com/acme/support-agent
```

The [CLI](https://docs.sevalla.com/developer-tools/cli.md) (`@sevalla/cli` 2.1.0, which reads `SEVALLA_API_TOKEN`) and the [MCP server](https://docs.sevalla.com/ai-agents/overview.md) can run each step too; each step names the command and the tool.

**1. Pick a location**

[List clusters](https://api-docs.sevalla.com/v3/resources/list-clusters) (`listClustersV3`) returns every location with its `display_name` and `location`. This picks Frankfurt, Germany (`eu-frankfurt-1`):

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
CLUSTER_ID=$(curl -s "$API/resources/clusters" -H "$AUTH" | jq -r '.[] | select(.location == "eu-frankfurt-1") | .id')
```

CLI: `sevalla resources clusters`. MCP: `get_platform_options`.

**2. Look up the sizes**

[List process resource types](https://api-docs.sevalla.com/v3/resources/list-process-resource-types) (`listProcessResourceTypesV3`) and [List database resource types](https://api-docs.sevalla.com/v3/resources/list-database-resource-types) (`listDatabaseResourceTypesV3`) return the sizes by name:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
H1_ID=$(curl -s "$API/resources/process-resource-types" -H "$AUTH" | jq -r '.[] | select(.name == "h1") | .id')
DB1_ID=$(curl -s "$API/resources/database-resource-types" -H "$AUTH" | jq -r '.[] | select(.name == "db1") | .id')
```

CLI: `sevalla resources process-types` and `sevalla resources db-types`. MCP: `get_platform_options`.

**3. Create the database**

[Create database](https://api-docs.sevalla.com/v3/databases/create-database) (`createDatabaseV3`) needs the password you choose. The API never returns it, so keep it for `DB_URL`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DB_PASSWORD=$(openssl rand -hex 16)
DB_ID=$(jq -n --arg cluster "$CLUSTER_ID" --arg size "$DB1_ID" --arg password "$DB_PASSWORD" \
  '{display_name: "support-db", type: "postgresql", version: "18", cluster_id: $cluster, resource_type_id: $size, db_name: "support", db_user: "support", db_password: $password}' \
  | curl -s -X POST "$API/databases" -H "$AUTH" -H "$JSON" -d @- | jq -r .id)
```

CLI: `sevalla databases create --name support-db --type postgresql --db-version 18 --cluster $CLUSTER_ID --resource-type $DB1_ID --db-name support --db-user support --db-password $DB_PASSWORD`. MCP: `create_database`, which generates the password itself and never returns it; called after the application exists, with `connect_to_application_id` and `env_var_name` set to `DB_URL`, it also does the work of the internal connection step and adds `DB_URL` in the `postgresql://` form, which `app/db.py` accepts.

**4. Wait for the database**

[Get database](https://api-docs.sevalla.com/v3/databases/get-database) (`getDatabaseV3`) reports `status` `creating`, then `ready`, with the internal hostname and port:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for i in $(seq 1 60); do [ "$(curl -s "$API/databases/$DB_ID" -H "$AUTH" | jq -r .status)" = ready ] && break; sleep 10; done
DB_HOST=$(curl -s "$API/databases/$DB_ID" -H "$AUTH" | jq -r .internal_hostname)
DB_PORT=$(curl -s "$API/databases/$DB_ID" -H "$AUTH" | jq -r .internal_port)
```

CLI: `sevalla databases get $DB_ID`. MCP: `get_database`.

**5. Create the application**

[Create application](https://api-docs.sevalla.com/v3/applications/create-application) (`createApplicationV3`) creates the application with a web process and doesn't deploy it:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
APP_ID=$(jq -n --arg cluster "$CLUSTER_ID" --arg repo "$REPO" \
  '{display_name: "support-agent", cluster_id: $cluster, source: "privateGit", git_type: "github", repo_url: $repo, default_branch: "main"}' \
  | curl -s -X POST "$API/applications" -H "$AUTH" -H "$JSON" -d @- | jq -r .id)
```

CLI: `sevalla apps create --name support-agent --cluster $CLUSTER_ID --source privateGit --git-type github --repository $REPO --branch main`. MCP: `create_application` with `deploy` set to `false`.

**6. Find the web process**

[List processes](https://api-docs.sevalla.com/v3/applications/list-processes) (`listProcessesV3`):

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
WEB_ID=$(curl -s "$API/applications/$APP_ID/processes" -H "$AUTH" | jq -r '.data[] | select(.type == "web") | .id')
```

CLI: `sevalla apps processes list --app-id $APP_ID`. MCP: `get_application`.

**7. Set the web process to H1**

The API creates the web process at **S1**, not the dashboard's **H1**. [Update process](https://api-docs.sevalla.com/v3/applications/update-process) (`updateProcessV3`) sets the size:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
jq -n --arg size "$H1_ID" '{resource_type_id: $size}' \
  | curl -s -X PATCH "$API/applications/$APP_ID/processes/$WEB_ID" -H "$AUTH" -H "$JSON" -d @- | jq -r .resource_type_name
```

It prints `h1`. CLI: `sevalla apps processes update $WEB_ID --app-id $APP_ID --resource-type $H1_ID`. MCP: `scale_process` with `size_id` and `apply_now` set to `false`.

**8. Connect the database**

[Create internal connection](https://api-docs.sevalla.com/v3/applications/create-internal-connection) (`createApplicationInternalConnectionV3`) opens the private network path. Unlike the dashboard, it adds no environment variables:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
jq -n --arg db "$DB_ID" '{target_id: $db, target_type: "database"}' \
  | curl -s -X POST "$API/applications/$APP_ID/internal-connections" -H "$AUTH" -H "$JSON" -d @- | jq -r .id
```

CLI: `sevalla apps internal-connections create $APP_ID --target-id $DB_ID --target-type database`. MCP: `sevalla_api_create` with `createApplicationInternalConnectionV3` and the same body.

**9. Add the environment variables**

[Create environment variable](https://api-docs.sevalla.com/v3/applications/create-environment-variable) (`createEnvironmentVariableV3`), once per variable, available during runtime only. `DB_URL` has the format the dashboard uses. Keep `API_TOKEN` for the checks.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
API_TOKEN=$(openssl rand -hex 32)
for pair in "DB_URL=postgres://support:$DB_PASSWORD@$DB_HOST:$DB_PORT/support" "OPENAI_API_KEY=$OPENAI_API_KEY" "API_TOKEN=$API_TOKEN"; do
  jq -n --arg key "${pair%%=*}" --arg value "${pair#*=}" '{key: $key, value: $value, is_runtime: true, is_buildtime: false}' \
    | curl -s -X POST "$API/applications/$APP_ID/env-vars" -H "$AUTH" -H "$JSON" -d @- | jq -r .key
done
```

CLI: `sevalla apps env-vars create $APP_ID --key API_TOKEN --value $API_TOKEN`, which also makes the variable available during the build. MCP: `set_environment_variables` with `runtime` set to `true` and `buildtime` set to `false`.

**10. Create the migration job**

[Create process](https://api-docs.sevalla.com/v3/applications/create-process) (`createProcessV3`) needs `resource_type_id` and `scaling_strategy` for every type, and `job_start_policy` for a job. Without `scaling_strategy`, it answers `400` with `Request doesn't match the schema`; a job without `job_start_policy` gets `jobStartPolicy must be provided if the process type is job.`

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
jq -n --arg size "$H1_ID" \
  '{display_name: "Migrate", type: "job", entrypoint: "python -m app.db", job_start_policy: "beforeDeployment", resource_type_id: $size, scaling_strategy: {type: "manual", config: {instanceCount: 1}}}' \
  | curl -s -X POST "$API/applications/$APP_ID/processes" -H "$AUTH" -H "$JSON" -d @- | jq -r .id
```

CLI: `sevalla apps processes create $APP_ID --name Migrate --type job --resource-type $H1_ID --command "python -m app.db" --job-start-policy beforeDeployment`. MCP: `sevalla_api_create` with `createProcessV3` and the same body.

**11. Create the background worker**

[Create process](https://api-docs.sevalla.com/v3/applications/create-process) (`createProcessV3`) again:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
jq -n --arg size "$H1_ID" \
  '{display_name: "Worker", type: "worker", entrypoint: "exec python -m app.worker", resource_type_id: $size, scaling_strategy: {type: "manual", config: {instanceCount: 1}}}' \
  | curl -s -X POST "$API/applications/$APP_ID/processes" -H "$AUTH" -H "$JSON" -d @- | jq -r .id
```

CLI: `sevalla apps processes create $APP_ID --name Worker --type worker --resource-type $H1_ID --command "exec python -m app.worker"`. MCP: `sevalla_api_create` with `createProcessV3` and the same body.

**12. Deploy**

[Trigger deployment](https://api-docs.sevalla.com/v3/applications/trigger-deployment) (`triggerDeploymentV3`) deploys the latest commit of the branch:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DEPLOYMENT_ID=$(curl -s -X POST "$API/applications/$APP_ID/deployments" -H "$AUTH" -H "$JSON" -d '{}' | jq -r .id)
```

CLI: `sevalla apps deployments trigger $APP_ID`. MCP: `trigger_deployment`.

**13. Wait for the deployment**

[Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) until `status` is `success`, or `failed`, `cancelled` or `skipped`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
while :; do
  STATUS=$(curl -s "$API/applications/$APP_ID/deployments/$DEPLOYMENT_ID" -H "$AUTH" | jq -r .status)
  case $STATUS in success|failed|cancelled|skipped) break ;; esac
  sleep 15
done
echo "$STATUS"
```

It prints `success`. On `failed`, [Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`) returns the build log: `curl -s "$API/applications/$APP_ID/deployments/$DEPLOYMENT_ID/logs?limit=5000" -H "$AUTH" | jq -r '.[].message'`. CLI: `sevalla apps deployments get $DEPLOYMENT_ID --app-id $APP_ID` and `sevalla apps deployments logs $DEPLOYMENT_ID --app-id $APP_ID`. MCP: `get_deployment` and `get_deployment_logs`.

**14. Run the checks**

[List application domains](https://api-docs.sevalla.com/v3/applications/list-application-domains) (`listApplicationDomainsV3`) returns the domain:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
HOST=$(curl -s "$API/applications/$APP_ID/domains" -H "$AUTH" | jq -r '.data[] | select(.is_primary) | .name')
```

Then run the checks in [Verify the deployment](#verify-the-deployment) with `$HOST` in place of `support-agent-x7k2m.sevalla.app`. CLI: `sevalla apps domains list --app-id $APP_ID`. MCP: `get_application`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.