mcpbeat Sign in

Continuation Token Pagination Contract Skill for Cursor

Model an endpoint that returns a page plus a next-token as Flow<Resource<Pair<items, token?>>> — a null token means the end, the caller stores only the token, and a bounded prefetch primes the first pages before anything is shown. Use when wiring an opaque-cursor API into a repository, or when a list stops loading after one failed request and never recovers, or when paging fires twice for one trigger.

2k tokens
context cost
the whole folder, loaded on every use
1
files
instructions only
0
copies elsewhere
how many repositories repackaged it
100
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/maxrave-dev/kotlin-footguns --skill continuation-token-pagination-contract

The instruction itself

3 sections, as written by the author

Page plus next-token, as one repository contract

The API answers with items and an opaque token for the next page. Everything downstream follows

from stating that once, in the return type:

// adapted: parameter names generalized; signatures otherwise as written
fun getRelatedData(id: String): Flow<Resource<Pair<List<Track>, String?>>>
fun getRadioFromEndpoint(endpoint: Endpoint): Flow<Resource<Pair<List<Track>, String?>>>
  • Pair(items, token) — the page and the way to ask for the next one.
  • null token means end of pages. Nothing else may mean that.
  • The envelope (Resource, see repository-resource-flow-pattern) carries failure, so a failure is

never expressed as an empty page or a null token.

The consumer stores exactly one thing — the token — and never a page number:

// adapted: names generalized; structure as written
override fun loadMore() {
    if (queueData.value.queueState == StateSource.STATE_INITIALIZING) return   // re-entrancy guard
    val token = queueData.value.data.continuation ?: return
    scope.launch {
        _queueData.update { it.copy(queueState = StateSource.STATE_INITIALIZING) }
        songRepository.getContinueTrack(playlistId, token).lastOrNull().let { response ->
            val list = response?.first
            if (list != null) {
                loadMoreCatalog(list)                                          // restores the flag
                _queueData.update { it.copy(data = it.data.copy(continuation = response.second)) }
            } else {
                _queueData.update { it.copy(data = it.data.copy(continuation = null)) }
                // …end-of-pages handling, which must also restore the flag
            }
        }
    }
}

The in-flight flag that makes loadMore() safe to call from a scroll listener *and* from an

end-of-queue event belongs to queue-rebuild-state-machine; what matters here is that the paging

path is one of its users and has to honour it on every exit.

Traps

**A paged read that cannot report failure makes a network hiccup indistinguishable from the end of

the list.** One read in this interface returns a bare Flow<Pair<List<T>?, String?>> instead of the

envelope, and its implementation has nowhere to put an error, so it does the only thing left:

.onFailure { emit(Pair(null, null)) }

The consumer above reads list == null, writes continuation = null, and stops paging — for good,

because the token it needed to retry has just been erased. One dropped request permanently truncates

the list and there is nothing in the state to distinguish it from a list that genuinely ended. Put

every paged read behind the same envelope — **and then read the next trap, because the envelope

alone does not fix this.** It buys you a distinguishable failure; it does not stop you throwing that

distinction away. The enveloped consumer in this same codebase pattern-matches a clean

is Resource.Error and clears the token in that branch anyway, arriving at the identical permanent

end by a longer route.

Clear the token when you stop, and only when you stop. An exhausted token left in state is

retried on every subsequent trigger, which is a request per scroll that can only ever answer "no".

The error path has to make the opposite choice: it should keep the token, because that page is still

retryable — clearing it there is what turns a transient failure into a permanent end. The envelope

makes the failure *visible*; only the failure branch can keep the list recoverable, and the two

are separate edits. Auditing one without the other is why this bug survives the migration that was

supposed to remove it.

The failure path of a prefetch loop must advance the loop. The bounded prefetch that primes the

first pages reassigns the token *inside* onSuccess only. It does not live behind the two

signatures above — it is a sibling repository's paged read, the one that builds a radio queue —

so do not go looking for it under those methods; the point is that the same contract is served from

more than one place and only one of them prefetches:

// adapted — names generalized; literals kept, they are the mechanism
var count = 0
while (continuation != null && count < 3) {
    remote.next(endpoint, continuation = continuation)
        .onSuccess { page ->
            data.addAll(page.items)
            continuation = page.continuation
            if (data.size >= 50) count = 3     // enough to start; stop early
            count++
        }
        .onFailure { count = 3 }               // without this, the same page forever
}

On failure the token still holds its old value and the condition is unchanged, so omitting the

counter jump is not a retry — it is an unbounded loop re-requesting one page. Whatever the failure

policy, the loop variable has to move.

Prefetch needs two stop conditions because pages are of unknown size. A budget of three pages

might yield six items or six hundred; a target of fifty items might never arrive if the source

returns short pages. The page budget bounds the work, the accumulated-item target expresses the

actual goal ("enough to start"), and either one alone is the wrong guarantee.

Terminal operators are part of the contract, and they disagree. Call sites here use

.lastOrNull(), .single() and .collect { } against paged reads in the same file. They behave

differently the moment a read emits more than once — single() throws, lastOrNull() silently

drops the earlier emission — so a repository that later adds a cached first emission (see

cache-then-network) breaks some callers loudly and others invisibly. Decide whether a paged read

emits once, write it down in the interface, and keep the operators consistent with it.

A two-branch when over the envelope has to do two jobs on the error branch. Clear or keep the

token *and* release the in-flight flag. Releasing only on success is the classic version of this

bug: the first failure leaves the flag set, and the guard at the top of loadMore() then rejects

every future call — the list stops loading and no error is ever shown, because the code that would

have shown one never runs again.

Verifying it

Force a failure — a happy-path run proves none of this.

  • Make the second page fail. The list must keep the first page, keep its token, and still page

when triggered again. If it never loads again, the flag was not released; if it reports the end of

the list, failure and end are sharing a representation.

  • Log the token on every request and assert it changes. A loop re-requesting one page looks like

healthy traffic in a network inspector and identical in the UI to a slow feed.

  • Assert the final token is null after the real last page, then trigger once more and assert **no

request is made**.

How to use it

Copy the folder

Take maxrave-dev/continuation-token-pagination-contract from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.