This file provides project-specific guidance to Claude Code when working with this Val Town application.
Note: For general Val Town platform guidelines, see AGENTS.md. This file contains architecture rules specific to THIS val.
This is a Notion webhook integration application that:
If you're starting from scratch, follow these steps to scaffold the complete val structure:
mkdir -p backend/controllers backend/routes/api backend/routes/tasks backend/routes/views backend/services mkdir -p frontend/components mkdir -p shared
Create main.http.tsx with:
.http.tsx extension - this tells Val Town it's an HTTP triggerapp.onError((err, c) => { throw err; })frontend/index.htmlexport default app.fetchVal Town Trigger Naming:
filename.http.tsx (or .http.ts)filename.cron.tsxfilename.email.tsxOrganize 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.tsbackend/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 validation{success, data, error, details?} formatfilterSensitiveProperties() helper to remove button propertiesbackend/routes/authCheck.ts:
import { Context, Next } from "npm:hono@4"authCheck(c, next) function:
x-api-key headerDeno.env.get('API_KEY')await next() if validverifyNotionWebhook(c, next) for webhook signature verificationshared/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 typefrontend/index.html:
<link href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.classless.min.css" rel="stylesheet" /><script src="https://esm.town/v/std/catch"></script><div id="root"></div><script type="module" src="/frontend/index.tsx"></script>frontend/index.tsx:
/** @jsxImportSource https://esm.sh/react@18.2.0 */<App /> to root elementfrontend/components/App.tsx:
frontend/components/NotionBlock.tsx:
frontend/components/NotionProperty.tsx:
getPropertyValue from shared/utilsCreate README.md in:
backend/ - Backend structure and guidelinesfrontend/ - Frontend structure and React guidelinesshared/ - Shared code guidelinesDocument required environment variables:
NOTION_API_KEY - Notion integration token (required)API_KEY - API authentication (optional)NOTION_WEBHOOK_SECRET - Webhook verification (optional)FIRECRAWLER_API_KEY)After scaffolding, verify:
app.fetch{success, data, error}{success, data, error, details?}This val runs in different environments, so import patterns matter.
For code that ONLY runs in the backend (controllers, services, routes, main.http.tsx):
Use npm: specifier:
import { Hono } from "npm:hono@4";
import { Client } from "npm:@notionhq/client@2";
Benefits:
Val Town utilities still use URLs:
import { readFile, serveFile } from "https://esm.town/v/std/utils@85-main/index.ts";
For code in frontend/ and shared/ that runs in the BROWSER:
Use esm.sh with version pins:
// Frontend React components
import { useState } from "https://esm.sh/react@18.2.0?deps=react@18.2.0";
// Shared utilities (work in both browser and Deno)
import { getPropertyValue } from "../../shared/utils.ts";
Why esm.sh for frontend:
npm: support)| Location | Environment | Import Pattern | Example |
|---|---|---|---|
backend/ | Deno only | npm:package@version | npm:hono@4 |
main.http.tsx | Deno only | npm:package@version | npm:hono@4 |
frontend/ | Browser | https://esm.sh/package@version | https://esm.sh/react@18.2.0 |
shared/ | Both | https://esm.sh/package@version | https://esm.sh/date-fns@3 |
| Val Town utils | Deno | https://esm.town/v/... | Val Town standard library |
Backend (use npm:):
npm:hono@4npm:@notionhq/client@2Frontend (use esm.sh):
https://esm.sh/react@18.2.0https://esm.sh/react-dom@18.2.0/clientShared (use esm.sh):
https://esm.sh/date-fns@3https://esm.sh/zod@3This val follows a 3-layer architecture. Every feature MUST follow this pattern. NEVER skip layers.
Request → Route → Controller → Service → External API
↓
Response ← Format ← Standard Response ← Result
Location:
routes/api/ - API endpoints for data operationsroutes/tasks/ - Notion webhook handlersroutes/views/ - User-facing HTML/React viewsResponsibility: HTTP ONLY
authCheck.tsRules:
200 - Success400 - Validation errors, bad request401 - Authentication failures500 - Server/external service errorsExample pattern:
app.post('/api/tasks/:id', async (c) => {
const pageId = c.req.param('id');
const body = await c.req.json();
const result = await pageController.updatePage(pageId, body);
if (!result.success) {
return c.json({ error: result.error, details: result.details }, 400);
}
return c.json(result.data);
});
Responsibility: Business logic and orchestration
IMPORTANT: Work with Notion pages generically
pageId and work with any Notion page/api/tasks/:id), but they should call generic page controllersMUST return this exact structure:
{
success: boolean,
data: any | null,
error: string | null,
details?: string // Additional error context
}
Rules:
Example pattern:
export async function updatePage(pageId: string, properties: any) {
// Validate
if (!pageId || !properties) {
return {
success: false,
data: null,
error: "Invalid input",
details: "pageId and properties are required"
};
}
// Call service
const result = await notionService.updatePage(pageId, properties);
if (!result.success) {
return {
success: false,
data: null,
error: "Failed to update page",
details: result.error
};
}
return {
success: true,
data: result.data,
error: null
};
}
Responsibility: External API calls ONLY
Create services for:
notionService.ts)firecrawlerService.ts)blobService.ts)Rules:
{success, data, error}Example pattern:
export async function getNotionPage(pageId: string) {
try {
const notion = new Client({ auth: Deno.env.get('NOTION_API_KEY') });
const page = await notion.pages.retrieve({ page_id: pageId });
return {
success: true,
data: page,
error: null
};
} catch (err) {
return {
success: false,
data: null,
error: err.message
};
}
}
When asked to "build a /tasks/ endpoint" or any new feature, follow this checklist:
✅ Create route file in appropriate directory:
routes/tasks/routes/api/routes/views/✅ Extract request data in route (params, query, body)
✅ Apply authentication if needed using authCheck.ts middleware
✅ Create controller function in backend/controllers/[feature].ts
✅ Create/update service functions in backend/services/[service].ts
{success, data, error} structure✅ Return standardized response from controller
✅ Format HTTP response in route based on controller result
✅ Add TypeScript types to shared/types.ts if data is shared across layers
✅ Import route into main.http.tsx and mount on Hono app
✅ Test the endpoint with curl or similar tool
All Notion webhooks live in routes/tasks/. When creating webhook handlers:
200 status quickly (webhooks timeout)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:
https://esm.sh/@notionhq/clientDeno.env.get('NOTION_API_KEY'){success, data, error} structureWhen integrating a simple external service (like Firecrawler):
Create service file: backend/services/[serviceName].ts
Initialize client and export typed functions:
import { SomeClient } from "npm:some-client@1";
export const client = new SomeClient({ apiKey: Deno.env.get('SERVICE_API_KEY') });
export async function fetchFromService(params: ServiceParams) {
try {
const result = await client.fetch(params);
return { success: true, data: result, error: null };
} catch (err: any) {
return { success: false, data: null, error: err.message };
}
}
When integrating a complex service with multiple concerns (like Notion with pages, databases, blocks):
Create service directory: backend/services/[serviceName]/
Create index.ts - Initialize client and re-export:
import { Client } from "npm:@servicename/client@2";
export const client = new Client({ auth: Deno.env.get("SERVICE_API_KEY") });
export * from "./pages.ts";
export * from "./databases.ts";
// 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 };
}
}
Deno.env.get('SERVICE_API_KEY')shared/types.ts if data is used across layersnotionService.ts, pageController.ts)getNotionPage, updatePage)NotionWebhookPayload, PageResponse)/api/notion-tasks, /tasks/webhook-handler)Controllers: Use generic page controllers
pageController.ts - Generic Notion page operations (get, update, create)webhookController.ts - Webhook processing logicpageId generically, not task/project-specificServices: Organize by external API
notion/ - Notion API integration
index.ts - Client initialization and re-exportspages.ts - Page operationsdatabases.ts - Database operationsfirecrawlerService.ts - Firecrawler integrationblobService.ts - Val Town blob storageRoutes: Organize by purpose
routes/api/ - RESTful API endpoints (/api/tasks, /api/tasks/:id)routes/tasks/ - Webhook handlers (/tasks/notion-webhook)routes/views/ - User-facing pages (/views/dashboard)Shared: Cross-layer code
shared/types.ts - TypeScript interfacesshared/utils.ts - Pure utility functions (work in browser AND Deno)Services: Return errors, don't throw
return { success: false, data: null, error: "API call failed" };
Controllers: Add context to service errors
if (!result.success) {
return {
success: false,
data: null,
error: "Failed to update page",
details: result.error
};
}
Routes: Map to HTTP status codes
if (!result.success) {
return c.json({ error: result.error, details: result.details }, 400);
}
Add to main.http.tsx: Hono error unwrapper for better stack traces
app.onError((err, c) => {
throw err;
});
routes/authCheck.ts401 for authentication failuresExample:
import { authCheck } from './authCheck.ts';
app.post('/api/protected', authCheck, async (c) => {
// Protected route logic
});
frontend/index.html - HTML shellfrontend/index.tsx - React entry pointfrontend/components/App.tsx - Main componentfrontend/components/NotionBlock.tsx - Notion block rendererfrontend/components/NotionProperty.tsx - Property displayALWAYS:
/** @jsxImportSource https://esm.sh/react@18.2.0 */?deps=react@18.2.0,react-dom@18.2.0Data Fetching:
/api/* routesshared/types.tsThis val uses Pico CSS - a classless CSS framework that automatically styles semantic HTML.
<article>, <main>, <kbd>, etc.)<link> tag, no build step❌ NEVER use CSS class names (no className attributes)
❌ NEVER use TailwindCSS, Bootstrap, or other class-based frameworks
✅ ALWAYS use semantic HTML elements
✅ Use inline styles sparingly - Only for dynamic/conditional styling
Use these semantic elements - Pico styles them automatically:
Layout:
<main> - Main content container<article> - Self-contained content (cards, sections)<section> - Thematic grouping of content<header>, <footer> - Page/section headers and footers<aside> - Sidebar contentTypography:
<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 / tagsLists:
<ul>, <ol>, <li> - Lists<dl>, <dt>, <dd> - Definition listsForms:
<input>, <textarea>, <select> - Form inputs<button> - Buttons<label> - Form labelsaria-busy attribute for loading states on buttonsLinks & Media:
<a> - Links<time> - Dates and times<figure>, <figcaption> - Images with captionsCard layout:
<article> <h2>Card Title</h2> <p>Card content goes here.</p> <button>Action</button> </article>
Form with loading state:
<div style={{ display: 'flex', gap: '1rem' }}>
<input type="text" placeholder="Enter value" style={{ flex: 1 }} />
<button disabled={loading} aria-busy={loading}>
Submit
</button>
</div>
Error message:
{error && <mark>{error}</mark>}
Tags/badges:
Conditional styling:
<span style={checked ? { textDecoration: 'line-through', opacity: 0.6 } : undefined}>
Task text
</span>
Already included in frontend/index.html:
<link href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.classless.min.css" rel="stylesheet" />
Types (shared/types.ts):
Utils (shared/utils.ts):
Required environment variables for this val:
NOTION_API_KEY - Notion integration tokenFIRECRAWLER_API_KEY - Firecrawler service (if used)Access via: Deno.env.get('VAR_NAME')
NEVER hardcode secrets - always use environment variables
Override default property names via env vars. Defaults are in /backend/utils/notionConfig.ts.
Pattern: TODOS_PROP_{SCREAMING_SNAKE_KEY} or PROJECTS_PROP_{SCREAMING_SNAKE_KEY}
Logic:
Todos Database Properties (TODOS_PROP_*):
| Env Var | Default | Key |
|---|---|---|
| TODOS_PROP_NAME | Name | name |
| TODOS_PROP_BLOCK_ID | Block ID | blockId |
| TODOS_PROP_BLOCK_URL | Block URL | blockUrl |
| TODOS_PROP_PAGE_URL | Page URL | pageUrl |
| TODOS_PROP_LAST_EDITED_TIME | Todo last edited time | lastEditedTime |
| TODOS_PROP_AUTHOR | Author | author |
| TODOS_PROP_OWNER | Owner | owner |
| TODOS_PROP_OTHER_PEOPLE | Other people | otherPeople |
| TODOS_PROP_DUE_DATE | Due date | dueDate |
| TODOS_PROP_LINKS | Links | links |
| TODOS_PROP_SUMMARY | Summary | summary |
| TODOS_PROP_PROJECTS | (disabled) | projects |
| TODOS_PROP_STATUS | (disabled) | status |
| TODOS_FUZZY_MATCH_RELATIONS | (disabled) | fuzzyMatch (comma-separated) |
Projects Database Properties (PROJECTS_PROP_*):
| Env Var | Default | Key |
|---|---|---|
| PROJECTS_PROP_GROUP_BY | Clients | groupBy |
| PROJECTS_PROP_DATE_START | Dates | dateStart |
| PROJECTS_PROP_DATE_END | Dates | dateEnd |
Examples:
# Override property names TODOS_PROP_DUE_DATE=Deadline # Enable optional features (disabled by default) TODOS_PROP_projects=Projects db TODOS_FUZZY_MATCH_RELATIONS=Contacts,Companies # Enable checkbox status sync (marks checked todos as "Done") # Auto-detects property type (status, select, checkbox) and finds done option TODOS_PROP_STATUS=Status # Disable a property TODOS_PROP_OWNER=
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:
Return helpful error messages with details field
Filter sensitive data in controllers before returning:
Example:
// Remove sensitive properties
const filtered = {
...notionData,
properties: Object.entries(notionData.properties)
.filter(([key, value]) => value.type !== 'button')
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {})
};
When the user requests a new feature, clarify:
Is this a webhook handler or API endpoint?
routes/tasks/routes/api/What external service needs integration?
backend/services/[name].tsWhat data needs to be saved back to Notion?
notionService.tsDoes this need authentication?
authCheck middlewareIs this frontend-facing?
frontend/components/Before implementing, verify:
{success, data, error} structureshared/types.tsUse AGENTS.md for:
Use CLAUDE.md (this file) for:
main.http.tsx is the HTTP entry point:
app.fetch for Val Town/apiAlways mount new routes in main.http.tsx:
import { taskRoutes } from './backend/routes/tasks/index.ts';
app.route('/tasks', taskRoutes);
This val uses Hono as the web framework. Follow these patterns:
import { Hono } from "npm:hono@4";
import { Context, Next } from "npm:hono@4";
Note: Use npm: specifier for backend-only packages (better performance, modern Deno way).
When creating route modules (e.g., backend/routes/api/pages.ts):
import { Hono } from "npm:hono@4";
import * as pageController from '../../controllers/pageController.ts';
const pages = new Hono();
// GET /api/pages/:id
pages.get('/:id', async (c) => {
const pageId = c.req.param('id');
const result = await pageController.getPage(pageId);
if (!result.success) {
return c.json({ error: result.error, details: result.details }, 400);
}
return c.json(result.data);
});
// POST /api/pages/:id
pages.post('/:id', async (c) => {
const pageId = c.req.param('id');
const body = await c.req.json();
const result = await pageController.updatePage(pageId, body.properties);
if (!result.success) {
return c.json({ error: result.error, details: result.details }, 400);
}
return c.json(result.data);
});
export default pages;
Create an index file for each route directory (e.g., backend/routes/api/index.ts):
import { Hono } from "npm:hono@4";
import pages from './pages.ts';
const api = new Hono();
api.route('/pages', pages);
// Add more routes as needed
export default api;
Then mount in main.http.tsx:
import api from './backend/routes/api/index.ts';
app.route('/api', api);
// URL params
const id = c.req.param('id');
// Query params
const query = c.req.query('q');
// Headers
const apiKey = c.req.header('x-api-key');
// JSON body
const body = await c.req.json();
// Form data
const formData = await c.req.formData();
// JSON response
return c.json({ data: 'value' });
// JSON with status code
return c.json({ error: 'Not found' }, 404);
// HTML response
return c.html('<h1>Hello</h1>');
// Plain text
return c.text('Hello');
// Redirect (use this pattern, not Response.redirect)
return new Response(null, { status: 302, headers: { Location: '/new-url' }});
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);
Already configured in main.http.tsx:
app.onError((err, c) => {
throw err; // Re-throw to see full stack trace
});
Already configured in main.http.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));
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