Thanks for your interest in contributing! This guide covers the basics of adding recipes or improvements to the MAX Agentic Cookbook.
Note: This is a new project and we're still figuring things out. We're not sure how many folks will contribute yet, so we're keeping the process lightweight and flexible. If something doesn't work or you have ideas to improve the workflow, let us know!
- Python 3.11 or higher
- Node.js 22.x or higher
- uv - Fast Python package installer (install here)
Note: You'll need to fork the repo first. Direct push access is restricted.
-
Fork github.com/modular/max-agentic-cookbook on GitHub
-
Clone your fork:
git clone https://github.com/YOUR_USERNAME/max-recipes.git cd max-recipes -
Add upstream:
git remote add upstream https://github.com/modular/max-agentic-cookbook.git
-
Create a branch in your fork:
git checkout -b feature/your-feature-name
-
Make changes and test locally with both dev servers running
-
Format frontend code:
cd frontend npm run format -
Run tests (optional but recommended):
cd frontend # Run unit tests npm run test:run # Run E2E tests (requires browser installation: npm run playwright:install) npm run test:e2e
-
Commit with clear messages:
git commit -m "Add RAG recipe" -
Push to your fork:
git push origin feature/your-feature-name
-
Create PR from your fork to upstream
mainbranch
- Use descriptive titles:
Add XorFix Y - Include what changed and why
- Add screenshots for UI changes (optional)
- Address review feedback by pushing to your branch
Maintainers will review and merge approved PRs.
Keep your fork updated:
git fetch upstream
git checkout main
git rebase upstream/main
git push origin mainThe Cookbook uses a FastAPI backend + React SPA architecture with separate backend and frontend projects.
max-recipes/
├── backend/ # FastAPI + uv (Python 3.11+)
│ ├── src/
│ │ ├── main.py # Entry point
│ │ ├── core/ # Config and utilities
│ │ └── recipes/ # Recipe routers
│ └── pyproject.toml # Python dependencies
│
├── frontend/ # Vite + React + TypeScript SPA
│ ├── src/
│ │ ├── recipes/ # Recipe components + data
│ │ │ ├── registry.ts # Recipe metadata (pure data)
│ │ │ └── components.ts # React component mapping
│ │ ├── components/ # Shared UI components
│ │ ├── routing/ # Routing infrastructure
│ │ ├── lib/ # Custom hooks, API, types
│ │ └── App.tsx # Entry point
│ └── package.json # Frontend dependencies
│
└── docs/ # Documentation
Recipes consist of multiple files in specific frontend and backend locations:
Frontend files (frontend/src/recipes/[recipe-name]/):
ui.tsx- React component (exportsComponentfunction)README.mdx- Documentation (MDX format)
Backend file (backend/src/recipes/[recipe_name].py):
[recipe_name].py- FastAPI router with recipe logic
Example recipes to reference:
- SSE Streaming:
multiturn-chat(frontend) +multiturn_chat.py(backend) - NDJSON Progressive Streaming:
image-captioning(frontend) +image_captioning.py(backend) - Batch Processing:
batch-text-classification(frontend) +batch_text_classification.py(backend)
Registration steps:
-
Add recipe data to
frontend/src/recipes/registry.ts:{ slug: 'my-recipe', title: 'My Recipe', tags: ['Technology', 'Pattern'], description: 'What this recipe does...' }
-
Register components in
frontend/src/recipes/components.ts:// UI component export const recipeComponents = { 'my-recipe': lazyComponentExport(() => import('./my-recipe/ui')), // ... } // README component export const readmeComponents = { 'my-recipe': lazy(() => import('./my-recipe/README.mdx')), // ... }
-
Create backend router in
backend/src/recipes/my_recipe.py -
Include router in
backend/src/main.py:from .recipes import my_recipe app.include_router(my_recipe.router)
Routes auto-generate from registry - no manual route definitions needed!
Recipe System Architecture:
Recipes are registered in two files:
registry.ts- Pure data (slug, title, tags, description)components.ts- React component mapping (UI and README components)
This separation keeps data structures independent from React, making the registry easier to work with and reducing coupling.
Style Guidelines:
- Functional components with React hooks
- 4 spaces, no semicolons, single quotes (as defined in the Prettier config)
Type Safety (Critical):
Never use the any type in TypeScript code.
- ❌ Never use
any- bypasses type checking and defeats the purpose of TypeScript - ✅ Use
unknown- for truly dynamic data that will be validated at runtime (e.g.,body: unknownin fetch calls) - ✅ Use proper interfaces - define explicit interfaces like
RecipePropsfor component props - ✅ Use generic types - e.g.,
ComponentType<RecipeProps>instead ofComponentType<any> - ✅ Use type guards -
isImplemented(recipe)for runtime type narrowing
// ❌ BAD - bypasses type checking
function process(data: any) { ... }
const Component: ComponentType<any> = ...
// ✅ GOOD - maintains type safety
function process(data: unknown) { ... } // Will validate before use
const Component: ComponentType<RecipeProps> = ...
interface RecipeProps {
endpoint: Endpoint | null
model: Model | null
pathname: string
}Shared Types:
All shared types live in frontend/src/lib/types.ts for consistency across the codebase.
Key types defined:
- Backend API types:
Recipe,HealthCheckResponse,RecipesListResponse - LLM types:
Endpoint,Model - Component props:
RecipeProps - Recipe metadata types:
RecipePlaceholder,RecipeImplemented,RecipeItem,RecipeMetadata - Navigation types:
NavItem,NavSection
Path Aliases:
The project uses TypeScript path aliases to simplify imports and avoid relative path hell. These aliases are configure in vite.config.ts.
// Before: relative imports
import { theme } from '../../../lib/theme'
import { Header } from '../../components/Header'
// After: path aliases
import { theme } from '~/lib/theme'
import { Header } from '~/components/Header'The project strictly separates data structures from React components to maintain clean architecture:
✅ DO:
- Keep data structures in pure TypeScript files (no React imports)
- Put React component mappings in separate files
- Example:
registry.ts(pure data) +components.ts(React components)
❌ DON'T:
- Mix React imports with data definitions
- Include component references in data structures
- Use inline component definitions in configuration objects
Example:
// ✅ GOOD - registry.ts (pure data)
export const recipes = {
Foundations: [
{
slug: 'multiturn-chat',
title: 'Multi-Turn Chat',
tags: ['SSE', 'Streaming'],
description: '...',
},
],
}
// ✅ GOOD - components.ts (React mapping)
import { lazy } from 'react'
export const recipeComponents = {
'multiturn-chat': lazy(() => import('./multiturn-chat/ui')),
}
// ❌ BAD - mixing data with React
import { lazy } from 'react'
export const recipes = {
Foundations: [
{
slug: 'multiturn-chat',
component: lazy(() => import('./multiturn-chat/ui')), // ❌ React in data
},
],
}Always define shared types in lib/types.ts rather than using adhoc inline objects or duplicating type definitions.
✅ DO:
- Define interfaces in
lib/types.tsfor any type used in multiple places - Import and reuse types across components
- Use type guards for runtime validation
❌ DON'T:
- Define inline object types with hardcoded properties
- Duplicate type definitions across files
- Use anonymous types in function parameters
Example:
// ✅ GOOD - in lib/types.ts
export interface NavItem {
number: number
title: string
tags?: string[]
slug?: string
}
// ✅ GOOD - components import and use
import type { NavItem } from '~/lib/types'
function NavItem({ item }: { item: NavItem }) { ... }
// ❌ BAD - inline type duplication
function NavItem({ item }: { item: { title: string; number: number; slug?: string } }) { ... }Benefits:
- Single source of truth for data shapes
- Changes propagate through type system automatically
- Better IDE autocomplete and refactoring
- Self-documenting code
- Easier to add new fields (update once in types.ts)
When to Create a New Type:
- Type is used in 2+ places
- Type represents a domain concept (Recipe, Endpoint, etc.)
- Type has validation logic or type guards
- Type may evolve over time
Key locations:
- Define shared types in
frontend/src/lib/types.ts - Use TypeScript path aliases (
~/lib,~/components, etc.) for clean imports
Type Hints:
Use modern Python type hints throughout:
# ✅ GOOD - Modern type syntax
from pydantic import BaseModel
class ChatRequest(BaseModel):
"""Request model for chat endpoint."""
endpoint_id: str
model: str
messages: list[dict[str, str]]
temperature: float | None = None
async def process_chat(
request: ChatRequest,
endpoint: Endpoint
) -> StreamingResponse:
"""Process chat request with streaming response."""
...
# ❌ BAD - Missing or weak types
def process_chat(request, endpoint):
...Best Practices:
- Use Pydantic
BaseModelfor all request/response models - Modern union syntax:
str | Noneinstead ofOptional[str] - Type all function parameters and return values
- Include docstrings on Pydantic models explaining each field
Async Patterns:
Prefer async patterns for better concurrency and streaming:
from openai import AsyncOpenAI
# ✅ GOOD - Async for streaming
client = AsyncOpenAI(base_url=endpoint.base_url, api_key=endpoint.api_key)
stream = await client.chat.completions.create(
model=request.model,
messages=messages,
stream=True
)
async for chunk in stream:
# Process streaming chunks
...
# Use async generators for streaming responses
async def generate_stream():
"""Generate streaming response data."""
async for chunk in stream:
yield format_chunk(chunk)When to use async:
- Streaming responses (always)
- Multiple concurrent API calls
- I/O-bound operations
Streaming Patterns:
Two main streaming formats:
-
SSE (Server-Sent Events) - For token-by-token streaming:
from fastapi.responses import StreamingResponse async def generate_sse(): async for token in stream: yield f"data: {json.dumps({'delta': token})}\n\n" return StreamingResponse( generate_sse(), media_type="text/event-stream" )
-
NDJSON (Newline-Delimited JSON) - For progressive batch results:
async def generate_ndjson(): for result in results: yield f"{json.dumps(result)}\n" return StreamingResponse( generate_ndjson(), media_type="application/x-ndjson" )
-
Batch Response (Non-Streaming) - For complete results all at once:
# Process all items in parallel using asyncio.gather() tasks = [process_item(item) for item in request.batch] results = await asyncio.gather(*tasks) # Return complete JSON array (not streaming) return results # FastAPI serializes to JSON automatically
See multiturn_chat.py for SSE, image_captioning.py for NDJSON, and batch_text_classification.py for batch processing examples.
Required Code Endpoint:
Every recipe must provide a code viewing endpoint:
from fastapi import APIRouter
from fastapi.responses import Response
from ..core.code_reader import read_source_file
router = APIRouter(prefix="/api/recipes", tags=["recipes"])
@router.get("/your-recipe/code", response_class=Response)
def get_recipe_code():
"""Return the source code for this recipe as plain text."""
return Response(
content=read_source_file(__file__),
media_type="text/plain"
)General Best Practices:
- Follow FastAPI best practices (dependency injection, error handling)
- Use
HTTPExceptionfor error responses with appropriate status codes - Retrieve endpoints securely via
get_cached_endpoint(endpoint_id) - Never expose API keys to the frontend
- Add educational inline comments explaining patterns for learning
- Register your router in
backend/src/main.py
Documentation Standards:
All recipe modules should include comprehensive documentation:
-
Module Docstring - Comprehensive overview at top of file explaining:
- Purpose and use case
- Key features (bulleted list)
- Architecture and implementation details
- Protocol/format specifications
-
Section Comments - Organize code with clear section headers:
# ============================================================================ # Types and Models # ============================================================================
-
Function Docstrings - Document all public functions with purpose and parameters
-
Inline Comments - Explain complex logic and the "why" behind implementation choices
See multiturn_chat.py and image_captioning.py for examples.
Server State via Frontend (SWR):
- API data fetching with automatic caching and revalidation
- See
frontend/src/lib/hooks.tsandfrontend/src/lib/api.ts
Frontend Client State (URL Query Params):
- Endpoint/model selection via
?e=endpoint-id&m=model-name - Shareable URLs, browser back/forward support
- No React Context needed
Token streaming via Server-Sent Events (SSE):
- Used in multi-turn chat for real-time token delivery
- See
backend/src/recipes/multiturn_chat.py
NDJSON streaming for batch operations:
- Used in image captioning for progressive updates
- See
backend/src/recipes/image_captioning.py
API Key Protection:
- API keys stored in
backend/.env.local(gitignored) - Loaded by
backend/src/core/endpoints.py - Never sent to client (only endpoint IDs exposed)
- Backend proxies requests to LLM endpoints
Request flow:
- Client sends endpoint ID (not credentials)
- Backend validates endpoint ID exists
- Backend looks up credentials from cache
- Backend makes authenticated request to LLM
- API key never leaves server
- Modern Python web framework
- Automatic request validation
- Native async/await support for streaming
- OpenAPI docs out of the box
- Better AI ecosystem integration with Python
- Want a Python backend (not Node.js)
- Don't need SSR/SEO features
- Lightweight (~15KB vs ~40KB)
- Simple API
- Automatic caching and revalidation
- Excellent for our use case
- Comprehensive component library
- Built-in dark mode
- Accessible by default
- Minimal bundle size impact
- Fast dependency resolution
- Modern Python package manager
- Better lockfile support
- Compatible with standard Python packaging
- Docker: docs/docker.md - Container deployment
- Project Context: .claude/project-context.md - Comprehensive reference
- Issues: GitHub Issues
- Forum: forum.modular.com
By contributing, you agree that your contributions will be licensed under the Apache License 2.0 with LLVM Exception. See LICENSE for details.
Thanks for contributing!