xiaohongshu
xiaohongshu
lightning-address
Use when implementing Lightning address functionality - provides complete patterns for resolving Lightning addresses to invoices, generating invoices from addresses, displaying Lightning addresses in UI, and integrating with QR codes
Full skill instructions
Complete implementation guide for Lightning addresses (LNURL-pay protocol). Supports resolving Lightning addresses to invoices, generating invoices with amount and optional comments, displaying Lightning addresses in UI, and integrating with QR code functionality.
Core Capabilities:
No external packages required - uses native fetch API for HTTP requests.
Optional dependencies:
qr-code-generator skill)Lightning addresses follow email-like format:
[email protected]
Examples:
Lightning addresses use the LNURL-pay protocol (LNURL specification):
Resolve Address → Fetch LNURL-pay endpoint
https://domain.com/.well-known/lnurlp/usernameRequest Invoice → Call callback URL with amount
{callback}?amount={millisats}&comment={optional}interface LNURLPayResponse {
status: 'OK' | 'ERROR';
tag: 'payRequest';
commentAllowed?: number; // Max comment length in characters
minSendable: number; // Minimum amount in millisats
maxSendable: number; // Maximum amount in millisats
metadata: string; // JSON string with payment metadata
callback: string; // URL to request invoice
}
interface LNURLInvoiceResponse {
status: 'OK' | 'ERROR';
reason?: string; // Error reason if status is ERROR
pr?: string; // BOLT11 invoice (payment request)
}
CRITICAL: Always validate Lightning address format before attempting resolution.
// lib/lightningAddress.ts
/**
* Validate if a string is a valid Lightning address
* @param address - The string to validate
* @returns true if the string appears to be a valid Lightning address
*/
export function isValidLightningAddress(address: string): boolean {
if (!address || typeof address !== 'string') {
return false;
}
const trimmed = address.trim();
// Lightning addresses follow email format: [email protected]
// Must contain exactly one @ symbol
const parts = trimmed.split('@');
if (parts.length !== 2) {
return false;
}
const [username, domain] = parts;
// Username must be non-empty
if (!username || username.length === 0) {
return false;
}
// Domain must be valid (contains at least one dot, valid TLD)
if (!domain || !domain.includes('.')) {
return false;
}
// Basic domain validation (must have TLD)
const domainParts = domain.split('.');
if (domainParts.length < 2 || domainParts[domainParts.length - 1].length < 2) {
return false;
}
return true;
}
Usage:
if (isValidLightningAddress(address)) {
// Safe to resolve
const invoice = await getInvoiceFromLightningAddress(address, 1000);
} else {
// Invalid address format
console.error('Invalid Lightning address format');
}
Resolve Lightning address to LNURL-pay endpoint:
// lib/lightningAddress.ts
/**
* Resolve Lightning address to LNURL-pay endpoint
* @param lightningAddress - Lightning address ([email protected])
* @returns LNURL-pay response with metadata
*/
export async function resolveLightningAddress(
lightningAddress: string
): Promise<LNURLPayResponse> {
// Validate input
if (!isValidLightningAddress(lightningAddress)) {
throw new Error('Invalid Lightning address format. Expected: [email protected]');
}
// Parse Lightning address
const [username, domain] = lightningAddress.split('@');
// Construct LNURL-pay endpoint
const lnurlEndpoint = `https://${domain}/.well-known/lnurlp/${username}`;
// Fetch LNURL-pay metadata
const response = await fetch(lnurlEndpoint, {
headers: { 'Accept': 'application/json' },
});
if (!response.ok) {
throw new Error(
`Failed to resolve Lightning address: ${response.status} ${response.statusText}`
);
}
const data: LNURLPayResponse = await response.json();
// Validate response
if (data.status && data.status !== 'OK') {
throw new Error('LNURL endpoint returned error status');
}
if (data.tag !== 'payRequest') {
throw new Error(`Unexpected LNURL tag: ${data.tag}`);
}
return data;
}
Generate invoice from Lightning address:
// lib/lightningAddress.ts
interface GetInvoiceParams {
lightningAddress: string;
amountSats: number;
comment?: string;
}
/**
* Get BOLT11 invoice from Lightning address
* @param params - Lightning address, amount in sats, and optional comment
* @returns BOLT11 invoice string
* @throws Error if resolution fails
*/
export async function getInvoiceFromLightningAddress(
params: GetInvoiceParams
): Promise<string> {
const { lightningAddress, amountSats, comment } = params;
// Validate inputs
if (!isValidLightningAddress(lightningAddress)) {
throw new Error('Invalid Lightning address format. Expected: [email protected]');
}
if (amountSats <= 0) {
throw new Error('Amount must be greater than 0');
}
// Step 1: Resolve LNURL-pay endpoint
const lnurlData = await resolveLightningAddress(lightningAddress);
// Step 2: Validate amount against min/max bounds
const minSats = lnurlData.minSendable / 1000;
const maxSats = lnurlData.maxSendable / 1000;
if (amountSats < minSats) {
throw new Error(`Amount ${amountSats} sats is below minimum ${minSats} sats`);
}
if (amountSats > maxSats) {
throw new Error(`Amount ${amountSats} sats exceeds maximum ${maxSats} sats`);
}
// Step 3: Request invoice from callback URL
const amountMsats = amountSats * 1000;
const callbackUrl = new URL(lnurlData.callback);
callbackUrl.searchParams.set('amount', amountMsats.toString());
// Add comment if provided and allowed
if (comment && lnurlData.commentAllowed) {
if (comment.length > lnurlData.commentAllowed) {
throw new Error(
`Comment length ${comment.length} exceeds maximum ${lnurlData.commentAllowed} characters`
);
}
callbackUrl.searchParams.set('comment', comment);
}
// Fetch invoice
const invoiceResponse = await fetch(callbackUrl.toString(), {
headers: { 'Accept': 'application/json' },
});
if (!invoiceResponse.ok) {
throw new Error(
`Failed to get invoice: ${invoiceResponse.status} ${invoiceResponse.statusText}`
);
}
const invoiceData: LNURLInvoiceResponse = await invoiceResponse.json();
// Check if we have a valid invoice
if (invoiceData.pr) {
return invoiceData.pr;
}
// If no invoice but has status field, check for errors
if (invoiceData.status && invoiceData.status !== 'OK') {
const errorReason = invoiceData.reason || 'Invoice generation failed';
throw new Error(`Invoice generation failed: ${errorReason}`);
}
// If we get here, no invoice was provided
throw new Error('No invoice returned from provider');
}
Usage:
try {
const invoice = await getInvoiceFromLightningAddress({
lightningAddress: '[email protected]',
amountSats: 1000,
comment: 'Thanks for the coffee!'
});
// Use invoice for payment
} catch (error) {
console.error('Failed to get invoice:', error);
}
Create a React hook for Lightning address operations:
// hooks/useLightningAddress.ts
import { useState, useCallback } from 'react';
import { getInvoiceFromLightningAddress, isValidLightningAddress } from '@/lib/lightningAddress';
import type { GetInvoiceParams } from '@/lib/lightningAddress';
interface UseLightningAddressReturn {
getInvoice: (params: GetInvoiceParams) => Promise<string>;
isLoading: boolean;
error: string | null;
}
/**
* Hook for resolving Lightning addresses to invoices
*
* @example
* ```tsx
* const { getInvoice, isLoading, error } = useLightningAddress();
*
* const invoice = await getInvoice({
* lightningAddress: '[email protected]',
* amountSats: 1000,
* comment: 'Thanks for the coffee!'
* });
* ```
*/
export function useLightningAddress(): UseLightningAddressReturn {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
/**
* Fetches a Lightning invoice for the given address and amount
*
* @param params - Lightning address, amount in sats, and optional comment
* @returns BOLT11 invoice string
* @throws Error if resolution fails
*/
const getInvoice = useCallback(async (params: GetInvoiceParams): Promise<string> => {
setIsLoading(true);
setError(null);
try {
const invoice = await getInvoiceFromLightningAddress(params);
setIsLoading(false);
return invoice;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
setError(errorMessage);
setIsLoading(false);
throw err;
}
}, []);
return {
getInvoice,
isLoading,
error,
};
}
Usage in components:
function PaymentForm() {
const { getInvoice, isLoading, error } = useLightningAddress();
const [lightningAddress, setLightningAddress] = useState('');
const [amount, setAmount] = useState(0);
const handlePay = async () => {
try {
const invoice = await getInvoice({
lightningAddress,
amountSats: amount,
});
// Process payment with invoice
} catch (error) {
// Error already set in hook
}
};
return (
<form onSubmit={handlePay}>
<input
value={lightningAddress}
onChange={(e) => setLightningAddress(e.target.value)}
placeholder="[email protected]"
/>
<input
type="number"
value={amount}
onChange={(e) => setAmount(Number(e.target.value))}
placeholder="Amount in sats"
/>
{error && <div className="error">{error}</div>}
<button type="submit" disabled={isLoading}>
{isLoading ? 'Loading...' : 'Get Invoice'}
</button>
</form>
);
}
Display Lightning address with copy and QR functionality:
// components/LightningAddressDisplay.tsx
import { useState } from 'react';
import { Copy, Check, QrCode } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard';
import { useQRCode } from '@/hooks/useQRCode';
import { QRModal } from '@/components/ui/qr-modal';
interface LightningAddressDisplayProps {
lightningAddress: string;
className?: string;
}
export function LightningAddressDisplay({
lightningAddress,
className,
}: LightningAddressDisplayProps) {
const [copied, setCopied] = useState(false);
const [showQR, setShowQR] = useState(false);
const [qrCodeUrl, setQrCodeUrl] = useState<string>('');
const { copy } = useCopyToClipboard();
const { generateQRCode } = useQRCode();
const handleCopy = async () => {
await copy(lightningAddress);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleShowQR = async () => {
const qr = await generateQRCode(lightningAddress);
setQrCodeUrl(qr);
setShowQR(true);
};
// Parse address for display
const [username, domain] = lightningAddress.split('@');
return (
<>
<div className={`flex gap-2 items-center ${className}`}>
<button
onClick={handleCopy}
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-muted/80 rounded-md border text-base font-mono transition-colors flex-1 h-[40px] min-w-0"
title="Click to copy Lightning address"
>
<div className="min-w-0 flex-1 flex items-center">
<span className="truncate min-w-0 flex-1">{username}</span>
<span className="flex-shrink-0 text-muted-foreground">@{domain}</span>
</div>
{copied ? (
<Check className="h-4 w-4 text-green-600 flex-shrink-0" />
) : (
<Copy className="h-4 w-4 text-muted-foreground flex-shrink-0" />
)}
</button>
<button
onClick={handleShowQR}
className="flex items-center justify-center px-2 py-1.5 hover:bg-muted rounded-md transition-colors h-[40px] w-[42px] flex-shrink-0"
title="Show QR Code"
>
<QrCode className="h-4 w-4" />
</button>
</div>
<QRModal
isOpen={showQR}
onClose={() => setShowQR(false)}
title="Lightning Address"
description="Scan this QR code to send a payment"
qrCodeUrl={qrCodeUrl}
content={lightningAddress}
icon="zap"
/>
</>
);
}
Reusable QR code modal with expiry support:
// components/ui/qr-modal.tsx
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { X, Copy, Check, QrCode, Zap, Clock } from 'lucide-react';
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard';
import { useEffect, useState } from 'react';
interface QRModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
description: string;
qrCodeUrl: string;
content: string;
icon?: 'qr' | 'zap';
expiryTimestamp?: number;
onExpiry?: () => void;
}
export function QRModal({
isOpen,
onClose,
title,
description,
qrCodeUrl,
content,
icon = 'qr',
expiryTimestamp,
onExpiry,
}: QRModalProps) {
const { copy, copied } = useCopyToClipboard();
const [timeRemaining, setTimeRemaining] = useState<number | null>(null);
const handleCopy = () => {
copy(content);
};
// Countdown timer effect
useEffect(() => {
if (!isOpen || !expiryTimestamp) return;
const updateTimer = () => {
const now = Math.floor(Date.now() / 1000);
const remaining = expiryTimestamp - now;
if (remaining <= 0) {
setTimeRemaining(0);
if (onExpiry) {
onExpiry();
}
onClose();
} else {
setTimeRemaining(remaining);
}
};
updateTimer();
const interval = setInterval(updateTimer, 1000);
return () => clearInterval(interval);
}, [isOpen, expiryTimestamp, onExpiry, onClose]);
// Format time remaining as MM:SS
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
if (!isOpen) return null;
const IconComponent = icon === 'zap' ? Zap : QrCode;
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onClick={onClose}
>
<Card
className="w-full max-w-md border-0 shadow-lg bg-background"
onClick={(e) => e.stopPropagation()}
>
<CardHeader className="relative">
<Button
onClick={onClose}
className="absolute top-2 right-2"
size="sm"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
<CardTitle className="flex items-center pr-8">
<IconComponent className="h-5 w-5 mr-2" />
{title}
</CardTitle>
<CardDescription>
{description}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{expiryTimestamp && timeRemaining !== null && timeRemaining <= 3600 && (
<div className="flex items-center justify-center gap-2 text-sm">
<Clock className="h-4 w-4" />
<span className={timeRemaining < 60 ? "text-destructive font-semibold" : "text-muted-foreground"}>
Expires in {formatTime(timeRemaining)}
</span>
</div>
)}
<div className="flex justify-center">
<div className="bg-white p-4 rounded-lg border shadow-sm">
<img
src={qrCodeUrl}
alt={`${title} QR Code`}
className="w-64 h-64 object-contain"
/>
</div>
</div>
<div className="text-center">
<div
className="bg-muted p-3 rounded-lg border font-mono text-sm cursor-pointer hover:bg-muted/80 transition-colors relative group"
onClick={handleCopy}
title="Click to copy full content"
>
{content.length > 33
? `${content.substring(0, 15)}...${content.substring(content.length - 15)}`
: content}
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
{copied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4 text-muted-foreground" />
)}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
Example integration showing Lightning address in wallet header:
// components/wallet/WalletHeader.tsx (excerpt)
import { LightningAddressDisplay } from '@/components/LightningAddressDisplay';
export function WalletHeader({
userPubkey,
npubCashUsername,
mode,
// ... other props
}: WalletHeaderProps) {
return (
<div className="flex flex-col items-center pt-10 space-y-2">
{/* ... balance display ... */}
{/* Lightning Address Row */}
{userPubkey && mode === 'lightning' && npubCashUsername && (
<div className="mt-3 px-4 w-full max-w-[400px] mx-auto">
<LightningAddressDisplay
lightningAddress={`${npubCashUsername}@npubx.cash`}
/>
</div>
)}
</div>
);
}
Handle all error cases gracefully:
// lib/lightningAddress.ts
export class LightningAddressError extends Error {
constructor(
message: string,
public code: 'INVALID_FORMAT' | 'RESOLUTION_FAILED' | 'AMOUNT_INVALID' | 'INVOICE_FAILED',
public originalError?: Error
) {
super(message);
this.name = 'LightningAddressError';
}
}
export async function getInvoiceFromLightningAddress(
params: GetInvoiceParams
): Promise<string> {
try {
// Validate format
if (!isValidLightningAddress(params.lightningAddress)) {
throw new LightningAddressError(
'Invalid Lightning address format. Expected: [email protected]',
'INVALID_FORMAT'
);
}
// Validate amount
if (params.amountSats <= 0) {
throw new LightningAddressError(
'Amount must be greater than 0',
'AMOUNT_INVALID'
);
}
// Resolve and get invoice
const lnurlData = await resolveLightningAddress(params.lightningAddress);
// ... rest of implementation with proper error handling
} catch (error) {
if (error instanceof LightningAddressError) {
throw error;
}
// Wrap unknown errors
throw new LightningAddressError(
error instanceof Error ? error.message : 'Unknown error occurred',
'INVOICE_FAILED',
error instanceof Error ? error : undefined
);
}
}
Provide helpful error messages in UI:
// components/LightningAddressForm.tsx
function LightningAddressForm() {
const { getInvoice, isLoading, error } = useLightningAddress();
// Optional: User feedback notifications
// Option 1: Console logging
// const logMessage = (message: string) => console.log(message);
// Option 2: Toast notifications (if useToast hook is available)
// const { toast } = useToast();
// Option 3: No notification handler
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const invoice = await getInvoice({
lightningAddress: formData.address,
amountSats: formData.amount,
});
// Process invoice
} catch (error) {
let message = 'Failed to get invoice';
if (error instanceof LightningAddressError) {
switch (error.code) {
case 'INVALID_FORMAT':
message = 'Invalid Lightning address. Use format: [email protected]';
break;
case 'AMOUNT_INVALID':
message = error.message;
break;
case 'RESOLUTION_FAILED':
message = 'Could not resolve Lightning address. Check your connection.';
break;
case 'INVOICE_FAILED':
message = 'Failed to generate invoice. Please try again.';
break;
}
}
// Optional: User feedback - choose one:
// Option 1: Console logging
// console.error('Failed to get invoice:', message);
// Option 2: Toast notification (if toast is available)
// toast({ variant: 'destructive', title: 'Error', description: message });
// Option 3: No notification (silent failure)
}
};
// ... rest of component
}
Show min/max amounts to users:
// hooks/useLightningAddressInfo.ts
import { useState, useCallback } from 'react';
import { resolveLightningAddress } from '@/lib/lightningAddress';
interface LightningAddressInfo {
minSats: number;
maxSats: number;
commentAllowed?: number;
}
export function useLightningAddressInfo() {
const [info, setInfo] = useState<LightningAddressInfo | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchInfo = useCallback(async (lightningAddress: string) => {
setIsLoading(true);
setError(null);
try {
const lnurlData = await resolveLightningAddress(lightningAddress);
setInfo({
minSats: lnurlData.minSendable / 1000,
maxSats: lnurlData.maxSendable / 1000,
commentAllowed: lnurlData.commentAllowed,
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch info');
} finally {
setIsLoading(false);
}
}, []);
return { info, fetchInfo, isLoading, error };
}
Usage:
function PaymentForm({ lightningAddress }: { lightningAddress: string }) {
const { info, fetchInfo, isLoading } = useLightningAddressInfo();
const [amount, setAmount] = useState(0);
useEffect(() => {
fetchInfo(lightningAddress);
}, [lightningAddress, fetchInfo]);
return (
<form>
{info && (
<div className="text-sm text-muted-foreground">
Amount range: {info.minSats.toLocaleString()} - {info.maxSats.toLocaleString()} sats
</div>
)}
<input
type="number"
value={amount}
onChange={(e) => setAmount(Number(e.target.value))}
min={info?.minSats}
max={info?.maxSats}
/>
</form>
);
}
Add comment input with character limit:
// components/LightningAddressPaymentForm.tsx
function LightningAddressPaymentForm({ lightningAddress }: { lightningAddress: string }) {
const { info, fetchInfo } = useLightningAddressInfo();
const { getInvoice } = useLightningAddress();
const [comment, setComment] = useState('');
useEffect(() => {
fetchInfo(lightningAddress);
}, [lightningAddress, fetchInfo]);
const maxCommentLength = info?.commentAllowed || 0;
const canAddComment = maxCommentLength > 0;
return (
<form>
{/* Amount input */}
{canAddComment && (
<div>
<label>
Comment (optional)
{maxCommentLength > 0 && (
<span className="text-sm text-muted-foreground">
{' '}(max {maxCommentLength} characters)
</span>
)}
</label>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
maxLength={maxCommentLength}
rows={2}
/>
<div className="text-xs text-muted-foreground text-right">
{comment.length} / {maxCommentLength}
</div>
</div>
)}
</form>
);
}
Test Lightning address validation and resolution:
// lib/__tests__/lightningAddress.test.ts
import { describe, it, expect, vi } from 'vitest';
import { isValidLightningAddress, getInvoiceFromLightningAddress } from '../lightningAddress';
describe('isValidLightningAddress', () => {
it('validates valid addresses', () => {
expect(isValidLightningAddress('[email protected]')).toBe(true);
expect(isValidLightningAddress('[email protected]')).toBe(true);
});
it('rejects invalid formats', () => {
expect(isValidLightningAddress('invalid')).toBe(false);
expect(isValidLightningAddress('@domain.com')).toBe(false);
expect(isValidLightningAddress('user@')).toBe(false);
expect(isValidLightningAddress('user@domain')).toBe(false);
});
});
describe('getInvoiceFromLightningAddress', () => {
it('resolves address and gets invoice', async () => {
// Mock fetch responses
global.fetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
status: 'OK',
tag: 'payRequest',
minSendable: 1000,
maxSendable: 1000000000,
callback: 'https://strike.me/api/invoice',
metadata: '[]',
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
status: 'OK',
pr: 'lnbc100n1p...',
}),
});
const invoice = await getInvoiceFromLightningAddress({
lightningAddress: '[email protected]',
amountSats: 1000,
});
expect(invoice).toBe('lnbc100n1p...');
});
it('validates amount against min/max', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({
status: 'OK',
tag: 'payRequest',
minSendable: 10000, // 10 sats
maxSendable: 1000000, // 1000 sats
callback: 'https://strike.me/api/invoice',
metadata: '[]',
}),
});
await expect(
getInvoiceFromLightningAddress({
lightningAddress: '[email protected]',
amountSats: 5, // Below minimum
})
).rejects.toThrow('below minimum');
});
});
Problem: Attempting to resolve invalid addresses causes unnecessary API calls.
Solution: Always validate format first:
if (!isValidLightningAddress(address)) {
throw new Error('Invalid Lightning address format');
}
Problem: Requesting invoices with amounts outside provider limits causes errors.
Solution: Always validate against min/max from LNURL-pay response:
if (amountSats < minSats || amountSats > maxSats) {
throw new Error('Amount out of range');
}
Problem: Sending comments longer than allowed causes invoice generation to fail.
Solution: Check commentAllowed and validate length:
if (comment && lnurlData.commentAllowed && comment.length > lnurlData.commentAllowed) {
throw new Error('Comment too long');
}
Problem: Network failures or provider errors cause unhandled exceptions.
Solution: Always check response status and handle errors:
if (!response.ok) {
throw new Error(`Failed: ${response.status} ${response.statusText}`);
}
Problem: LNURL-pay uses millisats, but amounts are often in sats.
Solution: Always convert when calling callback URL:
const amountMsats = amountSats * 1000;
callbackUrl.searchParams.set('amount', amountMsats.toString());
Problem: Some providers return invoices without status field, causing validation to fail.
Solution: Check for invoice first, then status:
if (invoiceData.pr) {
return invoiceData.pr; // Invoice present, return it
}
if (invoiceData.status && invoiceData.status !== 'OK') {
throw new Error(invoiceData.reason || 'Invoice generation failed');
}
To implement Lightning address functionality:
isValidLightningAddress() before resolution.well-known/lnurlp/ endpointKey principle: Lightning addresses use the LNURL-pay protocol - always resolve the endpoint first to get metadata (min/max amounts, callback URL) before requesting invoices.
xiaohongshu
technical spec
product ux expert
database patterns
Conduct multi-agent task orchestration and workflow coordination.
Initialize project with Conductor artifacts (product definition,
Expert in web animations, transitions, and motion design using Framer Motion and CSS
Creates Mermaid and ASCII diagrams for flowcharts, architecture, ERDs, state machines, mindmaps, and more. Use when user mentions diagram, flowchart, mermaid, ASCII diagram, text diagram, terminal diagram, visualize, C4, mindmap, architecture diagram, sequence diagram, ERD, or needs visual docume...
PostgreSQL bindings for H3 hexagonal grid system. Use when working with H3 cells in Postgres, including spatial indexing, geometry/geography integration, and raster analysis.
Context-Driven Development skill for projects using Conductor. Use this skill when you detect a `conductor/` directory in the project, when working on tasks defined in a `plan.md` file, or when the user asks about tracks, specs, or plans. Automatically applies TDD workflow, tracks task completion...
Display project status, active tracks, and next actions
Official Stakpak application containerization standard operating procedure, a step-by-step guidline to properly dockerize applications. This is a rule book curated by the Stakpak Team.
Generate, edit, and beat-sync AI video with leading models in one workspace.
The world's fastest calendar for remote work
Transform Your Design with AI Designer by ImgCreator.ai
Revolutionizing Video Production with AI-Powered Creativity
Extend an image past the frame and let AI fill the new aspect ratio.
Discover your celebrity doppelgänger with StarByFace!
ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.
Opus.ai: Revolutionize Your Web Experience