| name: | val-tool-playground |
|---|---|
| description: | Build developer tools and playgrounds on Val Town β single-purpose utilities that serve both a human UI and a programmatic API from one endpoint. Use this skill whenever creating tool vals, utility vals, playground vals, or any Val Town HTTP val that does one thing well with a web UI and composable API. Trigger when the user says "create a tool", "build a playground", "make a utility", or wants a quick web tool for inspecting, decoding, parsing, looking up, generating, or exploring something. |
A skill for building developer tools and playgrounds on Val Town. These are single-purpose utilities designed around one core principle: every tool is both a UI and an API.
Think: JWT decoders, DNS lookup tools, HTTP header inspectors, cron expression testers, hash/encode utilities, QR generators, web content extractors, OG tag previewers, sitemap validators, user-agent parsers, regex playgrounds, color pickers, API response explorers, CSS animation playgrounds, diff viewers.
The most useful developer tools are the ones you can compose. A tool that only works through a web form is a dead end. A tool that returns structured output from a URL is a building block.
Design every tool so that:
- A human can open it in a browser and use it interactively
- A script can
curlit and pipe the output somewhere - An LLM can fetch it as context for a prompt
- Another tool can call it as a dependency
This last point is increasingly important. Tools should be agent-friendly β their output should be parseable and useful when an LLM fetches the URL directly. Think of every tool as a potential node in an agentic workflow.
When the tool processes a URL as input, make it a path parameter:
https://your-tool.val.run/https://example.com/article
This is more composable than query params because you can construct it by concatenation. Query params are for modifiers and flags (?json=1, ?selector=.content), not primary input.
For tools with non-URL input (text, tokens, expressions), use query params or POST with a JSON body β whatever maps most naturally to how the tool would be called programmatically.
The API endpoint should return the most useful raw format β plain text, markdown, CSV β not JSON. JSON is for metadata.
Why: A tool that returns markdown can be piped directly into an LLM context window. A tool that returns JSON requires parsing first. Optimize for the common case.
Provide ?json=1 for when callers need structured metadata alongside the content.
Each val does one thing. Don't build a Swiss Army knife. Build a screwdriver that works perfectly and can be composed with other screwdrivers.
Composition happens at the URL level: chain tools together by passing one tool's output URL to another. This only works if each tool has a clear, singular purpose.
Every tool val serves from a single HTTP endpoint with routing based on the request:
GET / β HTML page (the playground UI)
GET /path-or-params β raw output (the API)
GET /path-or-params?json=1 β structured JSON with metadata
POST / with JSON body β process input and return JSON
The UI is a convenience layer. The API is the product.
export default async function (req: Request): Promise<Response> {
const url = new URL(req.url);
// 1. Handle OPTIONS for CORS preflight
// 2. Handle POST (for paste/upload input)
// 3. Check for target input in path or query params
// β Return raw output or JSON based on ?json param
// 4. Fall through to HTML UI
}
Simple tools (single concern, short HTML):
main.ts # HTTP handler + inline HTML renderer
README.md
Complex tools (multiple features, rich UI):
main.ts # HTTP handler (routing only)
lib/
renderer.ts # HTML page generation
parser.ts # Core logic
types.ts # TypeScript interfaces
README.md
Keep main.ts thin β just routing. Push logic into lib/.
Always add CORS headers on API responses so tools are callable from anywhere:
const cors = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
Return proper HTTP status codes with error messages in both modes:
// API consumers get JSON errors
if (wantJson) return Response.json({ error: e.message }, { status: 502, headers: cors });
// Raw consumers get plain text errors
return new Response(`Error: ${e.message}`, { status: 502, headers: cors });
In the UI, show errors inline β never use alert().
We use Pico CSS v2 as the styling foundation. It provides semantic HTML styling with zero classes, system theme support, and consistent form/button/card components.
Core rules:
- System theme only β never force light or dark
- No external images or favicons β use emoji or inline SVG
- Vanilla HTML/CSS/JS β no frameworks for tools
- Prefer Pico's native components over custom CSS (accordions, tooltips, buttons, switches, dropdowns are all built in)
- Use ARIA attributes for state (
aria-busyfor loading,aria-invalidfor validation,aria-selectedfor tabs) - No Hono β raw Request/Response is sufficient for tool vals
For the base HTML template, component patterns, tabs implementation, and the full list of native Pico components, see references/pico-ui-patterns.md.
Every tool follows this layout skeleton:
βββββββββββββββββββββββββββββββββββ
β π Tool Name β β emoji h1
β One-line description + links β β subtitle (hgroup or p)
βββββββββββββββββββββββββββββββββββ€
β [Tab A] [Tab B] β β optional: when tool has multiple modes
βββββββββββββββββββββββββββββββββββ€
β [input...............] [Action] β β fieldset role="group"
β βΈ Advanced options β β optional: <details> for extra params
βββββββββββββββββββββββββββββββββββ€
β Title: X Β· Words: N Β· 42ms β β optional: metadata bar (.meta)
β ββββββββββββββββββββββββ[Copy] β
β β output content β β pre.result with copy button overlay
β β ... β
β ββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββ€
β API docs / proxy hint β β optional: inline API reference
βββββββββββββββββββββββββββββββββββ€
β author Β· source β β footer with links
βββββββββββββββββββββββββββββββββββ
Always present:
<main class="container">at 800β900px max-width- Emoji
<h1>+ descriptive subtitle - Primary input via
<fieldset role="group">(input + action button) - Results container (dynamically rendered)
- Footer with author link
Added based on complexity:
- Tabs β when the tool has multiple input modes (e.g., decode vs encode, URL vs paste HTML)
<details>for advanced options β content selectors, extra flags, format choices- Metadata bar β flex row of extracted stats (title, word count, timing, etc.)
- Copy button β absolute-positioned over output
<pre>block - API hint section β inline docs showing the proxy URL pattern and params
Pin npm versions. Use npm: prefix. Fall back to https://esm.sh/ if sandboxed:
import { Window } from "npm:happy-dom@17.4.4";
export default async function (req: Request): Promise<Response> { ... }
- No filesystem (
fs,Deno.readFile) β use SQLite/blob for persistence - No subprocess (
Deno.Command,child_process) - 30s execution timeout
- Don't use
Response.redirectβ broken on Val Town
Keep brief. Lead with the API β the UI is self-explanatory:
# Tool Name
One-line description.
**Live:** https://tool.val.run
## API
\`\`\`
https://tool.val.run/https://example.com
\`\`\`
### Options
| Param | Description |
|-------|-------------|
| `?json=1` | Full JSON with metadata |
## Limitations
- Known constraints
- Val name: kebab-case (
jwt-decoder,dns-lookup) - Description: terse, starts with verb or noun
- Emoji in H1 only, not in val name or description
- Don't forget the API β every tool must work without the UI
- Don't over-abstract β single
main.tsis fine for simple tools - Don't force a color theme β respect system preference
- Don't reach for frameworks β if you need React, it's probably not a tool val
- Don't build multi-purpose tools β split concerns into separate vals that compose