An orchestrator that delegates to other agents soon has thousands of tasks on each remote agent, some carrying hundreds of messages. How do you enumerate the tasks you care about without pulling them all, and read a task without downloading its whole conversation on every poll? In A2A the first is answered by ListTasks with cursor pagination and the second by historyLength.

This article explains both from the wire up, then builds a server and client that survive the awkward case: a task that changes while you page. Protocol facts come from the A2A 1.0.0 specification at a2a-protocol.org; the code is Python with sqlite3 and the output is from a real run. Where the specification is silent, the article says so.

Advertisement

The problem paging solves

Without paging, a list call either returns everything, which grows without bound and times out when you have the most work, or an arbitrary slice the caller cannot continue from. Paging splits one result into pages requested one at a time, with a position marker carried between calls.

An offset marker says "skip the first 200 rows": the database still reads and discards them, and inserts ahead of the offset slide the window, causing duplicates or gaps. A cursor (keyset) says "continue after the row whose sort key was (timestamp, id)": every page starts with an index seek, at the same cost at any depth. A2A specifies cursor-style paging: the server hands back an opaque nextPageToken and the client returns it unchanged as pageToken.

What A2A 1.0 specifies for ListTasks

ListTasks is one of the core operations, alongside SendMessage, GetTask, CancelTask and SubscribeToTask. Its request and response fields, from the 1.0.0 specification:

FieldMeaning
contextIdOnly tasks in this conversation context.
statusOnly tasks in this TaskState, for example TASK_STATE_WORKING.
pageSizeMaximum tasks to return; the server may return fewer. Unspecified means at most 50. Minimum 1, maximum 100.
pageTokenThe nextPageToken from the previous response.
historyLengthMaximum messages to include in each task's history.
statusTimestampAfterOnly tasks whose status was updated after this ISO 8601 timestamp.
includeArtifactsDefaults to false, in which case the artifacts field is omitted from every task.
tenantOptional opaque routing identifier.
response tasksThe matching tasks for this page.
response nextPageTokenAlways present; an empty string on the last page.
response pageSize, totalSizeThe page size used, and the number of matching tasks before pagination.

Two rules shape every implementation. Tasks must be sorted by status timestamp, most recently updated first. And implementations must scope results so a client sees only tasks it is authorised to access. The JSON-RPC and HTTP+JSON forms of one call look like this:

--> {"jsonrpc": "2.0", "id": "q1", "method": "ListTasks",
     "params": {"contextId": "ctx-7", "pageSize": 50, "historyLength": 0}}
<-- {"jsonrpc": "2.0", "id": "q1", "result": {
       "tasks": [{"id": "t-912", "contextId": "ctx-7",
                  "status": {"state": "TASK_STATE_COMPLETED",
                             "timestamp": "2026-10-02T09:14:03.118Z"}}, ...],
       "nextPageToken": "Q2sXb1...",
       "pageSize": 50,
       "totalSize": 212}}

# HTTP+JSON binding of the same call
GET /tasks?contextId=ctx-7&pageSize=50&historyLength=0

Three details trip clients. The list ends at nextPageToken: "", so a loop that tests for the field's presence never stops. A short page does not mean the end; servers may return fewer. And artifacts are off by default, which suits listings; fetch them with GetTask for tasks you open.

Advertisement

Designing the page token

The token is opaque to the client. A good one carries the sort key of the last row served, the filters and principal it was issued for, and an expiry, and is signed so a client cannot edit the cursor to escape its authorization scope.

Because the sort key is the status timestamp and two tasks can share a timestamp, the key must include a tiebreaker. The pair (timestamp, task id) is unique, so the seek condition ts < ? OR (ts = ? AND id < ?) never skips or repeats a row with a tied timestamp. Back it with a composite index on (owner, timestamp descending, id descending) so each page is one index range scan. Fetching pageSize + 1 rows tells you whether another page exists without a second query.

import base64, hashlib, hmac, json, time
from datetime import datetime, timedelta, timezone

SECRET = b"from-your-secret-store"     # when rotating, accept the previous key for one TTL
TOKEN_TTL_S = 600
FILTERS = ("contextId", "status", "statusTimestampAfter", "historyLength", "includeArtifacts")
EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)

class InvalidParams(ValueError):       # map to JSON-RPC -32602 at the transport edge
    pass

def to_us(iso):
    return (datetime.fromisoformat(iso.replace("Z", "+00:00")) - EPOCH) // timedelta(microseconds=1)

def to_iso(us):
    return (EPOCH + timedelta(microseconds=us)).isoformat(timespec="microseconds").replace("+00:00", "Z")

def encode_token(principal, filters, ts_us, task_id):
    body = json.dumps({"p": principal, "f": filters, "ts": ts_us, "id": task_id,
                       "exp": int(time.time()) + TOKEN_TTL_S}, separators=(",", ":")).encode()
    sig = hmac.new(SECRET, body, hashlib.sha256).digest()[:16]
    return base64.urlsafe_b64encode(sig + body).decode().rstrip("=")

def decode_token(token, principal, filters):
    try:
        raw = base64.urlsafe_b64decode(token + "=" * (-len(token) % 4))
    except ValueError:
        raise InvalidParams("pageToken is malformed")
    sig, body = raw[:16], raw[16:]
    if not hmac.compare_digest(sig, hmac.new(SECRET, body, hashlib.sha256).digest()[:16]):
        raise InvalidParams("pageToken signature invalid")
    cur = json.loads(body)                     # signed by us, so well-formed
    if cur["exp"] < time.time():
        raise InvalidParams("pageToken expired")
    if cur["p"] != principal or cur["f"] != filters:
        raise InvalidParams("pageToken was issued for a different caller or filter set")
    return cur["ts"], cur["id"]

def list_tasks(db, principal, params):
    size = params.get("pageSize", 50)
    if not 1 <= size <= 100:
        raise InvalidParams("pageSize must be between 1 and 100")
    filters = {k: params[k] for k in FILTERS if k in params}
    sql, args = "SELECT id, context_id, state, ts_us FROM tasks WHERE owner = ?", [principal]
    if "contextId" in filters:
        sql += " AND context_id = ?"; args.append(filters["contextId"])
    if "status" in filters:
        sql += " AND state = ?"; args.append(filters["status"])
    if "statusTimestampAfter" in filters:
        sql += " AND ts_us > ?"; args.append(to_us(filters["statusTimestampAfter"]))
    if params.get("pageToken"):
        ts, tid = decode_token(params["pageToken"], principal, filters)
        sql += " AND (ts_us < ? OR (ts_us = ? AND id < ?))"; args += [ts, ts, tid]
    sql += " ORDER BY ts_us DESC, id DESC LIMIT ?"; args.append(size + 1)
    rows = db.execute(sql, args).fetchall()
    page, more = rows[:size], len(rows) > size
    token = encode_token(principal, filters, page[-1][3], page[-1][0]) if more else ""
    # history trimming (historyLength) and artifacts (includeArtifacts) are omitted here
    return {"tasks": [{"id": i, "contextId": cx, "status": {"state": st, "timestamp": to_iso(ts)}}
                      for i, cx, st, ts in page],
            "nextPageToken": token, "pageSize": size}

Binding the filter set means a client that changes status mid-walk gets an error instead of silently mixing two result sets; an expired token fails clearly so the client restarts rather than seeing an empty page that looks like success.

Be careful with totalSize on large stores: an exact COUNT(*) under the caller's filters scans every matching row on every page. Compute it once per walk and carry it in the token, or cache it briefly. Clients should treat it as a progress hint, never a loop condition.

The task that moves while you page

Keyset paging is exact only for rows whose sort key does not change, and A2A's sort key, the status timestamp, changes whenever a task changes state. Page ten tasks four at a time. After page 1, task t01, due on page 3, completes. Its timestamp is now the newest, so it moves to the front, behind the cursor, and the client never sees it.

Keyset paging over tasks sorted newest-first, and the task that jumps behind the cursorstatus timestamp, newest firstPage 1t09 t08 t07 t06Page 2t05 t04 t03 t02Page 3t01 t00 (expected)cursor = (timestamp, id) of t06, signed into nextPageTokent01 completesnew timestamp is the newestmoves to the front, already pagedWithout catch-uppage 3 returns only t00: t01 is missedWith catch-upListTasks statusTimestampAfter = first newest - skewA cursor walk is exact only for rows that do not move. Any row whose sort key changeswhile you page must be picked up by a second, time-bounded query.
The cursor walks backwards in time. A task updated during the walk acquires the newest timestamp and lands in territory the client has already paged.

The fix uses the protocol's own filter. Record the newest status timestamp on the first page. After the walk ends, run a second walk with statusTimestampAfter set to that timestamp minus a small skew, and merge the results by task id, letting the later copy win. Anything that moved during the first walk has a newer timestamp and is caught. The skew covers equal timestamps, because the filter is strictly after, and clock steps on the server; the merge removes the duplicates it causes.

# Uses to_us / to_iso from the server block above.
def pages(call, params):
    token = ""
    while True:
        resp = call("ListTasks", {**params, **({"pageToken": token} if token else {})})
        yield resp["tasks"]
        token = resp["nextPageToken"]
        if not token:
            return

def list_all(call, page_size=100, skew_us=2_000_000):
    """Every visible task, including ones whose status changed while we were paging."""
    seen, newest = {}, None
    for tasks in pages(call, {"pageSize": page_size}):
        for t in tasks:
            newest = newest or t["status"]["timestamp"]
            seen[t["id"]] = t
    if newest:   # tasks updated mid-walk jumped behind the cursor; fetch them by time
        since = to_iso(to_us(newest) - skew_us)
        for tasks in pages(call, {"pageSize": page_size, "statusTimestampAfter": since}):
            seen.update((t["id"], t) for t in tasks)
    return seen

Running this code against an in-memory sqlite table of ten tasks updated one second apart, with a page size of four, and completing t01 after page 1, gives:

catch-up off
  page 1: ['t09', 't08', 't07', 't06']     <- t01 completes after this page is served
  page 2: ['t05', 't04', 't03', 't02']
  page 3: ['t00']
  saw 9 of 10 tasks; t01 -> MISSING
catch-up on
  page 1: ['t09', 't08', 't07', 't06']
  page 2: ['t05', 't04', 't03', 't02']
  page 3: ['t00']
  catch-up: ['t01', 't09', 't08']
  saw 10 of 10 tasks; t01 -> TASK_STATE_COMPLETED
token reused by another principal: pageToken was issued for a different caller or filter set
token reused with a new status filter: pageToken was issued for a different caller or filter set

The catch-up also returned t09 and t08, which fall inside the two-second skew; the merge makes that harmless. The same query with your last high-water mark is how a local mirror syncs incrementally instead of re-listing.

Long message histories

A multi-turn task can accumulate hundreds of messages. The specification's tool is historyLength, accepted by GetTask, ListTasks and SendMessage configuration with one meaning: unset returns the server's default amount, 0 returns no history, and N returns at most the N most recent messages.

Use it deliberately. Listings and poll loops waiting for a state change should send 0; they need status, not transcripts. To display progress or resume after a restart, ask for a small window such as 5. Avoid unset, because the server's default may be the whole history.

The protocol has no standard operation for paging backwards through a task's older messages; historyLength returns only the tail. If you need full transcripts of long tasks:

  • Keep your own copy. The client sent every user turn and received every agent message. Store them as they arrive, keyed by task and message id, and read the remote tail only to reconcile after a gap.
  • Move bulk output into artifacts, which listings exclude by default, instead of growing message lists.
  • Start a new task in the same contextId that references the old one when a conversation runs long, so each history stays bounded.
  • A private extension. If you control both sides, expose history paging through A2A's extension mechanism, declared in the Agent Card. It is not standard, so standard clients must still work with historyLength alone.

Server-side, store messages in their own table keyed by (task id, sequence), not as a growing JSON column on the task row, so historyLength is an index seek and listings never deserialise transcripts they discard.

Failure modes

  • Offset paging behind an opaque token. Encoding an offset in the token passes review and then duplicates or drops tasks under write load, and gets slower with depth. Encode the keyset.
  • Timestamp-only cursor. Two tasks with the same status timestamp at a page boundary: one is skipped or repeated. Add the task id as tiebreaker, in the cursor and the index.
  • Unsigned cursor. A client edits the decoded token and walks past its own scope. Sign tokens, bind them to principal and tenant, and always re-apply authorization in the query.
  • No catch-up. A sync job that only walks pages silently misses tasks that changed mid-walk, usually the ones that just completed and that you most wanted.

Operational guidance and trade-offs

Give tokens a lifetime long enough for a slow client to finish a walk, ten minutes is ample, and rotate the signing key with an overlap of one lifetime. Rate-limit ListTasks per principal; keyset walks are cheap, a client restarting walks in a tight loop is not.

Use the 100 maximum page size for bulk sync and small pages for interactive views; with history at 0 and artifacts off, 100 tasks is a small response. Log pages, rows, catch-up rows and token errors per walk. Rising catch-up counts mean mirrors lag; rising expiry errors mean the lifetime is too short.

The central trade-off is sort order. Newest-updated-first suits dashboards and "what changed" queries and makes the catch-up possible, and it is also why rows move under the cursor. Design the client for it rather than inventing an order the protocol does not define.

For the Task object and task store, see A2A task architecture. For contexts, multi-turn tasks and rehydrating remote history, see agent conversation architecture. For how requests are dispatched and errors mapped, see the JSON-RPC request flow and A2A error codes.

What to do next

  1. Check your ListTasks loop ends on an empty nextPageToken and never on a short page.
  2. Set historyLength explicitly on every GetTask, ListTasks and SendMessage call: 0 for listings and polls, a small N for display.
  3. On the server, replace any offset or timestamp-only cursor with (status timestamp, task id) and add the matching composite index.
  4. Sign page tokens, bind them to principal, tenant and filters, and give them an expiry; return invalid-parameters errors for violations.
  5. Add the catch-up pass with statusTimestampAfter to every sync job, and log how many tasks it recovers.
  6. Store messages in their own table keyed by (task, sequence) and keep a client-side copy of any transcript you need in full.
Key takeaway: A2A 1.0 pages tasks with ListTasks: an opaque nextPageToken that is an empty string on the last page, a default of 50 and a maximum of 100 tasks per page, newest-updated first, scoped to what the caller may see. Implement the token as a signed keyset cursor over status timestamp and task id, bound to the caller and filters. Because the sort key changes when a task changes, finish every walk with a statusTimestampAfter catch-up query and merge by task id. Keep messages small by setting historyLength on every call; the protocol has no standard way to page older history, so store transcripts you need yourself.