Sessions & State

Pagination

Built-in pagination for list commands

Pagination#

For commands that return lists of items, Surf provides built-in pagination with a standard response envelope. Agents can detect paginated commands in the manifest and iterate automatically.

Declaring a Paginated Command

Set paginated: true for defaults, or pass a config object to customize limits and pagination style.

typescript
const surf = await createSurf({
name: 'My Store',
commands: {
'products.list': {
description: 'List all products',
paginated: true, // Defaults: { defaultLimit: 20, maxLimit: 100, style: 'cursor' }
params: {
category: { type: 'string' },
},
run: async ({ category, cursor, limit }) => {
const products = await db.products.list({ category, cursor, limit })
return paginatedResult(products.items, {
nextCursor: products.nextCursor,
total: products.total,
})
},
},
ย 
'orders.history': {
description: 'Get order history',
auth: 'required',
paginated: {
defaultLimit: 10,
maxLimit: 50,
style: 'cursor',
},
run: async ({ cursor, limit }, ctx) => {
const orders = await db.orders.forUser(ctx.claims!.userId, { cursor, limit })
return paginatedResult(orders.items, {
nextCursor: orders.nextCursor,
total: orders.total,
})
},
},
},
})

When paginated is set, Surf automatically injects cursor, limit, and offset into the command's accepted params. The limit is clamped to maxLimit and defaults to defaultLimit.

The paginatedResult Helper

Use paginatedResult() from @surfjs/core to build the standard response envelope. It derives hasMore from nextCursor automatically.

typescript
import { paginatedResult } from '@surfjs/core'
ย 
// Basic usage โ€” hasMore is derived from nextCursor
paginatedResult(items, { nextCursor: 'abc123' })
// => { items: [...], nextCursor: 'abc123', hasMore: true }
ย 
paginatedResult(items, { nextCursor: null })
// => { items: [...], nextCursor: null, hasMore: false }
ย 
// With total count
paginatedResult(items, { nextCursor: 'abc123', total: 142 })
// => { items: [...], nextCursor: 'abc123', hasMore: true, total: 142 }
ย 
// Explicit hasMore override
paginatedResult(items, { hasMore: false })

Response Envelope

Paginated commands return a standard envelope that agents can rely on:

json
{
"ok": true,
"result": {
"items": [
{ "id": "prod_1", "name": "Laptop", "price": 999 },
{ "id": "prod_2", "name": "Mouse", "price": 29 }
],
"nextCursor": "eyJpZCI6InByb2RfMiJ9",
"hasMore": true,
"total": 142
}
}

Client-Side Iteration

Agents iterate by passing the nextCursor from each response as the cursor param of the next request:

typescript
import { SurfClient } from '@surfjs/client'
ย 
const client = await SurfClient.discover('https://shop.example.com')
ย 
// Iterate through all pages
let cursor: string | undefined
const allProducts = []
ย 
do {
const page = await client.execute('products.list', {
category: 'electronics',
limit: 20,
cursor,
})
ย 
allProducts.push(...page.items)
cursor = page.nextCursor ?? undefined
} while (cursor)
ย 
console.log(`Fetched ${allProducts.length} products`)

Cursor vs Offset Style

Surf supports two pagination styles. Cursor-based is the default and recommended approach โ€” it's stable under concurrent inserts and deletes. Offset-based is available for simpler use cases.

typescript
// Cursor-based (default) โ€” stable, recommended
{
paginated: { style: 'cursor', defaultLimit: 20 }
}
// Client sends: { cursor: "eyJ...", limit: 20 }
ย 
// Offset-based โ€” simpler, but can skip/duplicate items on mutations
{
paginated: { style: 'offset', defaultLimit: 20, maxLimit: 100 }
}
// Client sends: { offset: 40, limit: 20 }

โš ๏ธ Note: Offset-based pagination can produce inconsistent results when items are inserted or deleted between requests. Prefer cursor-based pagination for production APIs.