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:
- 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
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.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
backend/services/notionService.ts:
- Import Notion client:
import { Client } from "https://esm.sh/@notionhq/client@2" - Create
getNotionClient()helper that getsNOTION_API_KEYfrom env - Export service functions:
getNotionPage(pageId)- Fetch page by IDupdateNotionPage(pageId, properties)- Update page propertiescreateNotionPage(databaseId, properties)- Create new pagequeryNotionDatabase(databaseId, filter?, sorts?)- Query databasegetNotionDatabase(databaseId)- Get database infogetPageBlocks(pageId)- Get page content blocks
- All functions return
{success, data, error}format - Catch errors and return error objects (don't throw)
backend/controllers/pageController.ts:
- Import notionService
- Export controller functions:
getPage(pageId)- Get page with validationupdatePage(pageId, properties)- Update with validationcreatePage(databaseId, properties)- Create with validationgetPageContent(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
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-keyheader - Compare with
Deno.env.get('API_KEY') - Return 401 if invalid
- Call
await next()if valid
- Check for API key in
- Export
verifyNotionWebhook(c, next)for webhook signature verification
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
shared/utils.ts:
- Export utility functions:
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 type
- ONLY use standard JavaScript (no Deno or browser-specific APIs)
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
getPropertyValuefrom shared/utils - Render different property types (title, select, date, checkbox, etc.)
- Use semantic HTML (Pico CSS provides automatic styling)
Create README.md in:
- Project root - Overview and architecture
backend/- Backend structure and guidelinesfrontend/- Frontend structure and React guidelinesshared/- Shared code guidelines
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)
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
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
Location:
routes/api/- API endpoints for data operationsroutes/tasks/- Notion webhook handlersroutes/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- Success400- Validation errors, bad request401- Authentication failures500- 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);
});
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
pageIdand 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
};
}
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
};
}
}
When asked to "build a /tasks/ endpoint" or any new feature, follow this checklist:
-
✅ Create route file in appropriate directory:
- Webhook handlers →
routes/tasks/ - API endpoints →
routes/api/ - Views →
routes/views/
- Webhook handlers →
-
✅ Extract request data in route (params, query, body)
-
✅ Apply authentication if needed using
authCheck.tsmiddleware -
✅ Create controller function in
backend/controllers/[feature].ts- Implement validation
- Implement business logic
- Orchestrate service calls
-
✅ Create/update service functions in
backend/services/[service].ts- Make external API calls
- Return
{success, data, error}structure
-
✅ Return standardized response from controller
-
✅ Format HTTP response in route based on controller result
-
✅ Add TypeScript types to
shared/types.tsif data is shared across layers -
✅ Import route into
main.tsxand mount on Hono app -
✅ Test the endpoint with curl or similar tool
All Notion webhooks live in routes/tasks/. When creating webhook handlers:
- Validate webhook signature/authentication
- Extract Notion event data
- Call appropriate controller
- Return
200status quickly (webhooks timeout) - Use async processing for slow operations
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
When integrating a NEW external service (like Firecrawler):
-
Create service file:
backend/services/[serviceName].ts -
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 };
}
}
-
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.tsif data is used across layers -
Call service from controller, never from routes
- 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)
-
Controllers: Use generic page controllers
pageController.ts- Generic Notion page operations (get, update, create)webhookController.ts- Webhook processing logic- Note: Controllers should work with
pageIdgenerically, not task/project-specific
-
Services: Name by external service
notionService.ts- Notion API callsfirecrawlerService.ts- Firecrawler integrationblobService.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 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.tsx: Hono error unwrapper for better stack traces
app.onError((err, c) => {
throw err;
});
- 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
401for authentication failures
Example:
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 display
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
This val uses Pico CSS - a classless CSS framework that automatically styles semantic HTML.
- 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
❌ 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 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-busyattribute for loading states on buttons
Links & Media:
<a>- Links<time>- Dates and times<figure>,<figcaption>- Images with captions
Card 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):
- 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
Required environment variables for this val:
NOTION_API_KEY- Notion integration tokenFIRECRAWLER_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
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
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 }), {})
};
When the user requests a new feature, clarify:
-
Is this a webhook handler or API endpoint?
- Webhook →
routes/tasks/ - API →
routes/api/
- Webhook →
-
What external service needs integration?
- New service → create
backend/services/[name].ts
- New service → create
-
What data needs to be saved back to Notion?
- Update Notion → use/extend
notionService.ts
- Update Notion → use/extend
-
Does this need authentication?
- Protected → apply
authCheckmiddleware
- Protected → apply
-
Is this frontend-facing?
- Yes → create React component in
frontend/components/
- Yes → create React component in
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
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
main.tsx is the HTTP entry point:
- Exports
app.fetchfor 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);
This val uses Hono as the web framework. Follow these patterns:
import { Hono } from "https://esm.sh/hono@4";
import { Context, Next } from "https://esm.sh/hono@4";
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;
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);
// 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.tsx:
app.onError((err, c) => {
throw err; // Re-throw to see full stack trace
});
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));
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