This file provides project-specific guidance to Claude Code when working with this Val Town application.
Note: For general Val Town platform guidelines, see AGENTS.md. This file contains architecture rules specific to THIS val.
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
If you're starting from scratch, follow these steps to scaffold the complete val structure:
mkdir -p backend/controllers backend/routes/api backend/routes/tasks backend/routes/views backend/services backend/utils backend/crons backend/email mkdir -p frontend/components frontend/hooks mkdir -p shared
Directory Purpose:
backend/controllers/- Business logicbackend/routes/- HTTP endpoints (webhook handlers, APIs, views)backend/services/- External API integrationsbackend/utils/- Backend-only utilities (pure functions, no external deps)backend/crons/- Val Town cron trigger handlers (scheduled tasks)backend/email/- Val Town email triggers & utilities (incoming/outgoing emails)frontend/components/- React componentsfrontend/hooks/- Custom React hooksshared/- Cross-platform code (types, utilities)
Create main.http.tsx with:
- IMPORTANT: Use
.http.tsxextension (see AGENTS.md for Val Town trigger types) - Hono app initialization
- Error unwrapper:
app.onError((err, c) => { throw err; }) - Static file serving using Val Town utils (see AGENTS.md for serveFile pattern)
- Root route serving
frontend/index.html - Authentication middleware (array-based protection pattern)
- Commented route mounting examples
- Export:
export default app.fetch
Organize services by external API. For Notion, create a directory structure:
backend/services/notion/index.ts:
- Import Notion client:
import { Client } from "npm:@notionhq/client@2" - Initialize and export client:
export const notion = new Client({ auth: Deno.env.get("NOTION_API_KEY") }) - Re-export all functions from submodules:
export * from "./pages.ts"export * from "./databases.ts"
backend/services/notion/pages.ts:
- Import shared client:
import { notion } from "./index.ts" - Export page-related functions:
getNotionPage(pageId)- Fetch page by IDupdateNotionPage(pageId, properties)- Update page propertiescreateNotionPage(databaseId, properties)- Create new pagegetPageBlocks(pageId)- Get page content blocks
backend/services/notion/databases.ts:
- Import shared client:
import { notion } from "./index.ts" - Export database-related functions:
queryNotionDatabase(databaseId, filter?, sorts?)- Query databasegetNotionDatabase(databaseId)- Get database info
Service function rules:
- All functions return
{success, data, error}format - Catch errors and return error objects (don't throw)
- Use the shared
notionclient from index.ts
IMPORTANT: Organize controllers by purpose or context. Each controller handles a specific domain or concern.
backend/controllers/healthController.ts:
- Export
getHealth()function for system health checks - Returns system information:
status: 'ok'- Health statustimestamp- Current ISO timestampproject- Val Town project info (frombackend/utils/valtown.ts)configuration- Environment variable status (NOTION_API_KEY, WEBHOOK_SECRET)
- Uses
backend/utils/valtown.tsfor project information - No service calls needed (checks environment variables and parses import.meta.url)
- All functions return
{success, data, error, details?}format - PUBLIC - No authentication required
- Default baseline view: Displayed on root
/byApp.tsx- Shows project name in H1:
username/projectname - Shows endpoint path in H2:
/api/health - Displays full JSON response
- As you add more info to health endpoint, it automatically appears in UI
- Shows project name in H1:
backend/controllers/pageController.ts:
- Import Notion service:
import * as notionService from '../services/notion/index.ts' - Export controller functions:
getPage(pageId)- Get page with validationupdatePage(pageId, properties)- Update with validationcreatePage(databaseId, properties)- Create with validationgetPageContent(pageId)- Get blocks with validationgetRecentPages(hours?)- Get recently edited pages
- All functions return
{success, data, error, details?}format - Include
filterSensitiveProperties()helper to remove button properties - Validate inputs before calling services
Controller Organization Pattern:
- Group related operations in one controller (e.g., all page operations in
pageController.ts) - Separate concerns by domain (health, pages, webhooks, etc.)
- Controllers can grow to include more functions as features are added
- May call services (Notion, external APIs) or just check system state (health)
backend/routes/authCheck.ts:
- Import Hono types:
import { Context, Next } from "npm:hono@4" - Export
webhookAuth(c, next)function:- Check for
X-API-KEYheader (case-insensitive) - Compare with
Deno.env.get('WEBHOOK_SECRET') - If WEBHOOK_SECRET not configured, log warning and bypass auth (development mode)
- Return 401 if invalid or missing
- Call
await next()if valid - Log failed authentication attempts
- Check for
shared/types.ts:
- Define
ControllerResponse<T>interface with{success, data, error, details?} - Define Notion types:
NotionPage,NotionDatabase,NotionWebhookPayload - Define API types:
UpdatePageRequest,CreatePageRequest, etc. - Use generic types that work in browser and Deno
shared/utils.ts:
- Export utility functions:
isValidPageId(pageId)- Validate Notion ID formatnormalizePageId(pageId)- Remove hyphensformatPageId(pageId)- Add hyphens in 8-4-4-4-12 formatgetPlainText(richText)- Extract text from Notion rich textgetPropertyValue(properties, name)- Get value by property type
- ONLY use standard JavaScript (no Deno or browser-specific APIs)
backend/utils/ directory:
- IMPORTANT: Only for pure utility functions with NO external dependencies and NO business logic
- Can use Deno APIs (unlike shared/utils.ts which must be browser-compatible)
backend/utils/valtown.ts (default utility):
- Import Val Town standard library:
import { parseProject } from "https://esm.town/v/std/utils@85-main/index.ts" - Export functions:
getProjectInfo()- Get Val Town project info (username, name, branch, links)getProjectName()- Get just the project/val namegetProjectUsername()- Get the owner's usernamegetBlobKeyPrefix()- Get namespaced blob key prefix (username_projectname)getProjectUrl()- Get the Val Town project URL
- Used by
healthController.tsto populate project info - Used by services for blob key namespacing
Additional utils (create as needed):
cryptoUtils.ts- JWT signing, hashing with Web Crypto APIvalidationUtils.ts- Advanced validation using Deno featuresenvUtils.ts- Environment variable parsing helpers
Rules:
- Export pure functions only (no side effects)
- Keep functions small and focused on single responsibilities
- Do NOT add:
- External API calls (those go in
services/) - Business logic (that goes in
controllers/) - Browser-compatible code (that goes in
shared/utils.ts)
- External API calls (those go in
IMPORTANT: These are Val Town-specific features for scheduled tasks and email handling.
Val Town Trigger Documentation:
- Cron triggers: https://docs.val.town/vals/cron/
- Email triggers: https://docs.val.town/vals/email/
- Email utilities: https://docs.val.town/reference/std/email/
Create cron handlers for scheduled tasks:
When to create:
- ✅ Periodic data synchronization (hourly, daily, weekly)
- ✅ Scheduled cleanup or maintenance tasks
- ✅ Regular status checks or health monitoring
- ✅ Batch processing at intervals
- ✅ Scheduled reports or summaries
File naming: [taskName].cron.tsx
Pattern:
// backend/crons/syncNotionData.cron.tsx
import * as pageController from '../controllers/pageController.ts';
export default async function syncNotionData(interval: Interval) {
console.log(`[Cron] Starting sync at ${new Date().toISOString()}`);
try {
const result = await pageController.syncAllPages();
if (!result.success) {
console.error(`[Cron] Sync failed: ${result.error}`);
return;
}
console.log(`[Cron] Sync completed`);
} catch (err) {
console.error(`[Cron] Error:`, err);
}
}
Rules:
- Use
.cron.tsxextension - Export default async function accepting
interval: Interval - Call controllers (same architecture as routes)
- Execute and complete (no return value needed)
- Add logging for monitoring
- Configure schedule in Val Town UI
Create email handlers for two purposes:
1. Email Triggers - Handle incoming emails
When to create:
- ✅ Process incoming support/contact emails
- ✅ Create Notion pages from email content
- ✅ Auto-reply to specific patterns
- ✅ Parse and route inbound messages
File naming: [handlerName].email.tsx
Pattern:
// backend/email/processTicket.email.tsx
import { email } from "https://esm.town/v/std/email";
import * as ticketController from '../controllers/ticketController.ts';
export default async function processTicket(email: Email) {
console.log(`[Email] From: ${email.from}, Subject: ${email.subject}`);
try {
const result = await ticketController.createTicket({
from: email.from,
subject: email.subject,
body: email.text
});
if (!result.success) {
return await email.send({
to: email.from,
subject: "Re: " + email.subject,
text: "Error processing your email."
});
}
return await email.send({
to: email.from,
subject: "Re: " + email.subject,
text: `Ticket created: ${result.data.ticketId}`
});
} catch (err) {
console.error(`[Email] Error:`, err);
}
}
2. Email Utilities - Send emails from code
When to create:
- ✅ Send notifications from controllers
- ✅ Email reports from crons
- ✅ Send alerts or confirmations
File naming: [utilityName].ts
Pattern:
// backend/email/notifications.ts
import { email } from "https://esm.town/v/std/email";
export async function sendNotification(to: string, subject: string, body: string) {
try {
await email.send({ to, subject, text: body });
return { success: true, data: null, error: null };
} catch (err: any) {
return { success: false, data: null, error: err.message };
}
}
Rules for email triggers:
- Use
.email.tsxextension - Export default async function accepting
email: Email - Call controllers for business logic
- Return email response using
email.send() - Configure email address in Val Town UI
Rules for email utilities:
- Use
.tsextension (regular TypeScript) - Export functions that send emails
- Return
{success, data, error}format - Can be called by controllers, services, or crons
All three trigger types follow the same architecture:
HTTP Route ┐
Cron ├──→ Controller → Service → External API
Email ┘
Controllers can also call email utilities:
Controller → Email Utility → email.send()
frontend/index.html:
- Include Pico CSS:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" /> - Add custom styles for root padding:
<style> #root > div { padding-block: var(--pico-block-spacing-vertical); } </style>
- Include error catching script (see AGENTS.md)
- Root div:
<div id="root"></div> - Load React:
<script type="module" src="/frontend/index.tsx"></script>
frontend/index.tsx:
- JSX import source and pinned React versions (see AGENTS.md for React configuration)
- Import App component
- Render
<App />to root element
frontend/hooks/useHealth.ts:
- Custom React hook for fetching
/api/healthdata - Returns
{ health, loading, error } - Fetches on component mount (empty dependency array)
- Handles HTTP errors gracefully
frontend/components/App.tsx:
- JSX import source and pinned React imports
- Default pattern: Displays
/api/healthendpoint data- H1: Shows
project.username/project.namefrom health data - H2: Clickable link to
/api/healthendpoint (opens in new tab) - Displays full health JSON in
<pre>tag - Link to Val Town environment variables (constructed from
project.links.self.project + "/environment-variables")
- H1: Shows
- Uses
useHealth()custom hook for data fetching - Handles loading and error states
- This is the baseline default view - as
/api/healthgrows with more system info, it automatically appears here
frontend/components/NotionBlock.tsx:
- Render different Notion block types (paragraph, heading, lists, code, etc.)
- Use semantic HTML (Pico CSS provides automatic styling)
frontend/components/NotionProperty.tsx:
- Import
getPropertyValuefrom shared/utils - Render different property types (title, select, date, checkbox, etc.)
- Use semantic HTML (Pico CSS provides automatic styling)
IMPORTANT Documentation Pattern: Use top-level README.md files ONLY. Do NOT create README files in subdirectories.
Create README.md in these locations:
-
Project root (
/README.md) - Overview and architecture- Project purpose and identity
- Quick start guide
- Architecture overview diagram
- Links to detailed documentation
-
backend/README.md- Complete backend documentation- All 4 layers documented: Routes, Controllers, Services, Utils
- Directory structure visualization
- Example patterns for each layer
- Rules and best practices
- How to add new features (10-step checklist)
- Error handling strategy
- Authentication patterns
- Import patterns
-
frontend/README.md- Complete frontend documentation- React component structure
- Styling guidelines (Pico CSS)
- State management patterns
- API integration patterns
- Component examples
-
shared/README.md- Complete shared code documentationtypes.tsguidelines and examplesutils.tsguidelines and examples- Browser/Deno compatibility rules
- When to use
shared/utils.tsvsbackend/utils/ - Testing compatibility guide
- Common use cases with examples
❌ DO NOT create README files in:
backend/controllers/backend/routes/backend/services/backend/utils/← Document in/backend/README.mdinsteadfrontend/components/- Any other subdirectories
Why Top-Level Only:
- Easier to maintain (fewer files to update)
- Complete context in one place per layer
- No duplication across multiple files
- Simpler navigation for developers
When Adding Features:
When you add new functionality to the codebase, ALWAYS update the relevant top-level README:
- Added controller/service/route/util? → Update
/backend/README.md - Added React component? → Update
/frontend/README.md - Added shared type/util? → Update
/shared/README.md - Changed architecture? → Update root
/README.mdandCLAUDE.md
README Content Standards:
Each top-level README should include:
- Directory structure visualization
- Purpose and responsibilities of each file/subdirectory
- Code examples for common patterns
- Rules and guidelines (what goes where)
- Best practices
- How to add new features
- Cross-references to other documentation
Document required environment variables:
NOTION_API_KEY- Notion integration token (required)WEBHOOK_SECRET- Shared secret for API and webhook authentication (required for production, optional for development)RECENT_PAGES_LOOKBACK_HOURS- Hours to look back for recent pages (optional, defaults to 24)- Add service-specific keys as needed (e.g.,
FIRECRAWLER_API_KEY)
After scaffolding, verify:
- ✅ All directories exist
- ✅ main.http.tsx exports
app.fetch - ✅ Service functions return
{success, data, error} - ✅ Controller functions return
{success, data, error, details?} - ✅ Frontend has JSX import source and pinned React versions
- ✅ Shared code has no Deno/browser-specific APIs
- ✅ README files explain each directory's purpose
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