• Blog
  • Docs
  • Pricing
  • We’re hiring!
Log inSign up
kamalnrf

kamalnrf

val-tool-playground-skill

Skill for building consistent tool vals with Pico CSS
Public
Like
val-tool-playground-skill
Home
Code
1
SKILL.md
Connections
Environment variables
Branches
1
Pull requests
Remixes
History
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.
Sign up now
Code
/
SKILL.md
Code
/
SKILL.md
Search
…
Viewing readonly version of main branch: v2
View latest version
SKILL.md
name:
val-tool-playground
description:
Build composable developer tool vals 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 wraps a library/API. Trigger when the user says "create a val", "build a tool", "make a playground", or wants a quick web utility with both a UI and API interface.

Val Tool Playground

A skill for building developer tool vals on Val Town. These are single-purpose utilities designed around one core principle: every tool is both a UI and an API.

Philosophy

Every tool is a URL

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 curl it and pipe the output somewhere
  • An LLM can fetch it as context for a prompt
  • Another tool can call it as a dependency

URL as interface

The URL itself should be the primary API. For proxy-style tools where the input is a URL, make it a path parameter:

https://defuddle.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.

Raw output by default

The API endpoint should return the most useful raw format — plain text, markdown, CSV — not JSON. JSON is for metadata. A tool that returns markdown can be piped directly into an LLM context window. A tool that returns JSON requires parsing first.

Provide ?json=1 for when callers need structured metadata alongside the content.

One concern per tool

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.

Architecture

Dual-mode HTTP handler

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

Handler structure

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 }

File structure

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/.

API Design

CORS

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", };

Error handling

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().

UI Patterns

We use Pico CSS v2 as the styling foundation because it provides semantic HTML styling with zero classes for most elements, system theme support out of the box, and consistent form/button/card components.

Base template

<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="color-scheme" content="light dark"> <title>Tool Name</title> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"> <style> :root { --pico-font-size: 16px; } .container { max-width: 900px; } </style> </head> <body> <main class="container">...</main> </body> </html>

Key rules:

  • System theme only — never force light or dark. Include <meta name="color-scheme" content="light dark">.
  • No external images or favicons — use emoji or inline SVG
  • No frameworks (React, etc.) for simple tools — vanilla HTML/CSS/JS

Header

Use <hgroup> — Pico automatically mutes the subtitle:

<hgroup> <h1>📄 Tool Name</h1> <p>What it does. Link to <a href="#">underlying library</a> if wrapping one.</p> </hgroup>

Tabs (the one custom component)

Pico has no tab component, so this is the one pattern we always write custom CSS for. Use ARIA roles for accessibility:

<div role="tablist"> <button role="tab" aria-selected="true" aria-controls="tab-a">Tab A</button> <button role="tab" aria-selected="false" aria-controls="tab-b">Tab B</button> </div> <div role="tabpanel" id="tab-a" class="active">...</div> <div role="tabpanel" id="tab-b">...</div>
[role="tablist"] { border-bottom: 1px solid var(--pico-muted-border-color); margin-bottom: 1.5rem; } [role="tab"] { background: none; border: none; border-bottom: 3px solid transparent; border-radius: 0; padding: 0.75rem 1.5rem; margin: 0; color: var(--pico-muted-color); font-weight: 500; cursor: pointer; } [role="tab"][aria-selected="true"] { color: var(--pico-primary); border-bottom-color: var(--pico-primary); } [role="tab"]:hover { color: var(--pico-primary); } [role="tabpanel"] { display: none; } [role="tabpanel"].active { display: block; }
document.querySelectorAll('[role="tab"]').forEach(tab => { tab.addEventListener('click', () => { document.querySelectorAll('[role="tab"]').forEach(t => t.setAttribute('aria-selected', 'false')); document.querySelectorAll('[role="tabpanel"]').forEach(p => p.classList.remove('active')); tab.setAttribute('aria-selected', 'true'); document.getElementById(tab.getAttribute('aria-controls')).classList.add('active'); }); });

Input patterns

URL input with action button — use Pico's fieldset group:

<fieldset role="group"> <input type="url" id="urlInput" placeholder="https://example.com" autofocus> <button id="fetchBtn" aria-busy="false">Extract</button> </fieldset>

Loading state — Pico's aria-busy adds a spinner to buttons:

function setLoading(btn, loading) { btn.setAttribute('aria-busy', loading); btn.disabled = loading; }

Enter key to submit:

urlInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') fetchBtn.click(); });

Output patterns

Code/markdown output with copy button (custom — Pico has no equivalent):

<div class="output-wrap"> <button class="copy-btn" data-tooltip="Copy to clipboard">&#x2398; Copy</button> <pre class="result"><code id="output"></code></pre> </div>
.output-wrap { position: relative; } .copy-btn { position: absolute; top: 0.5rem; right: 0.5rem; background: var(--pico-secondary-background); border: 1px solid var(--pico-muted-border-color); border-radius: 4px; padding: 0.3rem 0.6rem; font-size: 0.8rem; z-index: 1; cursor: pointer; } pre.result { max-height: 70vh; overflow: auto; white-space: pre-wrap; word-break: break-word; padding: 1rem; padding-top: 2.5rem; margin: 0; font-size: 0.85rem; }

Metadata bar (custom — no Pico equivalent):

.meta { display: flex; flex-wrap: wrap; gap: 0.5rem 1.5rem; font-size: 0.8rem; color: var(--pico-muted-color); }

Use Pico's native components — don't reinvent

Before writing custom CSS, check if Pico already handles it:

  • Cards — <article> with <header> and <footer>
  • Accordion — <details> + <summary> (Pico styles it natively)
  • Loading — aria-busy="true" on any element
  • Buttons — .secondary, .contrast, .outline variants
  • Button groups — role="group" wrapper, aria-current="true" for active
  • Tooltips — data-tooltip="text" on any element, pure CSS
  • Validation — aria-invalid="true|false" on inputs, <small> helper text inherits color
  • Switch toggle — <input type="checkbox" role="switch">
  • Dropdown — <details class="dropdown"> + <summary> + <ul>
  • Nav — <nav> with <ul> auto-distributes horizontally
  • Modal — <dialog> + <article>, .modal-is-open on <html>
  • Progress — <progress> for determinate, <progress /> for spinner
  • Grid — .grid for auto-layout equal columns
  • Tables — wrap in <div class="overflow-auto"> for mobile
  • Typography — <small>, <mark>, <kbd>, <del>, blockquotes all styled

Footer

<footer> <a href="https://www.val.town/u/kamalnrf">kamalnrf</a> · <a href="https://github.com/...">source</a> </footer>

Val Town Conventions

Imports

Pin npm versions. Use npm: prefix. Fall back to https://esm.sh/ if sandboxed:

import { Window } from "npm:happy-dom@17.4.4";

Handler signature

export default async function (req: Request): Promise<Response> { ... }

Constraints

  • No filesystem (fs, Deno.readFile) — use SQLite/blob for persistence
  • No subprocess (Deno.Command, child_process)
  • 30s execution timeout
  • Custom subdomains via Val Town settings (e.g. defuddle.val.run)

README

Keep brief. Lead with the API — the UI is self-explanatory:

# Tool Name One-line description with link to underlying library. **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

Naming

  • 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

Anti-Patterns

  • Don't use frameworks for simple tools — vanilla HTML/CSS/JS
  • Don't force a color theme — respect system preference
  • Don't use alert() — show feedback inline
  • Don't over-abstract — single main.ts is fine for simple tools
  • Don't use Hono for single-purpose tools — raw Request/Response
  • Don't forget the API — every tool must work without the UI
  • Don't use Response.redirect — broken on Val Town
FeaturesVersion controlCode intelligenceCLIMCP
Use cases
TeamsAI agentsSlackGTM
DocsShowcaseTemplatesNewestTrendingAPI examplesNPM packages
AboutAlternativesPricingBlogNewsletterCareers
We’re hiring!
Brandhi@val.townStatus
X (Twitter)
Discord community
GitHub discussions
YouTube channel
Bluesky
Open Source Pledge
Terms of usePrivacy policyAbuse contact
© 2026 Val Town, Inc.