Skill Tracer
Skills › Scientific Agent Skills › modal › web-endpoints.md

web-endpoints.md

294 lines · skills/modal/references/web-endpoints.md at 5f2bc03

Open on GitHub

Most marks come from matching words in the file, so treat them as pointers.

1# Modal Web Endpoints
2
3Reviewed against SDK 1.6.0 and official [Web Functions](https://modal.com/docs/guide/webhooks),
4[proxy authentication](https://modal.com/docs/guide/webhook-proxy-auth),
5[URLs](https://modal.com/docs/guide/webhook-urls) and
6[request timeouts](https://modal.com/docs/guide/webhook-timeouts) documentation.
7Examples use the Web Function API, which is public unless auth is explicitly enabled.
8SDK registration and selected local ASGI handlers were tested; no endpoint was deployed.
9Model-dependent fragments are illustrative.

Modal Web Endpoints

Reviewed against SDK 1.6.0 and official Web Functions, proxy authentication, URLs and request timeouts documentation. Examples use the Web Function API, which is public unless auth is explicitly enabled. SDK registration and selected local ASGI handlers were tested; no endpoint was deployed. Model-dependent fragments are illustrative.

Raw lines 1 to 10
11## Table of Contents
13- [Simple Endpoints](#simple-endpoints)
14- [Deployment](#deployment)
15- [ASGI Apps](#asgi-apps-fastapi-starlette-fasthtml)
16- [WSGI Apps](#wsgi-apps-flask-django)
17- [Custom Web Servers](#custom-web-servers)
18- [WebSockets](#websockets)
19- [Authentication](#authentication)
20- [Streaming](#streaming)
21- [Concurrency](#concurrency)
22- [Limits](#limits)

Table of Contents

  • Simple Endpoints
  • Deployment
  • ASGI Apps
  • WSGI Apps
  • Custom Web Servers
  • WebSockets
  • Authentication
  • Streaming
  • Concurrency
  • Limits
Raw lines 11 to 23
24## Simple Endpoints
26The easiest way to create a web endpoint:
28```python
29import modal
31image = modal.Image.debian_slim().uv_pip_install("fastapi[standard]==0.136.3")
32app = modal.App("api-service", image=image)
34@app.function()
35@modal.fastapi_endpoint()
36def hello(name: str = "World"):
37 return {"message": f"Hello, {name}!"}
38```
40FastAPI must be installed in the remote Image. The remaining fragments reuse this
41`app`/`image` unless defining their own; imports executed locally also need local packages.

Simple Endpoints

The easiest way to create a web endpoint:

import modal

image = modal.Image.debian_slim().uv_pip_install("fastapi[standard]==0.136.3")
app = modal.App("api-service", image=image)

@app.function()
@modal.fastapi_endpoint()
def hello(name: str = "World"):
    return {"message": f"Hello, {name}!"}

FastAPI must be installed in the remote Image. The remaining fragments reuse this app/image unless defining their own; imports executed locally also need local packages.

Raw lines 24 to 42
43### POST Endpoints
45```python
46@app.function()
47@modal.fastapi_endpoint(method="POST")
48def predict(data: dict):
49 result = model.predict(data["text"])
50 return {"prediction": result}
51```

POST Endpoints

@app.function()
@modal.fastapi_endpoint(method="POST")
def predict(data: dict):
    result = model.predict(data["text"])
    return {"prediction": result}
Raw lines 43 to 52
53### Query Parameters
55Parameters are automatically parsed from query strings:
57```python
58@app.function()
59@modal.fastapi_endpoint()
60def search(query: str, limit: int = 10):
61 return {"results": do_search(query, limit)}
62```
64Access via: `https://your-app.modal.run?query=hello&limit=5`
65This is a placeholder. Use the URL printed by `modal serve`/`modal deploy` or
66`Function.get_web_url()`, rather than constructing the hostname. Scalar typed parameters
67are query parameters even for POST; use a `dict` or Pydantic model for a JSON body.

Query Parameters

Parameters are automatically parsed from query strings:

@app.function()
@modal.fastapi_endpoint()
def search(query: str, limit: int = 10):
    return {"results": do_search(query, limit)}

Access via: https://your-app.modal.run?query=hello&limit=5 This is a placeholder. Use the URL printed by modal serve/modal deploy or Function.get_web_url(), rather than constructing the hostname. Scalar typed parameters are query parameters even for POST; use a dict or Pydantic model for a JSON body.

Raw lines 53 to 68
69## Deployment
71### Development Mode
73```bash
74modal serve script.py
75```
77- Creates a temporary public URL
78- Hot-reloads on file changes
79- Perfect for development and testing
80- URL expires when you stop the command

Development Mode

modal serve script.py
  • Creates a temporary public URL
  • Hot-reloads on file changes
  • Perfect for development and testing
  • URL expires when you stop the command
Raw lines 71 to 81
82### Production Deployment
84```bash
85modal deploy script.py
86```
88- Creates a permanent URL
89- Persists the App and URL; compute still scales to zero by default
90- Autoscales based on traffic
91- URL format: `https://<workspace>--<app-name>-<function-name>.modal.run`

Production Deployment

modal deploy script.py
  • Creates a permanent URL
  • Persists the App and URL; compute still scales to zero by default
  • Autoscales based on traffic
  • URL format: https://<workspace>--<app-name>-<function-name>.modal.run
Raw lines 82 to 92
93## ASGI Apps (FastAPI, Starlette, FastHTML)
95For full framework applications, use `@modal.asgi_app`:
97```python
98from fastapi import FastAPI
100web_app = FastAPI()
102@web_app.get("/")
103async def root():
104 return {"status": "ok"}
106@web_app.post("/predict")
107async def predict(request: dict):
108 return {"result": model.run(request["input"])}
110@app.function(image=image, gpu="L40S")
111@modal.asgi_app()
112def fastapi_app():
113 return web_app
114```

ASGI Apps (FastAPI, Starlette, FastHTML)

For full framework applications, use @modal.asgi_app:

from fastapi import FastAPI

web_app = FastAPI()

@web_app.get("/")
async def root():
    return {"status": "ok"}

@web_app.post("/predict")
async def predict(request: dict):
    return {"result": model.run(request["input"])}

@app.function(image=image, gpu="L40S")
@modal.asgi_app()
def fastapi_app():
    return web_app
Raw lines 93 to 115
116### With Class Lifecycle
118```python
119@app.cls(gpu="L40S", image=image)
120class InferenceService:
121 @modal.enter()
122 def load_model(self):
123 self.model = load_model()
125 @modal.asgi_app()
126 def serve(self):
127 from fastapi import FastAPI
128 app = FastAPI()
130 @app.post("/generate")
131 async def generate(request: dict):
132 return self.model.generate(request["prompt"])
134 return app
135```

With Class Lifecycle

@app.cls(gpu="L40S", image=image)
class InferenceService:
    @modal.enter()
    def load_model(self):
        self.model = load_model()

    @modal.asgi_app()
    def serve(self):
        from fastapi import FastAPI
        app = FastAPI()

        @app.post("/generate")
        async def generate(request: dict):
            return self.model.generate(request["prompt"])

        return app
Raw lines 116 to 136
137## WSGI Apps (Flask, Django)
139```python
140from flask import Flask
142flask_app = Flask(__name__)
144@flask_app.route("/")
145def index():
146 return {"status": "ok"}
148flask_image = modal.Image.debian_slim().uv_pip_install("flask")
150@app.function(image=flask_image)
151@modal.wsgi_app()
152def flask_server():
153 return flask_app
154```
156WSGI is synchronous — concurrent inputs run on separate threads.

WSGI Apps (Flask, Django)

from flask import Flask

flask_app = Flask(__name__)

@flask_app.route("/")
def index():
    return {"status": "ok"}

flask_image = modal.Image.debian_slim().uv_pip_install("flask")

@app.function(image=flask_image)
@modal.wsgi_app()
def flask_server():
    return flask_app

WSGI is synchronous — concurrent inputs run on separate threads.

Raw lines 137 to 157
158## Custom Web Servers
160For non-standard web frameworks (aiohttp, Tornado, TGI):
162```python
163server_image = modal.Image.debian_slim(python_version="3.11").uv_pip_install("vllm==0.21.0")
165@app.function(image=server_image, gpu="H100")
166@modal.web_server(port=8000)
167def serve():
168 import subprocess
169 subprocess.Popen([
170 "vllm", "serve", "Qwen/Qwen3-8B",
171 "--max-model-len", "4096",
172 "--host", "0.0.0.0", # Must bind to 0.0.0.0, not localhost
173 "--port", "8000",
174 ])
175```
177The application must bind to `0.0.0.0` (not `127.0.0.1`).
179> **Security:** The command above uses a fixed argument list. Do not interpolate
180> unsanitized user input (model names, paths, flags) into `subprocess` arguments —
181> validate against an allowlist or pass untrusted values as data, not as command
182> arguments, to avoid command injection.

Custom Web Servers

For non-standard web frameworks (aiohttp, Tornado, TGI):

server_image = modal.Image.debian_slim(python_version="3.11").uv_pip_install("vllm==0.21.0")

@app.function(image=server_image, gpu="H100")
@modal.web_server(port=8000)
def serve():
    import subprocess
    subprocess.Popen([
        "vllm", "serve", "Qwen/Qwen3-8B",
        "--max-model-len", "4096",
        "--host", "0.0.0.0",  # Must bind to 0.0.0.0, not localhost
        "--port", "8000",
    ])

The application must bind to 0.0.0.0 (not 127.0.0.1).

Security: The command above uses a fixed argument list. Do not interpolate unsanitized user input (model names, paths, flags) into subprocess arguments — validate against an allowlist or pass untrusted values as data, not as command arguments, to avoid command injection.
Raw lines 158 to 183
184## WebSockets
186Modal's web proxy supports WebSockets. Use an ASGI or custom server framework that
187implements WebSockets; plain WSGI/Flask handlers do not implement that protocol:
189```python
190from fastapi import FastAPI, WebSocket
192web_app = FastAPI()
194@web_app.websocket("/ws")
195async def websocket_endpoint(websocket: WebSocket):
196 await websocket.accept()
197 while True:
198 data = await websocket.receive_text()
199 result = process(data)
200 await websocket.send_text(result)
202@app.function()
203@modal.asgi_app()
204def ws_app():
205 return web_app
206```
208- Full WebSocket protocol (RFC 6455)
209- Messages up to 2 MiB each
210- No RFC 8441 or RFC 7692 support yet

WebSockets

Modal's web proxy supports WebSockets. Use an ASGI or custom server framework that implements WebSockets; plain WSGI/Flask handlers do not implement that protocol:

from fastapi import FastAPI, WebSocket

web_app = FastAPI()

@web_app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    await websocket.accept()
    while True:
        data = await websocket.receive_text()
        result = process(data)
        await websocket.send_text(result)

@app.function()
@modal.asgi_app()
def ws_app():
    return web_app
  • Full WebSocket protocol (RFC 6455)
  • Messages up to 2 MiB each
  • No RFC 8441 or RFC 7692 support yet
Raw lines 184 to 211
212## Authentication

Authentication

Raw lines 212 to 213
214### Proxy Auth Tokens (Built-in)
216Modal provides first-class endpoint protection via proxy auth tokens:
218```python
219@app.function()
220@modal.fastapi_endpoint(requires_proxy_auth=True)
221def protected(text: str):
222 return {"result": text}
223```
225Create a **Proxy Token** (distinct from the Modal SDK token) in the dashboard or
226`modal workspace proxy-tokens` CLI. Clients send its ID/secret as `Modal-Key` and
227`Modal-Secret`, or `Authorization: Bearer <proxy-id>.<proxy-secret>`. Missing or invalid
228credentials receive 401 before the handler runs. Merely sending these headers does
229not protect an endpoint whose decorator omits `requires_proxy_auth=True`.

Proxy Auth Tokens (Built-in)

Modal provides first-class endpoint protection via proxy auth tokens:

@app.function()
@modal.fastapi_endpoint(requires_proxy_auth=True)
def protected(text: str):
    return {"result": text}

Create a Proxy Token (distinct from the Modal SDK token) in the dashboard or modal workspace proxy-tokens CLI. Clients send its ID/secret as Modal-Key and Modal-Secret, or Authorization: Bearer <proxy-id>.<proxy-secret>. Missing or invalid credentials receive 401 before the handler runs. Merely sending these headers does not protect an endpoint whose decorator omits requires_proxy_auth=True.

Raw lines 214 to 230
231### Custom Bearer Tokens
233```python
234from fastapi import Header, HTTPException
236@app.function(secrets=[modal.Secret.from_name("auth-secret")])
237@modal.fastapi_endpoint(method="POST")
238def secure_predict(data: dict, authorization: str = Header(None)):
239 import os
240 import hmac
241 expected = os.environ["AUTH_TOKEN"]
242 if not authorization or not hmac.compare_digest(authorization, f"Bearer {expected}"):
243 raise HTTPException(status_code=401, detail="Unauthorized")
244 return {"result": model.predict(data["text"])}
245```

Custom Bearer Tokens

from fastapi import Header, HTTPException

@app.function(secrets=[modal.Secret.from_name("auth-secret")])
@modal.fastapi_endpoint(method="POST")
def secure_predict(data: dict, authorization: str = Header(None)):
    import os
    import hmac
    expected = os.environ["AUTH_TOKEN"]
    if not authorization or not hmac.compare_digest(authorization, f"Bearer {expected}"):
        raise HTTPException(status_code=401, detail="Unauthorized")
    return {"result": model.predict(data["text"])}
Raw lines 231 to 246
247### Client IP Access
249Use a FastAPI `Request` and inspect `request.client.host` (if `request.client` is not
250None). Do not trust an arbitrary user-supplied header as proof of client identity.

Client IP Access

Use a FastAPI Request and inspect request.client.host (if request.client is not None). Do not trust an arbitrary user-supplied header as proof of client identity.

Raw lines 247 to 251
252## Streaming
254### Server-Sent Events (SSE)
256```python
257from fastapi.responses import StreamingResponse
259@app.function(gpu="H100")
260@modal.fastapi_endpoint()
261def stream_generate(prompt: str):
262 def generate():
263 for token in model.stream(prompt):
264 yield f"data: {token}\n\n"
265 return StreamingResponse(generate(), media_type="text/event-stream")
266```

Server-Sent Events (SSE)

from fastapi.responses import StreamingResponse

@app.function(gpu="H100")
@modal.fastapi_endpoint()
def stream_generate(prompt: str):
    def generate():
        for token in model.stream(prompt):
            yield f"data: {token}\n\n"
    return StreamingResponse(generate(), media_type="text/event-stream")
Raw lines 254 to 267
268## Concurrency
270Handle multiple requests per container using `@modal.concurrent`:
272```python
273@app.function(gpu="L40S")
274@modal.concurrent(max_inputs=10)
275@modal.fastapi_endpoint(method="POST")
276async def batch_predict(data: dict):
277 return {"result": await model.predict_async(data["text"])}
278```

Concurrency

Handle multiple requests per container using @modal.concurrent:

@app.function(gpu="L40S")
@modal.concurrent(max_inputs=10)
@modal.fastapi_endpoint(method="POST")
async def batch_predict(data: dict):
    return {"result": await model.predict_async(data["text"])}
Raw lines 268 to 279
280## Limits
282- Request body: up to 4 GiB
283- Response body: unlimited
284- Rate limit: 200 requests/second (5-second burst for new accounts)
285- Rate limit is workspace-wide and excess HTTP requests return 429; workspace limits can differ
286- Cold starts occur when no container is ready (`min_containers` reduces their frequency)
287- After 150 seconds a Web Function HTTP request returns a 303 result URL; clients
288 must follow redirects. This is separate from the underlying Function timeout.
289 CORS-dependent clients cannot rely on that redirect mechanism. Prefer `.spawn()`
290 plus a call-ID polling endpoint for long jobs, and handle seven-day output expiry.
292The newer `@app.server()` primitive serves latency-sensitive HTTP applications and
293requires Proxy Tokens by default; its contract is distinct from these Web Functions.
294See the official [Servers guide](https://modal.com/docs/guide/servers) before migrating.

Limits

  • Request body: up to 4 GiB
  • Response body: unlimited
  • Rate limit: 200 requests/second (5-second burst for new accounts)
  • Rate limit is workspace-wide and excess HTTP requests return 429; workspace limits can differ
  • Cold starts occur when no container is ready (min_containers reduces their frequency)
  • After 150 seconds a Web Function HTTP request returns a 303 result URL; clients must follow redirects. This is separate from the underlying Function timeout. CORS-dependent clients cannot rely on that redirect mechanism. Prefer .spawn() plus a call-ID polling endpoint for long jobs, and handle seven-day output expiry.

The newer @app.server() primitive serves latency-sensitive HTTP applications and requires Proxy Tokens by default; its contract is distinct from these Web Functions. See the official Servers guide before migrating.

Raw lines 280 to 294
Starts an agent spawn :289