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-sideFilterError. - TypeScript SDK: The
FilterSchemainvalidation.tsisz.record(z.string(), z.unknown()).optional()— effectively untyped. TheFilterstype resolves toRecord<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()— nopage, nosize, noreverseparam. 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
totalrequires aCOUNT(*)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
- Typed filter dictionaries with IDE autocomplete in both SDKs, while preserving full backward compatibility with raw
dict/Recordusage. - 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. - Cursor-based pagination as an opt-in alternative alongside the existing offset pagination, requiring no breaking changes to existing code.
- Client-side filter validation that catches common mistakes (invalid column names, invalid operators, wrong types for comparison operators) before the request is sent.
- 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_paginationintegration, 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
- Raw
dict[str, object]/Record<string, unknown>must continue to work everywhere filters are accepted. Typed filters are an overlay, not a replacement. - Validation is advisory by default: malformed filters produce warnings (Python
warnings.warn, TypeScriptconsole.warn), not exceptions. A strict mode opt-in raises/throws. - 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 Name | Internal Column | Resources |
|---|---|---|
id | name | Peer, Session, Workspace |
created_at | created_at | Peer, Session, Workspace, Message |
is_active | is_active | Peer, Session, Workspace |
workspace_id | workspace_name | Peer, Session, Message, Document |
session_id | session_name | Peer, Session, Message, Document |
peer_id | peer_name | Peer, Session, Message |
metadata | h_metadata | Peer, Session, Workspace, Message |
token_count | token_count | Message |
observer_id | observer | Document (Conclusion) |
observed_id | observed | Document (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_listIntegration 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 filters3.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
| Scenario | Default behavior | Strict mode behavior |
|---|---|---|
| Valid filter | Pass through | Pass through |
| Unknown column name | warn() + pass through | Raise/throw |
| Invalid comparison operator | warn() + pass through | Raise/throw |
AND/OR/NOT with non-list | warn() + pass through | Raise/throw |
| Nesting depth > 5 | warn() + pass through | Raise/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 orderingOnly 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.py — peers(), 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.py — messages():
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):
| Class | Method | Current params | Add |
|---|---|---|---|
Honcho (client.py) | peers() | filters | page, size, reverse |
Honcho (client.py) | sessions() | filters | page, size, reverse |
Peer (peer.py) | sessions() | filters | page, size, reverse |
Session (session.py) | messages() | filters | page, size, reverse |
ConclusionScope (conclusions.py) | list() | page, size, session | reverse (page/size already exposed) |
AsyncHoncho (aio.py) | peers() | filters | page, size, reverse |
AsyncHoncho (aio.py) | sessions() | filters | page, size, reverse |
AsyncPeer (aio.py) | sessions() | filters | page, size, reverse |
AsyncSession (aio.py) | messages() | filters | page, size, reverse |
AsyncConclusionScope (aio.py) | list() | page, size, session | reverse |
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:
| Class | Method | Add |
|---|---|---|
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 pagesize=50— same as today’s server defaultreverse=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):
| Parameter | Type | Default | Description |
|---|---|---|---|
page | int (existing) | 1 | Page number for offset pagination. Ignored if cursor is set. |
size | int (existing) | 50 | Items per page. Used by both offset and cursor modes. |
cursor | str (new) | undefined | Opaque cursor string. If present, enables cursor mode. |
direction | str (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: Thecreated_attimestamp of the boundary item.id: The primary key of the boundary item (for tie-breaking when multiple items share the samecreated_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
| Aspect | Offset mode (existing) | Cursor mode (new) |
|---|---|---|
| Activation | Default (no cursor param) | Pass cursor query param |
| First page | page=1 | cursor="" or omit cursor |
| Next page | page=N+1 | cursor=response.next_cursor |
| Previous page | page=N-1 | cursor=response.prev_cursor |
| Total count | total field (computed) | Not available (total=-1) |
| Page count | pages field (computed) | Not available (pages=-1) |
| Consistency under mutation | Items may be skipped/duped | Stable (keyset-based) |
__iter__ auto-fetches all | Yes | Only via iter_all() (Python) or [Symbol.asyncIterator] (TS) |
| Random page access | Yes (page=N) | No (sequential only) |
3.4 Other SDK Quality Improvements
3.4.1 Python SDK
-
__all__exports: Add or update__all__in__init__.pyto export all public types including the new filter types, cursor page types. -
Deprecation helpers: Add a
_deprecateddecorator utility that emitsDeprecationWarningwith a standardized message format including the target removal version. -
Filter validation error type: Add
FilterValidationError(ValueError)to provide structured error information (field name, operator, reason) rather than a plain string. -
Type narrowing for pagination: The
SyncPage.__iter__currently returnsIterator[U] | Iterator[T]. This should be narrowed: whentransform_funcisNone, items areT; whentransform_funcis set, items areU. Use@overloadon__init__to achieve this. -
py.typedmarker: Ensuresdks/python/src/honcho/py.typedexists so that downstream projects recognize inline types.
3.4.2 TypeScript SDK
-
JSDoc completeness: Add JSDoc to all private API methods in
client.ts,peer.ts,session.ts,conclusions.ts. -
Export map: Ensure
package.jsonexportsfield exposes the new filter types and cursor pagination types. -
Strict filter mode in constructor: Add optional
strictFiltersboolean toHonchoConfigSchema.
export const HonchoConfigSchema = z.object({
// ... existing fields ...
strictFilters: z.boolean().optional(),
})- Error class for filter validation: Add
FilterValidationError extends Errorwith structured fields (column,operator,message).
4. Implementation Phases
Phase 1: Filter Type Definitions (Python + TypeScript)
Estimated effort: 2-3 days
- Create
sdks/python/src/honcho/filters.pywith all TypedDict definitions and thevalidate_filter()function. - Extend
sdks/typescript/src/validation.tswith per-resource filter schemas. - 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)
- Python:
- Add
strict_filters/strictFiltersoption to client constructors. - Add
FilterValidationErrorclass to both SDKs. - Update
__init__.py/package.jsonexports.
Phase 2: Offset Pagination Controls
Estimated effort: 1-2 days
- Add
page,size,reversekeyword 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()(addreverseonly) - Plus all async mirrors in
aio.py - TypeScript:
client.ts:peers(),client.ts:sessions(),peer.ts:sessions(),session.ts:messages(),conclusions.ts:list()
- Python:
- Pass
page,size, andreverseas query parameters in both initial requests andfetch_nextclosures. - Ensure
fetch_nextpreserves the caller’ssizeandreversesettings (currentlyfetch_nextonly passespage). - Write unit tests verifying that
page,size, andreverseare correctly passed as query params. - Write integration tests verifying pagination with explicit page sizes.
Phase 3: Cursor-Based Pagination (SDK Layer)
Estimated effort: 3-4 days
- Add
SyncCursorPageandAsyncCursorPagetosdks/python/src/honcho/pagination.py. - Add
CursorPageandCursorPageResponsetosdks/typescript/src/pagination.tsandtypes/api.ts. - Add
cursorparameter overloads to all list methods in both SDKs:Honcho.peers(),Honcho.sessions(),Honcho.workspaces()Peer.sessions()Session.messages()ConclusionScope.list()- Plus all async mirrors
- 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
- Python: Add
__all__to all modules, addpy.typed, add_deprecateddecorator. - TypeScript: Complete JSDoc coverage, update export map.
- Both: Add integration tests that verify filter validation warnings/errors.
Phase 5: Documentation
Estimated effort: 1 day
- Add filter examples to SDK README / docs site.
- Document cursor pagination usage patterns.
- Add migration guide showing the non-breaking nature of the changes.
5. Files to Modify (Exact Paths)
Python SDK
| File | Change |
|---|---|
sdks/python/src/honcho/filters.py | NEW — TypedDict filter definitions, validation function |
sdks/python/src/honcho/pagination.py | Add SyncCursorPage, AsyncCursorPage classes |
sdks/python/src/honcho/client.py | Update peers(), sessions(), workspaces() signatures + cursor overloads; add strict_filters init param |
sdks/python/src/honcho/peer.py | Update sessions(), search() filter type signatures + cursor overload on sessions() |
sdks/python/src/honcho/session.py | Update messages(), search() filter type signatures + cursor overload on messages() |
sdks/python/src/honcho/conclusions.py | Update ConclusionScope.list() filter handling + cursor overload |
sdks/python/src/honcho/aio.py | Mirror all changes from sync classes for HonchoAio, PeerAio, SessionAio, ConclusionScopeAio |
sdks/python/src/honcho/__init__.py | Export new types: filter TypedDicts, cursor page classes, FilterValidationError |
sdks/python/src/honcho/api_types.py | Add CursorPageResponse model |
sdks/python/src/honcho/py.typed | NEW — empty marker file for PEP 561 |
TypeScript SDK
| File | Change |
|---|---|
sdks/typescript/src/validation.ts | Add per-resource filter schemas, ComparisonFilterSchema, strictFilters config option |
sdks/typescript/src/pagination.ts | Add CursorPage class, CursorFetcher type |
sdks/typescript/src/types/api.ts | Add CursorPageResponse<T> interface |
sdks/typescript/src/client.ts | Update peers(), sessions(), workspaces() with filter types + cursor overloads |
sdks/typescript/src/peer.ts | Update sessions(), search() with filter types + cursor overload on sessions() |
sdks/typescript/src/session.ts | Update messages(), search() with filter types + cursor overload on messages() |
sdks/typescript/src/conclusions.ts | Update ConclusionScope.list() with cursor overload |
sdks/typescript/src/index.ts | Export new types |
sdks/typescript/package.json | Update exports map if needed |
Server (API Contract Only — Not Full Implementation)
| File | Change |
|---|---|
src/schemas.py | Add optional cursor fields to paginated response schema |
src/routers/peers.py | Accept optional cursor and direction query params on list endpoint |
src/routers/sessions.py | Same |
src/routers/messages.py | Same |
src/routers/conclusions.py | Same |
src/routers/workspaces.py | Same |
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 | SyncCursorPagebased on a runtime flag are harder to type correctly. The@overloadapproach 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.
- Mitigation: Provide clear documentation and consider a separate method name (e.g.,
-
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 returnedtest_validate_filter_unknown_column— warning returned, no exceptiontest_validate_filter_strict_unknown_column— ValueError raisedtest_validate_filter_invalid_logical_operator_value— warning for non-list ANDtest_validate_filter_nesting_depth_exceeded— warning at depth 6test_validate_filter_comparison_operators— all operators acceptedtest_validate_filter_wildcard— ”*” accepted without warningtest_validate_filter_metadata_nested— nested metadata filter accepted
-
tests/test_pagination_cursor.py:test_sync_cursor_page_items— items property returns transformed itemstest_sync_cursor_page_iter—__iter__yields current page onlytest_sync_cursor_page_iter_all—iter_all()follows next pagestest_sync_cursor_page_no_next—get_next_page()returns None whenhas_next=falsetest_sync_cursor_page_prev— backward navigation workstest_async_cursor_page_aiter— async iteration workstest_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 passestest_peer_filter_schema_rejects_unknown_column— unknown column fails in stricttest_message_filter_schema_token_count— numeric filter on token_count
- Same test matrix as Python but using Zod
-
__tests__/pagination-cursor.test.ts:test_cursor_page_items— items getter workstest_cursor_page_async_iterator— for-await-of yields all pagestest_cursor_page_to_array— toArray collects everythingtest_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
basedpyrightacross 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")returnsSyncCursorPage,honcho.peers()returnsSyncPage.
-
TypeScript: Run
tsc --noEmitacross the SDK. Verify that:honcho.peers({ id: "alice" })compiles.honcho.peers({ invalidColumn: "x" })produces a type error when usingPeerFiltertype.- 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.tomlorpackage.jsondependencies (Pydantic and Zod are already dependencies).
8. Open Questions
-
Separate method vs. overload for cursor pagination: Should cursor pagination use a separate method name (e.g.,
peers_cursor()) instead of an overload onpeers(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. -
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.
- Empty string
-
Server-side
totalin cursor mode: Should the server still computetotalin 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. -
Validation strictness per-call vs. per-client: Current design supports both (
strict_filterson client constructor, andvalidate_filter(strict=True)as a standalone function). Is per-call strictness worth the API complexity, or should it be client-level only? -
Forward compatibility of filter TypedDicts: When the server adds a new filterable column (e.g., a hypothetical
labelcolumn on messages), the SDK’s TypedDict will not include it. Users would need to fall back to rawdictuntil the SDK is updated. Is this acceptable friction, or should we add an escape hatch likeextra_filters: dict[str, object]? -
Cursor page
__iter__behavior: In the current design,SyncCursorPage.__iter__yields the current page only (unlikeSyncPage.__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. ShouldSyncCursorPage.__iter__also auto-fetch for consistency, with a separateiter_page()for current-page-only iteration?