Claude-skill-registry api-envelope-pattern
Standard API envelope pattern for all responses (success/list/error). Wraps data in consistent structure with sys metadata, automatic payload compaction (30-40% reduction), timestamp injection. Triggers on "formatEntity", "formatEntityList", "formatErrorEntity", "API", "route handler", "dal".
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/api-envelope-pattern" ~/.claude/skills/majiayu000-claude-skill-registry-api-envelope-pattern && rm -rf "$T"
skills/data/api-envelope-pattern/SKILL.mdAPI Envelope Pattern
ALL API responses MUST use standard envelope pattern. NEVER return raw Convex docs or plain data. Consistent structure enables mobile clients, monitoring, caching.
Standard Envelope Structure
Every response wrapped in
ApiResponse<T>:
// Success (single entity) { status: "success", sys: { entity: "conversation", id: "j97...", timestamps?: { created: "2024-01-15T10:30:00.000Z", updated: "2024-01-15T12:45:00.000Z", retrieved: "2024-01-15T13:00:00.000Z" } }, data: { ... } // Compacted data (nulls/empty removed) } // List response { status: "success", sys: { entity: "list" }, data: [ { sys: { entity: "conversation", id: "j97..." }, data: { ... } } ] } // Error response { status: "error", sys: { entity: "error" }, error: "Resource not found" // or { message, code, details } }
Entity Formatters
Use these helpers from
@/lib/utils/formatEntity:
formatEntity (Single Item)
// From convex/api/dal/conversations.ts return formatEntity(conversation, "conversation", conversation._id); // Auto-injects timestamps from Convex _creationTime + updatedAt // Compacts data (removes null/undefined/empty)
formatEntityList (Arrays)
// From convex/api/dal/conversations.ts return conversations.map((conversation) => formatEntity(conversation, "conversation", conversation._id) ); // Each item wrapped with sys metadata // Lists use sys.entity = "list" at top level
formatErrorEntity (Errors)
// String error return NextResponse.json(formatErrorEntity("Verse not found"), { status: 404 }); // Structured error (from error middleware) return NextResponse.json( formatErrorEntity({ message: error.message, code: error.code, }), { status: error.statusCode } ); // With details (e.g., Zod validation) formatErrorEntity({ message: "Validation failed", code: "VALIDATION_ERROR", details: zodError.issues, })
Compact Utility (30-40% Payload Reduction)
Automatically strips null/undefined/empty values:
// From lib/utils/payload.ts compact({ a: 1, b: null, c: undefined, d: "", e: [], f: { nested: null } }) // Returns: { a: 1 } // Applied automatically in formatEntity/formatEntityList // Recursive for nested objects and arrays
What gets removed:
andnull
valuesundefined- Empty strings (
)"" - Empty arrays (
)[] - Empty objects after compaction (
){}
Timestamp Injection
Automatic timestamp extraction from Convex:
// formatEntity reads: // - data._creationTime → sys.timestamps.created // - data.updatedAt → sys.timestamps.updated // - new Date() → sys.timestamps.retrieved // Example output { sys: { entity: "conversation", id: "j97...", timestamps: { created: "2024-01-15T10:30:00.000Z", updated: "2024-01-15T12:45:00.000Z", retrieved: "2024-01-15T13:00:00.000Z" } } }
DAL Layer Pattern
Data Access Layer (DAL) functions MUST return formatted entities:
// From lib/api/dal/conversations.ts export const conversationsDAL = { create: async (_userId: string, data: CreateData) => { const conversationId = await convex.mutation(api.conversations.create, data); const conversation = await convex.query(api.conversations.get, { conversationId }); // ALWAYS format before returning return formatEntity(conversation, "conversation", conversation._id); }, list: async (_userId: string, limit = 50, archived = false) => { const conversations = await convex.query(api.conversations.list, { limit, archived }); // Map each item through formatEntity return conversations.map((c) => formatEntity(c, "conversation", c._id) ); }, delete: async (userId: string, conversationId: string, sessionToken: string) => { await authConvex.mutation(api.conversations.deleteConversation, { conversationId }); // Even for operations without response data return formatEntity( { deleted: true, conversationId }, "conversation", conversationId ); }, };
Route Handler Pattern
API routes wrap DAL responses:
// From app/api/v1/conversations/route.ts async function postHandler(req: NextRequest, { userId }: { userId: string }) { const body = await parseBody(req, createSchema); const result = await conversationsDAL.create(userId, body); // DAL already formatted - return directly return NextResponse.json(result, { status: 201 }); } async function getHandler(req: NextRequest, { userId }: { userId: string }) { const conversations = await conversationsDAL.list(userId, limit, archived); // Wrap list in top-level envelope return NextResponse.json( formatEntity( { items: conversations, total: conversations.length }, "list", ), { headers: { "Cache-Control": cacheControl } } ); } export const POST = withErrorHandling(withAuth(postHandler)); export const GET = withErrorHandling(withAuth(getHandler));
Error Handling Middleware
withErrorHandling automatically wraps errors:
// From lib/api/middleware/errors.ts export function withErrorHandling(handler) { return async (req, context) => { try { return await handler(req, context); } catch (error) { // ApiError - custom errors if (error instanceof ApiError) { return NextResponse.json( formatErrorEntity({ message: error.message, code: error.code }), { status: error.statusCode } ); } // Zod validation errors if (error instanceof z.ZodError) { return NextResponse.json( formatErrorEntity({ message: "Validation failed", code: "VALIDATION_ERROR", details: error.issues, }), { status: 400 } ); } // Pattern matching Convex errors if (error.message.includes("not found")) { return NextResponse.json(formatErrorEntity("Resource not found"), { status: 404, }); } // Fallback return NextResponse.json(formatErrorEntity("Internal server error"), { status: 500, }); } }; }
Frontend Unwrapping
Clients MUST unwrap
.data property:
// From lib/api/client.ts async function fetchWithAuth<T>(url, options, getToken) { const response = await fetch(url, options); const data: ApiResponse<T> = await response.json(); if (!response.ok || data.status === "error") { const msg = typeof data.error === "string" ? data.error : data.error?.message; const code = typeof data.error === "object" ? data.error?.code : undefined; throw new ApiClientError(response.status, code, msg); } // CRITICAL: Unwrap envelope before returning return data.data as T; } // Usage in components const client = useApiClient(); const conversation = await client.get<Conversation>("/api/v1/conversations/123"); // conversation is unwrapped - direct access to fields
Type Definitions
// From lib/api/types.ts export type ApiResponse<T> = { status: "success" | "error"; sys: { entity: string; id?: string; timestamps?: { created?: string; updated?: string; retrieved?: string; }; async?: boolean; }; data?: T; error?: string | { message: string; code?: string; details?: unknown }; }; export type EntityListItem<T> = { sys: { entity: string; id?: string; }; data: T; };
Key Files
- Core formattersapps/web/src/lib/utils/formatEntity.ts
- compact() utilityapps/web/src/lib/utils/payload.ts
- TypeScript typesapps/web/src/lib/api/types.ts
- Auto-wrapping errorsapps/web/src/lib/api/middleware/errors.ts
- Frontend unwrappingapps/web/src/lib/api/client.ts
- DAL examplesapps/web/src/lib/api/dal/*.ts
- Route handler examplesapps/web/src/app/api/v1/*/route.ts
Common Mistakes
❌ Returning raw Convex documents:
// WRONG return NextResponse.json(conversation);
✅ Always use formatEntity:
// CORRECT return NextResponse.json(formatEntity(conversation, "conversation", conversation._id));
❌ Forgetting to unwrap on frontend:
// WRONG - accessing envelope directly const title = response.data.title; // undefined!
✅ Use client helper that unwraps:
// CORRECT const conversation = await client.get<Conversation>("/api/v1/conversations/123"); const title = conversation.title; // works!
❌ Manually creating envelope structure:
// WRONG - bypasses compaction and timestamp logic return { status: "success", sys: { entity: "user" }, data: user };
✅ Always use formatter functions:
// CORRECT return formatEntity(user, "user", user._id);
❌ Not using error formatter:
// WRONG return NextResponse.json({ error: "Not found" }, { status: 404 });
✅ Use formatErrorEntity:
// CORRECT return NextResponse.json(formatErrorEntity("Not found"), { status: 404 });
Benefits
- Consistency: All endpoints same structure
- Mobile-friendly: REST clients expect envelopes
- Monitoring: sys metadata enables tracking
- Caching: Timestamps enable ETags/Last-Modified
- Performance: 30-40% smaller payloads via compact()
- Type safety: Single ApiResponse<T> type
- Error handling: Structured error format
- Debugging: entity/id in every response
Mobile API Integration
Mobile clients (React Native) use REST endpoints, not Convex SDK. Envelope pattern critical for:
- SSE streaming (parseable chunks)
- React Query caching (needs metadata)
- Offline queue (structured errors)
- Request deduplication (sys.id)
See
docs/api/mobile-integration.md for full mobile patterns.