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

lightweight

buckAnAcre

Public
Like
buckAnAcre
Home
Code
5
.vtignore
AGENTS.md
CLAUDE.md
deno.json
H
main.ts
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
/
/
x
/
lightweight
/
buckAnAcre
/
branch
/
main
/
version
/
7
/
code
/
CLAUDE.md
/
CLAUDE.md
Code
/
/
x
/
lightweight
/
buckAnAcre
/
branch
/
main
/
version
/
7
/
code
/
CLAUDE.md
/
CLAUDE.md
Search
11/11/2025
Viewing readonly version of main branch: v7
View latest version
CLAUDE.md

CLAUDE.md

This file provides project-specific development guidelines for working with this Val Town application.

Prerequisites: This assumes you've completed initial setup. If scaffolding a new val from scratch, see SCAFFOLD.md first.

Note: For general Val Town platform patterns (triggers, redirects, standard library, React config), see AGENTS.md.

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

Architecture Overview

This val follows a strict 3-layer MVC architecture. See sections below for detailed patterns.

⚠️ CRITICAL: Notion Webhook Integration

When creating ANY /tasks/* endpoint:

This application receives webhooks from Notion automations. The payload structure is NOT what you might expect:

  • ❌ WRONG: Looking for body.pageId
  • ✅ CORRECT: Page ID is at body.data.id

Always check body.data.id first when handling Notion webhooks. See the detailed "Notion Integration Patterns" section for the complete webhook handler pattern and payload format.

Quick check:

// Notion automation format (ALWAYS check this first) if (body?.data?.object === "page" && body?.data?.id) { pageId = body.data.id; }

This is documented in detail in the "Notion Integration Patterns" section below.

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)

    • ⚠️ CRITICAL for /tasks/* routes: Follow the Notion webhook payload format pattern in the "Notion Integration Patterns" section
    • Always check body.data.id first for Notion automation webhooks
    • Fall back to body.pageId for simple API calls
  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 (see payload formats below)
  • Call appropriate controller
  • Return 200 status quickly (webhooks timeout)
  • Val Town doesn't support background tasks - process synchronously

CRITICAL: Notion Webhook Payload Formats

IMPORTANT: When creating /tasks/* endpoints that receive Notion webhooks, you MUST handle the Notion automation payload format correctly.

Notion Automation Webhook Format

When webhooks are triggered from Notion automations or buttons, the payload structure is:

{ "source": { "type": "automation", "automation_id": "...", "action_id": "...", "event_id": "...", "user_id": "...", "attempt": 1 }, "data": { "object": "page", // or "database" "id": "page-id-here", // ⚠️ THE PAGE ID IS HERE "created_time": "...", "last_edited_time": "...", "properties": { ... }, "url": "...", ... } }

Key Points:

  • ⚠️ Page ID location: body.data.id (NOT body.pageId)
  • ⚠️ Object type: Check body.data.object === "page" to validate
  • The entire page object is in body.data
  • Source metadata is in body.source

Simple API Format (for direct calls)

For direct API calls (not from Notion), you may also support:

{ "pageId": "page-id-here", "category": "proposal", // optional ...additional params }

Webhook Handler Pattern for /tasks/* Routes

ALWAYS use this pattern for Notion webhook handlers:

import { Hono } from "npm:hono@4"; import * as controller from "../../controllers/someController.ts"; const route = new Hono(); route.post("/", async (c) => { let body: any; try { body = await c.req.json(); } catch (err: any) { console.error("Failed to parse request body:", err.message); return c.json({ error: "Invalid JSON in request body" }, 400); } // Extract pageId from either format let pageId: string | undefined; let category = "default-category"; // 1. Check for Notion automation format (body.data.id) if (body?.data?.object === "page" && body?.data?.id) { pageId = body.data.id; console.log("Detected Notion automation webhook format"); } // 2. Check for simple API format (body.pageId) else if (body?.pageId) { pageId = body.pageId; category = body.category || "default-category"; console.log("Detected simple API format"); } // Validate pageId if (!pageId) { console.error( "Webhook called with missing pageId. Body:", JSON.stringify(body) ); return c.json( { error: "Missing pageId", details: "Expected Notion automation format (data.id) or simple format (pageId)", }, 400 ); } // Process the request try { const result = await controller.someFunction(pageId, category); if (!result.success) { console.error(`Operation failed for ${pageId}:`, result.error); return c.json( { status: "error", error: result.error, details: result.details, }, 500 ); } console.log(`Operation successful:`, result.data); return c.json({ status: "success", message: "Operation completed", data: result.data, }); } catch (err: any) { console.error("Error processing webhook:", err.message); return c.json( { status: "error", error: "Internal server error", details: err.message, }, 500 ); } }); export default route;

Rules for /tasks/* webhook handlers:

  • ✅ ALWAYS check for body.data.id first (Notion automation format)
  • ✅ Fall back to body.pageId for simple API calls
  • ✅ Log which format was detected
  • ✅ Include full body in error logs when pageId is missing
  • ✅ Process synchronously (Val Town doesn't support background tasks)
  • ✅ Return detailed error messages
  • ❌ NEVER assume body.pageId exists - check body.data.id first
  • ❌ NEVER use c.executionCtx.waitUntil() (Cloudflare Workers only)

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 (optional in dev mode)
  • BLOB_KEY_PREFIX_PROJECT - Static project identifier for blob keys (e.g., "acme", "internal") - REQUIRED
  • BLOB_KEY_PREFIX_TYPE - Static type identifier for blob keys (e.g., "proposals", "tasks") - REQUIRED
  • Additional service API keys as needed

Blob Key Format: Blob keys are automatically generated using the format: ${BLOB_KEY_PREFIX_PROJECT}--${BLOB_KEY_PREFIX_TYPE}--${pageId}

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.