Living document for Val Town platform patterns, APIs, and best practices.
Last updated: 2026-01-29
Val Town is a serverless platform that runs TypeScript/JavaScript using the Deno runtime. Key characteristics:
require(), must use import/export.ts, .tsx, .js in importsHandle HTTP requests. Files typically named *.http.ts or *.http.tsx.
export default async function(req: Request): Promise<Response> {
return new Response("Hello World");
}
Run on schedule. Files typically named *.cron.ts.
export default async function(interval: Interval) {
console.log("Cron ran at", new Date());
}
Scheduling limits:
Internal polling pattern (for near-real-time within 1-min cron):
export default async function() {
const POLL_INTERVAL_MS = 5000;
const RUN_DURATION_MS = 55000; // Leave 5s buffer
const startTime = Date.now();
while (Date.now() - startTime < RUN_DURATION_MS) {
await pollAndProcess();
await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
}
}
Triggered by incoming emails. Files typically named *.email.ts.
export default async function(email: Email) {
console.log("Received email from:", email.from);
}
General-purpose code, can export functions/values.
import { sqlite } from "https://esm.town/v/std/sqlite";
// Create table (idempotent)
await sqlite.execute(`
CREATE TABLE IF NOT EXISTS my_table (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
)
`);
// Query with parameters (ALWAYS use for user input!)
const result = await sqlite.execute({
sql: `SELECT * FROM my_table WHERE id = ?`,
args: [1]
});
// Result shape: { columns: string[], rows: any[][], rowsAffected: number, lastInsertRowid: bigint | null }
// Named parameters
await sqlite.execute({
sql: `INSERT INTO my_table (name) VALUES (:name)`,
args: { name: "value" }
});
// Batch queries (transactional)
await sqlite.batch([
`CREATE TABLE IF NOT EXISTS accounts (id TEXT, balance INTEGER)`,
{ sql: `UPDATE accounts SET balance = balance - :amount WHERE id = 'Bob'`, args: { amount: 10 } },
{ sql: `UPDATE accounts SET balance = balance + :amount WHERE id = 'Alice'`, args: { amount: 10 } }
]);
Schema migration pattern:
ALTER TABLE support_2, _3 suffix, migrate dataLimits:
import { blob } from "https://esm.town/v/std/blob";
await blob.setJSON("myKey", { hello: "world" });
const data = await blob.getJSON("myKey");
const keys = await blob.list("prefix_");
await blob.delete("myKey");
import { OpenAI } from "https://esm.town/v/std/openai";
const openai = new OpenAI();
const completion = await openai.chat.completions.create({
messages: [{ role: "user", content: "Hello" }],
model: "gpt-4o-mini",
max_tokens: 100
});
import { email } from "https://esm.town/v/std/email";
await email({
subject: "Test",
text: "Hello",
html: "<h1>Hello</h1>"
});
import {
readFile,
serveFile,
listFiles,
parseProject
} from "https://esm.town/v/std/utils@85-main/index.ts";
// Serve static files in Hono
app.get("/frontend/*", (c) => serveFile(c.req.path, import.meta.url));
// Read file content
const content = await readFile("/frontend/index.html", import.meta.url);
// List all project files
const files = await listFiles(import.meta.url);
// Get project metadata
const project = parseProject(import.meta.url);
// project.username, project.name, project.version, project.branch
// project.links.self.project (URL to project page)
Important: parseProject and utilities only run on server. Pass to client via HTML injection or API.
DISCORD_BOT_TOKEN, DISCORD_GUILD_IDconst DISCORD_API = "https://discord.com/api/v10";
async function discordRequest<T>(path: string, init: RequestInit): Promise<T> {
const response = await fetch(`${DISCORD_API}${path}`, {
...init,
headers: {
"Authorization": `Bot ${Deno.env.get("DISCORD_BOT_TOKEN")}`,
"Content-Type": "application/json",
...init.headers
}
});
// Handle rate limits
if (response.status === 429) {
const data = await response.json();
await new Promise(r => setTimeout(r, (data.retry_after ?? 1) * 1000));
return discordRequest(path, init); // Retry
}
if (!response.ok) throw new Error(`Discord API error: ${response.status}`);
return response.json();
}
| Endpoint | Method | Description |
|---|---|---|
/guilds/{guild_id}/channels | GET | List channels with topics |
/channels/{channel_id}/messages | GET | Fetch messages (?after=snowflake&limit=50) |
/channels/{channel_id}/messages | POST | Send message |
/channels/{channel_id}/messages/{message_id}/threads | POST | Create thread from message |
/channels/{channel_id}/invites | POST | Create invite |
Val Town can't use WebSockets, so use HTTP Interactions:
import { verifyDiscordRequest } from "https://esm.town/v/neverstew/verifyDiscordRequest";
export default async function(req: Request) {
const isValid = await verifyDiscordRequest(req, Deno.env.get("discordPublicKey"));
if (!isValid) return new Response("Invalid signature", { status: 401 });
const body = await req.json();
// Ping (Discord verification)
if (body.type === 1) {
return Response.json({ type: 1 });
}
// Slash command
if (body.type === 2) {
return Response.json({
type: 4,
data: { content: "Pong!" }
});
}
}
import { registerDiscordSlashCommand } from "https://esm.town/v/neverstew/registerDiscordSlashCommand";
await registerDiscordSlashCommand(
Deno.env.get("discordAppId"),
Deno.env.get("discordBotToken"),
{ name: "ping", description: "Say hi to your bot" }
);
For programmatic Val Town access:
import ValTown from "@valtown/sdk";
const client = new ValTown({ bearerToken: "..." });
// List vals
for await (const val of client.me.vals.list()) {
console.log(val.name);
}
// Run val
const result = await client.vals.execute("username/valName");
https://esm.sh for npm packages (works server + browser)https://esm.sh/react@18.2.0?deps=react@18.2.0,react-dom@18.2.0// Response.redirect is broken in Val Town
return new Response(null, {
status: 302,
headers: { Location: "/new-path" }
});
<script src="https://esm.town/v/std/catch"></script>
__dirname/__filename - Use import.meta.url insteadalert()/prompt()/confirm() - Browser APIs not availableshared/ must work in both frontend and backend (no Deno keyword)Val Town's built-in AI (Townie) has full MCP access:
System prompt available at: https://val.town/townie/system-prompt
vt): Deploy from terminal, vt tail for logs, vt watch for auto-sync@valtown/sdk