• Blog
  • Docs
  • Pricing
  • We’re hiring!
Log inSign up
lightweight

lightweight

scaffold

Unlisted
Like
scaffold
Home
Code
12
.claude
1
backend
7
frontend
5
shared
3
.vtignore
AGENTS.md
CLAUDE.md
README.md
deno.json
guidelines.md
H
main.http.tsx
webhook-auth-notes.md
Branches
1
Pull requests
Remixes
History
Environment variables
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.
Sign up now
Code
/
CLAUDE.md
Code
/
CLAUDE.md
Search
11/5/2025
Viewing readonly version of main branch: v75
View latest version
CLAUDE.md

CLAUDE.md

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.

Project Identity

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

Scaffolding a New Val

If you're starting from scratch, follow these steps to scaffold the complete val structure:

1. Create Directory 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 logic
  • backend/routes/ - HTTP endpoints (webhook handlers, APIs, views)
  • backend/services/ - External API integrations
  • backend/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 components
  • frontend/hooks/ - Custom React hooks
  • shared/ - Cross-platform code (types, utilities)

2. Create main.http.tsx (Hono Entry Point)

Create main.http.tsx with:

  • IMPORTANT: Use .http.tsx extension (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

3. Create Backend Service Files

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 ID
    • updateNotionPage(pageId, properties) - Update page properties
    • createNotionPage(databaseId, properties) - Create new page
    • getPageBlocks(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 database
    • getNotionDatabase(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 notion client from index.ts

4. Create Backend Controller Files

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 status
    • timestamp - Current ISO timestamp
    • project - Val Town project info (from backend/utils/valtown.ts)
    • configuration - Environment variable status (NOTION_API_KEY, WEBHOOK_SECRET)
  • Uses backend/utils/valtown.ts for 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 / by App.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

backend/controllers/pageController.ts:

  • Import Notion service: import * as notionService from '../services/notion/index.ts'
  • Export controller functions:
    • getPage(pageId) - Get page with validation
    • updatePage(pageId, properties) - Update with validation
    • createPage(databaseId, properties) - Create with validation
    • getPageContent(pageId) - Get blocks with validation
    • getRecentPages(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)

5. Create Authentication Middleware

backend/routes/authCheck.ts:

  • Import Hono types: import { Context, Next } from "npm:hono@4"
  • Export webhookAuth(c, next) function:
    • Check for X-API-KEY header (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

6. Create Shared Types

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

7. Create Shared Utils

shared/utils.ts:

  • Export utility functions:
    • isValidPageId(pageId) - Validate Notion ID format
    • normalizePageId(pageId) - Remove hyphens
    • formatPageId(pageId) - Add hyphens in 8-4-4-4-12 format
    • getPlainText(richText) - Extract text from Notion rich text
    • getPropertyValue(properties, name) - Get value by property type
  • ONLY use standard JavaScript (no Deno or browser-specific APIs)

8. Create Backend Utils

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 name
    • getProjectUsername() - Get the owner's username
    • getBlobKeyPrefix() - Get namespaced blob key prefix (username_projectname)
    • getProjectUrl() - Get the Val Town project URL
  • Used by healthController.ts to populate project info
  • Used by services for blob key namespacing

Additional utils (create as needed):

  • cryptoUtils.ts - JWT signing, hashing with Web Crypto API
  • validationUtils.ts - Advanced validation using Deno features
  • envUtils.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)

9. Create Val Town Triggers (Optional)

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/

Cron Handlers (backend/crons/)

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.tsx extension
  • 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

Email Handlers (backend/email/)

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.tsx extension
  • 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 .ts extension (regular TypeScript)
  • Export functions that send emails
  • Return {success, data, error} format
  • Can be called by controllers, services, or crons

Architecture with Triggers

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()

10. Create Frontend Files

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/health data
  • 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/health endpoint data
    • H1: Shows project.username/project.name from health data
    • H2: Clickable link to /api/health endpoint (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")
  • Uses useHealth() custom hook for data fetching
  • Handles loading and error states
  • This is the baseline default view - as /api/health grows 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 getPropertyValue from shared/utils
  • Render different property types (title, select, date, checkbox, etc.)
  • Use semantic HTML (Pico CSS provides automatic styling)

11. Create README Files

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 documentation

    • types.ts guidelines and examples
    • utils.ts guidelines and examples
    • Browser/Deno compatibility rules
    • When to use shared/utils.ts vs backend/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.md instead
  • frontend/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:

  1. Added controller/service/route/util? → Update /backend/README.md
  2. Added React component? → Update /frontend/README.md
  3. Added shared type/util? → Update /shared/README.md
  4. Changed architecture? → Update root /README.md and CLAUDE.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

12. Environment Variables

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)

13. Verify Scaffold

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

Import 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.

Backend Imports (Deno only)

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";

Frontend/Shared Imports (Browser compatible)

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

Quick Reference

LocationEnvironmentImport PatternExample
backend/Deno onlynpm:package@versionnpm:hono@4
main.http.tsxDeno onlynpm:package@versionnpm:hono@4
frontend/Browserhttps://esm.sh/package@versionhttps://esm.sh/react@18.2.0
shared/Bothhttps://esm.sh/package@versionhttps://esm.sh/date-fns@3
Val Town utilsDenohttps://esm.town/v/...Val Town standard library

Common Packages

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

Strict MVC Architecture

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

Layer 1: Routes (backend/routes/)

Location:

  • routes/api/ - API endpoints for data operations
  • routes/tasks/ - Notion webhook handlers
  • routes/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 - Success
    • 400 - Validation errors, bad request
    • 401 - Authentication failures
    • 500 - 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); });

Layer 2: Controllers (backend/controllers/)

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 pageId and 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, }; }

Layer 3: Services (backend/services/)

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, }; } }

Adding New Endpoints - Step-by-Step

When asked to "build a /tasks/ endpoint" or any new feature, follow this checklist:

  1. ✅ Create route file in appropriate directory:

    • Webhook handlers → routes/tasks/
    • API endpoints → routes/api/
    • Views → routes/views/
  2. ✅ Extract request data in route (params, query, body)

  3. ✅ Create controller function in backend/controllers/[feature].ts

    • Implement validation
    • Implement business logic
    • Orchestrate service calls
  4. ✅ Create/update service functions in backend/services/[service].ts

    • Make external API calls
    • Return {success, data, error} structure
  5. ✅ Return standardized response from controller

  6. ✅ Format HTTP response in route based on controller result

  7. ✅ Add TypeScript types to shared/types.ts if data is shared across layers

  8. ✅ Import route into main.http.tsx and mount on Hono app

  9. ✅ 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.

Notion Integration Patterns

Webhook Handlers (routes/tasks/)

All Notion webhooks live in routes/tasks/. When creating webhook handlers:

  • Validate webhook signature/authentication
  • Extract Notion event data
  • Call appropriate controller
  • Return 200 status quickly (webhooks timeout)
  • Use async processing for slow operations

Service Integration:

  • Follow the "External Service Integration Pattern" section below for organizing Notion service functions

External Service Integration Pattern

For Simple Services (Single File)

When integrating a simple external service (like Firecrawler):

  1. Create service file: backend/services/[serviceName].ts

  2. 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 }; } }

For Complex Services (Directory Structure)

When integrating a complex service with multiple concerns (like Notion with pages, databases, blocks):

  1. Create service directory: backend/services/[serviceName]/

  2. 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";
  1. 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 }; } }

Service Rules

  • 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.ts if data is used across layers
  • Call service from controller, never from routes
  • One client instance: Initialize once, reuse everywhere

File Organization

Naming Conventions

  • 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)

File Placement

  • 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 pageId generically, 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 integration
        • index.ts - Client initialization and re-exports
        • pages.ts - Page operations
        • databases.ts - Database operations
    • For simple services, use single files:
      • firecrawlerService.ts - Firecrawler integration
      • blobService.ts - Val Town blob storage
  • 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.ts which 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 formatters
      • envUtils.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/)
    • 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 interfaces
    • shared/utils.ts - Pure utility functions (work in browser AND Deno)

Error Handling Strategy

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 Rules

  • Authentication middleware lives in routes/authCheck.ts
  • Uses shared secret approach with X-API-KEY header
  • Applied globally in main.http.tsx using 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_SECRET environment variable

Development mode:

  • If WEBHOOK_SECRET not set, authentication is bypassed with console warning

Frontend Integration

Structure

  • frontend/index.html - HTML shell
  • frontend/index.tsx - React entry point
  • frontend/components/App.tsx - Main component
  • frontend/components/NotionBlock.tsx - Notion block renderer
  • frontend/components/NotionProperty.tsx - Property display

React Rules

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

Frontend Styling

This val uses Pico CSS - a minimal CSS framework that automatically styles semantic HTML.

Why Pico CSS?

  • 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

Rules

❌ 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

Semantic HTML Elements

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-busy attribute for loading states on buttons

Links & Media:

  • <a> - Links
  • <time> - Dates and times
  • <figure>, <figcaption> - Images with captions

Example Patterns

Card layout:

Create val
<article> <h2>Card Title</h2> <p>Card content goes here.</p> <button>Action</button> </article>

Form with loading state:

Create val
<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:

Create val
{ error && <mark>{error}</mark>; }

Tags/badges:

Create val
<kbd>Tag 1</kbd> <kbd>Tag 2</kbd>

Conditional styling:

Create val
<span style={checked ? { textDecoration: "line-through", opacity: 0.6 } : undefined} > Task text </span>

Loading Pico CSS

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.

Shared Code (shared/)

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

Environment Variables

Required environment variables for this val:

  • NOTION_API_KEY - Notion integration token
  • WEBHOOK_SECRET - Shared secret for API authentication
  • RECENT_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)

Data Validation

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

Response Filtering

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 }), {}), };

Questions to Ask User

When the user requests a new feature, clarify:

  1. Is this a webhook handler or API endpoint?

    • Webhook → routes/tasks/
    • API → routes/api/
  2. What external service needs integration?

    • New service → create backend/services/[name].ts
  3. What data needs to be saved back to Notion?

    • Update Notion → use/extend notionService.ts
  4. Should this endpoint be public (no authentication)?

    • Yes → Add to publicExceptions array in main.http.tsx
    • No → Authentication already applied to all /api/* and /tasks/* routes
  5. Is this frontend-facing?

    • Yes → create React component in frontend/components/

Common Patterns Checklist

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)

Documentation Maintenance

CRITICAL: When adding or modifying code, ALWAYS update the relevant top-level README.md file.

Update /backend/README.md when:

  • 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

Update /frontend/README.md when:

  • Adding new React components
  • Changing styling patterns
  • Adding state management patterns
  • Modifying API integration
  • Adding new hooks or utilities

Update /shared/README.md when:

  • Adding new TypeScript interfaces to types.ts
  • Adding utility functions to utils.ts
  • Changing data structures
  • Adding common patterns

Update root /README.md when:

  • 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.

Integration with AGENTS.md

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)

Entry Point

main.http.tsx is the HTTP entry point:

  • Exports app.fetch for 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);

Hono Framework Patterns

This val uses Hono as the web framework. Follow these patterns:

Importing Hono

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).

Creating Route Modules

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;

Mounting Routes

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);

Accessing Request Data

// 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();

Returning Responses

// 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

Middleware

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);

Error Handler and Static File Serving

Already configured in main.http.tsx - see Step 2 and AGENTS.md for patterns.

Route Organization Example

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
FeaturesVersion controlCode intelligenceCLIMCP
Use cases
TeamsAI agentsSlackGTM
DocsShowcaseTemplatesNewestTrendingAPI examplesNPM packages
PricingNewsletterBlogAboutCareers
We’re hiring!
Brandhi@val.townStatus
X (Twitter)
Discord community
GitHub discussions
YouTube channel
Bluesky
Open Source Pledge
Terms of usePrivacy policyAbuse contact
© 2025 Val Town, Inc.