# Add Cursor Pagination to a FastAPI Endpoint from a Linear Issue

Yes. Label the Linear issue and an agent adds cursor pagination to the FastAPI endpoint, runs pytest against Postgres and opens a pull request linked to the issue.

[Run this on Rush](https://cloud.getrush.ai/run/add-pagination/fastapi/), in a Project that has your repository.

## What the agent does

The run starts with the Linear issue in its prompt and the repository cloned into its own sandbox. In a FastAPI app like fastapi/full-stack-fastapi-template, the agent reads `backend/pyproject.toml` and finds FastAPI, SQLModel, Alembic and `psycopg`, Python pinned to 3.14 in `.python-version`, and uv as the package manager with a root `uv.lock`. The endpoint the issue names is `read_items` in `backend/app/api/routes/items.py`: it takes `skip: int = 0` and `limit: int = 100`, orders by `Item.created_at` descending, and for a user who is not a superuser filters on `Item.owner_id` before calling `.offset(skip).limit(limit)`. It returns `ItemsPublic`, which `backend/app/models.py` defines as `data: list[ItemPublic]` and `count: int`.

Offset pagination on that query has two known faults: rows shift between pages when items are created or deleted, and a large `skip` makes Postgres read and discard every earlier row. The agent replaces it with a keyset cursor on `(created_at, id)`, because `created_at` alone is not unique and the migration `fe56fa70289e_add_created_at_to_user_and_item.py` adds it as a nullable column. The cursor is an opaque, URL-safe base64 string of those two values; the next page is the rows strictly after it in `created_at DESC, id DESC` order, and the endpoint asks for `limit + 1` rows so it knows whether to return a `next_cursor`. Rows with a null `created_at` sort last and are handled in the comparison, rather than being dropped from every page.

Keeping old clients working for one release means `skip` still works when no `cursor` is sent, and `ItemsPublic` gains `next_cursor: str | None` without losing `count`. The React frontend calls `ItemsService.readItems({ query: { skip: 0, limit: 100 } })` in `frontend/src/routes/_layout/items.tsx` through a client generated from the OpenAPI schema by `scripts/generate-client.sh`, so the agent regenerates `frontend/src/client` and leaves the call working. If the issue asks for a composite index on `item (owner_id, created_at, id)`, it adds an Alembic revision under `backend/app/alembic/versions/` and runs `alembic upgrade head`; otherwise it notes the index as a follow-up in the pull request.

Then it proves the change the way the repository's own CI does. `.github/workflows/test-backend.yml` starts `db` and `mailpit` with `docker compose up -d --wait`, runs `uv run bash scripts/prestart.sh` (migrations and initial data) and then `scripts/tests-start.sh`, which wraps `coverage run -m pytest tests/`. The agent starts Postgres 18 in the sandbox, the version `compose.yml` pins for `db`, runs the same steps, and adds tests to `backend/tests/api/routes/test_items.py` next to the existing `test_create_item` and `test_read_item`: walking every page with the cursor returns each item once, a cursor from one user cannot read another user's items, a malformed cursor is a 422, and a request with `skip` and no `cursor` returns what it did before.

## Start it with one API call

`POST https://api.prix.dev/o/{org}/sessions`

```json
{
  "agent": "claude",
  "input": "Do the work the Linear issue describes: replace skip and limit on GET /api/v1/items/ in backend/app/api/routes/items.py with an opaque cursor over (created_at, id) and add next_cursor to ItemsPublic in backend/app/models.py, keeping skip working when no cursor is sent so old clients keep working for one release. Handle rows with a null created_at. Regenerate the frontend client with scripts/generate-client.sh. Add tests to backend/tests/api/routes/test_items.py for walking all pages, owner isolation, a malformed cursor and the old skip path. Start Postgres from compose.yml, run uv run bash scripts/prestart.sh and uv run pytest in backend, and open a pull request that links back to the issue with the test output.",
  "resources": [
    {
      "url": "https://github.com/your-org/your-app"
    },
    {
      "url": "https://linear.app/your-workspace/issue/ENG-123"
    }
  ]
}
```

1. **Get an API key.** In the console, open Settings, then API keys, and create a key for your org. Send it in the Authorization header as a Bearer token.
2. **Send the request.** POST the body in request.json to https://api.prix.dev/o/{org}/sessions, with your repository as the resources URL. The run starts in its own cloud sandbox.
3. **Read what came back.** Follow the run on its live stream or wait for the run.completed webhook, then read the result and its trajectory.

## What comes back

What comes back is a pull request on its own branch that links the Linear issue. Its diff touches `backend/app/api/routes/items.py` (the cursor query and the `skip` fallback), `backend/app/models.py` (`next_cursor` on `ItemsPublic`), the regenerated files under `frontend/src/client`, the new tests in `backend/tests/api/routes/test_items.py`, and an Alembic revision when the issue asked for an index. The pull request description explains the cursor format, the sort order and how long `skip` stays supported, and quotes the `pytest` output from the sandbox.

The repository's own checks run on the pull request as they do for any contributor: `Test Backend` repeats the pytest run against its own Postgres, and the Playwright and pre-commit workflows cover the frontend and formatting. The session keeps the transcript of every command the agent ran, so a reviewer can open the migration and test steps and read their output instead of trusting a summary.

Before merging, a reviewer checks that the cursor cannot be forged into another owner's rows (the owner filter applies before the cursor comparison), that a deleted item between pages does not break the next request, and that the deprecation plan for `skip` matches what the API's other clients need. If something is off, a follow-up message to the same session wakes the sandbox where it stopped and the agent pushes a fix to the same branch.

## Run it on every Linear issue labelled agent

In a Rush Project for the repository, add an automation with a `linear` trigger on `linear.issue.labeled` and the label `agent`. Each issue that gets the label starts one run with the issue as its context, so triage in Linear is the only step a person takes. Changes made by an app integration never fire it.

From your own server, the same thing is one `POST /o/{org}/sessions` per issue with the issue URL in `resources`, read through your Linear connection. Send an `Idempotency-Key` built from the issue id so a retried webhook does not start a second run, and set a webhook URL on the key to hear `run.completed` with the `session_url` when the pull request is open.

## Why run it on Rush?

This change is only finished when it has run against a real Postgres: a keyset query that looks right can still skip a row on a tie in `created_at` or leak rows across owners, and only a test against the database shows it. In a Rush sandbox the agent starts Postgres 18, the version the template's `compose.yml` pins, runs the Alembic migrations and the full pytest suite, and none of it touches the database or the uv environment on your laptop.

Each run is its own sandbox, so several labelled issues can be worked at once, up to the key's concurrent run limit, each on its own branch. The result is a pull request with the repository's checks on it and the complete record of the commands behind it, which is what lets a reviewer approve a change no person on the team started by hand.

## Frequently asked questions

### Will existing clients that send skip and limit break?

No, not in this change. When a request sends no `cursor`, the endpoint keeps the offset path and the same response fields, and `next_cursor` is added beside `data` and `count`. The pull request says how long `skip` is kept, so you can plan its removal for a later release.

### Why a cursor over created_at and id rather than created_at alone?

Two items can share a `created_at`, and a cursor on a non-unique column either repeats or skips rows at the tie. Adding `id` as the second sort key makes every position unique. In this template `created_at` is also nullable, so the query has to place null rows explicitly.

### Does the agent need my Postgres or my secrets?

No. The tests run against a Postgres the agent starts inside the sandbox, the version the repository's `compose.yml` names, with the throwaway values the template's settings expect. Nothing reaches your staging or production database.

### What if the Linear issue is vague?

The issue text becomes the prompt, so a two-line issue gets a narrower change than a detailed one. When the description leaves the contract open (cursor format, page size, how long to keep `skip`), the agent states the choice it made in the pull request, and you can correct it with a follow-up message to the same session.

### Does it update the generated frontend client?

Yes. The template generates `frontend/src/client` from the backend's OpenAPI schema with `scripts/generate-client.sh`, so the agent runs it after changing the endpoint and commits the regenerated files, and the existing `readItems` call in `items.tsx` keeps compiling.
