This file provides project-specific development guidelines for working with this Val Town application.
Prerequisites: This assumes you've completed initial setup. If scaffolding a new val from scratch, see SCAFFOLD.md first.
Note: For general Val Town platform patterns (triggers, redirects, standard library, React config), see AGENTS.md.
This is a Notion webhook integration application that:
- Receives webhooks from Notion when pages/databases change
- Processes Notion data and integrates with external services (Firecrawler, etc.)
- Saves processed results back to Notion
- Provides a React frontend for viewing and managing Notion data
This val follows a strict 3-layer MVC architecture. See sections below for detailed patterns.
Note: This project uses npm: imports for backend packages (diverging from the general Val Town pattern in AGENTS.md which recommends esm.sh everywhere). We use npm: for better performance with packages like Notion SDK that only run server-side.
This val runs in different environments, so import patterns matter.
For code that ONLY runs in the backend (controllers, services, routes, utils, main.http.tsx):
Use npm: specifier:
import { Hono } from "npm:hono@4";
import { Client } from "npm:@notionhq/client@2";
Benefits:
- ✅ Modern Deno way
- ✅ Faster resolution (no CDN roundtrip)
- ✅ Better caching
- ✅ More explicit about npm packages
Val Town utilities still use URLs:
import {
readFile,
serveFile,
} from "https://esm.town/v/std/utils@85-main/index.ts";
For code in frontend/ and shared/ that runs in the BROWSER:
Use esm.sh with version pins:
// Frontend React components
import { useState } from "https://esm.sh/react@18.2.0?deps=react@18.2.0";
// Shared utilities (work in both browser and Deno)
import { getPropertyValue } from "../../shared/utils.ts";
Why esm.sh for frontend:
- ✅ Works in browser (no
npm:support) - ✅ CDN delivery for client-side
- ✅ Automatic bundling and transpilation
| Location | Environment | Import Pattern | Example |
|---|---|---|---|
backend/ | Deno only | npm:package@version | npm:hono@4 |
main.http.tsx | Deno only | npm:package@version | npm:hono@4 |
frontend/ | Browser | https://esm.sh/package@version | https://esm.sh/react@18.2.0 |
shared/ | Both | https://esm.sh/package@version | https://esm.sh/date-fns@3 |
| Val Town utils | Deno | https://esm.town/v/... | Val Town standard library |
Backend (use npm:):
- Hono:
npm:hono@4 - Notion:
npm:@notionhq/client@2 - Database drivers, server-only utilities
Frontend (use esm.sh):
- React:
https://esm.sh/react@18.2.0 - React DOM:
https://esm.sh/react-dom@18.2.0/client - Browser-compatible libraries
Shared (use esm.sh):
- Date utilities:
https://esm.sh/date-fns@3 - Validation libraries:
https://esm.sh/zod@3 - Any package used in both frontend and backend
This val follows a 3-layer architecture. Every feature MUST follow this pattern. NEVER skip layers.
Request → Route → Controller → Service → External API
↓
Response ← Format ← Standard Response ← Result
Location:
routes/api/- API endpoints for data operationsroutes/tasks/- Notion webhook handlersroutes/views/- User-facing HTML/React views
Responsibility: HTTP ONLY
- Extract request parameters (query, body, headers)
- Apply authentication middleware from
authCheck.ts - Call controller functions
- Format controller responses into HTTP responses
- Map success/error to HTTP status codes
Rules:
- ❌ NO business logic in routes
- ❌ NEVER call services directly from routes
- ✅ Routes are thin wrappers around controllers
- ✅ Always return proper HTTP responses with status codes:
200- Success400- Validation errors, bad request401- Authentication failures500- Server/external service errors
Example pattern:
app.post("/api/tasks/:id", async (c) => {
const pageId = c.req.param("id");
const body = await c.req.json();
const result = await pageController.updatePage(pageId, body);
if (!result.success) {
return c.json({ error: result.error, details: result.details }, 400);
}
return c.json(result.data);
});
Responsibility: Business logic and orchestration
- Validate request data
- Orchestrate multiple service calls
- Transform and filter data
- Remove sensitive information
IMPORTANT: Work with Notion pages generically
- The fundamental object in Notion is a page (not task, project, etc.)
- Controllers and services should use
pageIdand work with any Notion page - Routes can be domain-specific (e.g.,
/api/tasks/:id), but they should call generic page controllers - This allows the architecture to handle any type of Notion page (tasks, projects, notes, etc.)
MUST return this exact structure:
{
success: boolean,
data: any | null,
error: string | null,
details?: string // Additional error context
}
Rules:
- ❌ NEVER return HTTP responses (controllers are HTTP-agnostic)
- ❌ NEVER call external APIs directly
- ✅ Always call service layer functions
- ✅ Return standardized response structure
- ✅ Validate all input data
- ✅ Filter sensitive data (e.g., button properties)
Example pattern:
export async function updatePage(pageId: string, properties: any) {
// Validate
if (!pageId || !properties) {
return {
success: false,
data: null,
error: "Invalid input",
details: "pageId and properties are required",
};
}
// Call service
const result = await notionService.updatePage(pageId, properties);
if (!result.success) {
return {
success: false,
data: null,
error: "Failed to update page",
details: result.error,
};
}
return {
success: true,
data: result.data,
error: null,
};
}
Responsibility: External API calls ONLY
- Make HTTP requests to external APIs
- Handle API authentication
- Parse and normalize API responses
- Manage retries and API-specific error handling
Create services for:
- Notion API (
notionService.ts) - Firecrawler (
firecrawlerService.ts) - Val Town blob storage (
blobService.ts) - Any other external service
Rules:
- ✅ Return structured results with
{success, data, error} - ✅ Handle all API errors gracefully (don't throw)
- ✅ Use environment variables for API keys
- ✅ Export typed functions
Example pattern:
export async function getNotionPage(pageId: string) {
try {
const notion = new Client({ auth: Deno.env.get("NOTION_API_KEY") });
const page = await notion.pages.retrieve({ page_id: pageId });
return {
success: true,
data: page,
error: null,
};
} catch (err) {
return {
success: false,
data: null,
error: err.message,
};
}
}
When asked to "build a /tasks/ endpoint" or any new feature, follow this checklist:
-
✅ Create route file in appropriate directory:
- Webhook handlers →
routes/tasks/ - API endpoints →
routes/api/ - Views →
routes/views/
- Webhook handlers →
-
✅ Extract request data in route (params, query, body)
-
✅ Create controller function in
backend/controllers/[feature].ts- Implement validation
- Implement business logic
- Orchestrate service calls
-
✅ Create/update service functions in
backend/services/[service].ts- Make external API calls
- Return
{success, data, error}structure
-
✅ Return standardized response from controller
-
✅ Format HTTP response in route based on controller result
-
✅ Add TypeScript types to
shared/types.tsif data is shared across layers -
✅ Import route into
main.http.tsxand mount on Hono app -
✅ Test the endpoint
Note: Authentication is applied globally in main.http.tsx for all /api/* and /tasks/* routes (except /api/health). No per-route authentication setup needed.
All Notion webhooks live in routes/tasks/. When creating webhook handlers:
- Validate webhook signature/authentication
- Extract Notion event data
- Call appropriate controller
- Return
200status quickly (webhooks timeout) - Use async processing for slow operations
Service Integration:
- Follow the "External Service Integration Pattern" section below for organizing Notion service functions
When integrating a simple external service (like Firecrawler):
-
Create service file:
backend/services/[serviceName].ts -
Initialize client and export typed functions:
import { SomeClient } from "npm:some-client@1";
export const client = new SomeClient({
apiKey: Deno.env.get("SERVICE_API_KEY"),
});
export async function fetchFromService(params: ServiceParams) {
try {
const result = await client.fetch(params);
return { success: true, data: result, error: null };
} catch (err: any) {
return { success: false, data: null, error: err.message };
}
}
When integrating a complex service with multiple concerns (like Notion with pages, databases, blocks):
-
Create service directory:
backend/services/[serviceName]/ -
Create index.ts - Initialize client and re-export:
import { Client } from "npm:@servicename/client@2";
export const client = new Client({ auth: Deno.env.get("SERVICE_API_KEY") });
export * from "./pages.ts";
export * from "./databases.ts";
- Create feature files - Import shared client:
// backend/services/notion/pages.ts
import { client } from "./index.ts";
export async function getPage(id: string) {
try {
const result = await client.pages.get(id);
return { success: true, data: result, error: null };
} catch (err: any) {
return { success: false, data: null, error: err.message };
}
}
- Store API keys in environment:
Deno.env.get('SERVICE_API_KEY') - Handle errors gracefully: Return error objects, don't throw
- Add types to
shared/types.tsif data is used across layers - Call service from controller, never from routes
- One client instance: Initialize once, reuse everywhere
- Files: camelCase (e.g.,
notionService.ts,pageController.ts) - Functions: camelCase, verb-first (e.g.,
getNotionPage,updatePage) - Types/Interfaces: PascalCase (e.g.,
NotionWebhookPayload,PageResponse) - Routes: kebab-case URLs (e.g.,
/api/notion-tasks,/tasks/webhook-handler)
-
Controllers: Organize by purpose or context
healthController.ts- System health checks (PUBLIC, default on frontend)pageController.ts- Generic Notion page operations (get, update, create)webhookController.ts- Webhook processing logic- Note: Controllers should work with
pageIdgenerically, not task/project-specific - Group related operations together (e.g., all page operations in one controller)
-
Services: Organize by external API
- For complex services, use directories:
notion/- Notion API integrationindex.ts- Client initialization and re-exportspages.ts- Page operationsdatabases.ts- Database operations
- For simple services, use single files:
firecrawlerService.ts- Firecrawler integrationblobService.ts- Val Town blob storage
- For complex services, use directories:
-
Utils: Backend-only utility functions (
backend/utils/)- IMPORTANT: Pure utility functions with NO external dependencies and NO business logic
- Can use Deno APIs (unlike
shared/utils.tswhich must work in browser) - Examples of what belongs here:
cryptoUtils.ts- Hashing, encryption, JWT signing (using Deno's Web Crypto)dateUtils.ts- Date formatting, timezone conversions (backend-specific)validationUtils.ts- Input validation helpers (using Deno features)logUtils.ts- Logging formattersenvUtils.ts- Environment variable parsers
- Examples of what does NOT belong here:
- ❌ External API calls (use
backend/services/) - ❌ Business logic (use
backend/controllers/) - ❌ Browser-compatible code (use
shared/utils.ts) - ❌ Database operations (use
backend/services/)
- ❌ External API calls (use
- Export pure functions with clear, single responsibilities
- No side effects (no global state, no I/O unless explicitly for that purpose)
-
Routes: Organize by purpose
routes/api/- RESTful API endpoints (/api/tasks,/api/tasks/:id)routes/tasks/- Webhook handlers (/tasks/notion-webhook)routes/views/- User-facing pages (/views/dashboard)
-
Shared: Cross-layer code
shared/types.ts- TypeScript interfacesshared/utils.ts- Pure utility functions (work in browser AND Deno)
Services: Return errors, don't throw
return { success: false, data: null, error: "API call failed" };
Controllers: Add context to service errors
if (!result.success) {
return {
success: false,
data: null,
error: "Failed to update page",
details: result.error,
};
}
Routes: Map to HTTP status codes
if (!result.success) {
return c.json({ error: result.error, details: result.details }, 400);
}
Add to main.http.tsx: Hono error unwrapper for better stack traces
app.onError((err, c) => {
throw err;
});
- Authentication middleware lives in
routes/authCheck.ts - Uses shared secret approach with
X-API-KEYheader - Applied globally in
main.http.tsxusing array-based protection - Protects
/api/*and/tasks/*routes with exceptions for public endpoints
Array-based protection pattern in main.http.tsx:
import { webhookAuth } from './backend/routes/authCheck.ts';
// Define protected route prefixes and public exceptions
const protectedPrefixes = ['/api', '/tasks'];
const publicExceptions = ['/api/health'];
// Apply middleware before mounting routes
app.use('*', async (c, next) => {
const path = c.req.path;
// Check if path is a public exception (exact match)
if (publicExceptions.includes(path)) {
await next();
return;
}
// Check if path starts with any protected prefix
const isProtected = protectedPrefixes.some(prefix => path.startsWith(prefix));
if (isProtected) {
await webhookAuth(c, next);
} else {
await next();
}
});
Usage in webhook configuration:
- Header name:
X-API-KEY - Header value: Value of
WEBHOOK_SECRETenvironment variable
Development mode:
- If
WEBHOOK_SECRETnot set, authentication is bypassed with console warning
frontend/index.html- HTML shellfrontend/index.tsx- React entry pointfrontend/components/App.tsx- Main componentfrontend/components/NotionBlock.tsx- Notion block rendererfrontend/components/NotionProperty.tsx- Property display
Configuration: See AGENTS.md for React 18.2.0 setup with pinned dependencies and jsxImportSource
Data Fetching:
- Fetch from
/api/*routes - Use shared types from
shared/types.ts - Handle loading and error states
This val uses Pico CSS - a minimal CSS framework that automatically styles semantic HTML.
- No CSS classes needed - Keeps HTML clean and maintainable (we use it in classless mode)
- Semantic HTML only - Use proper HTML elements (
<article>,<main>,<kbd>, etc.) - Automatic styling - Beautiful design out of the box
- CDN delivery - Single
<link>tag, no build step - Customizable - Easy to add custom styles when needed
❌ NEVER use CSS class names (no className attributes)
❌ NEVER use TailwindCSS, Bootstrap, or other class-based frameworks
✅ ALWAYS use semantic HTML elements
✅ Use inline styles sparingly - Only for dynamic/conditional styling
Use these semantic elements - Pico styles them automatically:
Layout:
<main>- Main content container<article>- Self-contained content (cards, sections)<section>- Thematic grouping of content<header>,<footer>- Page/section headers and footers<aside>- Sidebar content
Typography:
<h1>through<h6>- Headings<p>- Paragraphs<strong>- Bold/important text<em>- Italic/emphasized text<small>- Fine print<mark>- Highlighted text<code>- Inline code<pre><code>- Code blocks<kbd>- Keyboard input / tags
Lists:
<ul>,<ol>,<li>- Lists<dl>,<dt>,<dd>- Definition lists
Forms:
<input>,<textarea>,<select>- Form inputs<button>- Buttons<label>- Form labels- Use
aria-busyattribute for loading states on buttons
Links & Media:
<a>- Links<time>- Dates and times<figure>,<figcaption>- Images with captions
Card layout:
<article> <h2>Card Title</h2> <p>Card content goes here.</p> <button>Action</button> </article>
Form with loading state:
<div style={{ display: "flex", gap: "1rem" }}>
<input type="text" placeholder="Enter value" style={{ flex: 1 }} />
<button disabled={loading} aria-busy={loading}>
Submit
</button>
</div>
Error message:
{ error && <mark>{error}</mark>; }
Tags/badges:
Conditional styling:
<span
style={checked ? { textDecoration: "line-through", opacity: 0.6 } : undefined}
>
Task text
</span>
Already included in frontend/index.html:
<!-- Pico CSS - CSS framework --> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" /> <style> #root > div { padding-block: var(--pico-block-spacing-vertical); } </style>
Note: We use Pico's main stylesheet but apply it in a classless way (semantic HTML only). The custom style adds proper padding to the root React container.
Types (shared/types.ts):
- All TypeScript interfaces shared between frontend/backend
- Notion data structures
- API request/response types
- Common data models
Utils (shared/utils.ts):
- Pure functions that work in both Deno (backend) and browser (frontend)
- ❌ NEVER use Deno APIs (won't work in browser)
- ✅ Only use standard JavaScript/TypeScript
- Note: For backend-only utilities that need Deno APIs, use
backend/utils/instead
Required environment variables for this val:
NOTION_API_KEY- Notion integration tokenWEBHOOK_SECRET- Shared secret for API authenticationRECENT_PAGES_LOOKBACK_HOURS- Hours to look back for recent pages (optional, defaults to 24)FIRECRAWLER_API_KEY- Firecrawler service (if used)- Additional service API keys as needed
Access via: Deno.env.get('VAR_NAME') (see AGENTS.md for details)
Validate request data in controllers, not routes:
if (!pageId || typeof pageId !== "string") {
return {
success: false,
data: null,
error: "Invalid pageId",
details: "pageId must be a non-empty string",
};
}
Check:
- Required fields exist
- Types are correct
- Formats are valid
- Values are within expected ranges
Return helpful error messages with details field
Filter sensitive data in controllers before returning:
- Remove button properties from Notion data
- Don't expose internal IDs or tokens
- Clean up API responses before sending to frontend
- Strip unnecessary metadata
Example:
// Remove sensitive properties
const filtered = {
...notionData,
properties: Object.entries(notionData.properties)
.filter(([key, value]) => value.type !== "button")
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
};
When the user requests a new feature, clarify:
-
Is this a webhook handler or API endpoint?
- Webhook →
routes/tasks/ - API →
routes/api/
- Webhook →
-
What external service needs integration?
- New service → create
backend/services/[name].ts
- New service → create
-
What data needs to be saved back to Notion?
- Update Notion → use/extend
notionService.ts
- Update Notion → use/extend
-
Should this endpoint be public (no authentication)?
- Yes → Add to
publicExceptionsarray inmain.http.tsx - No → Authentication already applied to all
/api/*and/tasks/*routes
- Yes → Add to
-
Is this frontend-facing?
- Yes → create React component in
frontend/components/
- Yes → create React component in
Before implementing, verify:
- ✅ Following 3-layer architecture (Route → Controller → Service)
- ✅ Routes only handle HTTP, no business logic
- ✅ Controllers return standardized
{success, data, error}structure - ✅ Services handle external API calls
- ✅ Never skipping layers
- ✅ TypeScript types defined in
shared/types.ts - ✅ Environment variables used for secrets
- ✅ Error handling at each layer
- ✅ Validation in controllers
- ✅ Sensitive data filtered before returning
- ✅ Top-level README updated with new features (backend/frontend/shared)
CRITICAL: When adding or modifying code, ALWAYS update the relevant top-level README.md file.
- Adding new controller functions
- Adding new service integrations
- Adding new routes or middleware
- Adding utility functions to
backend/utils/ - Adding cron handlers to
backend/crons/ - Adding email triggers or utilities to
backend/email/ - Changing error handling patterns
- Modifying authentication
- Adding new React components
- Changing styling patterns
- Adding state management patterns
- Modifying API integration
- Adding new hooks or utilities
- Adding new TypeScript interfaces to
types.ts - Adding utility functions to
utils.ts - Changing data structures
- Adding common patterns
- Changing overall architecture
- Adding major features
- Modifying environment variables
- Changing deployment process
Remember: Do NOT create README files in subdirectories. All documentation goes in top-level READMEs only.
IMPORTANT: AGENTS.md is the authoritative source for Val Town platform patterns. Always reference it for platform-specific details.
Use AGENTS.md for:
- Val Town platform requirements (triggers, redirects, errors, etc.)
- Deno-specific patterns and limitations
- Standard library usage (blob, SQLite, email, etc.)
- Val Town utility functions (serveFile, readFile, etc.)
- React configuration (pinning, jsxImportSource)
- Platform-specific import patterns
- Common gotchas and solutions
Use CLAUDE.md (this file) for:
- This val's specific architecture (MVC 3-layer)
- Controller/Service/Route patterns
- Notion integration patterns
- Authentication implementation
- File organization beyond basic structure
- Response formats and error handling strategy
- Import pattern exceptions (npm: for backend performance)
- Pico CSS styling (diverges from AGENTS.md Tailwind recommendation)
main.http.tsx is the HTTP entry point:
- Exports
app.fetchfor Val Town - Uses Hono for routing
- Serves static frontend assets
- Mounts API routes at
/api - Includes error handler
Always mount new routes in main.http.tsx:
import { taskRoutes } from "./backend/routes/tasks/index.ts";
app.route("/tasks", taskRoutes);
This val uses Hono as the web framework. Follow these patterns:
import { Hono } from "npm:hono@4";
import { Context, Next } from "npm:hono@4";
Note: Use npm: specifier for backend-only packages (see Import Patterns section for rationale).
When creating route modules (e.g., backend/routes/api/pages.ts):
import { Hono } from "npm:hono@4";
import * as pageController from "../../controllers/pageController.ts";
const pages = new Hono();
// GET /api/pages/:id
pages.get("/:id", async (c) => {
const pageId = c.req.param("id");
const result = await pageController.getPage(pageId);
if (!result.success) {
return c.json({ error: result.error, details: result.details }, 400);
}
return c.json(result.data);
});
// POST /api/pages/:id
pages.post("/:id", async (c) => {
const pageId = c.req.param("id");
const body = await c.req.json();
const result = await pageController.updatePage(pageId, body.properties);
if (!result.success) {
return c.json({ error: result.error, details: result.details }, 400);
}
return c.json(result.data);
});
export default pages;
Create an index file for each route directory (e.g., backend/routes/api/index.ts):
import { Hono } from "npm:hono@4";
import pages from "./pages.ts";
const api = new Hono();
api.route("/pages", pages);
// Add more routes as needed
export default api;
Then mount in main.http.tsx:
import api from "./backend/routes/api/index.ts";
app.route("/api", api);
// URL params
const id = c.req.param("id");
// Query params
const query = c.req.query("q");
// Headers
const apiKey = c.req.header("x-api-key");
// JSON body
const body = await c.req.json();
// Form data
const formData = await c.req.formData();
// JSON response
return c.json({ data: "value" });
// JSON with status code
return c.json({ error: "Not found" }, 404);
// HTML response
return c.html("<h1>Hello</h1>");
// Plain text
return c.text("Hello");
// Redirect - see AGENTS.md for Val Town redirect workaround
Authentication is applied globally in main.http.tsx (see Authentication Rules section).
For other middleware needs (logging, rate limiting, etc.), you can apply per-route:
// Apply to single route
pages.post("/:id", someMiddleware, async (c) => {
// Route logic
});
// Apply to all routes in this module
pages.use("*", someMiddleware);
Already configured in main.http.tsx - see Step 2 and AGENTS.md for patterns.
backend/routes/
├── api/
│ ├── index.ts # Mounts all API routes
│ ├── pages.ts # /api/pages/* endpoints
│ └── databases.ts # /api/databases/* endpoints
├── tasks/
│ ├── index.ts # Mounts all webhook routes
│ └── notion.ts # /tasks/notion webhook handler
├── views/
│ └── dashboard.ts # /views/dashboard page
└── authCheck.ts # Auth middleware