This file provides step-by-step instructions for scaffolding a new Val Town application from scratch.
Note: This is a one-time setup guide. After completing these steps, see CLAUDE.md for ongoing development guidelines and architectural patterns.
This is a Notion webhook integration application that:
Follow these steps to scaffold the complete val structure:
mkdir -p backend/controllers backend/routes/api backend/routes/tasks backend/routes/views backend/services backend/utils backend/crons backend/email mkdir -p frontend/components frontend/hooks mkdir -p shared
Directory Purpose:
backend/controllers/ - Business logicbackend/routes/ - HTTP endpoints (webhook handlers, APIs, views)backend/services/ - External API integrationsbackend/utils/ - Backend-only utilities (pure functions, no external deps)backend/crons/ - Val Town cron trigger handlers (scheduled tasks)backend/email/ - Val Town email triggers & utilities (incoming/outgoing emails)frontend/components/ - React componentsfrontend/hooks/ - Custom React hooksshared/ - Cross-platform code (types, utilities)Create main.http.tsx with:
.http.tsx extension (see AGENTS.md for Val Town trigger types)app.onError((err, c) => { throw err; })frontend/index.htmlexport default app.fetchOrganize services by external API. For Notion, create a directory structure:
backend/services/notion/index.ts:
import { Client } from "npm:@notionhq/client@2"export const notion = new Client({ auth: Deno.env.get("NOTION_API_KEY") })export * from "./pages.ts"export * from "./databases.ts"backend/services/notion/pages.ts:
import { notion } from "./index.ts"getNotionPage(pageId) - Fetch page by IDupdateNotionPage(pageId, properties) - Update page propertiescreateNotionPage(databaseId, properties) - Create new pagegetPageBlocks(pageId) - Get page content blocksbackend/services/notion/databases.ts:
import { notion } from "./index.ts"queryNotionDatabase(databaseId, filter?, sorts?) - Query databasegetNotionDatabase(databaseId) - Get database infoService function rules:
{success, data, error} formatnotion client from index.tsIMPORTANT: Organize controllers by purpose or context. Each controller handles a specific domain or concern.
backend/controllers/healthController.ts:
getHealth() function for system health checksdata:
timestamp - Current ISO timestampproject - Val Town project info (from backend/utils/valtown.ts)configuration - Environment variable status (NOTION_API_KEY, WEBHOOK_SECRET)backend/utils/valtown.ts for project information{success, data, error, details?} formatdata (success indicated by HTTP 200)/ by App.tsx
username/projectname/api/healthbackend/controllers/pageController.ts:
import * as notionService from '../services/notion/index.ts'getPage(pageId) - Get page with validationupdatePage(pageId, properties) - Update with validationcreatePage(databaseId, properties) - Create with validationgetPageContent(pageId) - Get blocks with validationgetRecentPages(hours?) - Get recently edited pages{success, data, error, details?} formatfilterSensitiveProperties() helper to remove button propertiesController Organization Pattern:
pageController.ts)backend/routes/authCheck.ts:
import { Context, Next } from "npm:hono@4"webhookAuth(c, next) function:
X-API-KEY header (case-insensitive)Deno.env.get('WEBHOOK_SECRET')await next() if validshared/types.ts:
ControllerResponse<T> interface with {success, data, error, details?}NotionPage, NotionDatabase, NotionWebhookPayloadUpdatePageRequest, CreatePageRequest, etc.shared/utils.ts:
isValidPageId(pageId) - Validate Notion ID formatnormalizePageId(pageId) - Remove hyphensformatPageId(pageId) - Add hyphens in 8-4-4-4-12 formatgetPlainText(richText) - Extract text from Notion rich textgetPropertyValue(properties, name) - Get value by property typebackend/utils/ directory:
backend/utils/valtown.ts (default utility):
import { parseProject } from "https://esm.town/v/std/utils@85-main/index.ts"getProjectInfo() - Get Val Town project info (username, name, branch, links)getProjectName() - Get just the project/val namegetProjectUsername() - Get the owner's usernamegetBlobKeyPrefix() - Get namespaced blob key prefix (username_projectname)getProjectUrl() - Get the Val Town project URLhealthController.ts to populate project infoAdditional utils (create as needed):
cryptoUtils.ts - JWT signing, hashing with Web Crypto APIvalidationUtils.ts - Advanced validation using Deno featuresenvUtils.ts - Environment variable parsing helpersRules:
services/)controllers/)shared/utils.ts)IMPORTANT: These are Val Town-specific features for scheduled tasks and email handling.
Val Town Trigger Documentation:
Create cron handlers for scheduled tasks:
When to create:
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:
.cron.tsx extensioninterval: IntervalCreate email handlers for two purposes:
1. Email Triggers - Handle incoming emails
When to create:
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:
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:
.email.tsx extensionemail: Emailemail.send()Rules for email utilities:
.ts extension (regular TypeScript){success, data, error} formatAll three trigger types follow the same architecture:
HTTP Route ┐
Cron ├──→ Controller → Service → External API
Email ┘
Controllers can also call email utilities:
Controller → Email Utility → email.send()
frontend/index.html:
<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>
<div id="root"></div><script type="module" src="/frontend/index.tsx"></script>frontend/index.tsx:
<App /> to root elementfrontend/hooks/useHealth.ts:
/api/health data{ health, loading, error }frontend/components/App.tsx:
/api/health endpoint data
project.username/project.name from health data/api/health endpoint (opens in new tab)<pre> tagproject.links.self.project + "/environment-variables")useHealth() custom hook for data fetching/api/health grows with more system info, it automatically appears herefrontend/components/NotionBlock.tsx:
frontend/components/NotionProperty.tsx:
getPropertyValue from shared/utilsIMPORTANT 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
backend/README.md - Complete backend documentation
frontend/README.md - Complete frontend documentation
shared/README.md - Complete shared code documentation
types.ts guidelines and examplesutils.ts guidelines and examplesshared/utils.ts vs backend/utils/❌ DO NOT create README files in:
backend/controllers/backend/routes/backend/services/backend/utils/ ← Document in /backend/README.md insteadfrontend/components/Why Top-Level Only:
When Adding Features:
When you add new functionality to the codebase, ALWAYS update the relevant top-level README:
/backend/README.md/frontend/README.md/shared/README.md/README.md and CLAUDE.mdREADME Content Standards:
Each top-level README should include:
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)FIRECRAWLER_API_KEY)After scaffolding, verify:
app.fetch{success, data, error}{success, data, error, details?}Scaffolding complete! Now see CLAUDE.md for:
For Val Town platform patterns (redirects, React config, standard library usage), see AGENTS.md.