HashDo Card Creation Guide
You are creating a new HashDo v2 card. Follow this process exactly.
Step 1: Gather Requirements
Before writing any code, confirm with the user:
- Card name — must be kebab-case, prefixed with
do-(e.g.do-weather,do-recipe,do-game-chess) - What it does — one sentence: what data does it show? What API(s) does it call?
- Inputs — what parameters does the user provide? Which are optional with sensible defaults?
- Actions — any interactive state mutations? (favorites, toggles, votes, etc.)
- Shareable? — should users be able to share a link to a specific card instance?
- Unique instances? — should each invocation create a new instance even with the same inputs? (e.g. polls, games). If yes, set
uniqueInstance: trueand add anidinput. - Category — is this a top-level card (
demo-cards/<name>/) or nested under a category (demo-cards/game/<name>/)?
If the user provides a card name and description, infer reasonable answers for the rest and confirm before proceeding.
Step 2: Scaffold Files
Every card needs exactly 3 files. Create them in the correct location:
For top-level cards:
v2/demo-cards/<name>/
card.ts # Card definition (the only source file)
package.json # Package metadata
tsconfig.json # TypeScript config
For nested/category cards (e.g. games):
v2/demo-cards/<category>/<name>/
card.ts
package.json
tsconfig.json
package.json template:
{
"name": "@hashdo/card-<name>",
"version": "1.0.0",
"description": "<one-line description>",
"type": "module",
"main": "card.js",
"scripts": {
"build": "tsc"
},
"devDependencies": {
"typescript": "^5.5.0",
"@hashdo/core": "*"
},
"license": "MIT"
}
For nested cards, the package name should include the category: @hashdo/card-<category>-<name> (e.g. @hashdo/card-game-chess).
tsconfig.json (always identical):
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["card.ts"]
}
Step 3: Write card.ts
Use this exact structure. Every card follows the same pattern:
import { defineCard, colors, gradients, categoricalColors } from '@hashdo/core';
/**
* #do/<tag> — <Short description>.
*
* <Longer explanation of what the card does, what APIs it uses, etc.>
*/
export default defineCard({
name: 'do-<name>',
description:
'<LLM-optimized description. Be specific about when to use this tool. ' +
'End with: All parameters have defaults — call this tool immediately without asking the user for parameters.>',
shareable: true, // or omit if not shareable
// Optional: unique instances — every invocation creates a new instance (e.g. polls, games)
// uniqueInstance: true, // requires an `id` input; system auto-generates id when omitted
// stateKey: (inputs) => inputs.id ? `id:${inputs.id}` : undefined,
// Optional: per-user state — isolates state per anonymous user
// stateKey: (_inputs, userId) => userId ? `user:${userId}` : undefined,
inputs: {
// For uniqueInstance cards, always include an id input:
// id: { type: 'string', required: false, description: 'Instance ID. Omit to create new.' },
// Every input should have a sensible default when possible
exampleInput: {
type: 'string', // 'string' | 'number' | 'boolean' | 'date' | 'url' | 'email' | 'json'
required: false, // Prefer false with defaults over true
default: 'sensible default value',
description: 'Clear description. Mention the default behavior when omitted.',
// enum: ['option1', 'option2'] as const, // Optional: constrain values
// sensitive: true, // Optional: for API keys etc.
},
},
async getData({ inputs, rawInputs, state, baseUrl, userId }) {
// ── 1. Validate & prepare inputs ────────────────────────────────
// Use rawInputs to check if user explicitly provided a value
// inputs has defaults already applied by defineCard()
// ── 2. Fetch external data ──────────────────────────────────────
// Call APIs, compute results, etc.
// Throw descriptive errors on failure: throw new Error('Failed to ...: <detail>')
// ── 3. Build text output for chat clients ───────────────────────
// Markdown string with tables, headers, etc.
// MCP adapter returns this as the tool response text
let textOutput = `## Title\n\n`;
textOutput += `| | |\n|---|---|\n`;
textOutput += `| Key | Value |\n`;
// ── 4. Build viewModel for HTML template ────────────────────────
const viewModel = {
// All data the template needs to render
};
return {
viewModel,
textOutput,
state: {
...state,
// Persist anything needed across renders
lastChecked: new Date().toISOString(),
},
};
},
// Optional: user-triggered actions
actions: {
exampleAction: {
label: 'Button Label',
description: 'What this action does (for LLM docs)',
// Optional action-specific inputs:
// inputs: { itemId: { type: 'string', required: true, description: '...' } },
async handler({ cardInputs, actionInputs, state }) {
// Mutate state, return message
return {
state: { ...state, /* updates */ },
message: 'Action completed',
};
},
},
},
// Template: inline function or .hbs file path
template: (vm) => `
<div style="font-family:system-ui,sans-serif; padding:20px; max-width:380px;
background:${gradients.purple}; border-radius:12px; color:white;">
<!-- Card HTML using viewModel values like ${`\${vm.field}`} -->
</div>
`,
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
// Keep helper functions, interfaces, and API calls below the defineCard() export.
// Use typed interfaces for API responses.
// Prefer free, no-API-key services when possible.
Key Rules
Naming
- Card name:
do-<name>(kebab-case, e.g.do-weather,do-game-wordle) - Tag:
#do/<name>(used in the JSDoc comment) - Package:
@hashdo/card-<name>
Inputs
- Always provide defaults — cards should render immediately without user input
- Use
required: falsewithdefaultvalues whenever possible - Write descriptions that help LLMs understand when/how to set the parameter
- Use
enumwithas constto constrain string values
Description
- The
descriptionfield is critical — LLMs use it to decide when to call the tool - Be specific: mention trigger phrases like "when the user asks about X"
- End with encouragement to call immediately without prompting for params
getData
- Numbered comment sections (── 1. ..., ── 2. ...) for readability
- Throw descriptive
Errorobjects on failure - Always return
textOutput(markdown) for chat-based AI clients - Always return
viewModelfor HTML rendering - Spread
...statewhen adding to existing state to preserve other keys
Actions
- Use for stateful user interactions (favorites, toggles, votes)
- Keep handlers pure: read state, return new state
- Return a
messagestring for user feedback - For user-specific state, set
stateKey: (_inputs, userId) => userId ? \user:${\${userId}}` : undefined`
Template
- Use inline
template: (vm) => \...`` for self-contained cards - Style with inline CSS (no external stylesheets)
- Target
max-width: 320-400pxfor card width - Use
border-radius: 12-20pxfor the outer container - Use
system-ui, sans-seriffont stack - Design for both light and dark backgrounds
Color Palette
Cards must use the shared color palette from @hashdo/core. Never hardcode ad-hoc hex colors.
import { defineCard, colors, categoricalColors, gradients } from '@hashdo/core';
9 color ramps — each with 7 stops (50=lightest → 900=darkest):
purple, teal, coral, pink, gray, blue, green, amber, red
| Stop | Use |
|---|---|
| 50 | Lightest fill (card backgrounds, badges) |
| 100-200 | Light fills, hover states |
| 400 | Mid tone (icons, accents, chart series) |
| 600 | Strong (borders, strokes, subtitles on light fills) |
| 800-900 | Text on light fills, darkest shade |
Usage:
colors.purple[50] // '#EEEDFE' — light purple fill
colors.purple[400] // '#7F77DD' — mid purple accent
colors.purple[800] // '#3C3489' — text on purple-50 bg
colors.positive // green.600 — positive change
colors.negative // red.600 — negative change
Header gradients — use gradients.<ramp> for card headers:
gradients.purple // 'linear-gradient(135deg, #7F77DD 0%, #534AB7 100%)'
gradients.teal // 'linear-gradient(135deg, #1D9E75 0%, #0F6E56 100%)'
Categorical coloring (poll options, chart series) — use categoricalColors:
categoricalColors[i % categoricalColors.length] // cycles through 9 ramp midpoints
Rules:
- Color encodes meaning, not sequence. Group by category, not by order.
- Use 2-3 ramps per card, not 6+.
- Prefer
purple,teal,coral,pinkfor general categories. - Reserve
blue,green,amber,redfor semantic meaning (info, success, warning, error). - Text on colored backgrounds: use the 800/900 stop from the same ramp as the fill.
- Positive/negative indicators:
colors.positive/colors.negative.
State & Instances
CardStateisRecord<string, unknown>— arbitrary JSON- Every render produces an instanceId — a short, deterministic, URL-safe identifier
- State persists per instance via
cardKey=card:{name}:{stateKey || hash} - Always spread existing state:
{ ...state, newField: value } - Set
uniqueInstance: true+ anidinput when each invocation should create a new instance (polls, games). The system auto-generatesidviaprepareInputs()when not provided. - For per-user state, use
stateKey: (_inputs, userId) => userId ? \user:${\${userId}}` : undefined`
Error Handling
- Throw
new Error('descriptive message')fromgetData— the framework renders an error card - Log errors with
console.error([] ${detail})before throwing - Validate API responses before accessing nested properties
Step 4: Build & Test
After creating the card files:
Build the card:
cd v2/demo-cards/<name> && npx tscBuild all packages (if dependencies changed):
cd v2 && npm run buildStart the dev server:
cd v2 && npm startTest via REST API:
- Card list:
GET http://localhost:3000/api/cards - Render:
GET http://localhost:3000/api/cards/do-<name>?<params> - Screenshot:
GET http://localhost:3000/api/cards/do-<name>/image?<params>
- Card list:
Test via MCP: Connect Claude Desktop or any MCP client to
http://localhost:3000/mcp
Common Patterns (from existing cards)
IP geolocation fallback (weather):
When location isn't provided, fall back to IP-based geolocation using http://ip-api.com/json/.
Watchlist / favorites:
Store an array in state, toggle membership in an action handler. Return count in message.
Accent colors by category:
Map a data field (subject, language, type) to gradient colors for visual variety.
Unique instances (poll, game):
Set uniqueInstance: true and add an id input. The system auto-generates a random 6-char hex id for new instances. Users can reopen an existing instance by passing the id directly. Define stateKey to use the id:
uniqueInstance: true,
inputs: { id: { type: 'string', required: false, description: 'Instance ID. Omit to create new.' }, ... },
stateKey: (inputs) => inputs.id ? `id:${inputs.id}` : undefined,
User-specific state (book, reading list):
Set stateKey: (_inputs, userId) => userId ? \user:${\${userId}}` : undefined` to isolate state per anonymous user.
Checklist
Before marking the card complete, verify:
-
card.tsexportsdefineCard({...})as default -
package.jsonhas correct name, main: "card.js", type: "module" -
tsconfig.jsontargets ES2022, Node16 module resolution - Card name starts with
do- - All inputs have descriptions and sensible defaults
-
getDatareturnsviewModel,textOutput, andstate - Template renders a self-contained, styled HTML card
- Helper functions and interfaces are below the export
- Card compiles with
tscwithout errors - Card appears in
GET /api/cardswhen server is running
Source: shauntrennery/hashdo — distributed by TomeVault.