diff --git a/README.md b/README.md
index 36d212e..60e3e5d 100644
--- a/README.md
+++ b/README.md
@@ -1,22 +1,296 @@
-# djarea
+# Djarea
-Django + React server functions framework.
+Django + React server functions framework. RPC, not REST.
-| Package | Path | Registry |
-|---------|------|----------|
-| `djarea` (Python) | `django/` | PyPI / git |
-| `djarea` (TypeScript) | `react/` | npm / git |
+You define Python functions. Djarea generates typed React hooks. No API routes, no serializers, no endpoint boilerplate.
-## Installation
-
-```bash
-# Python
-uv add "djarea[channels,allauth] @ git+https://git.impactsoundworks.com/isw/djarea.git#subdirectory=django"
-
-# TypeScript
-npm install djarea@git+https://git.impactsoundworks.com/isw/djarea.git#workspace=react
+```python
+# Django
+@client(context='global')
+def current_user(request) -> UserOutput:
+ return UserOutput(email=request.user.email)
```
+```tsx
+// React (generated)
+const user = useCurrentUser() // typed, SSR-hydrated, auto-refreshed
+```
+
+## Packages
+
+| Package | Path | Install |
+|---------|------|---------|
+| `djarea` (Python) | `django/` | `uv add "djarea[channels] @ git+..."` |
+| `@rythazhur/djarea` (TypeScript) | `react/` | `npm install @rythazhur/djarea@git+...` |
+
## Quick Start
-See [django/README.md](django/README.md) and [react/README.md](react/README.md).
+### 1. Django setup
+
+```python
+# settings.py
+INSTALLED_APPS = [
+ "djarea",
+ "myapp",
+]
+
+# urls.py
+from django.urls import include, path
+urlpatterns = [
+ path("api/djarea/", include("djarea.urls")),
+]
+
+# asgi.py (for WebSocket support)
+from djarea import wrap_asgi
+from django.core.asgi import get_asgi_application
+application = wrap_asgi(get_asgi_application())
+```
+
+### 2. Define server functions
+
+```python
+# myapp/djarea_clients.py
+from django.http import HttpRequest
+from djarea.client import client
+from djarea.setup.registry import register
+from pydantic import BaseModel
+
+class EchoOutput(BaseModel):
+ message: str
+
+@client
+def echo(request: HttpRequest, text: str) -> EchoOutput:
+ return EchoOutput(message=text)
+
+register(echo, "echo")
+```
+
+### 3. Register in apps.py
+
+```python
+class MyAppConfig(AppConfig):
+ name = "myapp"
+
+ def ready(self):
+ import myapp.djarea_clients # noqa: F401
+```
+
+### 4. Generate TypeScript
+
+```bash
+# django.config.mjs
+export default {
+ source: {
+ django: {
+ managePath: '../backend/manage.py',
+ command: ['uv', 'run', 'python'],
+ },
+ },
+ output: 'src/api/generated.ts',
+}
+```
+
+```bash
+npx djarea-generate
+```
+
+This produces typed hooks, a typed provider, form hooks with Zod validation, and channel hooks.
+
+### 5. Use in React
+
+```tsx
+// layout.tsx
+import { DjangoContext } from '@/api'
+
+export default function Layout({ children }) {
+ return {children}
+}
+```
+
+```tsx
+// page.tsx
+import { useEcho, useCurrentUser, DjangoError } from '@/api'
+
+function MyComponent() {
+ const user = useCurrentUser()
+ const echo = useEcho()
+
+ const handleClick = async () => {
+ try {
+ const result = await echo({ text: 'hello' })
+ console.log(result.message) // typed
+ } catch (e) {
+ if (e instanceof DjangoError) {
+ console.log(e.code) // NOT_FOUND, VALIDATION_ERROR, etc.
+ }
+ }
+ }
+}
+```
+
+## Features
+
+| Backend | Frontend (generated) | Transport |
+|---------|---------------------|-----------|
+| `@client` | `useXxx()` | HTTP |
+| `@client(context='global')` | `useXxx()` + SSR hydration | HTTP |
+| `@client(context='local')` | `useXxx()` with params | HTTP |
+| `@client(websocket=True)` | `useXxx()` | WebSocket RPC |
+| `@client(auth=True\|'staff'\|callable)` | Auth errors as `DjangoError` | HTTP |
+| `DjareaFormMixin` | `useXxxForm()` + Zod validation | HTTP |
+| `ReactChannel` | `useXxxChannel()` | WebSocket |
+| `@compose(...)` | Combined providers | varies |
+
+## Architecture
+
+```
+React app
+ └─ ← generated provider (includes ChannelProvider)
+ ├─ useCurrentUser() ← generated context hook (SSR-hydrated)
+ ├─ useEcho() ← generated function hook
+ ├─ useContactForm() ← generated form hook (Zod + server validation)
+ └─ useChatChannel() ← generated channel hook (WebSocket)
+ │
+ ├─ HTTP: POST /api/djarea/call/ { fn: "echo", args: { text: "hi" } }
+ └─ WS: { action: "rpc", fn: "echo", args: { text: "hi" } }
+ │
+ Django executor
+ ├─ Pydantic input validation
+ ├─ Auth check (session, JWT, or custom)
+ ├─ Function execution
+ └─ Pydantic output serialization
+```
+
+The generated `DjangoContext` is the **only provider** needed. It wraps `DjareaProvider` + `ChannelProvider` and handles session init, CSRF, context auto-fetching, and WebSocket connection.
+
+## Code Generation
+
+`npx djarea-generate` reads Django schemas (no running server needed) and produces:
+
+| File | Contents |
+|------|----------|
+| `generated.djarea.ts` | Pydantic model types (via openapi-typescript) |
+| `generated.django.tsx` | `DjangoContext` provider + all typed hooks |
+| `generated.django.server.ts` | SSR hydration helper (`getDjangoHydration`) |
+| `generated.forms.ts` | Form hooks with Zod schemas (`useContactForm`, etc.) |
+| `generated.channels.ts` | Channel message types |
+| `generated.channels.hooks.tsx` | Channel hooks (`useChatChannel`, etc.) |
+| `index.ts` | Consolidated re-exports |
+
+## Error Handling
+
+All errors from server functions are thrown as `DjangoError`:
+
+```tsx
+try {
+ await echo({ text: 'hello' })
+} catch (e) {
+ if (e instanceof DjangoError) {
+ e.code // 'NOT_FOUND' | 'VALIDATION_ERROR' | 'UNAUTHORIZED' | 'FORBIDDEN' | ...
+ e.message // Human-readable message
+ e.details // Field-level validation errors, etc.
+ e.isAuthError()
+ e.isValidationError()
+ e.getFieldErrors('email')
+ }
+}
+```
+
+Error codes: `NOT_FOUND`, `VALIDATION_ERROR`, `UNAUTHORIZED`, `FORBIDDEN`, `BAD_REQUEST`, `INTERNAL_ERROR`, `NOT_IMPLEMENTED`.
+
+## Forms
+
+Django forms get typed React hooks with client-side Zod validation:
+
+```python
+# Django
+class ContactForm(DjareaFormMixin, forms.Form):
+ djarea = DjareaFormMeta(
+ name="contact",
+ title="Contact Us",
+ submit_label="Send",
+ live_validation=True,
+ )
+ name = forms.CharField(max_length=100)
+ email = forms.EmailField()
+ message = forms.CharField(widget=forms.Textarea)
+
+ def on_submit_success(self, request):
+ send_email(self.cleaned_data)
+ return {"sent": True}
+```
+
+```tsx
+// React (generated)
+const form = useContactForm()
+
+form.schema // { fields: { name: {...}, email: {...} }, title, submit_label }
+form.data // { name: '', email: '', message: '' }
+form.set('email', v) // typed setter
+form.errors // field-level errors (Zod + server)
+form.submit() // → { success: true, data: { sent: true } }
+```
+
+## Channels
+
+WebSocket channels with typed messages:
+
+```python
+# Django
+class ChatChannel(ReactChannel):
+ class Params(BaseModel):
+ room: str
+ class ReactMessage(BaseModel):
+ text: str
+ class DjangoMessage(BaseModel):
+ text: str
+ user: str
+
+ def authorize(self, params):
+ return self.user.is_authenticated
+
+ def group(self, params):
+ return f"chat_{params.room}"
+
+ def receive(self, params, msg):
+ return self.DjangoMessage(text=msg.text, user=self.user.email)
+```
+
+```tsx
+// React (generated)
+const chat = useChatChannel({ room: 'general' })
+
+chat.status // 'connecting' | 'connected' | 'disconnected'
+chat.messages // ChatDjangoMessage[]
+chat.send({ text: 'hello' })
+```
+
+## Testing
+
+```bash
+# Django unit tests
+cd django && uv sync --extra dev --extra channels && uv run pytest
+
+# React unit tests
+cd react && npm test
+
+# E2E integration tests (real browser, real backend)
+docker compose -f docker-compose.test.yml up -d
+cd e2e/harness && npm install && npx djarea-generate && npx vite --port 5174 &
+npx playwright test
+
+# All at once
+make test-all
+```
+
+## Project Structure
+
+```
+djarea/
+ django/ Python package (djarea)
+ react/ TypeScript package (@rythazhur/djarea)
+ example/ Integration test backend (Docker)
+ desktop/ PyWebView desktop test app
+ e2e/ Playwright E2E tests + React harness
+ Makefile Test orchestration
+```
diff --git a/django/README.md b/django/README.md
index 90d1367..0e1c7ff 100644
--- a/django/README.md
+++ b/django/README.md
@@ -1,29 +1,105 @@
-# djarea
+# djarea (Python)
-Django + React server functions framework. See the [monorepo root](../README.md) for full documentation.
+Django server functions framework. See the [monorepo root](../README.md) for full documentation.
-## Installation
+## Install
```bash
-# From git
uv add "djarea[channels,allauth] @ git+https://git.impactsoundworks.com/isw/djarea.git#subdirectory=django"
-
-# Local editable
-uv add -e "../../web/djarea/django[channels,allauth]"
```
-## Quick Start
+## Setup
+
+```python
+# settings.py
+INSTALLED_APPS = ["djarea", ...]
+
+# urls.py
+path("api/djarea/", include("djarea.urls"))
+
+# asgi.py (optional, for WebSocket)
+from djarea import wrap_asgi
+application = wrap_asgi(get_asgi_application())
+```
+
+## Define Functions
```python
from djarea.client import client
+from djarea.setup.registry import register
from pydantic import BaseModel
-class UserOutput(BaseModel):
- email: str
+class Output(BaseModel):
+ message: str
-@client(context='global')
-def current_user(request) -> UserOutput | None:
- if not request.user.is_authenticated:
- return None
- return UserOutput(email=request.user.email)
+@client
+def echo(request, text: str) -> Output:
+ return Output(message=text)
+
+register(echo, "echo")
+```
+
+Register in `apps.py`:
+
+```python
+def ready(self):
+ import myapp.djarea_clients
+```
+
+## Auth
+
+```python
+@client(auth=True) # requires authentication
+@client(auth='staff') # requires is_staff
+@client(auth='superuser') # requires is_superuser
+@client(auth=my_callable) # custom check
+```
+
+## Contexts
+
+```python
+@client(context='global') # fetched once, SSR-hydrated, becomes useCurrentUser()
+@client(context='local') # fetched with params, becomes
+```
+
+## Forms
+
+```python
+from djarea.forms import DjareaFormMixin, DjareaFormMeta
+
+class ContactForm(DjareaFormMixin, forms.Form):
+ djarea = DjareaFormMeta(name="contact", title="Contact Us")
+ name = forms.CharField()
+ email = forms.EmailField()
+
+ def on_submit_success(self, request):
+ return {"sent": True}
+```
+
+Auto-registers `contact.schema`, `contact.validate`, `contact.submit`. Generates `useContactForm()` with Zod validation.
+
+## Channels
+
+```python
+from djarea.channels import ReactChannel
+
+class ChatChannel(ReactChannel):
+ class Params(BaseModel):
+ room: str
+ class DjangoMessage(BaseModel):
+ text: str
+
+ def authorize(self, params):
+ return self.user.is_authenticated
+ def group(self, params):
+ return f"chat_{params.room}"
+```
+
+Generates `useChatChannel({ room })`.
+
+## Running Tests
+
+```bash
+uv sync --extra dev --extra channels
+uv run pytest
```
diff --git a/react/README.md b/react/README.md
index 9f13f39..ae0b38c 100644
--- a/react/README.md
+++ b/react/README.md
@@ -1,26 +1,103 @@
-# djarea (TypeScript)
+# @rythazhur/djarea (TypeScript)
-TypeScript client library for the Djarea framework. See the [monorepo root](../README.md) for full documentation.
+React client for the Djarea framework. See the [monorepo root](../README.md) for full documentation.
-## Installation
+## Install
```bash
-# From git
-npm install djarea@git+https://git.impactsoundworks.com/isw/djarea.git#workspace=react
-
-# Local development
-npm install djarea@file:../../web/djarea/react
+npm install @rythazhur/djarea@git+https://git.impactsoundworks.com/isw/djarea.git#workspace=react
```
-## Exports
+## Usage
-| Import | Purpose |
-|--------|---------|
-| `djarea` | Core: DjareaProvider, hooks, forms, errors |
-| `djarea/client` | HTTP clients, SSR helpers, `ensureDjangoSession()` |
-| `djarea/client/react` | React-specific client hooks |
-| `djarea/client/nextjs` | Next.js integration |
-| `djarea/channels` | WebSocket channels |
-| `djarea/jwt` | JWT token management |
-| `djarea/allauth` | Allauth UI components |
-| `djarea/allauth/nextjs` | Next.js allauth context |
+You don't use this package directly. You use the **generated hooks**.
+
+### 1. Configure
+
+```js
+// django.config.mjs
+export default {
+ source: {
+ django: {
+ managePath: '../backend/manage.py',
+ command: ['uv', 'run', 'python'],
+ },
+ },
+ output: 'src/api/generated.ts',
+}
+```
+
+### 2. Generate
+
+```bash
+npx djarea-generate # once
+npx djarea-generate --watch # dev mode
+```
+
+### 3. Wrap your app
+
+```tsx
+import { DjangoContext } from '@/api'
+
+
+
+
+```
+
+`DjangoContext` is the only provider you need. It handles HTTP, WebSocket, CSRF, session init, context auto-fetching, and channel connections.
+
+### 4. Use generated hooks
+
+```tsx
+import { useCurrentUser, useEcho, useContactForm, useChatChannel } from '@/api'
+
+// Context (SSR-hydrated, auto-refreshed)
+const user = useCurrentUser()
+
+// Server function
+const echo = useEcho()
+const result = await echo({ text: 'hello' })
+
+// Form (Zod + server validation)
+const form = useContactForm()
+form.set('email', 'test@example.com')
+await form.submit()
+
+// Channel (WebSocket)
+const chat = useChatChannel({ room: 'general' })
+chat.send({ text: 'hello' })
+chat.messages // typed, reactive
+```
+
+## Generated Files
+
+| File | Contents |
+|------|----------|
+| `generated.django.tsx` | `DjangoContext` + typed hooks |
+| `generated.djarea.ts` | Pydantic types |
+| `generated.forms.ts` | Form hooks with Zod |
+| `generated.channels.hooks.tsx` | Channel hooks |
+| `index.ts` | Re-exports everything |
+
+## Sub-exports
+
+| Import | When to use |
+|--------|------------|
+| `@rythazhur/djarea` | Core: DjareaProvider, hooks, forms, errors |
+| `@rythazhur/djarea/channels` | WebSocket channels |
+| `@rythazhur/djarea/jwt` | JWT token management |
+| `@rythazhur/djarea/client` | HTTP clients (CSR/SSR) |
+| `@rythazhur/djarea/allauth` | Allauth UI components |
+
+These are **library internals** used by the generated code. You should import from `@/api` (your generated index), not from the library directly.
+
+## Running Tests
+
+```bash
+# Unit tests (Vitest, jsdom)
+npm test
+
+# E2E tests (Playwright, real browser)
+# Requires Docker backend running
+npx playwright test
+```