# Add Cursor Pagination to a Django REST Framework API from a Linear Issue

Yes. An agent reads the issue, adds a CursorPagination class beside your limit-offset one, switches the endpoints the issue names, writes tests and opens a PR.

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

## What the agent does

The agent starts from the Linear issue and the repository in its own cloud sandbox, and reads how pagination is done before it adds a second kind. In a project laid out like HackSoftware/Django-Styleguide-Example, `styleguide_example/api/pagination.py` holds a `get_paginated_response(*, pagination_class, serializer_class, queryset, request, view)` helper and a `LimitOffsetPagination` subclass with `default_limit = 10`, `max_limit = 50` and a `get_paginated_response` that returns `limit`, `offset`, `count`, `next`, `previous` and `results` in that order. `styleguide_example/users/apis.py` uses it in `UserListApi`, which nests a `Pagination` class with `default_limit = 1`, validates `id`, `is_admin` and `email` through a `FilterSerializer`, and hands `user_list(filters=...)` to the helper. `styleguide_example/users/selectors.py` builds that queryset as `BaseUser.objects.all()` filtered by `BaseUserFilter`, with no ordering at all, which is the first thing a cursor needs.

It reads the model next. `BaseUser` in `styleguide_example/users/models.py` extends `BaseModel` from `styleguide_example/common/models.py`, so it carries `created_at` with `db_index=True` and `default=timezone.now`, plus `updated_at`, a unique `email`, `is_active`, `is_admin` and a `jwt_key`. A cursor over `created_at` alone can repeat or skip rows when two users share a timestamp, so the agent orders by `("-created_at", "-id")` and says in the pull request why both fields are there. It then writes a `CursorPagination` subclass in `api/pagination.py` on top of `rest_framework.pagination.CursorPagination`, with a `page_size`, a `max_page_size` and that `ordering`, and gives it the same shape of `get_paginated_response` the limit-offset class has, minus `count`, which a cursor cannot give without a second query. The `get_paginated_response` helper already takes any `pagination_class`, so `UserListApi` switches by changing its nested `Pagination` base class and nothing else in the view.

The tests follow the one that already exists. `styleguide_example/api/tests/pagination/test_get_paginated_response.py` builds an `ExampleListApi` with `APIRequestFactory`, creates users through `user_create`, and asserts the exact `OrderedDict` of the first page, including the `next` link `http://testserver/some/path?limit=1&offset=1`. The agent adds a sibling test for the cursor class that creates three users, requests the first page, follows the `cursor` query parameter it gets back, checks that the second page carries the remaining rows and a `previous` link, and checks that a user created between the two requests does not shift the page. It runs the suite the way `.github/workflows/django.yml` does: `pytest` with `DJANGO_SETTINGS_MODULE = config.django.test` from `pytest.ini`, where `config/django/test.py` swaps in `MD5PasswordHasher` and an in-memory cache, against the `postgres` service the workflow starts.

Before opening the pull request it runs the other two checks from that workflow, `ruff check .` under the 120-column `pyproject.toml` configuration and `mypy --config mypy.ini styleguide_example/`, which loads `mypy_django_plugin` and `mypy_drf_plugin`, and it updates the "Example List API" section of `README.md`, which shows the `limit`/`offset` response shape for `/api/users/`, so the documented shape matches the endpoint the issue changed. Endpoints the issue does not name keep `LimitOffsetPagination`.

## Start it with one API call

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

```json
{
  "agent": "claude",
  "input": "Add cursor pagination to the endpoints the attached Linear issue names. Read styleguide_example/api/pagination.py, styleguide_example/users/apis.py, styleguide_example/users/selectors.py, styleguide_example/users/models.py, styleguide_example/common/models.py and the existing test in styleguide_example/api/tests/pagination/. Add a CursorPagination subclass beside LimitOffsetPagination with an explicit ordering on created_at and id so the cursor is stable, and a get_paginated_response that returns next, previous and results. Switch only the views the issue names by changing their nested Pagination base class; leave the other endpoints on limit-offset. Write tests with APIRequestFactory that walk forward and back through three users and that show a row inserted between requests does not shift the page. Run ruff check ., mypy --config mypy.ini styleguide_example/ and pytest with the settings in pytest.ini against Postgres, update the Example List API section of README.md to the new response shape, and open a pull request that links the issue and explains the ordering choice.",
  "resources": [
    {
      "url": "https://github.com/your-org/your-app"
    },
    {
      "url": "https://linear.app/your-org/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 changes `styleguide_example/api/pagination.py` to add the cursor class, `styleguide_example/users/apis.py` to use it in the views the issue names, a new test module under `styleguide_example/api/tests/pagination/`, and the response example in `README.md`. The description links the Linear issue, shows a first-page response with its `next` cursor link, and gives the ordering and the reason for the tie-breaker on `id`.

The transcript at `GET /o/{org}/sessions/{id}/messages` holds the `ruff`, `mypy` and `pytest` runs with their full output, and `GET /o/{org}/p/{project}/sessions/{id}/changes` lists each changed file with its diff. Checks that do not pass end the run `needs_review` rather than as a pull request that claims to be green.

Before merging, a reviewer checks that the ordering matches an index (`created_at` is indexed on `BaseModel`; the pair with `id` uses the primary key), that no endpoint outside the issue changed, and that clients which read `count` from the old response have a plan for its absence. A follow-up on the same session can switch a second endpoint once the first is in.

## Run it on every Linear issue labelled agent

In the console, add an automation to the Project with this prompt and a `linear` trigger on `linear.issue.labeled`, filtered to the `agent` label and to the team whose issues name endpoints. When someone labels an issue such as "users list should page by cursor, offset pages drift under inserts", the trigger puts the issue after the prompt and the run scopes itself to the endpoints it names. Only label changes made by a person in your Rush org fire the trigger; changes made by an app integration never do, and the automation starts at most `max_runs_per_hour` runs in an hour.

From your own server, the same body goes to `POST /o/{org}/sessions` with the repository in `resources` and the issue's URL beside it, read through your Linear connection, plus a `request_id` made from the issue id so a retried call does not open a second pull request for the same issue.

## Why run it on Rush?

Pagination changes look small and break quietly: a cursor over a non-unique column skips rows, and the only way to see it is to insert data between requests and read both pages. In its own sandbox the agent runs the real test database the workflow uses, writes that scenario as a test, and runs `ruff`, `mypy` and `pytest` exactly as `django.yml` does before it opens anything.

The record stays with the result. Every command and its output is in the transcript, the diff is readable from the session, and several issues can run at once, each in a separate sandbox, so a cursor change on one endpoint never waits on an unrelated one.

## Frequently asked questions

### Does it switch every list endpoint?

No. It changes the views the Linear issue names and leaves the rest on `LimitOffsetPagination`. The `get_paginated_response` helper takes the pagination class as an argument, so the two kinds coexist in `api/pagination.py` and each view picks one through its nested `Pagination` class.

### Why order by created_at and id rather than created_at alone?

`BaseModel.created_at` defaults to `timezone.now`, so two users created in one request can share a value, and a cursor that compares only that field can repeat or drop one of them at a page boundary. Adding the primary key as a tie-breaker makes the order total. The pull request says this in its description.

### What happens to the count field?

A cursor response has no `count`; computing one would mean the extra query that cursor pagination exists to avoid. The new class returns `next`, `previous` and `results`, the README example is updated to match, and the pull request calls out the removed field so API clients can be checked before merge.

### Does it need Postgres to run the tests?

The workflow's `build` job runs `pytest` against a `postgres` service, so the agent starts the same database in the sandbox and uses the `DATABASE_URL` the project reads through `django-environ`. The test settings in `config/django/test.py` keep the rest in memory.
