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.
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:
| Field | Meaning |
|---|---|
contextId | Only tasks in this conversation context. |
status | Only tasks in this TaskState, for example TASK_STATE_WORKING. |
pageSize | Maximum tasks to return; the server may return fewer. Unspecified means at most 50. Minimum 1, maximum 100. |
pageToken | The nextPageToken from the previous response. |
historyLength | Maximum messages to include in each task's history. |
statusTimestampAfter | Only tasks whose status was updated after this ISO 8601 timestamp. |
includeArtifacts | Defaults to false, in which case the artifacts field is omitted from every task. |
tenant | Optional opaque routing identifier. |
response tasks | The matching tasks for this page. |
response nextPageToken | Always present; an empty string on the last page. |
response pageSize, totalSize | The 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=0Three 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.
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.
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 seenRunning 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 setThe 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
contextIdthat 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
historyLengthalone.
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
- Check your ListTasks loop ends on an empty
nextPageTokenand never on a short page. - Set
historyLengthexplicitly on every GetTask, ListTasks and SendMessage call: 0 for listings and polls, a small N for display. - On the server, replace any offset or timestamp-only cursor with (status timestamp, task id) and add the matching composite index.
- Sign page tokens, bind them to principal, tenant and filters, and give them an expiry; return invalid-parameters errors for violations.
- Add the catch-up pass with
statusTimestampAfterto every sync job, and log how many tasks it recovers. - Store messages in their own table keyed by (task, sequence) and keep a client-side copy of any transcript you need in full.