> ## 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 a LangGraph agent

> Deploy a LangGraph agent to Sevalla with FastAPI: Claude calls tools, threads live in PostgreSQL through the Postgres checkpointer, and a person approves refunds through an interrupt.

A [LangGraph](https://docs.langchain.com/oss/python/langgraph/overview) agent runs on Sevalla as an application: FastAPI runs the graph in the web process and streams each answer as Server-Sent Events, and LangGraph's PostgreSQL checkpointer saves every step of every thread in a Sevalla database, so a thread carries on after a deployment and on any instance. [Ingot](https://docs.sevalla.com/applications/builds/ingot.md), Sevalla's builder, installs the project from `uv.lock` and starts FastAPI with Uvicorn. You don't need LangChain's hosting, LangSmith Deployment (formerly LangGraph Platform): the graph runs inside your own application.

This guide builds a support agent for a store with Claude. It looks up orders with one tool and refunds them with another, which pauses the thread until a staff member approves the refund in a second request.

## 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.migrate`, **Before deployment**) |
| Services | PostgreSQL 18 over an internal connection; the Anthropic API over HTTPS |
| Disk | None |
| Size | **H1** for the web process 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>` |
| `ANTHROPIC_API_KEY` | Yes | Runtime | Yes | Your Anthropic API key |
| `API_TOKEN` | Yes | Runtime | Yes | Generate with `openssl rand -hex 32`. Clients send it as `Authorization: Bearer <token>`. |
| `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 [Anthropic API key](https://platform.claude.com/settings/keys). Set a spend limit on the account, 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 LangGraph, its PostgreSQL checkpointer, the Claude chat model and FastAPI:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
uv init --app --no-package --python 3.14 storefront-agent
cd storefront-agent
uv add langgraph langgraph-checkpoint-postgres langchain-anthropic "psycopg[binary]" "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 |
| - | - |
| `langgraph` | LangGraph 1.2, which runs the graph |
| `langgraph-checkpoint-postgres` | `AsyncPostgresSaver`, which saves threads in PostgreSQL |
| `langchain-anthropic` | `ChatAnthropic`, the chat model for Claude |
| `psycopg[binary]` | The PostgreSQL driver, psycopg 3, with its own copy of `libpq`, so the image needs no system packages |
| `fastapi[standard]` | FastAPI 0.162 with Uvicorn, the server Ingot starts, and the `fastapi dev` command |

### Build the graph

```python app/graph.py theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import SystemMessage
from langchain_core.tools import tool
from langgraph.graph import START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.types import interrupt

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


@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")


@tool
def refund_order(order_id: str, reason: str) -> str:
    """Refund an order. A staff member approves every refund before it's issued."""
    decision = interrupt({"action": "refund_order", "order_id": order_id, "reason": reason})
    if decision != "approve":
        return f"A staff member declined the refund for order {order_id}."
    # Issue the refund with your payment provider here.
    return f"A staff member approved the refund. Order {order_id} was refunded."


tools = [get_order_status, refund_order]
# One tool call at a time, so a thread waits for one decision at a time.
model = ChatAnthropic(model="claude-haiku-5-5").bind_tools(tools, parallel_tool_calls=False)
system = SystemMessage("You answer questions about Acme Storefront orders and refund orders when customers ask.")


async def agent(state: MessagesState):
    return {"messages": [await model.ainvoke([system, *state["messages"]])]}


builder = StateGraph(MessagesState)
builder.add_node(agent)
builder.add_node("tools", ToolNode(tools))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", tools_condition)
builder.add_edge("tools", "agent")
```

* The graph has 2 nodes. `agent` sends the conversation to Claude. When Claude asks for a tool, `tools_condition` routes to `tools`, which runs it, and the result goes back to `agent`. The run ends when Claude answers without a tool call.
* `MessagesState` holds the conversation. A thread is one conversation, and the checkpointer saves its state after every step.
* `interrupt(...)` pauses the thread and hands its value to the caller. The thread waits, for as long as it takes, until a request resumes it with a decision. LangGraph then runs `refund_order` again from the start, and this time `interrupt` returns the decision. Code before `interrupt` runs twice, so issue the refund after it.
* `parallel_tool_calls=False` makes Claude call one tool per step. A request to refund 2 orders then pauses twice, once for each order.
* `claude-haiku-5-5` 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 1,100 input and 60 output tokens. Change the model name to use another, such as `claude-sonnet-5-5` for harder tasks. `ChatAnthropic` reads the key from the `ANTHROPIC_API_KEY` environment variable.

### Create the checkpointer tables

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

from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver


async def main() -> None:
    async with AsyncPostgresSaver.from_conn_string(os.environ["DB_URL"]) as checkpointer:
        await checkpointer.setup()


asyncio.run(main())
```

`setup()` creates the tables `checkpoints`, `checkpoint_blobs`, `checkpoint_writes` and `checkpoint_migrations`, and runs the migrations that a newer version of the package adds. Running it again changes nothing. On Sevalla, a job runs it before each deployment, so the tables are ready before the new code starts, and 2 instances never race to create them. `DB_URL` is the variable Sevalla adds when you connect the database, and psycopg accepts its `postgres://` scheme as it is.

### Serve the graph

```python app/main.py theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import os
import secrets
from contextlib import asynccontextmanager
from typing import Literal

from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi.sse import EventSourceResponse, ServerSentEvent
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from langgraph.types import Command
from psycopg.rows import dict_row
from psycopg_pool import AsyncConnectionPool
from pydantic import BaseModel

from app.graph import builder

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


@asynccontextmanager
async def lifespan(app: FastAPI):
    # The settings AsyncPostgresSaver needs on every connection.
    kwargs = {"autocommit": True, "prepare_threshold": 0, "row_factory": dict_row}
    async with AsyncConnectionPool(os.environ["DB_URL"], kwargs=kwargs) as pool:
        app.state.graph = builder.compile(checkpointer=AsyncPostgresSaver(pool))
        yield


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(lifespan=lifespan, dependencies=[Depends(check_token)])


def thread(thread_id: str) -> dict:
    return {"configurable": {"thread_id": thread_id}}


async def thread_state(thread_id: str, request: Request):
    graph = request.app.state.graph
    state = await graph.aget_state(thread(thread_id))
    if state.next and not state.interrupts:
        # A deployment stopped the last run midway: finish its remaining steps first.
        await graph.ainvoke(None, thread(thread_id), durability="sync")
        state = await graph.aget_state(thread(thread_id))
    return state


def not_waiting(state=Depends(thread_state)):
    if state.interrupts:
        raise HTTPException(status_code=409, detail="The thread is waiting for a decision")


def waiting(state=Depends(thread_state)):
    if not state.interrupts:
        raise HTTPException(status_code=409, detail="The thread has no decision to make")


async def stream(graph, input, thread_id: str):
    # durability="sync" saves each step before the next one starts.
    async for mode, data in graph.astream(
        input, thread(thread_id), stream_mode=["messages", "updates"], durability="sync"
    ):
        if mode == "messages":
            chunk, metadata = data
            if metadata["langgraph_node"] == "agent" and chunk.text:
                yield ServerSentEvent(event="delta", data=chunk.text)
        elif "__interrupt__" in data:
            yield ServerSentEvent(event="interrupt", data=data["__interrupt__"][0].value)
        elif "agent" in data:
            for call in data["agent"]["messages"][-1].tool_calls:
                yield ServerSentEvent(event="tool", data=call["name"])
    yield ServerSentEvent(event="done")


class Message(BaseModel):
    message: str


class Decision(BaseModel):
    decision: Literal["approve", "reject"]


@app.post("/threads/{thread_id}/messages", response_class=EventSourceResponse, dependencies=[Depends(not_waiting)])
async def send_message(thread_id: str, body: Message, request: Request):
    async for event in stream(request.app.state.graph, {"messages": [("user", body.message)]}, thread_id):
        yield event


@app.post("/threads/{thread_id}/resume", response_class=EventSourceResponse, dependencies=[Depends(waiting)])
async def resume(thread_id: str, body: Decision, request: Request):
    async for event in stream(request.app.state.graph, Command(resume=body.decision), thread_id):
        yield event


@app.get("/threads/{thread_id}")
async def get_thread(thread_id: str, request: Request):
    state = await request.app.state.graph.aget_state(thread(thread_id))
    return {
        "messages": [{"type": m.type, "text": m.text} for m in state.values.get("messages", []) if m.text],
        "waiting_for": state.interrupts[0].value if state.interrupts else None,
    }
```

* **The checkpointer.** The application opens a pool of PostgreSQL connections when it starts and compiles the graph with an `AsyncPostgresSaver` on it. Every request reads and writes its thread in PostgreSQL, and nothing about a thread stays in the instance's memory.
* **The token.** 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"}`.
* **Threads.** A thread ID is any string you choose, such as a conversation's ID in your own database. A new ID starts a new thread.
* **Durability.** `durability="sync"` saves each step before the next one starts. If a deployment stops a run midway, the thread keeps every step that finished, and `thread_state` runs the remaining steps before the thread's next message. Without that, Claude would reject the thread's next message, because a tool call would have no result.

| Route | What it does |
| - | - |
| `POST /threads/{thread_id}/messages` | Sends `message` to the thread and streams the run. Answers `409` while the thread waits for a decision. |
| `POST /threads/{thread_id}/resume` | Sends `decision`, `approve` or `reject`, to the paused thread and streams the rest of the run. Answers `409` when there's nothing to decide. |
| `GET /threads/{thread_id}` | Returns the thread's messages and `waiting_for`: the refund that waits for a decision, or `null`. |

The streams send 4 kinds of Server-Sent Events, each with its data encoded as JSON:

| Event | Data |
| - | - |
| `tool` | The name of a tool Claude calls |
| `delta` | The next piece of the answer's text |
| `interrupt` | What the paused thread waits for, such as `{"action": "refund_order", "order_id": "1002", "reason": "..."}` |
| `done` | Nothing: the last event of the stream |

`EventSourceResponse` sets `Content-Type: text/event-stream` and `Cache-Control: no-cache`, and while the graph sends nothing, it sends a `: ping` comment every 15 seconds, which keeps the connection open through Cloudflare. Sevalla passes each event to the client as the application writes it.

### 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 storefront-db -e POSTGRES_USER=storefront -e POSTGRES_PASSWORD=storefront -e POSTGRES_DB=storefront -p 5432:5432 postgres:18
export DB_URL=postgres://storefront:storefront@localhost:5432/storefront
export API_TOKEN=local-token
export ANTHROPIC_API_KEY=sk-ant-...
uv run python -m app.migrate
uv run fastapi dev
```

Set `ANTHROPIC_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 in the thread `order-help`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -N http://127.0.0.1:8000/threads/order-help/messages \
  -H "Authorization: Bearer local-token" \
  -H "Content-Type: application/json" \
  -d '{"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: tool
data: "get_order_status"

event: delta
data: "Order 1001 has"

event: delta
data: " shipped."

event: done
```

Claude writes the answer, so its wording can differ. Press `Ctrl+C` to stop the server.

## 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). The job runs its own command in the same image, which you set when you create it. 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/storefront-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 `storefront-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 `storefront-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 Anthropic key and the API token**

Click the **Environment variables** tab, then click **Add environment variables**. Add `ANTHROPIC_API_KEY` with your Anthropic 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.migrate` as the **Custom start command**. Keep the **Start policy** at **Before deployment** and the **Resource** at **H1**, then click **Create process**.

**6. Deploy**

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

**7. 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 web process and the job run in **H1** (0.3 vCPU, 0.3 GB of memory), the smallest size: in testing, each web instance used about 280 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 Job selected, the Name and Custom start command fields, the Start policy set to Before deployment, and the H1 resource](https://assets.sevalla.com/screenshots/light/app-processes-job-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`, then `Web 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 `storefront-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 https://storefront-agent-x7k2m.sevalla.app/threads/order-help
   ```

   ```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   {"detail":"Not authenticated"}
   ```
4. Ask about an order, with your `API_TOKEN`. The `grep` commands keep the name of the tool Claude calls:

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

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

   Without the `grep` commands, the whole stream prints: the `tool` event after 1 to 2 seconds, then the answer's `delta` events, which say that order 1001 has shipped, and `done`.

![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)

## Approve a refund

A refund takes 2 requests: the customer's message pauses the thread, and a staff member's decision resumes it. In your product, the decision comes from your own admin tool, which calls `/resume` with the token; never let the customer's client call it.

**1. Ask for a refund**

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -sN https://storefront-agent-x7k2m.sevalla.app/threads/order-help/messages \
  -H "Authorization: Bearer $API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"message": "Refund order 1002, it arrived broken."}' \
  | grep -A1 '^event: tool$' | grep '^data:'
```

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

The whole stream shows `tool` with `refund_order`, then `interrupt` with `{"action": "refund_order", "order_id": "1002", "reason": "Customer reports the item arrived broken."}`, and `done`. Claude writes the `reason`, so its wording can differ. The run stops there.

**2. Check what the thread waits for**

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s https://storefront-agent-x7k2m.sevalla.app/threads/order-help \
  -H "Authorization: Bearer $API_TOKEN" | jq -r .waiting_for.order_id
```

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

`waiting_for` holds the refund. Until a decision arrives, a new message to the thread is refused:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s https://storefront-agent-x7k2m.sevalla.app/threads/order-help/messages \
  -H "Authorization: Bearer $API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"message": "Hello"}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"detail":"The thread is waiting for a decision"}
```

The answer's status is `409`. The thread waits in PostgreSQL, through deployments and restarts, for as long as the decision takes.

**3. Approve it**

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -sN https://storefront-agent-x7k2m.sevalla.app/threads/order-help/resume \
  -H "Authorization: Bearer $API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"decision": "approve"}'
```

The run continues where it stopped, and Claude's answer streams as `delta` events. Send `{"decision": "reject"}` instead to decline it. Read the tool's result and what the thread waits for now:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s https://storefront-agent-x7k2m.sevalla.app/threads/order-help \
  -H "Authorization: Bearer $API_TOKEN" | jq -c '[.messages[-2].text, .waiting_for]'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
["A staff member approved the refund. Order 1002 was refunded.",null]
```

`GET /threads/order-help` now shows the whole conversation. A second `/resume` answers `409` with `{"detail":"The thread has no decision to make"}`.

## Resume threads after a deployment and on other instances

Sevalla doesn't keep a client's requests on one instance, and a deployment replaces every instance. Neither matters here, because each thread lives in PostgreSQL. In testing, a thread paused at a refund before a deployment was resumed after it, on 2 instances: its `GET` requests reached both, `/resume` reached one, and the thread finished. To add instances, update the web process's **Instance count**. See [Set the number of instances](https://docs.sevalla.com/applications/scaling.md#set-the-number-of-instances).

When a deployment stops an instance, Sevalla waits 5 seconds and sends `SIGTERM`. Uvicorn stops accepting connections and lets the streams in progress run for up to 25 seconds more, then Sevalla stops the instance. A stream cut that way ends without its `done` event. The thread keeps every finished step, and its next message first runs the steps the cut run didn't. See [How processes run](https://docs.sevalla.com/applications/processes.md#how-processes-run).

## 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. `psycopg[binary]` brings its own `libpq`, so neither stage installs system packages. |
| `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.migrate` 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
```

`GET /threads/order-help` still returns the conversation from before, and new messages work the same. 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 web process's instances; threads carry on. After you add a package with `uv add`, commit `pyproject.toml` and `uv.lock` together. 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"}` or `{"detail":"Invalid token"}` | The request has no `Authorization` header, or its token doesn't match `API_TOKEN`. | Send `Authorization: Bearer` and the value of `API_TOKEN`. A change to the variable applies at the next deployment. |
| `409` with `{"detail":"The thread is waiting for a decision"}` | The thread is paused at a refund. | Send the decision to `/threads/<thread_id>/resume` first. |
| `409` with `{"detail":"The thread has no decision to make"}` | The thread isn't paused. | Send a message to `/threads/<thread_id>/messages` instead. |
| 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. |
| The runtime logs show `` `tool_use` ids were found without `tool_result` blocks immediately after `` | A message reached a thread whose last run stopped between Claude's tool call and the tool's result, in code that doesn't finish such runs. | Finish the run first, as `thread_state` does: `await graph.ainvoke(None, thread(thread_id))`. |
| 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 message again. |
| [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 Anthropic API key.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
export SEVALLA_API_KEY=svl_...
export ANTHROPIC_API_KEY=sk-ant-...
API=https://api.sevalla.com/v3
AUTH="Authorization: Bearer $SEVALLA_API_KEY"
JSON="Content-Type: application/json"
REPO=https://github.com/acme/storefront-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: "storefront-db", type: "postgresql", version: "18", cluster_id: $cluster, resource_type_id: $size, db_name: "storefront", db_user: "storefront", db_password: $password}' \
  | curl -s -X POST "$API/databases" -H "$AUTH" -H "$JSON" -d @- | jq -r .id)
```

CLI: `sevalla databases create --name storefront-db --type postgresql --db-version 18 --cluster $CLUSTER_ID --resource-type $DB1_ID --db-name storefront --db-user storefront --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 psycopg accepts too.

**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: "storefront-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 storefront-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://storefront:$DB_PASSWORD@$DB_HOST:$DB_PORT/storefront" "ANTHROPIC_API_KEY=$ANTHROPIC_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.migrate", 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.migrate" --job-start-policy beforeDeployment`. MCP: `sevalla_api_create` with `createProcessV3` and the same body.

**11. 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`.

**12. 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`.

**13. 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 `storefront-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.