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

# Serve a FastAPI app

> Deploy an ASGI service with sign-in, per-user storage and a bundled front end, using @cycls.app.

This guide is for developers who want a web service rather than a chat product.
You will build a notes API with sign-in, per-user storage and a single-page front
end, then deploy it.

**Prerequisites:** `CYCLS_API_KEY`, and Docker if you want to run locally.

## 1. The smallest app

```python api.py theme={null}
import cycls


@cycls.app()
def api():
    from fastapi import FastAPI

    app = FastAPI()

    @app.get("/")
    async def root():
        return {"ok": True}

    return app
```

```bash theme={null}
cycls run api.py            # http://localhost:8080
cycls run api.py --remote   # https://dev-api.cycls.ai
cycls deploy api.py         # https://api.cycls.ai
```

The decorated function returns an ASGI application. It runs inside the container
at startup, so import heavy dependencies in the body rather than at module level.

## 2. Add sign-in

```python theme={null}
@cycls.app(auth=cycls.Clerk())
def api():
    from fastapi import FastAPI

    app = FastAPI()

    @app.get("/me")
    async def me(user=api.auth):
        return {"id": user.id, "org": user.org_id, "plan": user.plan}

    return app
```

`api.auth` is a FastAPI dependency. A request without a valid token gets 401.
Any OIDC provider works with `cycls.JWT(jwks_url=...)`. See
[Authentication](/web/auth).

## 3. Add per-user storage

Storage needs a volume at `/workspace`.

```python theme={null}
notes_data = cycls.Volume("notes-data")


@cycls.app(auth=cycls.Clerk(), volumes={"/workspace": notes_data})
def api():
    from fastapi import FastAPI
    from pydantic import BaseModel
    from uuid import uuid4

    app = FastAPI()

    class NoteIn(BaseModel):
        title: str = ""
        body: str

    @app.post("/notes")
    async def create(note: NoteIn, ws=api.workspace):
        record = {"id": uuid4().hex[:12], **note.model_dump()}
        await cycls.DB(ws).put(f"docs/{record['id']}", record)
        return record

    @app.get("/notes")
    async def list_notes(ws=api.workspace):
        return [d async for _, d in cycls.DB(ws).items(prefix="docs/")]

    @app.delete("/notes/{note_id}")
    async def delete(note_id: str, ws=api.workspace):
        await cycls.DB(ws).delete(f"docs/{note_id}")
        return {"ok": True}

    return app
```

`api.workspace` resolves the authenticated user's own scope, so no route needs to
filter by user id. Two users calling `GET /notes` see different data.

## 4. Bundle a front end

Put the HTML beside the Python file and copy it into the image.

```python theme={null}
from pathlib import Path

HTML = str(Path(__file__).parent / "index.html")


@cycls.app(
    image=cycls.Image().copy(HTML, "index.html"),
    auth=cycls.Clerk(),
    volumes={"/workspace": notes_data},
)
def api():
    from fastapi import FastAPI
    from fastapi.responses import HTMLResponse

    app = FastAPI()

    @app.get("/")
    async def index():
        pk = api._auth_provider.resolve(api.prod).get("pk", "")
        return HTMLResponse(Path("index.html").read_text().replace("__CLERK_PK__", pk))

    return app
```

The publishable key is resolved at request time, so the same HTML works locally
and in production without a build step. The front end sends the token it gets
from Clerk as `Authorization: Bearer <token>`.

## 5. Search with an inverted index

`cycls.DB` has no query language, so build the index you need. Prefix scans are
the primitive.

```
docs/{id}            -> the note
idx/{term}/{id}      -> 1
```

```python theme={null}
import re

TOKEN = re.compile(r"[a-z0-9]+")


def tokenize(text):
    return set(TOKEN.findall(text.lower()))


@app.get("/search")
async def search(q: str, ws=api.workspace):
    db = cycls.DB(ws)
    ids = None
    for term in tokenize(q):
        prefix = f"idx/{term}/"
        hits = {k.removeprefix(prefix) async for k, _ in db.items(prefix=prefix)}
        ids = hits if ids is None else ids & hits
        if not ids:
            return []
    return [await db.get(f"docs/{i}") for i in ids or []]
```

Write the document and its index entries together:

```python theme={null}
import asyncio


@app.post("/notes")
async def create(note: NoteIn, ws=api.workspace):
    db = cycls.DB(ws)
    record = {"id": uuid4().hex[:12], **note.model_dump()}
    terms = tokenize(f"{record['title']} {record['body']}")
    await asyncio.gather(
        db.put(f"docs/{record['id']}", record),
        *[db.put(f"idx/{t}/{record['id']}", 1) for t in terms],
    )
    return record
```

<Warning>
  These writes are not transactional. If the process dies between the document
  write and an index write, the index is incomplete. For a small app, rebuild the
  index on demand. For a larger one, write the document first and treat the index
  as derived state you can regenerate.
</Warning>

## 6. Run untrusted commands

`cycls.Sandbox` wraps `bubblewrap` with a read-only root, a cleared environment,
and no network unless you enable it.

```python theme={null}
sandbox = cycls.Sandbox().setenv(PATH="/usr/local/bin:/usr/bin:/bin").network(False).timeout(60)


@app.post("/run")
async def run(cmd: str, ws=api.workspace):
    result = await sandbox.bind(str(ws.root), "/workspace").chdir("/workspace").run(["bash", "-lc", cmd])
    return {"output": result.output, "code": result.code, "timed_out": result.timed_out}
```

Only the caller's own workspace is bound, so one user's command cannot see
another user's files.

## 7. Deploy

```bash theme={null}
cycls deploy api.py
# https://api.cycls.ai
```

```bash theme={null}
cycls logs api -f
cycls volume ls notes-data
```

## App or agent

| Need                                              | Use                                            |
| ------------------------------------------------- | ---------------------------------------------- |
| A REST API or dashboard you design                | `@cycls.app`                                   |
| A chat product with a model loop, tools and files | `@cycls.agent`                                 |
| Both                                              | `@cycls.agent` plus custom routes on `.server` |

An agent is an app, so you can add routes to an agent with
`@my_agent.server.api_route(...)` instead of deploying two services.

## Next

<Card title="Batch jobs" icon="layer-group" href="/guides/batch-jobs">
  Fan work across instances with map.
</Card>
