---
title: "Api Design"
description: "API contract design for REST and GraphQL, covering resource shape, URL and header versioning with deprecation windows, RFC 9457 Problem Details error handling, and OpenAPI specs. Use when specifying the wire contract an endpoint exposes, choosing a versioning scheme, or standardizing error response bodies across services. Framework-agnostic protocol layer, not runtime implementation."
canonical: "https://orchestkit.yonyon.ai/docs/reference/skills/api-design"
---

# Api Design

API contract design for REST and GraphQL, covering resource shape, URL and header versioning with deprecation windows, RFC 9457 Problem Details error handling, and OpenAPI specs. Use when specifying the wire contract an endpoint exposes, choosing a versioning scheme, or standardizing error response bodies across services. Framework-agnostic protocol layer, not runtime implementation.

<span className="badge badge-gray">Reference</span> <span className="badge badge-yellow">medium</span>

> **Auto-activated** — this skill loads automatically when Claude detects matching context.

<ContextualSkillSidebar slug="api-design" />

> **Api Design** API contract design for REST and GraphQL, covering resource shape, URL and header versioning with deprecation windows, RFC 9457 Problem Details error handling, and OpenAPI specs. Use when specifying the wire contract an endpoint exposes, choosing a versioning scheme, or standardizing error response bodies across services. Framework-agnostic protocol layer, not runtime implementation.


# API Design

Comprehensive API design patterns covering REST/GraphQL framework design, versioning strategies, and RFC 9457 error handling. Each category has individual rule files in `rules/` loaded on-demand.

## Quick Reference

| Category | Rules | Impact | When to Use |
|----------|-------|--------|-------------|
| [API Framework](#api-framework) | 3 | HIGH | REST conventions, resource modeling, OpenAPI specifications |
| [Versioning](#versioning) | 2 | HIGH | URL path versioning, header versioning; deprecation windows are house policy in `references/ork-delta.md` |
| [Error Handling](#error-handling) | 1 | HIGH | Agent-facing RFC 9457 extensions; base spec and FastAPI wiring are upstream |
| [GraphQL](#graphql) | 2 | HIGH | Strawberry code-first, DataLoader, permissions, subscriptions |
| [gRPC](#grpc) | 2 | HIGH | Protobuf services, streaming, interceptors, retry |
| [Streaming](#streaming) | 2 | HIGH | SSE endpoints, WebSocket bidirectional, async generators |
| [Integrations](#integrations) | 2 | HIGH | Messaging platforms (WhatsApp, Telegram), Payload CMS patterns |

**Total: 14 rules across 7 categories.** House decisions rescued from thinned files live in `references/ork-delta.md`; vendor and spec material is linked, not restated (see [Upstream coverage](#upstream-coverage-do-not-restate)).

## API Framework

REST and GraphQL API design conventions for consistent, developer-friendly APIs.

| Rule | File | Key Pattern |
|------|------|-------------|
| REST Conventions | `rules/framework-rest-conventions.md` | Plural nouns, HTTP methods, status codes, pagination |
| Resource Modeling | `rules/framework-resource-modeling.md` | Hierarchical URLs, filtering, sorting, field selection |
| OpenAPI | `rules/framework-openapi.md` | OpenAPI 3.1 specs, documentation, schema definitions |

## Versioning

Strategies for API evolution without breaking clients.

| Rule | File | Key Pattern |
|------|------|-------------|
| URL Path | `rules/versioning-url-path.md` | `/api/v1/` prefix routing, version-specific schemas |
| Header | `rules/versioning-header.md` | `X-API-Version` header, content negotiation |

Deprecation and sunset: the house window (3 months notice, 6 months sunset, current + 1 supported) is in `references/ork-delta.md`; header mechanics are upstream (RFC 8594, RFC 9745).

## Error Handling

RFC 9457 Problem Details for machine-readable, standardized error responses.

| Rule | File | Key Pattern |
|------|------|-------------|
| Agent-Facing Errors | `rules/errors-agent-facing.md` | Agent extensions: `retryable`, `error_category`, content negotiation, token efficiency |

The RFC 9457 base format, FastAPI exception-handler wiring, and Pydantic 422 mapping are upstream (see [Upstream coverage](#upstream-coverage-do-not-restate)). The house pieces survive here: problem type URI convention and typed exception vocabulary in `references/ork-delta.md`, full working implementation in `examples/fastapi-problem-details.md`.

## GraphQL

Strawberry GraphQL code-first schema with type-safe resolvers and FastAPI integration.

| Rule | File | Key Pattern |
|------|------|-------------|
| Schema Design | `rules/graphql-strawberry.md` | Type-safe schema, DataLoader, union errors, Private fields |
| Patterns & Auth | `rules/graphql-schema.md` | Permission classes, FastAPI integration, subscriptions |

## gRPC

High-performance gRPC for internal microservice communication.

| Rule | File | Key Pattern |
|------|------|-------------|
| Service Definition | `rules/grpc-service.md` | Protobuf, async server, client timeout, code generation |
| Streaming & Interceptors | `rules/grpc-streaming.md` | Server/bidirectional streaming, auth, retry backoff |

## Streaming

Real-time data streaming with SSE, WebSockets, and proper cleanup.

| Rule | File | Key Pattern |
|------|------|-------------|
| SSE | `rules/streaming-sse.md` | SSE endpoints, LLM streaming, reconnection, keepalive |
| WebSocket | `rules/streaming-websocket.md` | Bidirectional, heartbeat, aclosing(), backpressure |

## Integrations

Messaging platform integrations and headless CMS patterns.

| Rule | File | Key Pattern |
|------|------|-------------|
| Messaging Platforms | `rules/messaging-integrations.md` | WhatsApp WAHA, Telegram Bot API, webhook security |
| Payload CMS | `rules/payload-cms.md` | Payload 3.0 collections, access control, CMS selection |

## Quick Start Example

```python
# REST endpoint with versioning and RFC 9457 errors
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse

router = APIRouter()

@router.get("/api/v1/users/{user_id}")
async def get_user(user_id: str, service: UserService = Depends()):
    user = await service.get_user(user_id)
    if not user:
        raise NotFoundProblem(
            resource="User",
            resource_id=user_id,
        )
    return UserResponseV1(id=user.id, name=user.full_name)
```

## Key Decisions

| Decision | Recommendation |
|----------|----------------|
| Versioning strategy | URL path (`/api/v1/`) for public APIs |
| Resource naming | Plural nouns, kebab-case |
| Pagination | Cursor-based for large datasets |
| Error format | RFC 9457 Problem Details with `application/problem+json` |
| Error type URI | Your API domain + `/problems/` prefix |
| Support window | Current + 1 previous version |
| Deprecation notice | 3 months minimum before sunset |
| Sunset period | 6 months after deprecation |
| GraphQL schema | Code-first with Strawberry types |
| N+1 prevention | DataLoader for all nested resolvers |
| GraphQL auth | Permission classes (context-based) |
| gRPC proto | One service per file, shared common.proto |
| gRPC streaming | Server stream for lists, bidirectional for real-time |
| SSE keepalive | Every 30 seconds |
| WebSocket heartbeat | ping-pong every 30 seconds |
| Async generator cleanup | aclosing() for all external resources |

## Common Mistakes

1. Verbs in URLs (`POST /createUser` instead of `POST /users`)
2. Inconsistent error formats across endpoints
3. Breaking contracts without version bump
4. Plain text error responses instead of Problem Details
5. Sunsetting versions without deprecation headers
6. Exposing internal details (stack traces, DB errors) in errors
7. Missing `Content-Type: application/problem+json` on error responses
8. Supporting too many concurrent API versions (max 2-3)
9. Caching without considering version isolation

## Upstream coverage (do not restate)

Topics removed in the 2026-07-31 wrap-plus-delta thinning. Consult the first-party source; only the ork delta (house policy, scars, working config) belongs in this skill.

| Topic | First-party source |
|-------|--------------------|
| RFC 9457 Problem Details spec (members, media type, `about:blank`, client parsing) | https://www.rfc-editor.org/rfc/rfc9457.html |
| FastAPI exception handlers, Pydantic validation errors (422), error catalog boilerplate | https://fastapi.tiangolo.com/tutorial/handling-errors/ |
| API versioning strategy tutorials and FastAPI versioned-router walkthroughs | https://fastapi.tiangolo.com/tutorial/bigger-applications/ |
| Deprecation and Sunset header mechanics | https://www.rfc-editor.org/rfc/rfc8594.html and https://www.rfc-editor.org/rfc/rfc9745.html |
| Generic REST reference (methods, status codes, pagination shapes, auth headers) | https://www.rfc-editor.org/rfc/rfc9110.html and https://developer.mozilla.org/en-US/docs/Web/HTTP |
| OpenAPI 3.1 spec authoring (template survives in `assets/openapi-template.yaml`) | https://spec.openapis.org/oas/v3.1.0 |
| gRPC proto style, service definition, status codes | https://grpc.io/docs/ and https://protobuf.dev/programming-guides/style/ |
| Payload CMS collection design, field types, access control | https://payloadcms.com/docs |
| Frontend API consumption (Zod boundary validation, ky, TanStack Query) | https://zod.dev and https://tanstack.com/query/latest/docs |
| API design / error handling / versioning review checklists | Derivable from the specs above; no checklist restatement kept |

## Evaluations

See `test-cases.json` for 13 test cases across all categories.

## Related Skills

- `fastapi-advanced` - FastAPI-specific implementation patterns
- `rate-limiting` - Advanced rate limiting implementations and algorithms
- `observability-monitoring` - Version usage metrics and error tracking
- `input-validation` - Validation patterns beyond API error handling
- `streaming-api-patterns` - SSE and WebSocket patterns for real-time APIs

## Capability Details

### rest-design
**Keywords:** rest, restful, http, endpoint, route, path, resource, CRUD
**Solves:**
- How do I design RESTful APIs?
- REST endpoint patterns and conventions
- HTTP methods and status codes

### graphql-design
**Keywords:** graphql, schema, query, mutation, connection, relay
**Solves:**
- How do I design GraphQL APIs?
- Schema design best practices
- Connection pattern for pagination

### endpoint-design
**Keywords:** endpoint, route, path, resource, CRUD, openapi
**Solves:**
- How do I structure API endpoints?
- What's the best URL pattern for this resource?
- RESTful endpoint naming conventions

### url-versioning
**Keywords:** url version, path version, /v1/, /v2/
**Solves:**
- How to version REST APIs?
- URL-based API versioning

### header-versioning
**Keywords:** header version, X-API-Version, content negotiation
**Solves:**
- Clean URL versioning
- Header-based API version

### deprecation
**Keywords:** deprecation, sunset, version lifecycle, backward compatible
**Solves:**
- How to deprecate API versions?
- Version sunset policy
- Breaking vs non-breaking changes

### problem-details
**Keywords:** problem details, RFC 9457, RFC 7807, structured error, application/problem+json
**Solves:**
- How to standardize API error responses?
- What format for API errors?

### agent-facing-errors
**Keywords:** agent error, AI agent, retryable, retry_after, error_category, content negotiation, accept header, token efficient, machine readable
**Solves:**
- How to design error responses for AI agent consumers?
- How to reduce token cost of error responses?
- How to enable deterministic agent error handling?
- Content negotiation for agents vs browsers vs LLMs

### validation-errors
**Keywords:** validation, field error, 422, unprocessable, pydantic
**Solves:**
- How to handle validation errors in APIs?
- Field-level error responses

### error-registry
**Keywords:** error registry, problem types, error catalog, error codes
**Solves:**
- How to document all API errors?
- Error type management


---

## Rules (14)

### Design agent-facing error responses with RFC 9457 + operational extensions for deterministic AI agent control flow — HIGH


## Agent-Facing Error Responses

Extend RFC 9457 Problem Details with agent-specific operational fields that enable deterministic error handling without LLM reasoning.

Provenance: OrchestKit house guidance, not vendor restatement. Landed via #1067 (v7.6.0, 2026-03-15) and maintained for src/skills/api-design; no first-party skill or spec covers agent-facing RFC 9457 extensions.

**Why this matters:** A standard HTML error page costs ~14,000 tokens. A structured RFC 9457 response costs ~250 tokens — a 98% reduction. More importantly, explicit `retryable` and `error_category` fields let agents branch deterministically instead of guessing from status codes.

### Agent Extension Fields

Add these operational fields alongside standard RFC 9457 members:

```python
from pydantic import BaseModel, Field
from enum import StrEnum
from typing import Any

class ErrorCategory(StrEnum):
    ACCESS_DENIED = "access_denied"         # 401/403 — don't retry
    RATE_LIMIT = "rate_limit"               # 429 — wait and retry
    NOT_FOUND = "not_found"                 # 404 — don't retry
    VALIDATION = "validation"               # 422 — fix input, don't retry
    CONFIG = "config"                       # Misconfiguration — escalate
    TIMEOUT = "timeout"                     # 408/504 — retry with backoff
    SERVER_ERROR = "server_error"           # 500 — retry cautiously
    QUOTA = "quota"                         # Plan/usage limit — escalate
    DEPENDENCY = "dependency"               # Upstream failure — retry
    UNSUPPORTED = "unsupported"             # Method/feature — don't retry

class AgentProblemDetail(BaseModel):
    """RFC 9457 + agent-facing operational extensions."""
    # Standard RFC 9457
    type: str = Field(description="URI identifying the problem type")
    title: str = Field(description="Short human-readable summary")
    status: int = Field(ge=400, le=599)
    detail: str | None = None
    instance: str | None = None

    # Agent operational extensions
    error_category: ErrorCategory
    retryable: bool = Field(description="Explicit: can retry succeed?")
    retry_after: int | None = Field(
        default=None,
        description="Seconds to wait before retrying",
    )
    owner_action_required: bool = Field(
        default=False,
        description="Whether a human must intervene",
    )
    what_you_should_do: str | None = Field(
        default=None,
        description="Agent-optimized guidance (< 50 words)",
    )

    model_config = {"extra": "allow"}
```

### Error Category → Agent Action Mapping

Design your categories so agents can branch without reasoning:

| Category | Retry? | Agent Action |
|----------|--------|-------------|
| `access_denied` | No | Log and escalate |
| `rate_limit` | Yes | Wait `retry_after` seconds, then retry |
| `not_found` | No | Report missing resource |
| `validation` | No | Fix input based on `errors` field |
| `config` | No | Escalate to owner |
| `timeout` | Yes | Retry with exponential backoff |
| `server_error` | Yes | Retry up to 3 times with backoff |
| `quota` | No | Escalate — plan upgrade needed |
| `dependency` | Yes | Retry with backoff |
| `unsupported` | No | Use alternative method/endpoint |

### Content Negotiation Middleware

Serve the same error identity in three formats via `Accept` header:

```python
from fastapi import Request
from fastapi.responses import JSONResponse, Response
import yaml

def negotiate_error_format(request: Request, problem: dict) -> Response:
    accept = request.headers.get("accept", "")

    if "application/problem+json" in accept or "application/json" in accept:
        return JSONResponse(
            status_code=problem["status"],
            content=problem,
            media_type="application/problem+json",
        )

    if "text/markdown" in accept:
        # LLM-optimized: YAML frontmatter + prose
        frontmatter = yaml.dump({
            k: v for k, v in problem.items()
            if k not in ("what_you_should_do", "detail")
        }, default_flow_style=False)
        body = f"""---
{frontmatter.strip()}
---

## What Happened

{problem.get("detail", problem["title"])}

## What You Should Do

{problem.get("what_you_should_do", "Contact support.")}
"""
        return Response(
            content=body,
            status_code=problem["status"],
            media_type="text/markdown",
        )

    # Default: HTML for browsers (existing error pages)
    return render_html_error(problem)
```

**Precedence rule:** First explicit structured type in Accept wins. Bare `*/*` defaults to HTML.

### Agent-Optimized Response Examples

**Rate limit (agent gets deterministic retry signal):**
```json
{
  "type": "https://api.example.com/problems/rate-limit-exceeded",
  "title": "Rate Limit Exceeded",
  "status": 429,
  "detail": "You have exceeded 100 requests per minute.",
  "error_category": "rate_limit",
  "retryable": true,
  "retry_after": 30,
  "owner_action_required": false,
  "what_you_should_do": "Wait 30 seconds, then retry with exponential backoff."
}
```

**Agent control flow (deterministic, no LLM reasoning needed):**
```python
async def handle_api_error(response: httpx.Response) -> str:
    if response.headers.get("content-type", "").startswith("application/problem"):
        error = response.json()
    else:
        return f"unstructured_error_{response.status_code}"

    if error.get("retryable"):
        wait = error.get("retry_after", 30)
        await asyncio.sleep(wait)
        return f"retry_after_{wait}s"

    if error.get("owner_action_required"):
        return f"escalate_{error.get('error_category')}"

    return f"stop_{error.get('error_category')}_{error.get('status')}"
```

### Token Efficiency Budget

| Format | Typical Size | Tokens | Use Case |
|--------|-------------|--------|----------|
| HTML error page | ~46 KB | ~14,000 | Browser rendering |
| JSON (problem+json) | ~500 B | ~250 | Agent control flow |
| Markdown (YAML + prose) | ~800 B | ~220 | LLM context window |

**Rule:** Agent-facing error responses MUST stay under 300 tokens. Omit HTML, CSS, and verbose prose.

### Anti-Patterns (FORBIDDEN)

```python
# NEVER return HTML to agents — wastes 14,000 tokens
return HTMLResponse("<h1>Error 429</h1><p>Too many requests</p>")

# NEVER omit retryable field — forces agent to guess
return {"type": "...", "status": 429, "detail": "Rate limited"}

# NEVER use ambiguous categories
return {"error_category": "error"}  # Useless — be specific

# NEVER put retry logic in prose only
return {"what_you_should_do": "Please wait 30 seconds and try again"}
# ↑ Missing retryable: true and retry_after: 30 — agent must parse prose
```

**Incorrect — status code only, agent must guess:**
```python
return JSONResponse({"error": "Too many requests"}, status_code=429)
```

**Correct — structured fields, agent branches deterministically:**
```python
return JSONResponse(
    content={
        "type": "https://api.example.com/problems/rate-limit-exceeded",
        "title": "Rate Limit Exceeded",
        "status": 429,
        "error_category": "rate_limit",
        "retryable": True,
        "retry_after": 30,
        "owner_action_required": False,
        "what_you_should_do": "Wait 30 seconds, then retry.",
    },
    status_code=429,
    media_type="application/problem+json",
)
```

### Key Rules

- Always include `error_category` and `retryable` on every error response
- Set `retry_after` (seconds) whenever `retryable` is true
- Set `owner_action_required` when the API consumer cannot self-resolve
- Keep `what_you_should_do` under 50 words — it enters the LLM context
- Use `Accept` header content negotiation: `application/problem+json` for agents, `text/markdown` for LLMs, `text/html` for browsers
- Design 8-15 error categories per domain — enough for deterministic branching, few enough to be learnable
- Agents should send `Accept: application/problem+json, */*` to signal structured error preference


### Keep OpenAPI specifications complete and up-to-date as the API provider-consumer contract — HIGH


## OpenAPI Specifications

Patterns for creating comprehensive OpenAPI 3.1 specifications with proper schema definitions, authentication, and error documentation.

**OpenAPI 3.1 Structure:**

```yaml
openapi: 3.1.0

info:
  title: Your API Name
  version: 1.0.0
  description: |
    Brief description of what this API does.

    ## Authentication
    This API uses Bearer tokens for authentication.

    ## Rate Limiting
    - 1000 requests per hour per API key

servers:
  - url: https://api.company.com/v1
    description: Production server
  - url: http://localhost:3000/v1
    description: Local development
```

**Endpoint Documentation with FastAPI:**

```python
@router.get(
    "/analyses/{analysis_id}",
    responses={
        404: {"model": ErrorResponse, "description": "Analysis not found"},
        500: {"model": ErrorResponse, "description": "Internal server error"},
    },
    summary="Get analysis details",
    description="Retrieve detailed information about a specific analysis",
)
async def get_analysis(
    analysis_id: Annotated[uuid.UUID, Path(description="Analysis UUID")]
) -> AnalysisResponse:
    ...
```

**Reusable Components:**

```yaml
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  parameters:
    PageParam:
      name: page
      in: query
      schema:
        type: integer
        minimum: 1
        default: 1

    PerPageParam:
      name: per_page
      in: query
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20

  headers:
    X-RateLimit-Limit:
      description: Maximum requests allowed per hour
      schema:
        type: integer
        example: 1000

    X-RateLimit-Remaining:
      description: Requests remaining in current window
      schema:
        type: integer

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              example: "VALIDATION_ERROR"
            message:
              type: string
            details:
              type: array
              items:
                type: object
                properties:
                  field:
                    type: string
                  message:
                    type: string
            request_id:
              type: string

  responses:
    NotFoundError:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

    ValidationError:
      description: Validation failed
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
```

**Per-Version OpenAPI Docs:**

```python
from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi

def custom_openapi_v1():
    return get_openapi(
        title="OrchestKit API",
        version="1.0.0",
        routes=v1_router.routes,
    )

def custom_openapi_v2():
    return get_openapi(
        title="OrchestKit API",
        version="2.0.0",
        routes=v2_router.routes,
    )

app.mount("/docs/v1", create_docs_app(custom_openapi_v1))
app.mount("/docs/v2", create_docs_app(custom_openapi_v2))
```

**Pydantic Schema Validation:**

```python
from pydantic import BaseModel, HttpUrl, Field

class AnalyzeRequest(BaseModel):
    url: HttpUrl
    analysis_id: str | None = None
    skill_level: str = Field(
        default="beginner",
        pattern="^(beginner|intermediate|advanced)$",
    )
```

**Incorrect — Missing response documentation:**
```python
# No response schema or error docs
@router.get("/analyses/{id}")
async def get_analysis(id: str):
    return await service.get(id)
```

**Correct — Full OpenAPI documentation:**
```python
@router.get(
    "/analyses/{id}",
    responses={
        404: {"model": ErrorResponse, "description": "Analysis not found"},
        500: {"model": ErrorResponse, "description": "Internal error"}
    },
    summary="Get analysis details"
)
async def get_analysis(id: Annotated[str, Path(description="Analysis UUID")]) -> AnalysisResponse:
    return await service.get(id)
```

**Key rules:**
- Use OpenAPI 3.1 for all new API specifications
- Define reusable schemas, parameters, and responses in `components`
- Document all error responses with examples
- Include security schemes and rate limit headers
- Generate per-version documentation when versioning


### Model REST resources with proper nesting and filters to minimize client round-trips — HIGH


## Resource Modeling

Patterns for modeling API resources with hierarchical relationships, filtering, sorting, and field selection.

**Hierarchical Relationships:**

```
# Express ownership and containment through URL hierarchy
GET /api/v1/analyses/{analysis_id}/artifact
GET /api/v1/teams/{team_id}/members
POST /api/v1/projects/{project_id}/tasks

# NOT query params for primary relationships
GET /api/v1/artifact?analysis_id={id}      # Avoid
GET /api/v1/analysis_artifact/{id}          # Avoid
```

**Query Parameter Filtering:**

```python
@router.get("/analyses")
async def list_analyses(
    status: str | None = None,
    content_type: str | None = None,
    created_after: datetime | None = None,
    created_before: datetime | None = None,
) -> list[AnalysisResponse]:
    filters = {}
    if status:
        filters["status"] = status
    if content_type:
        filters["content_type"] = content_type
    return await repo.find_all(filters=filters)
```

**Usage:**
```
GET /api/v1/analyses?status=completed&content_type=article
GET /api/v1/analyses?created_after=2025-01-01&created_before=2025-12-31
```

**Sorting:**

```python
@router.get("/analyses")
async def list_analyses(
    sort: str = Query(default="-created_at"),
) -> list[AnalysisResponse]:
    direction = "desc" if sort.startswith("-") else "asc"
    field = sort.lstrip("-")
    return await repo.find_all(order_by=field, direction=direction)
```

**Usage:**
```
GET /api/v1/analyses?sort=-created_at       # Newest first
GET /api/v1/analyses?sort=title             # Alphabetical
```

**Field Selection (Sparse Fieldsets):**

```python
@router.get("/analyses")
async def list_analyses(
    fields: str | None = None,
) -> list[dict[str, Any]]:
    selected_fields = fields.split(",") if fields else None
    results = await repo.find_all()

    if selected_fields:
        return [
            {k: v for k, v in item.model_dump().items() if k in selected_fields}
            for item in results
        ]
    return results
```

**Usage:**
```
GET /api/v1/analyses?fields=id,title,status
```

**GraphQL Connection Pattern (for GraphQL APIs):**

```graphql
type Query {
  users(first: Int, after: String): UserConnection!
}

type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type UserEdge {
  node: User!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}
```

**Best Practices:**

```python
# Empty collections: Return empty array, not null
{"data": []}   # Correct
{"data": null}  # Wrong

# Deleted resources: Return 404, not null
# 404 Not Found (correct)
# {"data": null} (wrong)

# Null fields: Be explicit
{"title": null, "description": ""}  # Clear intent
```

**Incorrect — Flat URLs with query params for hierarchy:**
```typescript
// Parent relationship in query param
GET /api/v1/artifacts?analysis_id=abc-123
```

**Correct — Hierarchical URLs:**
```typescript
// Express ownership in URL structure
GET /api/v1/analyses/abc-123/artifacts
```

**Key rules:**
- Use hierarchical URLs for parent-child relationships
- Support filtering via query parameters on list endpoints
- Use `-field` prefix for descending sort order
- Return empty arrays (not null) for empty collections
- Provide field selection for bandwidth optimization


### Follow REST conventions for naming, HTTP methods, and status codes consistently — HIGH


## REST API Conventions

Standard conventions for RESTful API design covering resource naming, HTTP methods, status codes, and pagination.

**Resource Naming:**

```
# Plural nouns for collections
GET /users
GET /users/123
POST /users

# Hierarchical relationships
GET /users/123/orders          # Orders for specific user
GET /teams/5/members           # Members of specific team
POST /projects/10/tasks        # Create task in project 10

# Kebab-case for multi-word resources
/shopping-carts
/order-items
/user-preferences
```

**HTTP Methods:**

| Method | Purpose | Idempotent | Safe | Example |
|--------|---------|------------|------|---------|
| GET | Retrieve resource(s) | Yes | Yes | `GET /users/123` |
| POST | Create resource | No | No | `POST /users` |
| PUT | Replace entire resource | Yes | No | `PUT /users/123` |
| PATCH | Partial update | No | No | `PATCH /users/123` |
| DELETE | Remove resource | Yes | No | `DELETE /users/123` |

**Status Codes:**

| Code | Name | Use Case |
|------|------|----------|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST (include `Location` header) |
| 204 | No Content | Successful DELETE |
| 400 | Bad Request | Invalid request syntax |
| 401 | Unauthorized | Missing or invalid auth |
| 403 | Forbidden | Authenticated but not authorized |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | Duplicate/constraint violation |
| 422 | Unprocessable | Validation failed |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Error | Server error |

**Cursor-Based Pagination (Recommended):**

```python
@router.get("/analyses")
async def list_analyses(
    cursor: str | None = None,
    limit: int = Query(default=20, le=100)
) -> PaginatedResponse:
    results = await repo.get_paginated(cursor=cursor, limit=limit)

    return {
        "data": results,
        "pagination": {
            "next_cursor": encode_cursor(results[-1].id) if results else None,
            "has_more": len(results) == limit
        }
    }
```

**Common Pitfalls:**

| Pitfall | Bad | Good |
|---------|-----|------|
| Verbs in URLs | `POST /createUser` | `POST /users` |
| Inconsistent naming | `/users, /userOrders` | `/users, /orders` |
| Ignoring HTTP methods | `POST /users/123/delete` | `DELETE /users/123` |
| Exposing internals | `/users-table` | `/users` |
| Generic errors | `"Something went wrong"` | `"Email already exists"` |

**Incorrect — Verbs in URLs:**
```python
# RPC-style endpoints
POST /createUser
POST /users/123/delete
GET /getUserOrders?id=123
```

**Correct — REST conventions:**
```python
# Resource-oriented
POST /users
DELETE /users/123
GET /users/123/orders
```

**Key rules:**
- Always use plural nouns for resources
- Use kebab-case for multi-word resource names
- Map CRUD to proper HTTP methods
- Include `Location` header in 201 responses
- Use cursor-based pagination for large datasets


### GraphQL Schema Patterns and FastAPI Integration — HIGH


## GraphQL Schema Patterns and FastAPI Integration

**Incorrect -- unprotected GraphQL endpoints:**
```python
# No authentication or authorization
@strawberry.type
class Query:
    @strawberry.field
    async def all_users(self, info: strawberry.Info) -> list[User]:
        return await info.context.user_service.list_all()  # Anyone can see all users!

# Exposing internal IDs
@strawberry.type
class User:
    id: int  # Exposes auto-increment ID!
```

**Correct -- permission classes for authorization:**
```python
from strawberry.permission import BasePermission

class IsAuthenticated(BasePermission):
    message = "User is not authenticated"

    async def has_permission(self, source, info: strawberry.Info, **kwargs) -> bool:
        return info.context.current_user_id is not None

class IsAdmin(BasePermission):
    message = "Admin access required"

    async def has_permission(self, source, info: strawberry.Info, **kwargs) -> bool:
        user_id = info.context.current_user_id
        if not user_id:
            return False
        user = await info.context.user_service.get(user_id)
        return user and user.role == "admin"

@strawberry.type
class Query:
    @strawberry.field(permission_classes=[IsAuthenticated])
    async def me(self, info: strawberry.Info) -> User:
        return await info.context.user_service.get(info.context.current_user_id)

    @strawberry.field(permission_classes=[IsAdmin])
    async def all_users(self, info: strawberry.Info) -> list[User]:
        return await info.context.user_service.list_all()
```

**Correct -- FastAPI integration with context getter:**
```python
from strawberry.fastapi import GraphQLRouter

schema = strawberry.Schema(query=Query, mutation=Mutation, subscription=Subscription)

async def get_context(request: Request, user_service=Depends(get_user_service)) -> GraphQLContext:
    return GraphQLContext(request=request, user_service=user_service)

graphql_router = GraphQLRouter(schema, context_getter=get_context, graphiql=True)
app = FastAPI()
app.include_router(graphql_router, prefix="/graphql")
```

**Correct -- subscriptions with Redis PubSub:**
```python
@strawberry.type
class Subscription:
    @strawberry.subscription
    async def user_updated(self, info: strawberry.Info, user_id: strawberry.ID) -> AsyncGenerator[User, None]:
        async for message in info.context.pubsub.subscribe(f"user:{user_id}:updated"):
            yield User(**message)
```

Key decisions:
- Use opaque IDs (strawberry.ID) not internal auto-increment
- Permission classes for field-level authorization
- Redis PubSub for subscription horizontal scaling
- Context getter for dependency injection


### Design type-safe GraphQL schemas with Strawberry to prevent N+1 query problems — HIGH


## Strawberry GraphQL Schema Design

**Incorrect -- N+1 queries in resolvers:**
```python
# Making database calls in resolver loops
@strawberry.type
class Post:
    author_id: strawberry.ID

    @strawberry.field
    async def author(self, info: strawberry.Info) -> "User":
        # N+1: One query per post!
        return await db.get_user(self.author_id)
```

**Correct -- DataLoader for batched loading:**
```python
from strawberry.dataloader import DataLoader

class UserLoader(DataLoader[str, "User"]):
    def __init__(self, user_repo):
        super().__init__(load_fn=self.batch_load)
        self.user_repo = user_repo

    async def batch_load(self, keys: list[str]) -> list["User"]:
        users = await self.user_repo.get_many(keys)
        user_map = {u.id: u for u in users}
        return [user_map.get(key) for key in keys]

@strawberry.type
class Post:
    author_id: strawberry.ID

    @strawberry.field
    async def author(self, info: strawberry.Info) -> "User":
        return await info.context.user_loader.load(self.author_id)
```

**Correct -- type-safe schema with Private fields:**
```python
import strawberry
from strawberry import Private

@strawberry.type
class User:
    id: strawberry.ID
    email: str
    name: str
    password_hash: Private[str]  # Not exposed in schema

    @strawberry.field
    def display_name(self) -> str:
        return f"{self.name} ({self.email})"

@strawberry.input
class CreateUserInput:
    email: str
    name: str
    password: str
```

**Correct -- union types for mutation error handling:**
```python
@strawberry.type
class CreateUserSuccess:
    user: User

@strawberry.type
class UserError:
    message: str
    code: str
    field: str | None = None

@strawberry.type
class CreateUserError:
    errors: list[UserError]

CreateUserResult = strawberry.union("CreateUserResult", [CreateUserSuccess, CreateUserError])
```

Key decisions:
- Schema approach: Code-first with Strawberry types
- N+1 prevention: DataLoader for ALL nested resolvers
- Pagination: Relay-style cursor pagination
- Auth: Permission classes (IsAuthenticated, IsAdmin)
- Errors: Union types for mutations
- Use when: Complex data relationships, client-driven fetching, real-time subscriptions
- Do NOT use when: Simple CRUD (use REST), internal microservices (use gRPC)


### Define and implement gRPC services with compile-time type safety for microservices — HIGH


## gRPC Service Definition and Implementation

**Incorrect -- REST for internal service communication:**
```python
# Using REST for high-frequency internal calls
response = requests.post("http://user-service/api/users",
                         json={"email": email, "name": name})
# High serialization overhead, no compile-time validation, manual error mapping
```

**Correct -- protobuf service definition:**
```text
syntax = "proto3";
package user.v1;

import "google/protobuf/timestamp.proto";

service UserService {
  rpc GetUser(GetUserRequest) returns (User);
  rpc CreateUser(CreateUserRequest) returns (User);
  rpc ListUsers(ListUsersRequest) returns (stream User);       // Server streaming
  rpc BulkCreateUsers(stream CreateUserRequest) returns (BulkCreateResponse);  // Client streaming
}

message User {
  string id = 1;
  string email = 2;
  string name = 3;
  UserStatus status = 4;
  google.protobuf.Timestamp created_at = 5;
}

enum UserStatus {
  USER_STATUS_UNSPECIFIED = 0;
  USER_STATUS_ACTIVE = 1;
  USER_STATUS_INACTIVE = 2;
}
```

**Correct -- async server implementation:**
```python
import grpc.aio
from app.protos import user_service_pb2 as pb2, user_service_pb2_grpc as pb2_grpc

class UserServiceServicer(pb2_grpc.UserServiceServicer):
    async def GetUser(self, request, context):
        user = await self.user_repo.get(request.user_id)
        if not user:
            await context.abort(grpc.StatusCode.NOT_FOUND, f"User {request.user_id} not found")
        return self._to_proto(user)

    async def CreateUser(self, request, context):
        if not request.email or "@" not in request.email:
            await context.abort(grpc.StatusCode.INVALID_ARGUMENT, "Invalid email")
        user = await self.user_repo.create(email=request.email, name=request.name)
        return self._to_proto(user)
```

**Correct -- client with timeout and retry:**
```python
class UserServiceClient:
    def __init__(self, host: str = "localhost:50051"):
        self.channel = grpc.insecure_channel(host, options=[
            ("grpc.keepalive_time_ms", 30000),
            ("grpc.keepalive_timeout_ms", 10000),
        ])
        self.stub = pb2_grpc.UserServiceStub(self.channel)

    def get_user(self, user_id: str, timeout: float = 5.0):
        try:
            return self.stub.GetUser(pb2.GetUserRequest(user_id=user_id), timeout=timeout)
        except grpc.RpcError as e:
            if e.code() == grpc.StatusCode.NOT_FOUND:
                raise UserNotFoundError(user_id)
            raise
```

Key decisions:
- Proto organization: One service per file, shared messages in common.proto
- Versioning: Package version (user.v1, user.v2), backward compatible
- Always set client-side deadlines (timeout)
- Always include health checks for load balancers
- Use when: Internal microservices, streaming, polyglot, strong typing needed
- Do NOT use when: Public APIs (use REST/GraphQL), simple CRUD, no HTTP/2


### Implement gRPC streaming patterns and interceptors for real-time data and observability — HIGH


## gRPC Streaming and Interceptors

**Incorrect -- ignoring stream cancellation:**
```python
# Client may disconnect but server keeps processing
def ListUsers(self, request, context):
    for user in all_users:
        yield self._to_proto(user)  # No cancellation check!
```

**Correct -- server streaming with cancellation check:**
```python
def ListUsers(self, request, context):
    """Server streaming: yield users one by one."""
    for user in self.user_repo.iterate(page_size=request.page_size or 100):
        if not context.is_active():  # Check if client disconnected
            return
        yield self._to_proto(user)
```

**Correct -- bidirectional streaming:**
```python
async def UserUpdates(self, request_iterator, context):
    """Bidirectional: receive updates, yield results."""
    async for request in request_iterator:
        if not context.is_active():
            return
        user = await self.user_repo.update(request.user_id, request.changes)
        yield self._to_proto(user)
```

**Correct -- auth interceptor:**
```python
class AuthInterceptor(grpc.ServerInterceptor):
    def __init__(self, auth_service):
        self.auth_service = auth_service
        self.public_methods = {"/user.v1.UserService/CreateUser"}

    def intercept_service(self, continuation, handler_call_details):
        if handler_call_details.method not in self.public_methods:
            metadata = dict(handler_call_details.invocation_metadata)
            token = metadata.get("authorization", "").replace("Bearer ", "")
            if not token or not self.auth_service.verify(token):
                return grpc.unary_unary_rpc_method_handler(
                    lambda req, ctx: ctx.abort(grpc.StatusCode.UNAUTHENTICATED, "Invalid token")
                )
        return continuation(handler_call_details)
```

**Correct -- client retry interceptor with exponential backoff:**
```python
class RetryInterceptor(grpc.UnaryUnaryClientInterceptor):
    def __init__(self, max_retries: int = 3):
        self.max_retries = max_retries
        self.retry_codes = {grpc.StatusCode.UNAVAILABLE, grpc.StatusCode.DEADLINE_EXCEEDED}

    def intercept_unary_unary(self, continuation, client_call_details, request):
        for attempt in range(self.max_retries):
            try:
                return continuation(client_call_details, request)
            except grpc.RpcError as e:
                if e.code() not in self.retry_codes or attempt == self.max_retries - 1:
                    raise
                time.sleep(2 ** attempt)  # Exponential backoff
```

Key decisions:
- Server streaming: Always check `context.is_active()` before yielding
- Auth: Interceptor with metadata, JWT tokens, public method allowlist
- Retry: Exponential backoff, only retry UNAVAILABLE and DEADLINE_EXCEEDED
- Always close channels to prevent resource leaks
- Never skip deadline/timeout on client calls


### Integrate messaging platforms securely with webhook validation and delivery guarantees — HIGH


# Messaging Platform Integrations

## Platform Selection

| Platform | API Style | Best For | Limitations |
|----------|-----------|----------|-------------|
| WhatsApp (WAHA) | REST + Webhooks | Business messaging, notifications | Session management, rate limits |
| Telegram Bot API | REST + Webhooks/Polling | Interactive bots, commands | 30 msg/sec per bot |
| Slack | REST + Events API | Team workflows, notifications | Workspace-scoped |

## WhatsApp via WAHA

```typescript
// Send message via WAHA (WhatsApp HTTP API)
const response = await fetch(`${WAHA_URL}/api/sendText`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    chatId: `${phone}@c.us`,
    text: message,
    session: "default",
  }),
});
```

## Telegram Bot API

```typescript
// Set webhook for Telegram bot
await fetch(`https://api.telegram.org/bot${TOKEN}/setWebhook`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    url: `${BASE_URL}/api/telegram/webhook`,
    secret_token: WEBHOOK_SECRET,
  }),
});
```

## Webhook Security (Critical)

Every platform provides a signature verification mechanism. **Always verify.**

```typescript
// Telegram: verify secret_token header
function verifyTelegramWebhook(req: Request): boolean {
  return req.headers["x-telegram-bot-api-secret-token"] === WEBHOOK_SECRET;
}
```

- **WhatsApp WAHA**: API key header authentication
- **Telegram**: `secret_token` in webhook registration, verified via header
- **Slack**: HMAC-SHA256 signing secret verification

## Anti-Patterns

**Incorrect:**
- Processing webhooks without signature verification — anyone can POST fake messages
- Synchronous processing of incoming messages — webhook timeout causes retries
- No idempotency on webhook handlers — duplicate messages on retry

**Correct:**
- Always verify webhook signatures before processing
- Acknowledge webhook immediately (200 OK), process async via queue
- Store message IDs and deduplicate on retry
- Rate-limit outgoing messages per platform limits

## References

- `references/whatsapp-waha.md` — WAHA setup, session lifecycle, message types
- `references/telegram-bot-api.md` — Bot setup, webhook config, keyboard patterns
- `references/webhook-security.md` — Signature verification patterns per platform


### Configure Payload CMS 3.0 collections and access control patterns for Next.js — HIGH


# Payload CMS 3.0 Patterns

## CMS Selection Decision Tree

| Factor | Payload | Sanity | Strapi |
|--------|---------|--------|--------|
| Runtime | Next.js (self-hosted) | Hosted (GROQ API) | Node.js (self-hosted) |
| TypeScript | First-class, generated types | Plugin-based | Partial |
| Data ownership | Full (your DB) | Sanity cloud | Full (your DB) |
| Admin UI | Customizable React | Sanity Studio | Built-in |
| Best for | Next.js apps, developer-owned | Content teams, editorial | REST-first APIs |

**Choose Payload when**: Next.js project, need full data ownership, developer-first workflow.
**Choose Sanity when**: Content-heavy editorial teams, need hosted GROQ API, real-time collaboration.

## Collection Design

```typescript
// Payload 3.0 collection config
import type { CollectionConfig } from "payload";

export const Posts: CollectionConfig = {
  slug: "posts",
  admin: { useAsTitle: "title" },
  access: {
    read: () => true,
    create: ({ req: { user } }) => Boolean(user),
    update: ({ req: { user } }) => user?.role === "admin",
    delete: ({ req: { user } }) => user?.role === "admin",
  },
  fields: [
    { name: "title", type: "text", required: true },
    { name: "content", type: "richText" },
    { name: "author", type: "relationship", relationTo: "users" },
    { name: "status", type: "select", options: ["draft", "published"] },
  ],
  hooks: {
    beforeChange: [({ data }) => ({ ...data, updatedAt: new Date() })],
  },
};
```

## Access Control Patterns

- **Collection-level**: `read`, `create`, `update`, `delete` functions
- **Field-level**: Per-field `access` for sensitive data
- **Role-based**: Check `user.role` in access functions
- **Local API**: Uses `overrideAccess: true` by default — be explicit when calling from server

## Anti-Patterns

**Incorrect:**
- Using Local API without `overrideAccess: false` in user-facing code — bypasses all access control
- Putting business logic in hooks instead of service layer — untestable
- Storing large files in the database — use S3/R2 upload adapter

**Correct:**
- Always set `overrideAccess: false` in API routes that serve user requests
- Keep hooks thin — validate/transform only, delegate to services
- Configure upload collections with S3-compatible storage adapter

## References

- `references/payload-vs-sanity.md` — Detailed comparison, decision matrix, migration paths
- Collection design (field types, relationships, blocks, validation) and access control (RBAC, field-level, multi-tenant) are first-party documented: https://payloadcms.com/docs


### Stream server-sent events with auto-reconnect for LLM responses and notifications — HIGH


## Server-Sent Events (SSE) Streaming

**Incorrect -- no keepalive or cleanup:**
```python
# No keepalive, no abort handling, no reconnection support
@app.get("/stream")
async def stream():
    async def generate():
        for item in data:
            yield f"data: {item}\n\n"
    return StreamingResponse(generate(), media_type="text/event-stream")
```

**Correct -- SSE with keepalive and abort handling (Next.js):**
```typescript
export async function GET(req: Request) {
  const encoder = new TextEncoder()

  const stream = new ReadableStream({
    async start(controller) {
      controller.enqueue(encoder.encode('data: Hello\n\n'))

      // Keep connection alive every 30s
      const interval = setInterval(() => {
        controller.enqueue(encoder.encode(': keepalive\n\n'))
      }, 30000)

      // Cleanup on client disconnect
      req.signal.addEventListener('abort', () => {
        clearInterval(interval)
        controller.close()
      })
    }
  })

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    }
  })
}
```

**Correct -- LLM token streaming pattern:**
```typescript
export async function POST(req: Request) {
  const { messages } = await req.json()
  const stream = await openai.chat.completions.create({ model: 'gpt-5.5', messages, stream: true })
  const encoder = new TextEncoder()

  return new Response(
    new ReadableStream({
      async start(controller) {
        for await (const chunk of stream) {
          const content = chunk.choices[0]?.delta?.content
          if (content) {
            controller.enqueue(encoder.encode("data: " + JSON.stringify({ content }) + "\n\n"))
          }
        }
        controller.enqueue(encoder.encode('data: [DONE]\n\n'))
        controller.close()
      }
    }),
    { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' } }
  )
}
```

**Correct -- reconnecting client with exponential backoff:**
```typescript
class ReconnectingEventSource {
  private eventSource: EventSource | null = null
  private reconnectDelay = 1000
  private maxReconnectDelay = 30000

  constructor(private url: string, private onMessage: (data: string) => void) {
    this.connect()
  }

  private connect() {
    this.eventSource = new EventSource(this.url)
    this.eventSource.onmessage = (event) => {
      this.reconnectDelay = 1000
      this.onMessage(event.data)
    }
    this.eventSource.onerror = () => {
      this.eventSource?.close()
      setTimeout(() => this.connect(), this.reconnectDelay)
      this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay)
    }
  }
}
```

Key decisions:
- SSE for one-way server-to-client (LLM streaming, notifications)
- Keepalive every 30s to prevent timeouts
- Handle browser 6-connection-per-domain limit (use HTTP/2)
- Exponential backoff for reconnection (1s to 30s)


### Implement WebSocket bidirectional streaming with async generator cleanup for resource safety — HIGH


## WebSocket and Async Generator Patterns

**Incorrect -- no message validation or heartbeat:**
```python
# No heartbeat, no validation, no reconnection
wss.on('connection', (ws) => {
  ws.on('message', (data) => {
    wss.clients.forEach((client) => client.send(data))  // Raw broadcast!
  })
})
```

**Correct -- WebSocket with heartbeat and validation:**
```typescript
const wss = new WebSocketServer({ port: 8080 })

wss.on('connection', (ws) => {
  // Heartbeat
  const heartbeat = setInterval(() => ws.ping(), 30000)

  ws.on('message', (data) => {
    const parsed = JSON.parse(data.toString())
    // Validate message structure
    if (!parsed.type || !parsed.text) return

    // Broadcast to connected clients
    wss.clients.forEach((client) => {
      if (client !== ws && client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify(parsed))
      }
    })
  })

  ws.on('close', () => clearInterval(heartbeat))
})
```

**Incorrect -- async generator without cleanup:**
```python
# Generator not closed if exception occurs mid-iteration
async for chunk in external_api_stream():  # Resource leak if exception!
    yield process(chunk)
```

**Correct -- aclosing() for guaranteed async generator cleanup:**
```python
from contextlib import aclosing

# Guaranteed cleanup with aclosing()
async def stream_llm_response(prompt: str):
    async with aclosing(llm.astream(prompt)) as stream:
        async for chunk in stream:
            yield chunk.content

# Consumption with proper cleanup
async def consume():
    async with aclosing(stream_llm_response("Hello")) as response:
        async for token in response:
            handle(token)
```

**When to use aclosing():**

| Scenario | Use aclosing() |
|----------|----------------|
| External API streaming (LLM, HTTP) | Always |
| Database streaming results | Always |
| File streaming | Always |
| Simple in-memory generators | Optional |
| Generator with try/finally cleanup | Always |

Key decisions:
- WebSocket for bidirectional real-time (chat, collaboration)
- SSE for one-way server-to-client (use SSE rule instead)
- Always implement heartbeat/ping-pong for WebSockets
- Always use `aclosing()` for external resource async generators
- Implement backpressure with ReadableStream flow control
- Monitor buffer sizes, pause production when consumer is slow


### Implement header-based API versioning with clean URLs and content negotiation — HIGH


## Header-Based Versioning

Version selection via HTTP headers for clean URLs, best suited for internal APIs.

**X-API-Version Header:**

```python
from fastapi import Header, HTTPException, Depends

SUPPORTED_VERSIONS = {1, 2}
DEFAULT_VERSION = 2

async def get_api_version(
    x_api_version: str = Header(default="1", alias="X-API-Version")
) -> int:
    try:
        version = int(x_api_version)
        if version not in SUPPORTED_VERSIONS:
            raise ValueError()
        return version
    except ValueError:
        raise HTTPException(
            400,
            f"Invalid API version. Supported: {SUPPORTED_VERSIONS}",
        )

@router.get("/users/{user_id}")
async def get_user(
    user_id: str,
    version: int = Depends(get_api_version),
    service: UserService = Depends(),
):
    user = await service.get_user(user_id)

    if version == 1:
        return UserResponseV1(id=user.id, name=user.full_name)
    else:
        return UserResponseV2(
            id=user.id,
            first_name=user.first_name,
            last_name=user.last_name,
        )
```

**Content Negotiation (Media Type Versioning):**

```python
from fastapi import Request

MEDIA_TYPES = {
    "application/vnd.orchestkit.v1+json": 1,
    "application/vnd.orchestkit.v2+json": 2,
    "application/json": 2,  # Default to latest
}

async def get_version_from_accept(request: Request) -> int:
    accept = request.headers.get("Accept", "application/json")
    return MEDIA_TYPES.get(accept, 2)

@router.get("/users/{user_id}")
async def get_user(
    user_id: str,
    version: int = Depends(get_version_from_accept),
):
    ...
```

**Testing Header Versioning:**

```python
import pytest
from httpx import AsyncClient

@pytest.mark.asyncio
async def test_header_versioning(client: AsyncClient):
    # Request with v1 header
    response = await client.get(
        "/api/users/123",
        headers={"X-API-Version": "1"},
    )
    data = response.json()
    assert "avatar_url" not in data

    # Request with v2 header
    response = await client.get(
        "/api/users/123",
        headers={"X-API-Version": "2"},
    )
    data = response.json()
    assert "avatar_url" in data
```

**When to Use Header vs URL Path:**

| Criteria | URL Path | Header |
|----------|----------|--------|
| **Visibility** | Clear in URL | Hidden in headers |
| **Testing** | Easy with browser/curl | Needs header tools |
| **Caching** | CDN-friendly | Requires Vary header |
| **Best for** | Public APIs | Internal APIs |
| **Multiple versions** | Separate route trees | Single route tree |

**Incorrect — No default version:**
```python
# Breaks when header missing
async def get_version(x_api_version: str = Header()):
    return int(x_api_version)  # Error if header absent!
```

**Correct — Default to latest version:**
```python
# Falls back to latest stable version
async def get_version(
    x_api_version: str = Header(default="2")
) -> int:
    return int(x_api_version)
```

**Key rules:**
- Default to latest stable version when header is absent
- Validate version against a supported versions set
- Return 400 with helpful message for unsupported versions
- Use header versioning only for internal APIs
- Always document which versions are supported


### Implement URL path versioning for public APIs without routing conflicts or code duplication — HIGH


## URL Path Versioning

The recommended versioning strategy for public APIs using URL path prefixes.

**FastAPI Directory Structure:**

```
backend/app/
├── api/
│   ├── v1/
│   │   ├── __init__.py
│   │   ├── routes/
│   │   │   ├── users.py
│   │   │   └── analyses.py
│   │   └── schemas/
│   │       ├── user.py
│   │       └── analysis.py
│   ├── v2/
│   │   ├── __init__.py
│   │   ├── routes/
│   │   │   ├── users.py      # Updated schemas
│   │   │   └── analyses.py
│   │   └── schemas/
│   │       ├── user.py       # New schema version
│   │       └── analysis.py
│   └── router.py             # Combines all versions
├── core/
└── services/                  # Shared across versions
```

**Router Setup:**

```python
# backend/app/api/router.py
from fastapi import APIRouter
from app.api.v1.router import router as v1_router
from app.api.v2.router import router as v2_router

api_router = APIRouter()
api_router.include_router(v1_router, prefix="/v1")
api_router.include_router(v2_router, prefix="/v2")

# main.py
app.include_router(api_router, prefix="/api")
```

**Version-Specific Schemas:**

```python
# v1/schemas/user.py
class UserResponseV1(BaseModel):
    id: str
    name: str  # Single name field

# v2/schemas/user.py
class UserResponseV2(BaseModel):
    id: str
    first_name: str  # Split into first/last
    last_name: str
    full_name: str   # Computed for convenience
```

**Shared Business Logic (Version-Agnostic Services):**

```python
# services/user_service.py (version-agnostic)
class UserService:
    async def get_user(self, user_id: str) -> User:
        return await self.repo.get_by_id(user_id)

# v1/routes/users.py
@router.get("/{user_id}", response_model=UserResponseV1)
async def get_user_v1(user_id: str, service: UserService = Depends()):
    user = await service.get_user(user_id)
    return UserResponseV1(id=user.id, name=user.full_name)

# v2/routes/users.py
@router.get("/{user_id}", response_model=UserResponseV2)
async def get_user_v2(user_id: str, service: UserService = Depends()):
    user = await service.get_user(user_id)
    return UserResponseV2(
        id=user.id,
        first_name=user.first_name,
        last_name=user.last_name,
        full_name=f"{user.first_name} {user.last_name}",
    )
```

**Strategy Comparison:**

| Strategy | Example | Pros | Cons |
|----------|---------|------|------|
| URL Path | `/api/v1/users` | Simple, visible, cacheable | URL pollution |
| Header | `X-API-Version: 1` | Clean URLs | Hidden, harder to test |
| Query Param | `?version=1` | Easy testing | Messy, cache issues |
| Content-Type | `Accept: application/vnd.api.v1+json` | RESTful | Complex |

**Incorrect — Versioned services:**
```python
# Services should be version-agnostic
class UserServiceV1:
    async def get_user(self, id: str):
        ...

class UserServiceV2:
    async def get_user(self, id: str):
        ...
```

**Correct — Version-agnostic services:**
```python
# Single service, version handled in response schemas
class UserService:
    async def get_user(self, id: str) -> User:
        return await self.repo.get_by_id(id)

# v1/routes/users.py returns UserResponseV1
# v2/routes/users.py returns UserResponseV2
```

**Key rules:**
- Always start with `/api/v1/` even if no v2 is planned
- Keep services version-agnostic; only schemas and routes are versioned
- Use schema inheritance for shared fields across versions
- Support max 2-3 concurrent versions
- Never version internal implementation (services, repositories)



---

## References (7)

### Graphql Api

# GraphQL API Design

## Schema Design Principles

### Nullable by Default
```graphql
type User {
  id: ID!              # Non-null (required)
  email: String!       # Non-null
  name: String         # Nullable (optional)
  avatar: String       # Nullable
}
```

### Use Connections for Lists
```graphql
type Query {
  users(first: Int, after: String): UserConnection!
}

type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type UserEdge {
  node: User!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}
```

### Input Types for Mutations
```graphql
input CreateUserInput {
  email: String!
  name: String!
  role: UserRole!
}

type Mutation {
  createUser(input: CreateUserInput!): CreateUserPayload!
}

type CreateUserPayload {
  user: User!
  errors: [UserError!]
}

type UserError {
  field: String!
  message: String!
  code: String!
}
```

## Query Design

**Fetch single resource:**
```graphql
query GetUser {
  user(id: "123") {
    id
    name
    email
    posts {
      id
      title
    }
  }
}
```

**Fetch list with filters:**
```graphql
query GetUsers {
  users(
    first: 10
    after: "cursor123"
    filter: { role: DEVELOPER, status: ACTIVE }
  ) {
    edges {
      node {
        id
        name
        email
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}
```

## Error Handling

**Field-Level Errors:**
```graphql
type CreateUserPayload {
  user: User
  errors: [UserError!]
}
```

**Response:**
```json
{
  "data": {
    "createUser": {
      "user": null,
      "errors": [
        {
          "field": "email",
          "message": "Email is already taken",
          "code": "DUPLICATE_EMAIL"
        }
      ]
    }
  }
}
```

### Ork Delta

# ork delta: api-design

OrchestKit-specific decisions rescued during the wrap-plus-delta thinning of
src/skills/api-design (2026-07-31). Vendor and spec tutorials that used to live
beside these entries were deleted; the "Upstream coverage (do not restate)"
table in SKILL.md points at their first-party sources. Only rules with a house
decision or a scar behind them live here.

## Put problem type URIs on your own API domain under /problems/
Why: House convention carried since the error-handling-rfc9457 skill was consolidated into api-design v2.0.0 (metadata.json, February 2026). OrchestKit examples standardize on `https://api.orchestkit.dev/problems/&lt;kebab-slug&gt;` so error `type` URIs stay stable, documentable at their URL, and greppable across services (see examples/fastapi-problem-details.md for the full registry in use).
Upstream: RFC 9457 Problem Details, https://www.rfc-editor.org/rfc/rfc9457.html

## Raise typed Problem exceptions, never bare HTTPException
Why: House exception vocabulary (ProblemException base plus ResourceNotFoundError, ValidationError, ConflictError, RateLimitError, AuthenticationError, AuthorizationError, each carrying machine-readable extension members such as `resource_id` and `retry_after`) fixed at the v2.0.0 consolidation (February 2026). The agent-facing extensions from #1067 build on these same classes, and SKILL.md's quick start plus examples/fastapi-problem-details.md depend on them; ad-hoc HTTPException payloads broke error-format consistency across endpoints, which is what the consolidation was fixing.
Upstream: FastAPI custom exception handlers, https://fastapi.tiangolo.com/tutorial/handling-errors/

## Hold the deprecation window: 3 months notice, 6 months sunset, current + 1 supported
Why: House lifecycle policy set when the api-versioning skill was consolidated into api-design v2.0.0 (February 2026): deprecation notice at least 3 months before sunset, sunset 6 months after deprecation, support latest stable plus 1 previous version, and never more than 2-3 concurrent versions. These numbers are OrchestKit policy, not spec requirements; the specs only define the header mechanics. The policy is also mirrored in SKILL.md Key Decisions so it survives file-level thinning.
Upstream: RFC 8594 (Sunset header), https://www.rfc-editor.org/rfc/rfc8594.html and RFC 9745 (Deprecation header), https://www.rfc-editor.org/rfc/rfc9745.html


### Payload Vs Sanity

# Payload vs Sanity — CMS Comparison

Detailed comparison and decision matrix for choosing between Payload CMS 3.0, Sanity, Strapi, and WordPress.

## Feature Comparison

| Feature | Payload 3.0 | Sanity v3 | Strapi v5 | WordPress |
|---------|-------------|-----------|-----------|-----------|
| **Language** | TypeScript | TypeScript + GROQ | JavaScript/TS | PHP |
| **Framework** | Built on Next.js | React (studio) | Koa.js | Monolithic |
| **Hosting** | Self-hosted | Hosted API + self-hosted studio | Self-hosted | Self/hosted |
| **Database** | MongoDB or Postgres | Hosted (proprietary) | SQLite/Postgres/MySQL | MySQL |
| **Auth** | Built-in (JWT + cookies) | Hosted or custom | Built-in (JWT) | Built-in (sessions) |
| **API** | REST + GraphQL auto-generated | GROQ + GraphQL | REST + GraphQL | REST + GraphQL (plugin) |
| **Rich Text** | Lexical (built-in) | Portable Text | CKEditor/custom | Gutenberg |
| **Admin UI** | React + Next.js | React (Sanity Studio) | React | PHP + React (Gutenberg) |
| **Type Safety** | Config IS the schema | Schema + codegen | Schema + codegen | None natively |
| **Plugins** | npm packages | npm packages | npm marketplace | Plugin ecosystem (massive) |
| **License** | MIT (open source) | Freemium (hosted) | MIT with EE features | GPLv2 |
| **Live Preview** | Built-in | Built-in | Via plugin | Theme preview |
| **Versioning** | Built-in per collection | Built-in | Via plugin | Built-in (revisions) |

## Cost Comparison

| Tier | Payload | Sanity | Strapi |
|------|---------|--------|--------|
| **Free** | Unlimited (self-host) | 100K API requests/mo, 3 users | Unlimited (self-host) |
| **Team** | Payload Cloud ($25/mo) | $99/mo (500K requests) | $29/mo (gold support) |
| **Enterprise** | Custom | Custom | Custom |

Payload is fully open source — cost is infrastructure only. Sanity's cost scales with API usage.

## Decision Matrix

### Choose Payload When:
- Building a **Next.js application** — Payload runs inside your Next.js app
- You want **full ownership** of data and infrastructure
- Your team is **TypeScript-first** — config-as-code is natural
- You need **custom access control** beyond simple roles
- Self-hosting is acceptable or preferred
- You want **one deployment** (CMS + frontend in same app)

### Choose Sanity When:
- **Content editors** are primary users, not developers
- You need **real-time collaborative editing** (Google Docs-style)
- Your content is consumed by **multiple frontends** (web, mobile, IoT)
- You want a **hosted API** with no infrastructure management
- **GROQ** query language fits your content querying needs
- Editorial workflow and content scheduling are critical

### Choose Strapi When:
- You need a **quick admin panel** with minimal configuration
- Your team prefers a **GUI-first** content modeling approach
- You want a **marketplace** of pre-built plugins
- The project is a **prototype or MVP** that may change CMS later
- You need **multi-database support** (SQLite for dev, Postgres for prod)

### Choose WordPress When:
- **Non-technical editors** need to manage content independently
- You need the **largest plugin ecosystem** (100K+ plugins)
- SEO tooling (Yoast, RankMath) is a core requirement
- Budget for development is limited — large talent pool
- Content is primarily **blog/marketing pages**

## Migration Considerations

### From Sanity to Payload
1. Export content via GROQ: `*[_type == "post"]`
2. Map Portable Text to Lexical rich text format
3. Recreate schemas as Payload collection configs
4. Migrate assets from Sanity CDN to local/S3 storage
5. Rebuild GROQ queries as Payload `where` clauses

### From Strapi to Payload
1. Export via Strapi REST API
2. Map Strapi content types to Payload collections 1:1
3. Convert Strapi lifecycle hooks to Payload hooks
4. Migrate media from Strapi uploads to Payload upload collections
5. Replace Strapi custom controllers with Payload custom endpoints

### From WordPress to Payload
1. Export via WP REST API (`/wp-json/wp/v2/posts`)
2. Convert ACF/custom fields to Payload field configs
3. Map WordPress taxonomies to Payload relationship fields
4. Migrate media library to Payload upload collection
5. Convert WordPress template hierarchy to Next.js layouts

## Architecture Comparison

```
Payload 3.0:                    Sanity:
┌─────────────────────┐        ┌──────────────┐    ┌──────────────┐
│   Your Next.js App  │        │ Sanity Studio │    │  Your App    │
│  ┌───────────────┐  │        │  (React SPA)  │    │ (any framework)│
│  │ Payload CMS   │  │        └──────┬───────┘    └──────┬───────┘
│  │  (embedded)   │  │               │                    │
│  └───────┬───────┘  │               ▼                    ▼
│          │          │        ┌──────────────┐    ┌──────────────┐
│          ▼          │        │  Sanity API   │    │  Sanity API  │
│  ┌───────────────┐  │        │   (hosted)    │    │   (hosted)   │
│  │ MongoDB/PG    │  │        └──────────────┘    └──────────────┘
│  └───────────────┘  │
└─────────────────────┘        Single hosted API, multiple consumers
Single deployment, full control
```

## When NOT to Use a Headless CMS

- **Static content** that rarely changes — use Markdown + static site generator
- **Application data** (user profiles, orders, analytics) — use a database directly
- **Real-time data** (chat, live feeds) — use purpose-built real-time tools
- Content that only developers edit — YAML/JSON config files may suffice


### Rest Patterns

# RESTful API Design Patterns

Comprehensive guide to RESTful API design patterns including resource modeling, HTTP methods, status codes, versioning, pagination, filtering, and error handling.

## Resource Modeling

### Naming Conventions

**Use plural nouns for collections:**
```
✅ GET /api/v1/analyses
✅ GET /api/v1/artifacts
✅ GET /api/v1/users

❌ GET /api/v1/analysis
❌ GET /api/v1/getArtifact
```

**Hierarchical relationships:**
```
✅ GET /api/v1/analyses/{analysis_id}/artifact
✅ GET /api/v1/teams/{team_id}/members
✅ POST /api/v1/projects/{project_id}/tasks

❌ GET /api/v1/artifact?analysis_id={id}  # Query param for relationship
❌ GET /api/v1/analysis_artifact/{id}      # Flat structure
```

**Use kebab-case for multi-word resources:**
```
✅ /api/v1/shopping-carts
✅ /api/v1/user-preferences
✅ /api/v1/order-items

❌ /api/v1/shoppingCarts  (camelCase)
❌ /api/v1/shopping_carts  (snake_case in URL)
```

### HTTP Methods (CRUD Operations)

| Method | Purpose | Idempotent | Safe | Response | Example |
|--------|---------|------------|------|----------|---------|
| **GET** | Retrieve resource(s) | ✅ | ✅ | 200 OK | `GET /analyses/123` |
| **POST** | Create resource | ❌ | ❌ | 201 Created | `POST /analyses` |
| **PUT** | Replace entire resource | ✅ | ❌ | 200 OK | `PUT /analyses/123` |
| **PATCH** | Partial update | ⚠️ | ❌ | 200 OK | `PATCH /analyses/123` |
| **DELETE** | Remove resource | ✅ | ❌ | 204 No Content | `DELETE /analyses/123` |
| **HEAD** | Metadata only | ✅ | ✅ | 200 OK | `HEAD /analyses/123` |
| **OPTIONS** | Allowed methods | ✅ | ✅ | 200 OK | `OPTIONS /analyses` |

**Idempotency Note**: PATCH can be designed to be idempotent by using absolute values instead of relative operations.

### HTTP Status Codes

#### Success (2xx)

**200 OK** - Successful GET, PUT, PATCH, DELETE with response body
```python
@router.get("/analyses/{analysis_id}")
async def get_analysis(analysis_id: uuid.UUID) -> AnalysisResponse:
    return AnalysisResponse(...)  # 200 OK
```

**201 Created** - Successful POST, include `Location` header
```python
@router.post("/analyses", status_code=status.HTTP_201_CREATED)
async def create_analysis(request: AnalyzeRequest) -> AnalyzeCreateResponse:
    # Include SSE endpoint in response
    return AnalyzeCreateResponse(
        analysis_id=str(analysis_uuid),
        sse_endpoint=f"/api/v1/analyze/{analysis_uuid}/stream"
    )
```

**202 Accepted** - Request accepted, processing asynchronously
```python
@router.post("/long-running-task", status_code=status.HTTP_202_ACCEPTED)
async def start_task() -> TaskStatusResponse:
    # Start background task
    return TaskStatusResponse(
        task_id="...",
        status="pending",
        status_url="/tasks/123/status"
    )
```

**204 No Content** - Successful DELETE or PUT with no response body
```python
@router.delete("/analyses/{analysis_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_analysis(analysis_id: uuid.UUID) -> None:
    await repo.delete(analysis_id)
```

#### Client Errors (4xx)

**400 Bad Request** - Invalid request syntax or malformed parameters
```json
{
  "error": {
    "code": "INVALID_REQUEST",
    "message": "Request body is not valid JSON",
    "timestamp": "2025-12-21T10:30:00Z"
  }
}
```

**401 Unauthorized** - Missing or invalid authentication
```json
{
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Missing or invalid authentication token",
    "timestamp": "2025-12-21T10:30:00Z"
  }
}
```

**403 Forbidden** - Authenticated but not authorized
```json
{
  "error": {
    "code": "FORBIDDEN",
    "message": "You do not have permission to access this resource",
    "timestamp": "2025-12-21T10:30:00Z"
  }
}
```

**404 Not Found** - Resource doesn't exist
```python
@router.get("/artifacts/{artifact_id}")
async def get_artifact(artifact_id: uuid.UUID) -> ArtifactResponse:
    artifact = await repo.get_artifact_by_id(artifact_id)

    if not artifact:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Artifact {artifact_id} not found"
        )
```

**422 Unprocessable Entity** - Validation failed
```python
try:
    content_type = detect_content_type(url_str)
except ContentTypeError as e:
    raise HTTPException(
        status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
        detail=f"Invalid URL format: {e!s}"
    ) from e
```

**429 Too Many Requests** - Rate limit exceeded
```json
HTTP/1.1 429 Too Many Requests
Retry-After: 3600
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1703163600

{
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "API rate limit exceeded. Try again in 1 hour.",
    "retry_after": 3600
  }
}
```

#### Server Errors (5xx)

**500 Internal Server Error** - Generic server error
```python
except Exception as e:
    logger.error(
        "analysis_creation_failed",
        error=str(e),
        exc_info=True
    )
    raise HTTPException(
        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
        detail="Failed to create analysis record"
    ) from e
```

**502 Bad Gateway** - Upstream service error
**503 Service Unavailable** - Temporary unavailability (maintenance)
**504 Gateway Timeout** - Upstream timeout

## API Versioning

### Strategy 1: URI Versioning (Recommended for Public APIs)

**OrchestKit uses this approach:**
```python
# app/core/config.py
API_V1_PREFIX = "/api/v1"

# app/main.py
app.include_router(
    analysis_router,
    prefix=f"{settings.API_V1_PREFIX}/analyze"
)
```

**URL structure:**
```
/api/v1/analyses
/api/v2/analyses  # New version with breaking changes
```

**Pros:**
- Clear and visible in URLs
- Easy to test and debug
- Cache-friendly
- Can route different versions to different servers

**Cons:**
- Verbose URLs
- Need to maintain multiple codebases

### Strategy 2: Header Versioning

```
GET /api/analyses
Accept: application/vnd.orchestkit.v2+json
API-Version: v2
```

**Pros:**
- Clean URLs
- RESTful purist approach

**Cons:**
- Not visible in browser
- Harder to test manually
- Need custom headers

### Strategy 3: Query Parameter (Avoid)

```
GET /api/analyses?version=2
```

**Cons:**
- Mixes with business logic parameters
- Can be forgotten
- Not cache-friendly

## Pagination

### Cursor-Based Pagination (Recommended for Large Datasets)

**Best for**: Real-time data, infinite scroll, datasets that change frequently

```python
@router.get("/analyses")
async def list_analyses(
    cursor: str | None = None,
    limit: int = Query(default=20, le=100)
) -> PaginatedResponse:
    results = await repo.get_paginated(cursor=cursor, limit=limit)

    return {
        "data": results,
        "pagination": {
            "next_cursor": encode_cursor(results[-1].id) if results else None,
            "has_more": len(results) == limit
        }
    }
```

**Response:**
```json
{
  "data": [...],
  "pagination": {
    "next_cursor": "eyJpZCI6MTIzfQ",
    "has_more": true
  }
}
```

**Client usage:**
```javascript
// First page
const page1 = await fetch('/api/v1/analyses?limit=20')
const { data, pagination } = await page1.json()

// Next page
if (pagination.has_more) {
  const page2 = await fetch(`/api/v1/analyses?cursor=${pagination.next_cursor}&limit=20`)
}
```

### Offset-Based Pagination (For Known Bounds)

**Best for**: Admin panels, small datasets, "jump to page N" UX

```python
@router.get("/analyses")
async def list_analyses(
    page: int = Query(default=1, ge=1),
    per_page: int = Query(default=20, le=100)
) -> PaginatedResponse:
    offset = (page - 1) * per_page
    results, total = await repo.get_paginated(offset=offset, limit=per_page)

    return {
        "data": results,
        "pagination": {
            "page": page,
            "per_page": per_page,
            "total": total,
            "total_pages": (total + per_page - 1) // per_page
        }
    }
```

**Response:**
```json
{
  "data": [...],
  "pagination": {
    "page": 2,
    "per_page": 20,
    "total": 487,
    "total_pages": 25
  }
}
```

## Filtering and Sorting

### Query Parameter Filtering

```python
@router.get("/analyses")
async def list_analyses(
    status: str | None = None,
    content_type: str | None = None,
    created_after: datetime | None = None,
    created_before: datetime | None = None
) -> list[AnalysisResponse]:
    filters = {}
    if status:
        filters["status"] = status
    if content_type:
        filters["content_type"] = content_type
    # ...

    return await repo.find_all(filters=filters)
```

**Usage:**
```
GET /api/v1/analyses?status=completed&content_type=article
GET /api/v1/analyses?created_after=2025-01-01&created_before=2025-12-31
```

### Sorting

```python
@router.get("/analyses")
async def list_analyses(
    sort: str = Query(default="-created_at")
) -> list[AnalysisResponse]:
    # Parse sort parameter: "-created_at" -> ("created_at", "desc")
    direction = "desc" if sort.startswith("-") else "asc"
    field = sort.lstrip("-")

    return await repo.find_all(
        order_by=field,
        direction=direction
    )
```

**Usage:**
```
GET /api/v1/analyses?sort=-created_at       # Newest first
GET /api/v1/analyses?sort=title              # Alphabetical
GET /api/v1/analyses?sort=-status,title      # Multiple fields
```

### Field Selection (Sparse Fieldsets)

```python
@router.get("/analyses")
async def list_analyses(
    fields: str | None = None
) -> list[dict[str, Any]]:
    selected_fields = fields.split(",") if fields else None
    results = await repo.find_all()

    if selected_fields:
        return [
            {k: v for k, v in item.model_dump().items() if k in selected_fields}
            for item in results
        ]

    return results
```

**Usage:**
```
GET /api/v1/analyses?fields=id,title,status
```

## Error Response Format

### Standard Error Structure

```python
# app/api/schemas/errors.py
from pydantic import BaseModel, ConfigDict

class ErrorDetail(BaseModel):
    field: str
    message: str
    code: str

class ErrorResponse(BaseModel):
    error: dict[str, Any]

    model_config = ConfigDict(
        json_schema_extra={
            "example": {
                "error": {
                    "code": "VALIDATION_ERROR",
                    "message": "Request validation failed",
                    "details": [
                        {
                            "field": "url",
                            "message": "Invalid URL format",
                            "code": "INVALID_URL"
                        }
                    ],
                    "timestamp": "2025-12-21T10:30:00Z",
                    "request_id": "req_abc123"
                }
            }
        }
    )
```

### FastAPI Exception Handlers

```python
# app/main.py
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
    return JSONResponse(
        status_code=exc.status_code,
        content={
            "error": {
                "code": exc.status_code,
                "message": exc.detail,
                "timestamp": datetime.now(UTC).isoformat(),
                "path": request.url.path
            }
        }
    )

@app.exception_handler(ValidationError)
async def validation_exception_handler(request: Request, exc: ValidationError):
    errors = []
    for error in exc.errors():
        errors.append({
            "field": ".".join(str(x) for x in error["loc"]),
            "message": error["msg"],
            "code": error["type"]
        })

    return JSONResponse(
        status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
        content={
            "error": {
                "code": "VALIDATION_ERROR",
                "message": "Request validation failed",
                "details": errors,
                "timestamp": datetime.now(UTC).isoformat()
            }
        }
    )
```

## Rate Limiting

### Response Headers

```python
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address

limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)

@router.get("/analyses")
@limiter.limit("100/minute")
async def list_analyses(request: Request) -> list[AnalysisResponse]:
    # Rate limited to 100 requests per minute
    pass
```

**Response headers:**
```
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 87
X-RateLimit-Reset: 1703163600
```

**When exceeded:**
```
HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1703163600

{
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Too many requests. Please try again in 60 seconds.",
    "retry_after": 60,
    "timestamp": "2025-12-21T10:30:00Z"
  }
}
```

## Best Practices

### 1. Always Return Consistent Response Format

```python
# Good: Consistent structure
{
  "data": {...},
  "metadata": {...}
}

# Bad: Inconsistent structure
{...}  # Sometimes flat object
{"results": [...]}  # Sometimes wrapped
```

### 2. Use Pydantic for Request/Response Validation

```python
from pydantic import BaseModel, HttpUrl, Field

class AnalyzeRequest(BaseModel):
    url: HttpUrl
    analysis_id: str | None = None
    skill_level: str = Field(default="beginner", pattern="^(beginner|intermediate|advanced)$")
```

### 3. Include Metadata in Responses

```python
{
  "analysis_id": "123",
  "url": "https://example.com",
  "created_at": "2025-12-21T10:30:00Z",
  "updated_at": "2025-12-21T11:00:00Z"
}
```

### 4. Use OpenAPI Documentation

```python
@router.get(
    "/analyses/{analysis_id}",
    responses={
        404: {"model": ErrorResponse, "description": "Analysis not found"},
        500: {"model": ErrorResponse, "description": "Internal server error"}
    },
    summary="Get analysis details",
    description="Retrieve detailed information about a specific analysis including status and artifacts"
)
async def get_analysis(
    analysis_id: Annotated[uuid.UUID, Path(description="Analysis UUID")]
) -> AnalysisResponse:
    ...
```

### 5. Handle Edge Cases

```python
# Empty collections: Return empty array, not null
{"data": []}  # ✅
{"data": null}  # ❌

# Deleted resources: Return 404, not null
# ❌ {"data": null}
# ✅ 404 Not Found

# Null fields: Be explicit
{
  "title": null,  # ✅ Explicitly null
  "description": ""  # ✅ Empty string if required
}
```

## Related Files

- See `assets/openapi-template.yaml` for full OpenAPI specification example
- See `examples/orchestkit-api-design.md` for OrchestKit-specific patterns
- See SKILL.md for GraphQL and gRPC patterns


### Telegram Bot Api

# Telegram Bot API

Reference for building Telegram bots with webhooks, commands, and interactive keyboards.

## Bot Creation

1. Open Telegram, search for `@BotFather`
2. Send `/newbot`, follow prompts
3. Save the bot token (`123456:ABC-DEF...`)

## Webhook Setup

```bash
# Set webhook
curl -X POST "https://api.telegram.org/bot<TOKEN>/setWebhook" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://app.com/telegram/webhook",
    "secret_token": "your-webhook-secret",
    "allowed_updates": ["message", "callback_query"]
  }'

# Verify webhook is set
curl "https://api.telegram.org/bot<TOKEN>/getWebhookInfo"

# Remove webhook (switch to polling)
curl -X POST "https://api.telegram.org/bot<TOKEN>/deleteWebhook"
```

### Webhook Verification

Telegram sends `X-Telegram-Bot-Api-Secret-Token` header matching your `secret_token`:

```typescript
function verifyTelegramWebhook(req: Request, secret: string): boolean {
  return req.headers['x-telegram-bot-api-secret-token'] === secret;
}
```

## Bot Commands

Register commands with BotFather so they appear in the menu:

```bash
POST /bot<TOKEN>/setMyCommands
{
  "commands": [
    { "command": "start", "description": "Start the bot" },
    { "command": "help", "description": "Show help" },
    { "command": "settings", "description": "Bot settings" }
  ]
}
```

Handle commands in your webhook:

```typescript
if (update.message?.text?.startsWith('/start')) {
  await sendMessage(chatId, 'Welcome! Use /help to see available commands.');
}
```

## Sending Messages

### Text with Formatting

```bash
POST /bot<TOKEN>/sendMessage
{
  "chat_id": 123456,
  "text": "*Bold* _italic_ `code` [link](https://example.com)",
  "parse_mode": "MarkdownV2"
}
```

### Inline Keyboards

```bash
POST /bot<TOKEN>/sendMessage
{
  "chat_id": 123456,
  "text": "Choose an action:",
  "reply_markup": {
    "inline_keyboard": [
      [
        { "text": "Approve", "callback_data": "approve_123" },
        { "text": "Reject", "callback_data": "reject_123" }
      ],
      [{ "text": "Visit site", "url": "https://example.com" }]
    ]
  }
}
```

### Handle Callback Queries

```typescript
if (update.callback_query) {
  const { id, data, message } = update.callback_query;

  // Answer the callback (removes loading spinner)
  await fetch(`${API}/answerCallbackQuery`, {
    method: 'POST',
    body: JSON.stringify({ callback_query_id: id })
  });

  // Process the action
  if (data.startsWith('approve_')) {
    await editMessage(message.chat.id, message.message_id, 'Approved!');
  }
}
```

## Media Messages

```bash
# Send photo
POST /bot<TOKEN>/sendPhoto
{ "chat_id": 123456, "photo": "https://example.com/image.jpg", "caption": "Check this" }

# Send document
POST /bot<TOKEN>/sendDocument
{ "chat_id": 123456, "document": "https://example.com/file.pdf" }
```

## Rate Limits

- **Private chats**: 1 message per second per chat
- **Groups**: 20 messages per minute per group
- **Global**: 30 messages per second across all chats
- **Bulk notifications**: Use `sendMessage` in a loop with 1/30s delay between calls


### Webhook Security

# Webhook Security

Patterns for verifying webhook authenticity and preventing replay attacks across messaging platforms.

## Core Principle

Never trust incoming webhooks without verification. All platforms provide a mechanism to prove the request originated from them.

## HMAC-SHA256 Verification (Generic)

```typescript
import crypto from 'crypto';

function verifyHmacSignature(
  payload: string | Buffer,
  signature: string,
  secret: string,
  prefix = ''
): boolean {
  const expected = prefix + crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}
```

## Platform-Specific Verification

### Slack

```typescript
function verifySlackWebhook(req: Request, signingSecret: string): boolean {
  const timestamp = req.headers['x-slack-request-timestamp'] as string;
  const signature = req.headers['x-slack-signature'] as string;

  // Replay protection: reject requests older than 5 minutes
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return false;
  }

  const baseString = `v0:${timestamp}:${req.rawBody}`;
  const expected = 'v0=' + crypto
    .createHmac('sha256', signingSecret)
    .update(baseString)
    .digest('hex');

  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
```

### WhatsApp (Meta Business API)

```typescript
function verifyMetaWebhook(req: Request, appSecret: string): boolean {
  const signature = (req.headers['x-hub-signature-256'] as string)?.replace('sha256=', '');
  if (!signature) return false;

  const expected = crypto
    .createHmac('sha256', appSecret)
    .update(req.rawBody)
    .digest('hex');

  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
```

### Telegram

Telegram uses a simpler token-based approach:

```typescript
function verifyTelegramWebhook(req: Request, secretToken: string): boolean {
  return req.headers['x-telegram-bot-api-secret-token'] === secretToken;
}
```

## Replay Protection

Signatures alone don't prevent replay attacks. Add timestamp validation:

```typescript
function isReplayAttack(timestamp: number, maxAgeSeconds = 300): boolean {
  return Math.abs(Date.now() / 1000 - timestamp) > maxAgeSeconds;
}
```

**Apply before signature check** — reject stale requests early.

## Idempotency

Messaging platforms may send duplicate webhooks (network retries, platform bugs). Track processed message IDs:

```typescript
async function processWebhook(messageId: string, handler: () => Promise<void>): Promise<void> {
  // Atomic check-and-set using Redis
  const isNew = await redis.set(`webhook:${messageId}`, '1', 'EX', 86400, 'NX');
  if (!isNew) return; // Already processed
  await handler();
}
```

**Key rules:**
- Use `timingSafeEqual` for all signature comparisons (prevents timing attacks)
- Validate timestamps before checking signatures (cheap rejection of stale requests)
- Store processed message IDs with TTL (24h is typical) for deduplication
- Use raw request body for signature verification — parsed JSON may differ from original bytes


### Whatsapp Waha

# WhatsApp via WAHA

WAHA (WhatsApp HTTP API) provides a self-hosted REST API for WhatsApp messaging without Meta's Business API.

## Setup

Run WAHA as a Docker container:

```bash
docker run -d \
  --name waha \
  -p 3000:3000 \
  -e WHATSAPP_HOOK_URL=https://app.com/webhook/whatsapp \
  -e WHATSAPP_HOOK_EVENTS=message,session.status \
  devlikeapro/waha:latest
```

For production, use Docker Compose with persistent storage:

```yaml
services:
  waha:
    image: devlikeapro/waha:latest
    ports:
      - "3000:3000"
    environment:
      WHATSAPP_HOOK_URL: https://app.com/webhook/whatsapp
      WHATSAPP_HOOK_EVENTS: message,message.ack,session.status
      WAHA_DASHBOARD_ENABLED: "true"
    volumes:
      - waha_data:/app/.sessions
    restart: unless-stopped

volumes:
  waha_data:
```

## Session Lifecycle

```bash
# Create and start session
POST /api/sessions/start
{ "name": "main", "config": { "proxy": null, "webhooks": [{ "url": "...", "events": ["message"] }] } }

# Get QR code for authentication
GET /api/sessions/main/qr   # Returns QR image
GET /api/sessions/main/auth  # Returns pairing code alternative

# Check session status
GET /api/sessions/main
# Response: { "name": "main", "status": "WORKING" | "SCAN_QR" | "STOPPED" }

# Stop session
POST /api/sessions/stop
{ "name": "main" }
```

**Status flow**: `STARTING` -> `SCAN_QR` -> `WORKING` -> `STOPPED`

## Message Types

### Text

```bash
POST /api/sendText
{ "session": "main", "chatId": "1234567890@c.us", "text": "Hello!" }
```

### Image / Document / Video

```bash
POST /api/sendFile
{
  "session": "main",
  "chatId": "1234567890@c.us",
  "file": {
    "mimetype": "image/jpeg",
    "url": "https://example.com/image.jpg",
    "filename": "photo.jpg"
  },
  "caption": "Check this out"
}
```

### Location

```bash
POST /api/sendLocation
{ "session": "main", "chatId": "1234567890@c.us", "latitude": 32.0853, "longitude": 34.7818 }
```

## Group Messaging

```bash
# chatId for groups uses @g.us suffix
POST /api/sendText
{ "session": "main", "chatId": "120363001234567890@g.us", "text": "Group message" }

# List groups
GET /api/groups?session=main
```

## Handling Incoming Messages

WAHA posts to your webhook URL:

```json
{
  "event": "message",
  "session": "main",
  "payload": {
    "id": "true_1234567890@c.us_AABBCCDD",
    "from": "1234567890@c.us",
    "to": "0987654321@c.us",
    "body": "User message text",
    "timestamp": 1707900000,
    "hasMedia": false
  }
}
```

**Key points:**
- `@c.us` suffix = individual chat, `@g.us` = group chat
- `id` is unique per message — use for deduplication
- Media messages have `hasMedia: true` and a separate download endpoint
- Session status changes also come via webhook (`session.status` event)



---

## Examples (2)

### Fastapi Problem Details

# FastAPI Problem Details Implementation

Complete example implementing RFC 9457 Problem Details in FastAPI.

## Problem Detail Schema

```python
# app/core/exceptions.py
from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime, timezone
from typing import Any


class ProblemDetail(BaseModel):
    """RFC 9457 Problem Details response schema."""

    type: str = Field(
        default="about:blank",
        description="URI reference identifying the problem type",
    )
    title: str = Field(
        description="Short, human-readable summary",
    )
    status: int = Field(
        description="HTTP status code",
    )
    detail: str | None = Field(
        default=None,
        description="Human-readable explanation specific to this occurrence",
    )
    instance: str | None = Field(
        default=None,
        description="URI reference identifying the specific occurrence",
    )
    # Common extensions
    trace_id: str | None = None
    timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

    model_config = ConfigDict(
        json_schema_extra={
            "example": {
                "type": "https://api.orchestkit.dev/problems/validation-error",
                "title": "Validation Error",
                "status": 422,
                "detail": "The url field is required",
                "instance": "/api/v1/analyses",
                "trace_id": "abc123",
                "timestamp": "2026-01-07T10:30:00Z",
            }
        }
    )


class ValidationProblem(ProblemDetail):
    """Problem detail with validation errors."""

    errors: list[dict[str, Any]] = Field(
        default_factory=list,
        description="List of validation errors",
    )


class RateLimitProblem(ProblemDetail):
    """Problem detail for rate limiting."""

    retry_after: int = Field(description="Seconds until retry is allowed")
    limit: int = Field(description="Request limit")
    window: str = Field(description="Time window for limit")
```

## Custom Exception Classes

```python
# app/core/exceptions.py
from fastapi import HTTPException


class ProblemException(Exception):
    """Base exception that renders as RFC 9457 Problem Detail."""

    def __init__(
        self,
        status_code: int,
        problem_type: str,
        title: str,
        detail: str | None = None,
        instance: str | None = None,
        **extensions,
    ):
        self.status_code = status_code
        self.problem_type = problem_type
        self.title = title
        self.detail = detail
        self.instance = instance
        self.extensions = extensions

    def to_problem_detail(self, trace_id: str | None = None) -> dict:
        """Convert to Problem Detail dict."""
        problem = {
            "type": self.problem_type,
            "title": self.title,
            "status": self.status_code,
            "timestamp": datetime.now(timezone.utc).isoformat(),
        }
        if self.detail:
            problem["detail"] = self.detail
        if self.instance:
            problem["instance"] = self.instance
        if trace_id:
            problem["trace_id"] = trace_id
        problem.update(self.extensions)
        return problem


class ResourceNotFoundError(ProblemException):
    """Resource not found error."""

    def __init__(
        self,
        resource_type: str,
        resource_id: str,
    ):
        super().__init__(
            status_code=404,
            problem_type="https://api.orchestkit.dev/problems/resource-not-found",
            title="Resource Not Found",
            detail=f"{resource_type} with ID '{resource_id}' was not found",
            resource_type=resource_type,
            resource_id=resource_id,
        )


class ValidationError(ProblemException):
    """Validation error with field-level details."""

    def __init__(
        self,
        errors: list[dict],
        detail: str = "One or more fields failed validation",
    ):
        super().__init__(
            status_code=422,
            problem_type="https://api.orchestkit.dev/problems/validation-error",
            title="Validation Error",
            detail=detail,
            errors=errors,
        )


class ConflictError(ProblemException):
    """Resource conflict error."""

    def __init__(
        self,
        detail: str,
        conflicting_field: str | None = None,
    ):
        super().__init__(
            status_code=409,
            problem_type="https://api.orchestkit.dev/problems/resource-conflict",
            title="Resource Conflict",
            detail=detail,
            conflicting_field=conflicting_field,
        )


class RateLimitError(ProblemException):
    """Rate limit exceeded error."""

    def __init__(
        self,
        retry_after: int,
        limit: int,
        window: str = "1 minute",
    ):
        super().__init__(
            status_code=429,
            problem_type="https://api.orchestkit.dev/problems/rate-limit-exceeded",
            title="Rate Limit Exceeded",
            detail=f"You have exceeded {limit} requests per {window}",
            retry_after=retry_after,
            limit=limit,
            window=window,
        )


class AuthenticationError(ProblemException):
    """Authentication required error."""

    def __init__(self, detail: str = "Authentication is required"):
        super().__init__(
            status_code=401,
            problem_type="https://api.orchestkit.dev/problems/authentication-required",
            title="Authentication Required",
            detail=detail,
        )


class AuthorizationError(ProblemException):
    """Insufficient permissions error."""

    def __init__(
        self,
        detail: str = "You don't have permission to access this resource",
        required_permission: str | None = None,
    ):
        super().__init__(
            status_code=403,
            problem_type="https://api.orchestkit.dev/problems/insufficient-permissions",
            title="Insufficient Permissions",
            detail=detail,
            required_permission=required_permission,
        )
```

## Exception Handlers

```python
# app/core/exception_handlers.py
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from sqlalchemy.exc import IntegrityError
from pydantic import ValidationError as PydanticValidationError

from app.core.exceptions import ProblemException


def setup_exception_handlers(app: FastAPI):
    """Register all exception handlers."""

    @app.exception_handler(ProblemException)
    async def problem_exception_handler(
        request: Request,
        exc: ProblemException,
    ) -> JSONResponse:
        """Handle custom problem exceptions."""
        trace_id = getattr(request.state, "request_id", None)
        exc.instance = request.url.path

        return JSONResponse(
            status_code=exc.status_code,
            content=exc.to_problem_detail(trace_id),
            media_type="application/problem+json",
        )

    @app.exception_handler(RequestValidationError)
    async def validation_exception_handler(
        request: Request,
        exc: RequestValidationError,
    ) -> JSONResponse:
        """Handle Pydantic validation errors."""
        errors = []
        for error in exc.errors():
            errors.append({
                "field": ".".join(str(x) for x in error["loc"][1:]),  # Skip 'body'
                "code": error["type"],
                "message": error["msg"],
            })

        trace_id = getattr(request.state, "request_id", None)

        return JSONResponse(
            status_code=422,
            content={
                "type": "https://api.orchestkit.dev/problems/validation-error",
                "title": "Validation Error",
                "status": 422,
                "detail": "Request validation failed",
                "instance": request.url.path,
                "trace_id": trace_id,
                "timestamp": datetime.now(timezone.utc).isoformat(),
                "errors": errors,
            },
            media_type="application/problem+json",
        )

    @app.exception_handler(IntegrityError)
    async def integrity_error_handler(
        request: Request,
        exc: IntegrityError,
    ) -> JSONResponse:
        """Handle database integrity errors."""
        trace_id = getattr(request.state, "request_id", None)

        # Parse constraint name from error
        detail = "A database constraint was violated"
        if "unique" in str(exc.orig).lower():
            detail = "A resource with this value already exists"

        return JSONResponse(
            status_code=409,
            content={
                "type": "https://api.orchestkit.dev/problems/resource-conflict",
                "title": "Resource Conflict",
                "status": 409,
                "detail": detail,
                "instance": request.url.path,
                "trace_id": trace_id,
                "timestamp": datetime.now(timezone.utc).isoformat(),
            },
            media_type="application/problem+json",
        )

    @app.exception_handler(Exception)
    async def generic_exception_handler(
        request: Request,
        exc: Exception,
    ) -> JSONResponse:
        """Handle unexpected exceptions."""
        import structlog
        logger = structlog.get_logger()

        trace_id = getattr(request.state, "request_id", None)

        # Log the full error
        logger.exception(
            "unhandled_exception",
            trace_id=trace_id,
            path=request.url.path,
            error=str(exc),
        )

        return JSONResponse(
            status_code=500,
            content={
                "type": "https://api.orchestkit.dev/problems/internal-error",
                "title": "Internal Server Error",
                "status": 500,
                "detail": "An unexpected error occurred. Please try again later.",
                "instance": request.url.path,
                "trace_id": trace_id,
                "timestamp": datetime.now(timezone.utc).isoformat(),
                "support_url": "https://support.orchestkit.dev",
            },
            media_type="application/problem+json",
        )
```

## Usage in Routes

```python
# app/api/v1/routes/analyses.py
from fastapi import APIRouter, Depends
from app.core.exceptions import ResourceNotFoundError, ValidationError

router = APIRouter()

@router.get("/analyses/{analysis_id}")
async def get_analysis(
    analysis_id: str,
    service: AnalysisService = Depends(get_analysis_service),
):
    """Get analysis by ID."""
    analysis = await service.get_by_id(analysis_id)

    if not analysis:
        raise ResourceNotFoundError(
            resource_type="Analysis",
            resource_id=analysis_id,
        )

    return AnalysisResponse.from_domain(analysis)


@router.post("/analyses")
async def create_analysis(
    request: AnalyzeRequest,
    service: AnalysisService = Depends(get_analysis_service),
):
    """Create a new analysis."""
    # Custom validation beyond Pydantic
    if not is_valid_url(str(request.url)):
        raise ValidationError(
            errors=[
                {
                    "field": "url",
                    "code": "invalid_url",
                    "message": "URL is not accessible or returns an error",
                }
            ]
        )

    return await service.create(request)
```

## OpenAPI Documentation

```python
# app/api/v1/routes/analyses.py
from fastapi import APIRouter
from app.core.exceptions import ProblemDetail, ValidationProblem

router = APIRouter()

@router.get(
    "/analyses/{analysis_id}",
    responses={
        404: {
            "model": ProblemDetail,
            "description": "Analysis not found",
            "content": {
                "application/problem+json": {
                    "example": {
                        "type": "https://api.orchestkit.dev/problems/resource-not-found",
                        "title": "Resource Not Found",
                        "status": 404,
                        "detail": "Analysis with ID 'abc123' was not found",
                    }
                }
            },
        },
        500: {
            "model": ProblemDetail,
            "description": "Internal server error",
        },
    },
)
async def get_analysis(analysis_id: str):
    ...
```

## Testing

```python
# tests/test_error_handling.py
import pytest
from httpx import AsyncClient

@pytest.mark.asyncio
async def test_not_found_returns_problem_detail(client: AsyncClient):
    response = await client.get("/api/v1/analyses/nonexistent")

    assert response.status_code == 404
    assert response.headers["content-type"] == "application/problem+json"

    problem = response.json()
    assert problem["type"] == "https://api.orchestkit.dev/problems/resource-not-found"
    assert problem["status"] == 404
    assert "Analysis" in problem["detail"]

@pytest.mark.asyncio
async def test_validation_error_includes_field_errors(client: AsyncClient):
    response = await client.post("/api/v1/analyses", json={"url": "not-a-url"})

    assert response.status_code == 422
    assert response.headers["content-type"] == "application/problem+json"

    problem = response.json()
    assert problem["type"] == "https://api.orchestkit.dev/problems/validation-error"
    assert "errors" in problem
    assert any(e["field"] == "url" for e in problem["errors"])
```


### Orchestkit Api Design

# OrchestKit API Design Decisions

Real-world API design decisions from the OrchestKit project, documenting endpoint structure, versioning strategy, and architectural choices.

## Project Context

**OrchestKit**: Intelligent Learning Integration Platform - Multi-agent system for analyzing technical content.

**Stack**: FastAPI (Python) + React 19 frontend
**API Base**: `http://localhost:8500/api/v1`
**Development Ports**:
- Backend API: `localhost:8500`
- Frontend: `localhost:5173`
- PostgreSQL: `localhost:5437`

## API Structure

### URI Versioning

**Decision**: Use URI-based versioning (`/api/v1/`)

**Location**: `backend/app/core/config.py`
```python
API_V1_PREFIX = "/api/v1"
```

**Rationale**:
- Clear visibility in URLs for debugging
- Easy to route different versions to different handlers
- Frontend can easily target specific API versions
- Cache-friendly (CDNs can cache different versions separately)

**Implementation**: `backend/app/main.py`
```python
from app.core.config import settings

# Include analysis router with versioned prefix
app.include_router(
    analysis_router,
    prefix=f"{settings.API_V1_PREFIX}/analyze"
)

# Include artifact router
app.include_router(
    artifact_router,
    prefix=settings.API_V1_PREFIX
)
```

## Endpoint Design

### Analysis Endpoints

**Location**: `backend/app/api/v1/analysis/endpoints.py`

#### 1. Create Analysis (Async Task Pattern)

```python
POST /api/v1/analyze
Content-Type: application/json

{
  "url": "https://example.com/article",
  "analysis_id": "optional-custom-id",  # Optional
  "skill_level": "beginner"              # Optional: beginner|intermediate|advanced
}
```

**Response**: `201 Created`
```json
{
  "analysis_id": "550e8400-e29b-41d4-a716-446655440000",
  "url": "https://example.com/article",
  "content_type": "article",
  "status": "pending",
  "sse_endpoint": "/api/v1/analyze/550e8400-e29b-41d4-a716-446655440000/stream"
}
```

**Design Decision**: Return immediately with analysis_id + SSE endpoint
- **Why**: Analysis workflow takes 30-120 seconds to complete
- **Pattern**: Async task creation + progress streaming (see SSE section)
- **Client flow**: Create analysis → Connect to SSE endpoint → Receive progress updates

**Implementation**:
```python
@router.post(
    "/analyze",
    status_code=status.HTTP_201_CREATED,
    responses={
        422: {"model": ErrorResponse, "description": "Validation error"},
        500: {"model": ErrorResponse, "description": "Internal server error"}
    }
)
async def create_analysis(
    request: AnalyzeRequest,
    fastapi_request: Request,
    analysis_repo: Annotated[IAnalysisRepository, Depends(get_analysis_repository)]
) -> AnalyzeCreateResponse:
    """Create analysis and start workflow asynchronously."""

    # 1. Detect content type
    content_type = detect_content_type(str(request.url))

    # 2. Normalize custom analysis_id if provided (optional)
    analysis_uuid = (
        normalize_analysis_id_to_uuid(request.analysis_id)
        if request.analysis_id
        else None  # Let DB generate UUID v7 via server_default
    )

    # 3. Create Analysis record (status: pending)
    # PostgreSQL 18 generates UUID v7 via server_default=text("uuidv7()")
    created_analysis = await analysis_repo.create_analysis(
        analysis_id=analysis_uuid,  # None → DB generates UUID v7
        url=url_str,
        content_type=content_type,
        status="pending"
    )
    analysis_uuid = cast("AnalysisID", created_analysis.id)

    # 4. Start workflow asynchronously (fire-and-forget)
    task = asyncio.create_task(
        run_workflow_task(analysis_uuid, url_str, request.skill_level)
    )
    background_tasks = fastapi_request.app.state.background_tasks
    background_tasks.add(task)
    task.add_done_callback(partial(_handle_task_completion, background_tasks=background_tasks))

    # 5. Return immediately with SSE endpoint
    sse_endpoint = f"{settings.API_V1_PREFIX}/analyze/{analysis_uuid}/stream"

    return AnalyzeCreateResponse(
        analysis_id=str(analysis_uuid),
        url=url_str,
        content_type=content_type,
        status="pending",
        sse_endpoint=sse_endpoint
    )
```

#### 2. Get Analysis Status

```python
GET /api/v1/analyze/{analysis_id}
```

**Response**: `200 OK`
```json
{
  "analysis_id": "550e8400-e29b-41d4-a716-446655440000",
  "url": "https://example.com/article",
  "content_type": "article",
  "status": "completed",
  "title": "Understanding React Server Components",
  "artifact_id": "660e8400-e29b-41d4-a716-446655440001",
  "created_at": "2025-12-21T10:30:00Z",
  "updated_at": "2025-12-21T10:32:45Z"
}
```

**Design Decision**: Return latest artifact_id in status response
- **Why**: Frontend needs artifact_id to fetch results
- **Alternative considered**: Separate endpoint for artifact lookup (rejected: extra round trip)

#### 3. Stream Analysis Progress (SSE)

```python
GET /api/v1/analyze/{analysis_id}/stream
Accept: text/event-stream
```

**Response**: Server-Sent Events stream
```
event: progress
data: {"type":"progress","stage":"extraction","status":"running","timestamp":"2025-12-21T10:30:15Z"}

event: progress
data: {"type":"progress","stage":"extraction","status":"complete","word_count":5234}

event: progress
data: {"type":"progress","stage":"analysis","status":"running","agent":"tech_comparator"}

event: complete
data: {"type":"complete","stage":"artifact_generation","timestamp":"2025-12-21T10:32:45Z"}
```

**Design Decision**: Use SSE instead of WebSockets
- **Why**: Unidirectional (server→client) is sufficient for progress updates
- **Benefit**: Simpler client code (native EventSource API), automatic reconnection
- **Trade-off**: No client→server messaging (not needed for this use case)

See `rules/streaming-sse.md` in this skill for details.

### Artifact Endpoints

**Location**: `backend/app/api/v1/analysis/artifacts.py`

#### 1. Get Artifact by Analysis

```python
GET /api/v1/analyze/{analysis_id}/artifact
```

**Response**: `200 OK`
```json
{
  "artifact_id": "660e8400-e29b-41d4-a716-446655440001",
  "analysis_id": "550e8400-e29b-41d4-a716-446655440000",
  "markdown_content": "# Understanding React Server Components\n\n...",
  "artifact_metadata": {
    "word_count": 5234,
    "section_count": 8
  },
  "trace_id": "trace_abc123",
  "created_at": "2025-12-21T10:32:45Z"
}
```

**Design Decision**: Hierarchical URL (`/analyze/\{id\}/artifact`)
- **Why**: Expresses relationship: "artifact belongs to analysis"
- **Alternative considered**: `/artifacts?analysis_id=\{id\}` (rejected: less RESTful)

#### 2. Get Artifact by ID

```python
GET /api/v1/artifacts/{artifact_id}
```

**Response**: Same as above

**Design Decision**: Provide both hierarchical AND direct ID lookup
- **Why**: Support different frontend access patterns
- **Use case 1**: After analysis complete → use hierarchical endpoint
- **Use case 2**: Direct link to artifact → use ID endpoint

#### 3. Download Artifact

```python
GET /api/v1/artifacts/{artifact_id}/download
```

**Response**: `200 OK` (file download)
```
Content-Type: text/markdown
Content-Disposition: attachment; filename="understanding-react-server-components-550e8400.md"

# Understanding React Server Components
...
```

**Design Decision**: Separate download endpoint with different response type
- **Why**: Different headers (Content-Disposition) and analytics (download_count)
- **Benefit**: Clean separation of view vs. download use cases

**Implementation**:
```python
@router.get("/artifacts/{artifact_id}/download", response_class=Response)
async def download_artifact(
    artifact_id: uuid.UUID,
    repo: Annotated[IArtifactRepository, Depends(get_artifact_repository)]
) -> Response:
    # Get artifact with analysis (for title)
    result = await repo.get_artifact_with_analysis(artifact_id)
    if not result:
        raise HTTPException(status_code=404, detail="Artifact not found")

    artifact, analysis = result

    # Extract title from analysis metadata
    title = None
    if analysis.extraction_metadata:
        title = analysis.extraction_metadata.get("title")

    # Generate filename: "article-title-uuid.md"
    filename = generate_filename(title, str(artifact.analysis_id))

    # Increment download_count for analytics
    await repo.increment_download_count(artifact_id)

    # Return with download headers
    return Response(
        content=artifact.markdown_content,
        media_type="text/markdown",
        headers={"Content-Disposition": f'attachment; filename="{filename}"'}
    )
```

### Health Check Endpoint

**Location**: `backend/app/api/v1/health.py`

```python
GET /api/v1/health
```

**Response**: `200 OK`
```json
{
  "status": "healthy",
  "version": "0.1.0",
  "environment": "development",
  "database": {
    "status": "connected"
  }
}
```

**Design Decision**: Include database connectivity check
- **Why**: Kubernetes readiness/liveness probes need to verify DB connection
- **Timeout**: 5 seconds (configurable via DB_TIMEOUT constant)
- **Error response**: Still returns 200 OK, but with `database.status: "disconnected"`

## Error Handling

### Standardized Error Format

**Location**: `backend/app/api/schemas/errors.py`

```python
from pydantic import BaseModel, ConfigDict

class ErrorResponse(BaseModel):
    error: dict[str, Any]

    model_config = ConfigDict(
        json_schema_extra={
            "example": {
                "error": {
                    "code": "VALIDATION_ERROR",
                    "message": "Request validation failed",
                    "timestamp": "2025-12-21T10:30:00Z"
                }
            }
        }
    )
```

### Example Error Responses

**404 Not Found**:
```json
{
  "error": {
    "code": "NOT_FOUND",
    "message": "Artifact 660e8400-e29b-41d4-a716-446655440001 not found",
    "timestamp": "2025-12-21T10:30:00Z"
  }
}
```

**422 Validation Error**:
```python
try:
    content_type = detect_content_type(url_str)
except ContentTypeError as e:
    raise HTTPException(
        status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
        detail=f"Invalid URL format: {e!s}"
    ) from e
```

Response:
```json
{
  "error": {
    "code": "UNPROCESSABLE_ENTITY",
    "message": "Invalid URL format: Must be a valid HTTP/HTTPS URL",
    "timestamp": "2025-12-21T10:30:00Z"
  }
}
```

## URL Normalization

### UUID Analysis IDs

**Decision**: Always use UUIDs for analysis_id (not string slugs)

**Normalization logic**: `backend/app/core/utils.py`
```python
def normalize_analysis_id_to_uuid(analysis_id: str) -> uuid.UUID:
    """Normalize analysis_id to UUID format.

    Supports:
    - Full UUID: "550e8400-e29b-41d4-a716-446655440000"
    - Short form: "550e8400" (first 8 chars)
    """
    # Try parsing as full UUID
    try:
        return uuid.UUID(analysis_id)
    except ValueError:
        pass

    # Try short form (8 chars)
    if len(analysis_id) == 8:
        try:
            # Pad to full UUID format
            full_uuid = f"{analysis_id}-0000-0000-0000-000000000000"
            return uuid.UUID(full_uuid)
        except ValueError:
            pass

    raise ValueError(f"Invalid analysis_id format: {analysis_id}")
```

**Benefit**: Allows short URLs while maintaining UUID uniqueness

## Repository Pattern

### Dependency Injection

**Pattern**: Use FastAPI Depends() for repository injection

```python
from typing import Annotated

@router.get("/artifacts/{artifact_id}")
async def get_artifact(
    artifact_id: Annotated[uuid.UUID, Path(description="Artifact UUID")],
    repo: Annotated[IArtifactRepository, Depends(get_artifact_repository)]
) -> ArtifactMetadataResponse:
    artifact = await repo.get_artifact_by_id(artifact_id)
    ...
```

**Benefits**:
- Easy testing (mock repository)
- Clean separation of concerns
- Type-safe with Annotated

## API Documentation

### OpenAPI Spec

**Auto-generated**: Available at `/docs` (Swagger UI) and `/redoc` (ReDoc)

**Custom documentation**:
```python
@router.get(
    "/analyze/{analysis_id}/stream",
    responses={
        404: {"model": ErrorResponse, "description": "Analysis not found"},
        500: {"model": ErrorResponse, "description": "Internal server error"}
    }
)
async def stream_analysis_progress_endpoint(
    analysis_id: Annotated[uuid.UUID, Path(description="Analysis UUID")],
    request: Request
):
    """Stream real-time analysis progress via Server-Sent Events (SSE).

    See app.api.v1.sse_handler.stream_analysis_progress for full documentation.
    """
    return await stream_analysis_progress_handler(analysis_id, request)
```

## Design Principles

### 1. Immediate Response for Long Operations

**Pattern**: Create → Return ID + Progress URL
- **Example**: POST /analyze → Returns analysis_id + sse_endpoint
- **Why**: Prevents timeout on long-running operations
- **Client UX**: Show loading state with progress updates

### 2. Include Related Resource URLs

**Pattern**: Include navigation URLs in responses
```json
{
  "analysis_id": "123",
  "sse_endpoint": "/api/v1/analyze/123/stream",  ← Progress URL
  "artifact_id": "456"                            ← Related resource
}
```

**Benefit**: Frontend doesn't need to construct URLs

### 3. Hierarchical URLs for Relationships

**Pattern**: `/parent/\{id\}/child` for 1:1 or 1:many relationships
- `/analyze/\{analysis_id\}/artifact` - Analysis has one latest artifact
- `/teams/\{team_id\}/members` - Team has many members

**Benefit**: Clear relationship modeling

### 4. UUID Path Parameters

**Pattern**: Use typed UUID path parameters
```python
analysis_id: Annotated[uuid.UUID, Path(description="Analysis UUID")]
```

**Benefit**: Automatic validation (400 if not valid UUID)

### 5. Repository + Dependency Injection

**Pattern**: Abstract database access behind repository interface
```python
class IArtifactRepository(Protocol):
    async def get_artifact_by_id(self, artifact_id: uuid.UUID) -> Artifact | None: ...

def get_artifact_repository() -> IArtifactRepository:
    return ArtifactRepository(get_db_session())
```

**Benefits**:
- Easy to mock for testing
- Clean architecture
- Database-agnostic API layer

## Related Files

- **SSE Implementation**: `backend/app/api/v1/analysis/sse_handler.py`
- **Event Broadcaster**: `backend/app/shared/services/messaging/broadcaster.py`
- **Error Schemas**: `backend/app/api/schemas/errors.py`
- **Config**: `backend/app/core/config.py`
- **API Schemas**: `backend/app/domains/analysis/schemas/api.py`

## References

- See `references/rest-patterns.md` for general REST patterns
- See `streaming-api-patterns` skill for SSE implementation details
- See `assets/openapi-template.yaml` for OpenAPI specification template
