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

lightweight

scaffold

Unlisted
Like
scaffold
Home
Code
10
backend
4
frontend
4
shared
3
.vtignore
AGENTS.md
CLAUDE.md
README.md
deno.json
guidelines.md
main.tsx
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
10/27/2025
Viewing readonly version of main branch: v35
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 mkdir -p frontend/components mkdir -p shared

2. Create main.tsx (Hono Entry Point)

Create main.tsx with:

  • Hono app initialization
  • Error unwrapper: app.onError((err, c) => { throw err; })
  • Static file serving for frontend and shared directories
  • Root route serving frontend/index.html
  • Commented route mounting examples
  • Export: export default app.fetch

3. Create Backend Service Files

backend/services/notionService.ts:

  • Import Notion client: import { Client } from "https://esm.sh/@notionhq/client@2"
  • Create getNotionClient() helper that gets NOTION_API_KEY from env
  • Export service functions:
    • getNotionPage(pageId) - Fetch page by ID
    • updateNotionPage(pageId, properties) - Update page properties
    • createNotionPage(databaseId, properties) - Create new page
    • queryNotionDatabase(databaseId, filter?, sorts?) - Query database
    • getNotionDatabase(databaseId) - Get database info
    • getPageBlocks(pageId) - Get page content blocks
  • All functions return {success, data, error} format
  • Catch errors and return error objects (don't throw)

4. Create Backend Controller Files

backend/controllers/pageController.ts:

  • Import notionService
  • 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
  • All functions return {success, data, error, details?} format
  • Include filterSensitiveProperties() helper to remove button properties
  • Validate inputs before calling services

5. Create Authentication Middleware

backend/routes/authCheck.ts:

  • Import Hono types: import { Context, Next } from "npm:hono@4"
  • Export authCheck(c, next) function:
    • Check for API key in x-api-key header
    • Compare with Deno.env.get('API_KEY')
    • Return 401 if invalid
    • Call await next() if valid
  • Export verifyNotionWebhook(c, next) for webhook signature verification

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 Frontend Files

frontend/index.html:

  • Include Pico CSS: <link href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.classless.min.css" rel="stylesheet" />
  • Include error catching: <script src="https://esm.town/v/std/catch"></script>
  • Root div: <div id="root"></div>
  • Load React: <script type="module" src="/frontend/index.tsx"></script>

frontend/index.tsx:

  • JSX import source: /** @jsxImportSource https://esm.sh/react@18.2.0 */
  • Import React and ReactDOM with pinned versions
  • Import App component
  • Render <App /> to root element

frontend/components/App.tsx:

  • JSX import source and pinned React imports
  • Basic UI with example API call
  • State management for data fetching
  • Error and loading states

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)

9. Create README Files

Create README.md in:

  • Project root - Overview and architecture
  • backend/ - Backend structure and guidelines
  • frontend/ - Frontend structure and React guidelines
  • shared/ - Shared code guidelines

10. Environment Variables

Document required environment variables:

  • NOTION_API_KEY - Notion integration token (required)
  • API_KEY - API authentication (optional)
  • NOTION_WEBHOOK_SECRET - Webhook verification (optional)
  • Add service-specific keys as needed (e.g., FIRECRAWLER_API_KEY)

11. Verify Scaffold

After scaffolding, verify:

  • ✅ All directories exist
  • ✅ main.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

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. ✅ Apply authentication if needed using authCheck.ts middleware

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

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

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

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

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

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

  10. ✅ Test the endpoint with curl or similar tool

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

Notion Service Functions

Create dedicated service functions for common Notion operations:

// backend/services/notionService.ts export async function getNotionPage(pageId: string) { /* ... */ } export async function updateNotionPage(pageId: string, properties: any) { /* ... */ } export async function queryNotionDatabase(databaseId: string, filter?: any) { /* ... */ } export async function createNotionPage(databaseId: string, properties: any) { /* ... */ }

Always:

  • Use official Notion SDK: https://esm.sh/@notionhq/client
  • Use environment variable: Deno.env.get('NOTION_API_KEY')
  • Return standardized {success, data, error} structure

External Service Integration Pattern

When integrating a NEW external service (like Firecrawler):

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

  2. Export typed functions:

export async function fetchFromService(params: ServiceParams) { try { // API call logic return { success: true, data: result, error: null }; } catch (err) { return { success: false, data: null, error: err.message }; } }
  1. Store API keys in environment: Deno.env.get('SERVICE_API_KEY')

  2. Handle errors gracefully: Return error objects, don't throw

  3. Add types to shared/types.ts if data is used across layers

  4. Call service from controller, never from routes

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: Use generic page controllers

    • 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
  • Services: Name by external service

    • notionService.ts - Notion API calls
    • firecrawlerService.ts - Firecrawler integration
    • blobService.ts - Val Town blob storage
  • 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.tsx: Hono error unwrapper for better stack traces

app.onError((err, c) => { throw err; });

Authentication Rules

  • Auth middleware lives in routes/authCheck.ts
  • Apply to protected routes using Hono middleware pattern
  • Extract and validate API keys, tokens, or webhook signatures
  • Return 401 for authentication failures

Example:

import { authCheck } from './authCheck.ts'; app.post('/api/protected', authCheck, async (c) => { // Protected route logic });

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

ALWAYS:

  • Use React 18.2.0 with pinned dependencies
  • Include at top of file: /** @jsxImportSource https://esm.sh/react@18.2.0 */
  • Pin all React deps: ?deps=react@18.2.0,react-dom@18.2.0

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 classless CSS framework that automatically styles semantic HTML.

Why Pico CSS?

  • No CSS classes needed - Keeps HTML clean and maintainable
  • 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

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:

<link href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.classless.min.css" rel="stylesheet" />

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

Environment Variables

Required environment variables for this val:

  • NOTION_API_KEY - Notion integration token
  • FIRECRAWLER_API_KEY - Firecrawler service (if used)
  • Additional service API keys as needed

Access via: Deno.env.get('VAR_NAME')

NEVER hardcode secrets - always use environment variables

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. Does this need authentication?

    • Protected → apply authCheck middleware
  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

Integration with AGENTS.md

Use AGENTS.md for:

  • Val Town platform requirements
  • Deno-specific patterns
  • Standard library usage
  • React configuration
  • Import patterns (esm.sh)
  • Platform limitations

Use CLAUDE.md (this file) for:

  • This val's architecture
  • MVC layer separation
  • Notion integration patterns
  • External service integration
  • File organization
  • Response formats

Entry Point

main.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.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 "https://esm.sh/hono@4"; import { Context, Next } from "https://esm.sh/hono@4";

Creating Route Modules

When creating route modules (e.g., backend/routes/api/pages.ts):

import { Hono } from "https://esm.sh/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 "https://esm.sh/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.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 (use this pattern, not Response.redirect) return new Response(null, { status: 302, headers: { Location: '/new-url' }});

Middleware

Apply middleware to routes:

import { authCheck } from '../authCheck.ts'; // Apply to single route pages.post('/:id', authCheck, async (c) => { // Protected route logic }); // Apply to all routes in this module pages.use('*', authCheck);

Error Handler

Already configured in main.tsx:

app.onError((err, c) => { throw err; // Re-throw to see full stack trace });

Static File Serving

Already configured in main.tsx:

import { serveFile } from "https://esm.town/v/std/utils@85-main/index.ts"; app.get("/frontend/*", c => serveFile(c.req.path, import.meta.url)); app.get("/shared/*", c => serveFile(c.req.path, import.meta.url));

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.