SDK Improvements: Typed Filter Validation, Cursor-Based Pagination, and Quality Enhancements

Status: Draft Owner: vineeth Last Updated: 2026-03-25 Target Release: v2.1 (additive, non-breaking)

Update 2026-07-13: Cursor-based pagination shipped (DEV-1186). Remaining scope = filter type-safety (DEV-1398), now tracked in the SDK Quality deliverable-project; the mocking server is its own deliverable (DEV-1655).


1. Problem Statement

The Python and TypeScript SDKs for Honcho have three categories of improvement needed:

1.1 Filter Validation is Absent or Minimal

The server-side filter system (src/utils/filter.py) supports a rich query language: logical operators (AND, OR, NOT with max nesting depth 5), comparison operators (gte, lte, gt, lt, ne, contains, icontains, in), per-resource column mappings, JSONB metadata filtering with nested key access, and wildcard support. None of this is reflected in the SDK types.

  • Python SDK: All filter parameters are typed as dict[str, object] | None. There is zero IDE autocomplete, zero static validation, and no documentation of what keys are valid. Users only discover invalid filters at runtime via server-side FilterError.
  • TypeScript SDK: The FilterSchema in validation.ts is z.record(z.string(), z.unknown()).optional() — effectively untyped. The Filters type resolves to Record<string, unknown> | undefined. Same problem: no autocomplete, no client-side validation.

1.2 Pagination Controls are Not Exposed

All list endpoints use fastapi_pagination with offset-based pagination (page number, 1-indexed, plus size). The API returns Page[T] with items, page, size, total, pages. SDK iterators auto-fetch all subsequent pages.

The core problem: most SDK list methods do not expose page or size as parameters. For example:

  • honcho.peers() — no page, no size, no reverse param. Always fetches page 1 at default size.
  • session.messages() — same. A developer who wants 100 messages per page, or wants to start at page 3, has no way to express this.
  • peer.sessions() — same.
  • Only conclusions.list(page=1, size=50) exposes these controls.

The internal fetch_next closures pass page to the API, but this is private — developers cannot control initial page, page size, or ordering. They are forced to iterate from page 1 with the server default size (50), even if they only need messages 200-250.

Additionally, offset pagination has known deficiencies for real-time data:

  • Insertions/deletions between pages cause items to be skipped or duplicated.
  • Computing total requires a COUNT(*) query on every request, which is expensive for large tables.
  • Jumping to arbitrary pages encourages patterns that do not scale.

There is no cursor-based alternative.

1.3 General SDK Quality Gaps

  • No deprecation warnings for upcoming changes.
  • Missing __all__ exports in several Python modules.
  • TypeScript SDK lacks JSDoc on several private API methods.
  • No standardized error types for filter validation failures in either SDK.

2. Goals / Non-Goals

Goals

  1. Typed filter dictionaries with IDE autocomplete in both SDKs, while preserving full backward compatibility with raw dict/Record usage.
  2. Expose offset pagination controls (page, size, reverse) on all list methods so developers can control page size, starting page, and ordering without resorting to private methods.
  3. Cursor-based pagination as an opt-in alternative alongside the existing offset pagination, requiring no breaking changes to existing code.
  4. Client-side filter validation that catches common mistakes (invalid column names, invalid operators, wrong types for comparison operators) before the request is sent.
  5. Improved SDK ergonomics: better error messages, complete exports, consistent docstrings.

Non-Goals

  • Builder pattern for filters: Decided against — too complex for the value. Filters stay as dicts/objects.
  • Removing offset pagination: Offset pagination remains the default. Cursor is opt-in.
  • Server-side cursor pagination in this spec: The server-side implementation (modifying fastapi_pagination integration, adding cursor columns, etc.) is a separate backend spec. This spec covers the API contract and SDK-side changes only.
  • Filter validation that blocks valid server-side filters: Validation must be permissive. Unknown keys should warn, not error, to avoid breaking forward compatibility when the server adds new filterable columns.

3. Design

3.1 Filter Validation

3.1.1 Design Principles

  1. Raw dict[str, object] / Record<string, unknown> must continue to work everywhere filters are accepted. Typed filters are an overlay, not a replacement.
  2. Validation is advisory by default: malformed filters produce warnings (Python warnings.warn, TypeScript console.warn), not exceptions. A strict mode opt-in raises/throws.
  3. Type definitions must reflect the actual server-side filter system faithfully.

3.1.2 Server-Side Filter Capabilities (Reference)

From src/utils/filter.py:

Allowed columns per resource type:

External NameInternal ColumnResources
idnamePeer, Session, Workspace
created_atcreated_atPeer, Session, Workspace, Message
is_activeis_activePeer, Session, Workspace
workspace_idworkspace_namePeer, Session, Message, Document
session_idsession_namePeer, Session, Message, Document
peer_idpeer_namePeer, Session, Message
metadatah_metadataPeer, Session, Workspace, Message
token_counttoken_countMessage
observer_idobserverDocument (Conclusion)
observed_idobservedDocument (Conclusion)

Logical operators: AND, OR, NOT (value must be a list of filter dicts, max nesting depth 5)

Comparison operators: gte, lte, gt, lt, ne, contains, icontains, in

Special values: "*" (wildcard, matches everything)

3.1.3 Python SDK: Pydantic TypedDict Models

New file: sdks/python/src/honcho/filters.py

"""Typed filter definitions for Honcho SDK.
 
These types provide IDE autocomplete and optional validation for filter
dictionaries. Raw dict[str, object] is always accepted as well.
"""
 
from __future__ import annotations
 
from typing import Any, Literal, TypedDict, Union
 
# ---------------------------------------------------------------------------
# Comparison operator dictionaries
# ---------------------------------------------------------------------------
 
class ComparisonFilter(TypedDict, total=False):
    """Comparison operators for field-level filtering."""
    gte: str | int | float
    lte: str | int | float
    gt: str | int | float
    lt: str | int | float
    ne: str | int | float
    contains: str | dict[str, Any]
    icontains: str
    in_: list[str | int | float]  # Note: serialized as "in" in the dict
 
 
# We use "in" as the actual key at runtime; "in_" is only for Python syntax.
# Users should use {"in": [...]} in their dicts. The typed version uses "in_"
# and we provide a helper to convert.
 
 
class DateTimeComparisonFilter(TypedDict, total=False):
    """Comparison operators specifically for datetime fields like created_at."""
    gte: str  # ISO 8601 datetime string, e.g. "2024-01-01" or "2024-01-01T12:00:00Z"
    lte: str
    gt: str
    lt: str
    ne: str
    in_: list[str]  # serialized as "in"
 
 
class MetadataFilter(TypedDict, total=False):
    """Filter for JSONB metadata fields.
 
    Keys are metadata field names. Values can be:
    - Direct values for equality matching
    - ComparisonFilter dicts for comparison operators
    - "*" for wildcard (matches any value for that key)
    """
    # This is intentionally loose -- metadata keys are user-defined.
    # The TypedDict serves as documentation; actual keys are dynamic.
    pass
 
 
# For metadata, since keys are arbitrary, we represent it as:
MetadataFilterValue = Union[
    str,
    int,
    float,
    bool,
    dict[str, Any],  # nested equality or comparison operators
    Literal["*"],
]
 
 
# ---------------------------------------------------------------------------
# Per-resource filter types
# ---------------------------------------------------------------------------
 
class PeerFilter(TypedDict, total=False):
    """Filter for peer list operations.
 
    Example:
        {"id": "alice", "metadata": {"role": "admin"}}
        {"created_at": {"gte": "2024-01-01"}}
        {"AND": [{"id": "alice"}, {"metadata": {"active": True}}]}
    """
    id: str | ComparisonFilter | Literal["*"]
    created_at: str | DateTimeComparisonFilter | Literal["*"]
    is_active: bool | Literal["*"]
    workspace_id: str | ComparisonFilter | Literal["*"]
    session_id: str | ComparisonFilter | Literal["*"]
    peer_id: str | ComparisonFilter | Literal["*"]
    metadata: dict[str, MetadataFilterValue]
    AND: list[PeerFilter]
    OR: list[PeerFilter]
    NOT: list[PeerFilter]
 
 
class SessionFilter(TypedDict, total=False):
    """Filter for session list operations.
 
    Example:
        {"is_active": True}
        {"metadata": {"project": "demo"}}
        {"OR": [{"id": "session-1"}, {"id": "session-2"}]}
    """
    id: str | ComparisonFilter | Literal["*"]
    created_at: str | DateTimeComparisonFilter | Literal["*"]
    is_active: bool | Literal["*"]
    workspace_id: str | ComparisonFilter | Literal["*"]
    session_id: str | ComparisonFilter | Literal["*"]
    peer_id: str | ComparisonFilter | Literal["*"]
    metadata: dict[str, MetadataFilterValue]
    AND: list[SessionFilter]
    OR: list[SessionFilter]
    NOT: list[SessionFilter]
 
 
class MessageFilter(TypedDict, total=False):
    """Filter for message list operations.
 
    Example:
        {"peer_id": "alice"}
        {"token_count": {"gte": 100}}
        {"created_at": {"gte": "2024-01-01", "lte": "2024-12-31"}}
    """
    workspace_id: str | ComparisonFilter | Literal["*"]
    session_id: str | ComparisonFilter | Literal["*"]
    peer_id: str | ComparisonFilter | Literal["*"]
    token_count: int | ComparisonFilter | Literal["*"]
    created_at: str | DateTimeComparisonFilter | Literal["*"]
    metadata: dict[str, MetadataFilterValue]
    AND: list[MessageFilter]
    OR: list[MessageFilter]
    NOT: list[MessageFilter]
 
 
class ConclusionFilter(TypedDict, total=False):
    """Filter for conclusion list operations.
 
    Example:
        {"observer_id": "alice", "observed_id": "bob"}
        {"session_id": "session-1"}
    """
    observer_id: str | ComparisonFilter | Literal["*"]
    observed_id: str | ComparisonFilter | Literal["*"]
    session_id: str | ComparisonFilter | Literal["*"]
    workspace_id: str | ComparisonFilter | Literal["*"]
    metadata: dict[str, MetadataFilterValue]
    AND: list[ConclusionFilter]
    OR: list[ConclusionFilter]
    NOT: list[ConclusionFilter]
 
 
class WorkspaceFilter(TypedDict, total=False):
    """Filter for workspace list operations."""
    id: str | ComparisonFilter | Literal["*"]
    created_at: str | DateTimeComparisonFilter | Literal["*"]
    is_active: bool | Literal["*"]
    metadata: dict[str, MetadataFilterValue]
    AND: list[WorkspaceFilter]
    OR: list[WorkspaceFilter]
    NOT: list[WorkspaceFilter]
 
 
# ---------------------------------------------------------------------------
# Union type accepted everywhere filters are used
# ---------------------------------------------------------------------------
 
# Every method that currently accepts dict[str, object] | None will be
# updated to accept: Filter | dict[str, object] | None
# where Filter is the appropriate per-resource TypedDict.
#
# Because TypedDict is structurally typed, any plain dict that happens
# to match the shape will satisfy the type checker. And because total=False,
# all keys are optional.
 
 
# ---------------------------------------------------------------------------
# Validation helper
# ---------------------------------------------------------------------------
 
VALID_COMPARISON_OPERATORS = {"gte", "lte", "gt", "lt", "ne", "contains", "icontains", "in"}
VALID_LOGICAL_OPERATORS = {"AND", "OR", "NOT"}
MAX_NESTING_DEPTH = 5
 
VALID_COLUMNS_BY_RESOURCE = {
    "peer": {"id", "created_at", "is_active", "workspace_id", "session_id", "peer_id", "metadata"},
    "session": {"id", "created_at", "is_active", "workspace_id", "session_id", "peer_id", "metadata"},
    "message": {"workspace_id", "session_id", "peer_id", "token_count", "created_at", "metadata"},
    "conclusion": {"observer_id", "observed_id", "session_id", "workspace_id", "metadata"},
    "workspace": {"id", "created_at", "is_active", "metadata"},
}
 
 
def validate_filter(
    filter_dict: dict[str, Any] | None,
    resource: str = "peer",
    *,
    strict: bool = False,
    _depth: int = 0,
) -> list[str]:
    """Validate a filter dictionary and return a list of warnings.
 
    Args:
        filter_dict: The filter to validate.
        resource: Resource type ("peer", "session", "message", "conclusion", "workspace").
        strict: If True, raise ValueError on the first warning instead of collecting.
        _depth: Internal recursion depth tracker.
 
    Returns:
        List of warning strings. Empty if the filter is valid.
 
    Raises:
        ValueError: If strict=True and any validation issue is found.
    """
    if filter_dict is None:
        return []
 
    warnings_list: list[str] = []
 
    if _depth > MAX_NESTING_DEPTH:
        msg = f"Filter nesting exceeds maximum depth of {MAX_NESTING_DEPTH}"
        if strict:
            raise ValueError(msg)
        warnings_list.append(msg)
        return warnings_list
 
    valid_columns = VALID_COLUMNS_BY_RESOURCE.get(resource, set())
 
    for key, value in filter_dict.items():
        if key in VALID_LOGICAL_OPERATORS:
            if not isinstance(value, list):
                msg = f"{key} operator must contain a list, got {type(value).__name__}"
                if strict:
                    raise ValueError(msg)
                warnings_list.append(msg)
            else:
                for sub_filter in value:
                    if isinstance(sub_filter, dict):
                        warnings_list.extend(
                            validate_filter(sub_filter, resource, strict=strict, _depth=_depth + 1)
                        )
        elif key not in valid_columns:
            msg = f"Unknown filter column '{key}' for resource '{resource}'. Valid columns: {sorted(valid_columns)}"
            if strict:
                raise ValueError(msg)
            warnings_list.append(msg)
        elif isinstance(value, dict):
            # Check if it looks like comparison operators
            for op_key in value:
                if op_key not in VALID_COMPARISON_OPERATORS and key != "metadata":
                    msg = f"Unknown comparison operator '{op_key}' on column '{key}'. Valid operators: {sorted(VALID_COMPARISON_OPERATORS)}"
                    if strict:
                        raise ValueError(msg)
                    warnings_list.append(msg)
 
    return warnings_list

Integration pattern for existing methods:

Each method that currently accepts dict[str, object] | None will have its type signature broadened. For example, in client.py:

# Before:
def peers(self, filters: dict[str, object] | None = None) -> SyncPage[PeerResponse, Peer]:
 
# After:
def peers(self, filters: PeerFilter | dict[str, object] | None = None) -> SyncPage[PeerResponse, Peer]:

The runtime behavior does not change — the dict is passed through to the API as-is. The TypedDict is purely for static analysis and IDE autocomplete. Optionally, if the user enables strict validation (via a client-level flag or per-call keyword), the validate_filter function is called before the request.

Client-level strict mode:

honcho = Honcho(workspace_id="my-workspace", strict_filters=True)
# Now all filter parameters will raise ValueError on invalid filters

3.1.4 TypeScript SDK: Zod Schema Extensions

Modifications to sdks/typescript/src/validation.ts:

// ---------------------------------------------------------------------------
// Comparison operator schemas
// ---------------------------------------------------------------------------
 
const ComparisonValueSchema = z.union([z.string(), z.number()])
const WildcardSchema = z.literal("*")
 
export const ComparisonFilterSchema = z.object({
  gte: ComparisonValueSchema.optional(),
  lte: ComparisonValueSchema.optional(),
  gt: ComparisonValueSchema.optional(),
  lt: ComparisonValueSchema.optional(),
  ne: ComparisonValueSchema.optional(),
  contains: z.union([z.string(), z.record(z.string(), z.unknown())]).optional(),
  icontains: z.string().optional(),
  in: z.array(ComparisonValueSchema).optional(),
}).strict().optional()
 
export const DateTimeComparisonFilterSchema = z.object({
  gte: z.string().optional(),
  lte: z.string().optional(),
  gt: z.string().optional(),
  lt: z.string().optional(),
  ne: z.string().optional(),
  in: z.array(z.string()).optional(),
}).strict().optional()
 
// ---------------------------------------------------------------------------
// Metadata filter schema
// ---------------------------------------------------------------------------
 
const MetadataFilterValueSchema: z.ZodType<unknown> = z.lazy(() =>
  z.union([
    z.string(),
    z.number(),
    z.boolean(),
    WildcardSchema,
    z.record(z.string(), z.unknown()),  // nested equality or comparison operators
  ])
)
 
export const MetadataFilterSchema = z.record(z.string(), MetadataFilterValueSchema).optional()
 
// ---------------------------------------------------------------------------
// Per-resource filter schemas
// ---------------------------------------------------------------------------
 
// Lazy references needed for recursive AND/OR/NOT
type FilterBase = Record<string, unknown>
 
const makeResourceFilterSchema = (allowedColumns: string[]) => {
  // Build a schema that validates column names against the allowed set
  // while still permitting AND/OR/NOT logical operators
  const logicalOps = ["AND", "OR", "NOT"]
  const allAllowed = new Set([...allowedColumns, ...logicalOps])
 
  return z.record(z.string(), z.unknown())
    .superRefine((data, ctx) => {
      for (const key of Object.keys(data)) {
        if (!allAllowed.has(key)) {
          ctx.addIssue({
            code: z.ZodIssueCode.custom,
            message: `Unknown filter column '${key}'. Valid columns: ${allowedColumns.sort().join(", ")}`,
            path: [key],
          })
        }
        if (logicalOps.includes(key)) {
          if (!Array.isArray(data[key])) {
            ctx.addIssue({
              code: z.ZodIssueCode.custom,
              message: `${key} operator must contain an array`,
              path: [key],
            })
          }
        }
      }
    })
}
 
export const PeerFilterSchema = makeResourceFilterSchema([
  "id", "created_at", "is_active", "workspace_id", "session_id", "peer_id", "metadata",
])
 
export const SessionFilterSchema = makeResourceFilterSchema([
  "id", "created_at", "is_active", "workspace_id", "session_id", "peer_id", "metadata",
])
 
export const MessageFilterSchema = makeResourceFilterSchema([
  "workspace_id", "session_id", "peer_id", "token_count", "created_at", "metadata",
])
 
export const ConclusionFilterSchema = makeResourceFilterSchema([
  "observer_id", "observed_id", "session_id", "workspace_id", "metadata",
])
 
export const WorkspaceFilterSchema = makeResourceFilterSchema([
  "id", "created_at", "is_active", "metadata",
])
 
// ---------------------------------------------------------------------------
// Typed filter types (exported for consumers)
// ---------------------------------------------------------------------------
 
export type ComparisonFilter = z.infer<typeof ComparisonFilterSchema>
export type DateTimeComparisonFilter = z.infer<typeof DateTimeComparisonFilterSchema>
export type PeerFilter = z.infer<typeof PeerFilterSchema>
export type SessionFilter = z.infer<typeof SessionFilterSchema>
export type MessageFilter = z.infer<typeof MessageFilterSchema>
export type ConclusionFilter = z.infer<typeof ConclusionFilterSchema>
export type WorkspaceFilter = z.infer<typeof WorkspaceFilterSchema>

Integration in client.ts:

// Before:
async peers(filters?: Filters): Promise<Page<Peer, PeerResponse>>
 
// After:
async peers(filters?: PeerFilter | Filters): Promise<Page<Peer, PeerResponse>>

The existing FilterSchema.parse() calls remain in place and continue to pass any dict through. The new per-resource schemas are used only when the user opts into strict validation:

const honcho = new Honcho({ workspaceId: "test", strictFilters: true })
// OR per-call:
await honcho.peers(PeerFilterSchema.parse({ id: "alice" }))

3.1.5 Validation Behavior Summary

ScenarioDefault behaviorStrict mode behavior
Valid filterPass throughPass through
Unknown column namewarn() + pass throughRaise/throw
Invalid comparison operatorwarn() + pass throughRaise/throw
AND/OR/NOT with non-listwarn() + pass throughRaise/throw
Nesting depth > 5warn() + pass throughRaise/throw
Raw dict / Record (untyped)Pass through (no check)Pass through (no check)

3.2 Offset Pagination Controls

3.2.0 Problem

Most SDK list methods do not expose page, size, or reverse parameters. The API supports them as query parameters, but the SDK hardcodes defaults:

# Current — no control over page size or starting page
messages = session.messages()  # Always page 1, default size, default order
 
# What developers need
messages = session.messages(page=1, size=100)  # Control page size
messages = session.messages(page=3, size=50)   # Jump to page 3
messages = session.messages(reverse=True)       # Reverse ordering

Only conclusions.list(page=1, size=50) currently exposes these — all other list methods must be updated to match.

3.2.0.1 Python SDK Changes

Add page, size, and reverse keyword arguments to every list method. These are optional with sensible defaults to maintain backward compatibility.

client.pypeers(), sessions():

def peers(
    self,
    filters: dict[str, object] | None = None,
    *,
    page: int = 1,
    size: int = 50,
    reverse: bool = False,
) -> SyncPage[PeerResponse, Peer]:
    """
    Get all peers in the current workspace.
 
    Args:
        filters: Optional filter criteria.
        page: Page number (1-indexed). Default: 1.
        size: Number of items per page. Default: 50.
        reverse: If True, reverses the default ordering. Default: False.
 
    Returns:
        A paginated result of Peer objects.
    """
    self._ensure_workspace()
    query: dict[str, Any] = {"page": page, "size": size}
    if reverse:
        query["reverse"] = "true"
    data = self._http.post(
        routes.peers_list(self.workspace_id),
        body={"filters": filters} if filters else None,
        query=query,
    )
 
    def transform(peer: PeerResponse) -> Peer:
        return Peer(peer.id, self, metadata=peer.metadata, configuration=peer.configuration)
 
    def fetch_next(next_page: int) -> SyncPage[PeerResponse, Peer]:
        next_query: dict[str, Any] = {"page": next_page, "size": size}
        if reverse:
            next_query["reverse"] = "true"
        next_data = self._http.post(
            routes.peers_list(self.workspace_id),
            body={"filters": filters} if filters else None,
            query=next_query,
        )
        return SyncPage(next_data, PeerResponse, transform, fetch_next)
 
    return SyncPage(data, PeerResponse, transform, fetch_next)

session.pymessages():

def messages(
    self,
    *,
    filters: dict[str, object] | None = None,
    page: int = 1,
    size: int = 50,
    reverse: bool = False,
) -> SyncPage[MessageResponse, Message]:
    """
    Get messages from this session with optional filtering.
 
    Args:
        filters: Dictionary of filter criteria.
        page: Page number (1-indexed). Default: 1.
        size: Number of items per page. Default: 50.
        reverse: If True, returns messages in reverse chronological order. Default: False.
    """
    self._honcho._ensure_workspace()
    query: dict[str, Any] = {"page": page, "size": size}
    if reverse:
        query["reverse"] = "true"
    data = self._honcho._http.post(
        routes.messages_list(self.workspace_id, self.id),
        body={"filters": filters} if filters else None,
        query=query,
    )
 
    def transform(response: MessageResponse) -> Message:
        return Message.from_api_response(response)
 
    def fetch_next(next_page: int) -> SyncPage[MessageResponse, Message]:
        next_query: dict[str, Any] = {"page": next_page, "size": size}
        if reverse:
            next_query["reverse"] = "true"
        next_data = self._honcho._http.post(
            routes.messages_list(self.workspace_id, self.id),
            body={"filters": filters} if filters else None,
            query=next_query,
        )
        return SyncPage(next_data, MessageResponse, transform, fetch_next)
 
    return SyncPage(data, MessageResponse, transform, fetch_next)

Methods to update (full list):

ClassMethodCurrent paramsAdd
Honcho (client.py)peers()filterspage, size, reverse
Honcho (client.py)sessions()filterspage, size, reverse
Peer (peer.py)sessions()filterspage, size, reverse
Session (session.py)messages()filterspage, size, reverse
ConclusionScope (conclusions.py)list()page, size, sessionreverse (page/size already exposed)
AsyncHoncho (aio.py)peers()filterspage, size, reverse
AsyncHoncho (aio.py)sessions()filterspage, size, reverse
AsyncPeer (aio.py)sessions()filterspage, size, reverse
AsyncSession (aio.py)messages()filterspage, size, reverse
AsyncConclusionScope (aio.py)list()page, size, sessionreverse

3.2.0.2 TypeScript SDK Changes

Same pattern — add optional page, size, reverse to list method options.

// Current — no pagination control
const messages = await session.messages();
 
// After — explicit control
const messages = await session.messages({ page: 1, size: 100 });
const page3 = await session.messages({ page: 3, size: 50 });
const reversed = await session.messages({ reverse: true });

Method signatures:

// session.ts
async messages(options?: {
  filters?: Filters;
  page?: number;    // default: 1
  size?: number;    // default: 50
  reverse?: boolean; // default: false
}): Promise<Page<Message, MessageResponse>>
 
// client.ts
async peers(options?: {
  filters?: Filters;
  page?: number;
  size?: number;
  reverse?: boolean;
}): Promise<Page<Peer, PeerResponse>>

Methods to update:

ClassMethodAdd
Honcho (client.ts)peers()page, size, reverse in options
Honcho (client.ts)sessions()same
Peer (peer.ts)sessions()same
Session (session.ts)messages()same
ConclusionScope (conclusions.ts)list()reverse (page/size may already exist)

3.2.0.3 Backward Compatibility

All new parameters are optional with defaults matching current behavior:

  • page=1 — same as today’s implicit first page
  • size=50 — same as today’s server default
  • reverse=False — same as today’s default ordering

Existing code like session.messages() continues to work identically.

3.2.0.4 Server-Side Support

The API already accepts page and size as query parameters (handled by fastapi_pagination). The reverse parameter is already supported on the messages list endpoint as a query param. No server-side changes are needed for this section.


3.3 Cursor-Based Pagination

3.3.1 API Contract

Cursor-based pagination is added as an opt-in alongside the existing offset pagination. The existing page/size parameters and response shape are unchanged.

Request changes (query parameters):

ParameterTypeDefaultDescription
pageint (existing)1Page number for offset pagination. Ignored if cursor is set.
sizeint (existing)50Items per page. Used by both offset and cursor modes.
cursorstr (new)undefinedOpaque cursor string. If present, enables cursor mode.
directionstr (new)"next""next" or "prev". Only used in cursor mode.

Response shape changes:

The existing PageResponse fields (items, page, size, total, pages) remain for offset mode. When cursor mode is active, additional fields are included:

{
  "items": [...],
  "size": 50,
 
  // Offset fields -- still present for backward compat but set to sentinel values
  "page": 0,
  "total": -1,
  "pages": -1,
 
  // Cursor fields -- only present when cursor mode was requested
  "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNC0wMS0xNVQxMDozMDowMFoiLCJpZCI6ImFiYzEyMyJ9",
  "prev_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNC0wMS0xNFQwOTowMDowMFoiLCJpZCI6Inh5ejc4OSJ9",
  "has_next": true,
  "has_prev": false
}

When offset mode is active (no cursor param), the cursor fields are absent. This maintains full backward compatibility.

3.3.2 Cursor Format

The cursor is a base64-encoded JSON object containing the sort key(s) needed for keyset pagination:

// Decoded cursor:
{
  "created_at": "2024-01-15T10:30:00Z",
  "id": "abc123"
}

The cursor contains:

  • created_at: The created_at timestamp of the boundary item.
  • id: The primary key of the boundary item (for tie-breaking when multiple items share the same created_at).

The cursor is opaque to clients — SDKs must not parse or construct cursors. They receive cursors from the API and pass them back verbatim.

3.3.3 Python SDK Changes

New file additions to sdks/python/src/honcho/pagination.py:

class SyncCursorPage(Generic[T, U]):
    """
    Cursor-based paginated result wrapper.
 
    Unlike SyncPage, this does not expose total/pages (not available in cursor mode).
    Provides forward/backward iteration via next_cursor/prev_cursor.
    """
 
    def __init__(
        self,
        data: dict[str, Any],
        item_type: type[T],
        transform_func: Callable[[T], U] | None = None,
        fetch_next: Callable[[str], "SyncCursorPage[T, U]"] | None = None,
        fetch_prev: Callable[[str], "SyncCursorPage[T, U]"] | None = None,
    ) -> None:
        self._data = data
        self._item_type = item_type
        self._transform_func = transform_func
        self._fetch_next = fetch_next
        self._fetch_prev = fetch_prev
 
        raw_items = data.get("items", [])
        self._raw_items: list[T] = [
            item_type.model_validate(item) for item in raw_items
        ]
 
    def __iter__(self) -> Iterator[U] | Iterator[T]:
        """Iterate over items on this page only. Does NOT auto-fetch next pages."""
        for item in self._raw_items:
            if self._transform_func is not None:
                yield self._transform_func(item)
            else:
                yield item
 
    def iter_all(self) -> Iterator[U] | Iterator[T]:
        """Iterate over all items across all pages (forward direction)."""
        page: SyncCursorPage[T, U] | None = self
        while page is not None:
            for item in page._raw_items:
                if self._transform_func is not None:
                    yield self._transform_func(item)
                else:
                    yield item
            page = page.get_next_page()
 
    @property
    def items(self) -> list[U] | list[T]:
        if self._transform_func is not None:
            return [self._transform_func(item) for item in self._raw_items]
        return list(self._raw_items)
 
    @property
    def next_cursor(self) -> str | None:
        return self._data.get("next_cursor")
 
    @property
    def prev_cursor(self) -> str | None:
        return self._data.get("prev_cursor")
 
    @property
    def has_next(self) -> bool:
        return self._data.get("has_next", False)
 
    @property
    def has_prev(self) -> bool:
        return self._data.get("has_prev", False)
 
    @property
    def size(self) -> int | None:
        return self._data.get("size")
 
    def get_next_page(self) -> "SyncCursorPage[T, U] | None":
        if not self.has_next or self.next_cursor is None or self._fetch_next is None:
            return None
        return self._fetch_next(self.next_cursor)
 
    def get_prev_page(self) -> "SyncCursorPage[T, U] | None":
        if not self.has_prev or self.prev_cursor is None or self._fetch_prev is None:
            return None
        return self._fetch_prev(self.prev_cursor)
 
 
class AsyncCursorPage(Generic[T, U]):
    """Async version of SyncCursorPage."""
    # Mirrors SyncCursorPage with async __aiter__, async iter_all,
    # async get_next_page, and async get_prev_page.
    # (Full implementation follows same pattern as AsyncPage above.)
    ...

Usage in resource methods:

A new keyword argument cursor is added to every list method. When cursor is provided, the method returns a SyncCursorPage / AsyncCursorPage instead of SyncPage / AsyncPage. The return type uses an overload:

from typing import overload
 
class Honcho:
    @overload
    def peers(
        self,
        filters: PeerFilter | dict[str, object] | None = None,
        *,
        cursor: str,
    ) -> SyncCursorPage[PeerResponse, Peer]: ...
 
    @overload
    def peers(
        self,
        filters: PeerFilter | dict[str, object] | None = None,
    ) -> SyncPage[PeerResponse, Peer]: ...
 
    def peers(
        self,
        filters: PeerFilter | dict[str, object] | None = None,
        *,
        cursor: str | None = None,
    ) -> SyncPage[PeerResponse, Peer] | SyncCursorPage[PeerResponse, Peer]:
        self._ensure_workspace()
 
        query_params: dict[str, Any] = {}
        if cursor is not None:
            query_params["cursor"] = cursor
        body = {"filters": filters} if filters else None
 
        data = self._http.post(
            routes.peers_list(self.workspace_id),
            body=body,
            query=query_params or None,
        )
 
        def transform(peer: PeerResponse) -> Peer:
            return Peer(peer.id, self, metadata=peer.metadata, configuration=peer.configuration)
 
        if cursor is not None:
            # Cursor mode
            def fetch_next(next_cursor: str) -> SyncCursorPage[PeerResponse, Peer]:
                next_data = self._http.post(
                    routes.peers_list(self.workspace_id),
                    body=body,
                    query={"cursor": next_cursor},
                )
                return SyncCursorPage(next_data, PeerResponse, transform, fetch_next, fetch_prev)
 
            def fetch_prev(prev_cursor: str) -> SyncCursorPage[PeerResponse, Peer]:
                prev_data = self._http.post(
                    routes.peers_list(self.workspace_id),
                    body=body,
                    query={"cursor": prev_cursor, "direction": "prev"},
                )
                return SyncCursorPage(prev_data, PeerResponse, transform, fetch_next, fetch_prev)
 
            return SyncCursorPage(data, PeerResponse, transform, fetch_next, fetch_prev)
        else:
            # Existing offset mode (unchanged)
            def offset_fetch_next(page: int) -> SyncPage[PeerResponse, Peer]:
                next_data = self._http.post(
                    routes.peers_list(self.workspace_id),
                    body=body,
                    query={"page": page},
                )
                return SyncPage(next_data, PeerResponse, transform, offset_fetch_next)
 
            return SyncPage(data, PeerResponse, transform, offset_fetch_next)

The first call uses cursor="" (empty string) or a dedicated sentinel like cursor="start" to request the first page in cursor mode. All subsequent calls use the next_cursor / prev_cursor from the response.

3.3.4 TypeScript SDK Changes

New classes in sdks/typescript/src/pagination.ts:

export interface CursorPageResponse<T> {
  items: T[]
  size: number
  next_cursor: string | null
  prev_cursor: string | null
  has_next: boolean
  has_prev: boolean
  // Backward-compat offset fields (sentinel values in cursor mode)
  page: number
  total: number
  pages: number
}
 
export type CursorFetcher<T> = (
  cursor: string,
  size: number,
  direction?: "next" | "prev"
) => Promise<CursorPageResponse<T>>
 
export class CursorPage<T, TOriginal = T> implements AsyncIterable<T> {
  private _data: CursorPageResponse<TOriginal>
  private _transformFunc?: (item: TOriginal) => T
  private _fetchCursor?: CursorFetcher<TOriginal>
 
  constructor(
    data: CursorPageResponse<TOriginal>,
    transformFunc?: (item: TOriginal) => T,
    fetchCursor?: CursorFetcher<TOriginal>
  ) {
    this._data = data
    this._transformFunc = transformFunc
    this._fetchCursor = fetchCursor
  }
 
  /**
   * Async iterator over all items across all pages (forward only).
   */
  async *[Symbol.asyncIterator](): AsyncIterator<T> {
    for (const item of this._data.items) {
      yield this._transformFunc ? this._transformFunc(item) : (item as unknown as T)
    }
 
    let currentPage: CursorPage<T, TOriginal> | null = this
    while (currentPage.hasNext) {
      const nextPage = await currentPage.getNextPage()
      if (!nextPage) break
      currentPage = nextPage
      for (const item of nextPage._data.items) {
        yield nextPage._transformFunc
          ? nextPage._transformFunc(item)
          : (item as unknown as T)
      }
    }
  }
 
  get items(): T[] {
    const items = this._data.items || []
    return this._transformFunc
      ? items.map(this._transformFunc)
      : (items as unknown as T[])
  }
 
  get length(): number { return this._data.items?.length ?? 0 }
  get size(): number { return this._data.size }
  get nextCursor(): string | null { return this._data.next_cursor }
  get prevCursor(): string | null { return this._data.prev_cursor }
  get hasNext(): boolean { return this._data.has_next }
  get hasPrev(): boolean { return this._data.has_prev }
 
  async getNextPage(): Promise<CursorPage<T, TOriginal> | null> {
    if (!this.hasNext || !this.nextCursor || !this._fetchCursor) return null
    const data = await this._fetchCursor(this.nextCursor, this._data.size, "next")
    return new CursorPage(data, this._transformFunc, this._fetchCursor)
  }
 
  async getPrevPage(): Promise<CursorPage<T, TOriginal> | null> {
    if (!this.hasPrev || !this.prevCursor || !this._fetchCursor) return null
    const data = await this._fetchCursor(this.prevCursor, this._data.size, "prev")
    return new CursorPage(data, this._transformFunc, this._fetchCursor)
  }
 
  async toArray(): Promise<T[]> {
    const all: T[] = []
    for await (const item of this) all.push(item)
    return all
  }
}

Integration in client.ts:

Methods gain an optional cursor parameter. TypeScript overloads express the return type:

async peers(filters?: PeerFilter | Filters): Promise<Page<Peer, PeerResponse>>
async peers(filters: PeerFilter | Filters | undefined, options: { cursor: string }): Promise<CursorPage<Peer, PeerResponse>>
async peers(
  filters?: PeerFilter | Filters,
  options?: { cursor?: string }
): Promise<Page<Peer, PeerResponse> | CursorPage<Peer, PeerResponse>> {
  // Implementation dispatches based on options?.cursor presence
}

3.3.5 Pagination Type in types/api.ts

Add the cursor response type:

export interface CursorPageResponse<T> extends PageResponse<T> {
  next_cursor: string | null
  prev_cursor: string | null
  has_next: boolean
  has_prev: boolean
}

3.3.6 Cursor Pagination Behavior Summary

AspectOffset mode (existing)Cursor mode (new)
ActivationDefault (no cursor param)Pass cursor query param
First pagepage=1cursor="" or omit cursor
Next pagepage=N+1cursor=response.next_cursor
Previous pagepage=N-1cursor=response.prev_cursor
Total counttotal field (computed)Not available (total=-1)
Page countpages field (computed)Not available (pages=-1)
Consistency under mutationItems may be skipped/dupedStable (keyset-based)
__iter__ auto-fetches allYesOnly via iter_all() (Python) or [Symbol.asyncIterator] (TS)
Random page accessYes (page=N)No (sequential only)

3.4 Other SDK Quality Improvements

3.4.1 Python SDK

  1. __all__ exports: Add or update __all__ in __init__.py to export all public types including the new filter types, cursor page types.

  2. Deprecation helpers: Add a _deprecated decorator utility that emits DeprecationWarning with a standardized message format including the target removal version.

  3. Filter validation error type: Add FilterValidationError(ValueError) to provide structured error information (field name, operator, reason) rather than a plain string.

  4. Type narrowing for pagination: The SyncPage.__iter__ currently returns Iterator[U] | Iterator[T]. This should be narrowed: when transform_func is None, items are T; when transform_func is set, items are U. Use @overload on __init__ to achieve this.

  5. py.typed marker: Ensure sdks/python/src/honcho/py.typed exists so that downstream projects recognize inline types.

3.4.2 TypeScript SDK

  1. JSDoc completeness: Add JSDoc to all private API methods in client.ts, peer.ts, session.ts, conclusions.ts.

  2. Export map: Ensure package.json exports field exposes the new filter types and cursor pagination types.

  3. Strict filter mode in constructor: Add optional strictFilters boolean to HonchoConfigSchema.

export const HonchoConfigSchema = z.object({
  // ... existing fields ...
  strictFilters: z.boolean().optional(),
})
  1. Error class for filter validation: Add FilterValidationError extends Error with structured fields (column, operator, message).

4. Implementation Phases

Phase 1: Filter Type Definitions (Python + TypeScript)

Estimated effort: 2-3 days

  1. Create sdks/python/src/honcho/filters.py with all TypedDict definitions and the validate_filter() function.
  2. Extend sdks/typescript/src/validation.ts with per-resource filter schemas.
  3. Update type signatures on all methods that accept filters:
    • Python: client.py (3 methods), peer.py (2 methods), session.py (2 methods), conclusions.py (1 method), aio.py (mirrors of all the above)
    • TypeScript: client.ts (3 methods), peer.ts (2 methods), session.ts (2 methods), conclusions.ts (1 method)
  4. Add strict_filters / strictFilters option to client constructors.
  5. Add FilterValidationError class to both SDKs.
  6. Update __init__.py / package.json exports.

Phase 2: Offset Pagination Controls

Estimated effort: 1-2 days

  1. Add page, size, reverse keyword arguments to all list methods that don’t already have them:
    • Python: client.py:peers(), client.py:sessions(), peer.py:sessions(), session.py:messages(), conclusions.py:list() (add reverse only)
    • Plus all async mirrors in aio.py
    • TypeScript: client.ts:peers(), client.ts:sessions(), peer.ts:sessions(), session.ts:messages(), conclusions.ts:list()
  2. Pass page, size, and reverse as query parameters in both initial requests and fetch_next closures.
  3. Ensure fetch_next preserves the caller’s size and reverse settings (currently fetch_next only passes page).
  4. Write unit tests verifying that page, size, and reverse are correctly passed as query params.
  5. Write integration tests verifying pagination with explicit page sizes.

Phase 3: Cursor-Based Pagination (SDK Layer)

Estimated effort: 3-4 days

  1. Add SyncCursorPage and AsyncCursorPage to sdks/python/src/honcho/pagination.py.
  2. Add CursorPage and CursorPageResponse to sdks/typescript/src/pagination.ts and types/api.ts.
  3. Add cursor parameter overloads to all list methods in both SDKs:
    • Honcho.peers(), Honcho.sessions(), Honcho.workspaces()
    • Peer.sessions()
    • Session.messages()
    • ConclusionScope.list()
    • Plus all async mirrors
  4. Write unit tests for cursor page iteration, forward/backward navigation, empty pages, single-page results.

Note: This phase can be implemented and merged before the server supports cursor pagination. The SDK will simply never receive cursor fields in the response until the server is updated, so cursor mode will return pages with has_next=false. This allows the SDK to ship ahead of the server.

Phase 4: General Quality Improvements

Estimated effort: 1-2 days

  1. Python: Add __all__ to all modules, add py.typed, add _deprecated decorator.
  2. TypeScript: Complete JSDoc coverage, update export map.
  3. Both: Add integration tests that verify filter validation warnings/errors.

Phase 5: Documentation

Estimated effort: 1 day

  1. Add filter examples to SDK README / docs site.
  2. Document cursor pagination usage patterns.
  3. Add migration guide showing the non-breaking nature of the changes.

5. Files to Modify (Exact Paths)

Python SDK

FileChange
sdks/python/src/honcho/filters.pyNEW — TypedDict filter definitions, validation function
sdks/python/src/honcho/pagination.pyAdd SyncCursorPage, AsyncCursorPage classes
sdks/python/src/honcho/client.pyUpdate peers(), sessions(), workspaces() signatures + cursor overloads; add strict_filters init param
sdks/python/src/honcho/peer.pyUpdate sessions(), search() filter type signatures + cursor overload on sessions()
sdks/python/src/honcho/session.pyUpdate messages(), search() filter type signatures + cursor overload on messages()
sdks/python/src/honcho/conclusions.pyUpdate ConclusionScope.list() filter handling + cursor overload
sdks/python/src/honcho/aio.pyMirror all changes from sync classes for HonchoAio, PeerAio, SessionAio, ConclusionScopeAio
sdks/python/src/honcho/__init__.pyExport new types: filter TypedDicts, cursor page classes, FilterValidationError
sdks/python/src/honcho/api_types.pyAdd CursorPageResponse model
sdks/python/src/honcho/py.typedNEW — empty marker file for PEP 561

TypeScript SDK

FileChange
sdks/typescript/src/validation.tsAdd per-resource filter schemas, ComparisonFilterSchema, strictFilters config option
sdks/typescript/src/pagination.tsAdd CursorPage class, CursorFetcher type
sdks/typescript/src/types/api.tsAdd CursorPageResponse<T> interface
sdks/typescript/src/client.tsUpdate peers(), sessions(), workspaces() with filter types + cursor overloads
sdks/typescript/src/peer.tsUpdate sessions(), search() with filter types + cursor overload on sessions()
sdks/typescript/src/session.tsUpdate messages(), search() with filter types + cursor overload on messages()
sdks/typescript/src/conclusions.tsUpdate ConclusionScope.list() with cursor overload
sdks/typescript/src/index.tsExport new types
sdks/typescript/package.jsonUpdate exports map if needed

Server (API Contract Only — Not Full Implementation)

FileChange
src/schemas.pyAdd optional cursor fields to paginated response schema
src/routers/peers.pyAccept optional cursor and direction query params on list endpoint
src/routers/sessions.pySame
src/routers/messages.pySame
src/routers/conclusions.pySame
src/routers/workspaces.pySame

6. Risk Assessment

Low Risk

  • Filter TypedDicts are purely additive: They only affect static type checking. Runtime behavior is unchanged. No existing code can break.
  • TypeScript Zod schema extensions: The existing FilterSchema (z.record(z.string(), z.unknown()).optional()) remains as a fallback. New schemas are optional overlays.

Medium Risk

  • Cursor pagination return type divergence: Methods that return SyncPage | SyncCursorPage based on a runtime flag are harder to type correctly. The @overload approach mitigates this for static analysis, but callers using dynamic cursor values (e.g., cursor=maybe_cursor) will get the union type and need to narrow.

    • Mitigation: Provide clear documentation and consider a separate method name (e.g., peers_cursor()) if the overload approach proves too confusing in practice. The current design prefers a single method with overloads to avoid API surface explosion.
  • Strict filter validation false positives: If the server adds new filterable columns that the SDK does not yet know about, strict mode will reject valid filters.

    • Mitigation: Strict mode is opt-in and off by default. Validation warns on unknown columns rather than rejecting them outright (unless strict mode is enabled). SDK updates can add new columns to the known set without breaking changes.

Low-Medium Risk

  • Cursor format coupling: If the server changes the cursor encoding (e.g., adds fields, changes encoding), old cursors become invalid. Clients must treat cursors as fully opaque.
    • Mitigation: Document that cursors are opaque and short-lived. Do not persist cursors across sessions or deployments.

7. Verification Plan

7.1 Unit Tests

Python:

  • tests/test_filters.py:

    • test_validate_filter_valid_peer_filter — no warnings returned
    • test_validate_filter_unknown_column — warning returned, no exception
    • test_validate_filter_strict_unknown_column — ValueError raised
    • test_validate_filter_invalid_logical_operator_value — warning for non-list AND
    • test_validate_filter_nesting_depth_exceeded — warning at depth 6
    • test_validate_filter_comparison_operators — all operators accepted
    • test_validate_filter_wildcard — ”*” accepted without warning
    • test_validate_filter_metadata_nested — nested metadata filter accepted
  • tests/test_pagination_cursor.py:

    • test_sync_cursor_page_items — items property returns transformed items
    • test_sync_cursor_page_iter__iter__ yields current page only
    • test_sync_cursor_page_iter_alliter_all() follows next pages
    • test_sync_cursor_page_no_nextget_next_page() returns None when has_next=false
    • test_sync_cursor_page_prev — backward navigation works
    • test_async_cursor_page_aiter — async iteration works
    • test_cursor_page_empty — empty items list, no cursors

TypeScript:

  • __tests__/filters.test.ts:

    • Same test matrix as Python but using Zod .parse() / .safeParse()
    • test_peer_filter_schema_accepts_valid — valid filter passes
    • test_peer_filter_schema_rejects_unknown_column — unknown column fails in strict
    • test_message_filter_schema_token_count — numeric filter on token_count
  • __tests__/pagination-cursor.test.ts:

    • test_cursor_page_items — items getter works
    • test_cursor_page_async_iterator — for-await-of yields all pages
    • test_cursor_page_to_array — toArray collects everything
    • test_cursor_page_no_next — hasNext false, getNextPage returns null

7.2 Integration Tests

  • Existing pagination integration tests must continue to pass unchanged (offset mode).
  • New integration tests for cursor mode will initially be skipped/marked pending until the server implements cursor support.
  • Filter validation integration tests: send a filter with an intentionally invalid column via the SDK in strict mode, verify the SDK raises before the request is made.

7.3 Type Checking

  • Python: Run basedpyright across the SDK. Verify that:

    • honcho.peers({"id": "alice"}) type-checks without error (dict literal matches PeerFilter).
    • honcho.peers({"invalid_column": "x"}) produces a type error (key not in PeerFilter).
    • Cursor overloads resolve correctly: honcho.peers(cursor="abc") returns SyncCursorPage, honcho.peers() returns SyncPage.
  • TypeScript: Run tsc --noEmit across the SDK. Verify that:

    • honcho.peers({ id: "alice" }) compiles.
    • honcho.peers({ invalidColumn: "x" }) produces a type error when using PeerFilter type.
    • Cursor overloads resolve correctly.

7.4 Backward Compatibility Verification

  • All existing SDK tests must pass without modification.
  • All existing SDK examples (sdks/python/examples/, sdks/typescript/examples/) must continue to work.
  • No changes to pyproject.toml or package.json dependencies (Pydantic and Zod are already dependencies).

8. Open Questions

  1. Separate method vs. overload for cursor pagination: Should cursor pagination use a separate method name (e.g., peers_cursor()) instead of an overload on peers(cursor=...)? The overload approach is more Pythonic and matches common SDK patterns (e.g., OpenAI SDK), but introduces a union return type. A separate method is more explicit but doubles the API surface.

  2. Cursor initialization: What value should a client pass to get the first cursor page? Options:

    • Empty string cursor="" — simple but feels like a sentinel.
    • Dedicated constant: cursor=FIRST_PAGE — explicit but requires importing a constant.
    • Omit cursor but pass a pagination="cursor" flag — more explicit about mode but adds another parameter.
  3. Server-side total in cursor mode: Should the server still compute total in cursor mode (expensive) or return -1/null? Current design says -1 (skip the COUNT query). If users need total counts alongside cursor pagination, they can make a separate count request.

  4. Validation strictness per-call vs. per-client: Current design supports both (strict_filters on client constructor, and validate_filter(strict=True) as a standalone function). Is per-call strictness worth the API complexity, or should it be client-level only?

  5. Forward compatibility of filter TypedDicts: When the server adds a new filterable column (e.g., a hypothetical label column on messages), the SDK’s TypedDict will not include it. Users would need to fall back to raw dict until the SDK is updated. Is this acceptable friction, or should we add an escape hatch like extra_filters: dict[str, object]?

  6. Cursor page __iter__ behavior: In the current design, SyncCursorPage.__iter__ yields the current page only (unlike SyncPage.__iter__ which auto-fetches all pages). This is intentional — cursor pages encourage explicit page management. But it is a behavioral inconsistency between the two page types. Should SyncCursorPage.__iter__ also auto-fetch for consistency, with a separate iter_page() for current-page-only iteration?