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.
This is a Notion webhook integration application that:
This val follows a strict 3-layer MVC architecture. See sections below for detailed 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.
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:
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)
✅ 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
Note: Authentication is applied globally in main.http.tsx for all /api/* and /tasks/* routes (except /api/health). No per-route authentication setup needed.
All Notion webhooks live in routes/tasks/. When creating webhook handlers:
200 status quickly (webhooks timeout)Service Integration:
When 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: 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 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 storageUtils: Backend-only utility functions (backend/utils/)
shared/utils.ts which must work in browser)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 formattersenvUtils.ts - Environment variable parsersbackend/services/)backend/controllers/)shared/utils.ts)backend/services/)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.http.tsx: Hono error unwrapper for better stack traces
app.onError((err, c) => {
throw err;
});
routes/authCheck.tsX-API-KEY headermain.http.tsx using array-based protection/api/* and /tasks/* routes with exceptions for public endpointsArray-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:
X-API-KEYWEBHOOK_SECRET environment variableDevelopment mode:
WEBHOOK_SECRET not set, authentication is bypassed with console warningfrontend/index.html - HTML shellfrontend/index.tsx - React entry pointfrontend/components/App.tsx - Main componentfrontend/components/NotionBlock.tsx - Notion block rendererfrontend/components/NotionProperty.tsx - Property displayConfiguration: See AGENTS.md for React 18.2.0 setup with pinned dependencies and jsxImportSource
Data Fetching:
/api/* routesshared/types.tsThis val uses Pico CSS - a minimal 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:
<!-- 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.
Types (shared/types.ts):
Utils (shared/utils.ts):
backend/utils/ insteadRequired environment variables for this val:
NOTION_API_KEY - Notion integration tokenWEBHOOK_SECRET - Shared secret for API authenticationRECENT_PAGES_LOOKBACK_HOURS - Hours to look back for recent pages (optional, defaults to 24)FIRECRAWLER_API_KEY - Firecrawler service (if used)Access via: Deno.env.get('VAR_NAME') (see AGENTS.md for details)
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.tsShould this endpoint be public (no authentication)?
publicExceptions array in main.http.tsx/api/* and /tasks/* routesIs this frontend-facing?
frontend/components/Before implementing, verify:
{success, data, error} structureshared/types.tsCRITICAL: When adding or modifying code, ALWAYS update the relevant top-level README.md file.
backend/utils/backend/crons/backend/email/types.tsutils.tsRemember: Do NOT create README files in subdirectories. All documentation goes in top-level READMEs only.
IMPORTANT: AGENTS.md is the authoritative source for Val Town platform patterns. Always reference it for platform-specific details.
Use 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 (see Import Patterns section for rationale).
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 - see AGENTS.md for Val Town redirect workaround
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);
Already configured in main.http.tsx - see Step 2 and AGENTS.md for patterns.
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