Example: Crypto Tracker addon (complete code)
Below is a self-contained teaching addon. Replace mock prices with a signed server route or allowlisted HTTP API before production.
⚠️ Warning: This sample uses Math.random() for demo prices — never ship that to users.
manifest.json
{
"schema": 1,
"id": "com.example.addon.crypto-tracker",
"name": "Crypto Tracker",
"version": "1.0.0",
"author": "Example Dev",
"license": "MIT",
"description": "Demo BTC/ETH price tile with voice query.",
"permissions": ["network.fetch", "storage.local", "voice.register", "voice.tts"],
"network": {
"allowlist": ["https://api.coingecko.com"]
},
"widget": {
"entry": "dist/widget.js",
"defaultRegion": "top-right",
"minWidth": 2,
"minHeight": 2
},
"voice": {
"intents": ["crypto.price"]
}
}src/CryptoWidget.tsx
import { useEffect, useState } from "react";
type Props = {
settings: { symbols?: string[] };
updateSettings: (p: Record<string, unknown>) => Promise<void>;
theme: "dark" | "light";
http: { get: (url: string) => Promise<{ json: () => Promise<unknown> }> };
piper?: { speak: (t: string) => Promise<void> };
voice?: {
registerIntent: (
id: string,
def: unknown,
handler: (slots: { symbol?: string }) => Promise<{ speak: string }>,
) => void;
};
};
export default function CryptoWidget(props: Props) {
const { settings, theme, http, voice, piper } = props;
const symbols = settings.symbols?.length ? settings.symbols : ["bitcoin", "ethereum"];
const [prices, setPrices] = useState<Record<string, number>>({});
useEffect(() => {
voice?.registerIntent(
"crypto.price",
{
slots: { symbol: { type: "string" } },
examples: ["Hey Piper, what's Bitcoin at?"],
},
async ({ symbol }) => {
const sym = (symbol || "bitcoin").toLowerCase();
const px = prices[sym] ?? (await fetchPrice(http, sym));
const line = `${sym} is about ${px.toFixed(0)} AUD (demo)`;
await piper?.speak(line);
return { speak: line };
},
);
}, [voice, piper, prices, http]);
useEffect(() => {
let alive = true;
(async () => {
const next: Record<string, number> = {};
for (const id of symbols) {
next[id] = await fetchPrice(http, id);
}
if (alive) setPrices(next);
})();
const t = setInterval(async () => {
const next: Record<string, number> = {};
for (const id of symbols) {
next[id] = await fetchPrice(http, id);
}
if (alive) setPrices(next);
}, 60_000);
return () => {
alive = false;
clearInterval(t);
};
}, [http, symbols.join("|")]);
return (
<div
data-theme={theme}
style={{
padding: 12,
borderRadius: 12,
background: theme === "dark" ? "#0f172a" : "#f8fafc",
color: theme === "dark" ? "#e2e8f0" : "#0f172a",
fontFamily: "system-ui, sans-serif",
}}
>
<div style={{ fontWeight: 700, marginBottom: 8 }}>Crypto (demo)</div>
{symbols.map((s) => (
<div key={s} style={{ display: "flex", justifyContent: "space-between" }}>
<span>{s}</span>
<span>{prices[s] != null ? `${prices[s].toFixed(2)} AUD` : "…"}</span>
</div>
))}
<button
type="button"
style={{ marginTop: 8 }}
onClick={() => props.updateSettings({ symbols: ["bitcoin"] })}
>
Show BTC only
</button>
</div>
);
}
async function fetchPrice(
http: Props["http"],
id: string,
): Promise<number> {
try {
const url = `https://api.coingecko.com/api/v3/simple/price?ids=${encodeURIComponent(
id,
)}&vs_currencies=aud`;
const res = await http.get(url);
const body = (await res.json()) as Record<string, { aud: number }>;
return body[id]?.aud ?? 0;
} catch {
return 42000 + Math.random() * 5000;
}
}src/entry.tsx (bundle iife / esm entry)
import CryptoWidget from "./CryptoWidget";
export default CryptoWidget;Build notes
- Bundle with esbuild / vite as iife named
CryptoWidgetor default export matching core loader. - Output to
dist/widget.jsreferenced by manifest.
Troubleshooting
429 from CoinGecko
Add caching server-side or reduce refresh interval.
Last updated on
Was this helpful?