Crown Apps
Beta. The SDK, the CLI and the developer program are in beta: interfaces may change between releases. Install with the beta tag.
Crown Apps are third-party extensions for Chain Daddy token pages. This repository is the SDK and runner framework you build them with: a TypeScript contract for widgets (React components rendered on a token page) and a zero-dependency helper library for runners (headless services driven by webhooks).
It is for external developers who want to ship something onto a token page — a chart, a swap box, a poll — or run a backend that reacts to platform and on-chain events.
| Widget | A React component rendered on a token page, fed structured data through props | The visitor's browser, inside the host page |
| Runner | A headless service that receives platform events by webhook and calls back through the Action API | Your own infrastructure |
The step-by-step walkthrough is Getting started on the docs site. This README is the reference.
Install
npm install @chaindaddy/apps@beta
Requires Node.js 20+. React 18 is a peer dependency for widget development.
import type { AppWidgetProps } from '@chaindaddy/apps/types/widget';
import { createWebhookHandler } from '@chaindaddy/apps/runner';
You do not need this package to ship an app. A project made by chaindaddy app init carries its own copy of the widget types (src/widget-types.ts), and its runner checks webhook signatures with node:crypto.
Quick Start: Widget
A widget is one React component plus a capp.json manifest. It receives the token's data through props. The quickest
start is a template: chaindaddy app init --template game|interactive|community copies a complete app from
templates/.
import type { AppWidgetProps } from '@chaindaddy/apps/types/widget';
interface MySettings {
showVolume?: boolean;
}
export function MyWidget({
appData,
settings,
theme,
onAction,
}: AppWidgetProps<MySettings>) {
return (
<div style={{ background: theme.cardBackground, color: theme.textPrimary }}>
<h3>{appData.symbol} - {appData.tokenName}</h3>
<p>Price: ${appData.market.price?.toFixed(6) ?? 'N/A'}</p>
{settings.showVolume && (
<p>24h Vol: ${appData.market.volume24h?.toLocaleString() ?? 'N/A'}</p>
)}
<button onClick={() => onAction({ type: 'toast', message: 'Hello!', variant: 'success' })}>
Toast
</button>
</div>
);
}
Export a plain named function, as above. The host loads the export named by entryComponent (or the default export if there is none) and calls it as a function component. A component wrapped in React.memo, forwardRef or lazy does not load, and neither does export default defineWidget({...}).
Pair it with a manifest that names the export:
{
"schemaVersion": "1",
"id": "your-developer-id/my-widget",
"name": "My Widget",
"version": "1.0.0",
"author": { "name": "Your Name", "developerId": "your-developer-id" },
"description": "One-line description of what the widget does.",
"type": "widget",
"entryComponent": "MyWidget",
"bundleUrl": "",
"bundleHash": "",
"bundleSize": 0,
"category": "utility",
"requiredData": ["profile:basic", "market:price"],
"permissions": [],
"minPlatformVersion": "1.0.0",
"license": "MIT"
}
schemaVersion is required.
- Always set
type. Without it the schema applies the widget and the runner rules together.
author.developerId must be the developer ID shown on your portal Account page, the one you submit with.
- Leave the three bundle fields empty.
chaindaddy app pack fills in the hash and size, and the operator sets the URL when it hosts the bundle.
Then build a single-file bundle and submit it; see Publishing.
Full working example: examples/hello-widget/.
Quick Start: Runner
A runner is an HTTP endpoint that verifies a webhook signature, handles the event, and returns a result. The SDK supplies signature verification, idempotency, and an Action API client.
import {
createWebhookHandler,
createActionClient,
withIdempotency,
MemoryStore,
} from '@chaindaddy/apps/runner';
const client = createActionClient({
apiBaseUrl: process.env.API_BASE_URL!,
appId: process.env.APP_ID!,
secret: process.env.WEBHOOK_SECRET!,
});
export const handle = createWebhookHandler({
secret: process.env.WEBHOOK_SECRET!,
onEvent: withIdempotency(async (event) => {
switch (event.eventType) {
case 'token.claimed':
await client.storage.set(event.crownId, 'claimedAt', event.timestamp);
break;
case 'market.price.threshold':
await client.storage.set(event.crownId, 'lastAlert', Date.now().toString());
break;
}
return { status: 'ok' };
}, new MemoryStore()),
});
MemoryStore is for development. In production, back idempotency with durable storage — the examples use DynamoDB and Cloudflare KV.
Deployable templates: examples/express-runner/, examples/lambda-runner/, examples/cloudflare-runner/.
Working in This Repository
Clone it to build the bundled widgets, run the SDK tests, or develop a widget against the source types.
npm install
npm run build
npm test
npm run typecheck
npm run lint
npm run new:widget
npm run validate:examples
npm run build writes widget bundles to dist/{appId}/, the compiled runner SDK to dist/runner/, and a manifest index to dist/index.json. It reports each bundle's size against its tier cap, so an oversized bundle fails loudly.
Manifest Format (capp.json)
Every app must include a capp.json in its directory root, validated against schema/capp.schema.json. The server validates every submission against the same schema and refuses one that fails (PACKAGE_MANIFEST_INVALID).
schemaVersion | string | yes | Always "1" |
id | string | yes | name, or <developerId>/<name> where developerId is your developer ID. Each part is lowercase letters, digits and hyphens, 3-64 characters. chaindaddy app init uses <developerId>/<dir> |
name | string | yes | Human-readable display name |
version | string | yes | Semantic version (major.minor.patch) |
author | object | yes | { name, url?, developerId }. developerId must equal the developer ID you submit with, or the server refuses the package (PACKAGE_DEVELOPER_MISMATCH) |
description | string | yes | Short description of widget functionality |
type | string | set it | widget, headless or full. Without it the schema requires both the widget fields and runner |
entryComponent | string | widget, full | Named export from the bundle that is the root React component |
bundleUrl | string | widget, full | URL of the JS bundle. The operator rewrites it to its hosted copy; leave it empty in a package |
bundleHash | string | widget, full | SHA-256 hex hash of the bundle. Rewritten to the hosted copy's hash |
bundleSize | number | widget, full | Bundle size in bytes, at most 2,000,000. Rewritten to the hosted copy's size |
runner | object | headless, full | Webhook endpoint, auth type, events and actions (see Runner SDK) |
icon | string | no | URL to widget icon for the dashboard |
category | string | yes | Where the widget picker lists it. One of: content (text, images, links, embeds), market (prices, charts, trades, swaps), media (video, streams, music, 3D), games, interactive (interactive media and experiences that are not games), art (visual art, illustration, generative work, collections), social (feeds, tips, community), governance (polls, votes), analytics (holder and on-chain stats), utility (tools that do a job), custom (anything else, listed as Other) |
requiredData | array | yes | Data scopes the widget needs (see below) |
configSchema | object | no | JSON Schema (draft-07) for widget-specific settings |
permissions | array | yes | Platform permissions requested (see below) |
minPlatformVersion | string | yes | Minimum platform version (semver) |
supportedChains | array | no | Chain keys supported. Omit for all-chain support |
tags | array | no | Freeform tags for search/filtering |
repositoryUrl | string | no | URL to widget source repository |
license | string | yes | SPDX license identifier |
Data Scopes
Declare only what your widget reads. requiredData is shown to the creator when they install the app and is checked in review. The host does not filter appData by it today.
profile:basic | Symbol, name, description, tags |
profile:social | Social links, verifications |
market:price | Price, volume, market cap |
market:holders | Holder count, holder stats |
token:ownership | Owner address, status, verification |
token:metadata | Metadata extensions, highlights URI |
token:identity | ENS/SNS, social identities |
chain:info | Chain ID, chain name, explorer URL |
Permissions
fetch:self-api | Fetch to the platform's own API |
storage:local | Scoped localStorage for the widget |
clipboard:write | Write to system clipboard |
navigate:internal | Client-side navigation within the platform |
ui:fullscreen | Ask the host to show the app fullscreen (fullscreen action) |
iap:purchase | Sell items from the token's store (iap-purchase action); see In-app purchases |
iap:consume | Spend the signed-in viewer's own consumable, with no server (iap-consume action): on their tap, and only items the store's owner lets apps spend. See In-app purchases |
notify:user | Send the signed-in user a notification, in-app and push (notify action): at most 5 a day, the user can mute the app, never by text or email |
net:connect | Connect to your own server: the wss:///https:// origins in network.origins (at most 8, exact hostnames), nothing else. Requests carry Origin: null; authenticate the visitor with the session-token action. See docs.chaindaddy.io › Your own server |
wallet:sign | Ask the viewer's wallet to sign EIP-712 typed data (sign-typed-data action); the host shows its own confirmation sheet first |
Widget API
Props
Every widget receives AppWidgetProps:
interface AppWidgetProps<TSettings = Record<string, unknown>> {
appData: AppDataContext;
settings: TSettings;
widgetId: string;
isOwner: boolean;
connectedAddress: string | null;
chainKey: string;
theme: AppWidgetTheme;
onAction: (action: AppWidgetAction) => void;
breakpoint?: 'compact' | 'standard' | 'wide';
}
appData
symbol, tokenName, description, tags — basic profile info
chainKey, chainId, chainName — chain info
tokenAddress, owner, status, tokenId — on-chain state (owner and status also arrive as crownOwner and crownStatus)
market — { price, volume24h, marketCap, holders, totalSupply, circulatingSupply }
socialLinks — { website, xdotcom, telegram, discord, github, whitepaper }
verifications — per-platform verification records
metadata — extensible key-value store
Theme
interface AppWidgetTheme {
mode: 'light' | 'dark';
accentColor: string;
backgroundColor: string;
cardBackground: string;
textPrimary: string;
textSecondary: string;
textMuted: string;
borderColor: string;
}
Actions
Widgets dispatch actions to the host via onAction():
type AppWidgetAction =
| { type: 'navigate'; path: string }
| { type: 'clipboard'; text: string }
| { type: 'track'; event: string; data?: Record<string, unknown> }
| { type: 'toast'; message: string; variant: 'success' | 'error' | 'info' };
The full union also covers api-call, connect-wallet, sign-typed-data, transaction, quote, open-settings, fullscreen, iap-purchase, iap-consume and paid-file. Every shape is declared in types/widget.ts.
iap-purchase sells an item from the token's store: the host opens its own purchase sheet and resolves { status: 'confirmed' | 'paid' | 'cancelled' | 'failed', orderId?, receipt? }. The app never touches the wallet or the amounts. Reading the catalog, the viewer's items and verifying receipts on a game server are in In-app purchases.
Lifecycle Hooks
Widgets can optionally implement lifecycle hooks:
interface AppWidgetLifecycleHooks {
onWidgetMount?(context: WidgetLifecycleContext): void | Promise<void>;
onWidgetUnmount?(context: WidgetLifecycleContext): void;
onDataUpdate?(prevData: AppDataContext, nextData: AppDataContext): void;
onSettingsChange?(prevSettings: unknown, nextSettings: unknown): void;
onVisibilityChange?(visible: boolean): void;
}
The lifecycle context provides widgetId, appData, scopedStorage, and an abortController for async cleanup.
Widgets can also export renderStatic() to support server-side rendering.
Settings
Widgets declare configurable settings with configSchema in the manifest — a standard JSON Schema (draft-07) object. Crown managers adjust these through the widget dashboard, and the values arrive as the settings prop.
{
"configSchema": {
"type": "object",
"properties": {
"greeting": {
"type": "string",
"description": "Custom greeting prefix",
"default": "Hello"
},
"showMarketData": {
"type": "boolean",
"description": "Whether to display market data",
"default": true
}
}
}
}
In your component, read settings.greeting and settings.showMarketData, typed against your own settings interface.
Runner SDK
Exports
verifyWebhook(secret) | Express middleware for signature verification |
verifySignature(secret, body, sig) | Sync HMAC verification (Node.js) |
verifySignatureAsync(secret, body, sig) | Async HMAC verification (all runtimes) |
createWebhookHandler(options) | Framework-agnostic webhook handler |
createActionClient(options) | Action API client factory |
withIdempotency(handler, store) | Idempotency wrapper for event handlers |
MemoryStore | In-memory idempotency store (development) |
validateManifest(manifest) | Runtime manifest validation |
signRequest(secret, body) | Sign outgoing Action API requests |
isRateLimited(response), getWaitTime(response) | Rate-limit helpers for Action API responses |
Event and action payload types ship alongside them: AppEventType, AppEventEnvelope, AppEvent, ActionScope, and the per-event interfaces.
Event Names and Delivery
The full event list (and payload types) is exported as APP_EVENT_TYPES / AppEventType. App-lifecycle events use the wire names token.app.enabled, token.app.disabled, and token.app.configured; the short forms app.enabled, app.disabled, and app.configured are also accepted when declaring runner.events in a manifest and map to the same wire events.
The backend delivers an event only to runners whose manifest subscribes to it. An empty or absent subscription list receives everything, and test.ping from runner verification is always delivered regardless of the list. Filtering on event.eventType in your handler, as the switch above does, remains good defensive practice.
Apps
The apps in apps/ are the ones Chain Daddy serves to every token page, free on every plan. Each is a normal
Crown App, built and checked the same way as yours, so they double as working references.
| Gallery | core-gallery | Images, video and audio the creator hosts, with optional holders-only pieces |
| Community Polls | core-poll | Token-weighted polls; each vote is an EIP-712 signature from the voter's wallet |
| Audio Player | core-anthem | A compact player for an uploaded track or any audio URL, falling back to the token's anthem |
| Video Player | core-intro-video | An uploaded video or any video URL, inline or full screen |
| Activity Feed | core-broadcasts | The token's recent broadcasts, highlights, media and polls |
| Community Wall | community-wall | A coin's wall of posts, run by its creator: the setup, then the coin page frames the wall itself |
| Item Store | item-store | The coin's own items with a buy button, on its coin page or its own website; each sale is the coin's own |
| Sign a message | sign-a-message | A visitor signs a short message in the app's own name, on a coin page or the coin's own website |
| HTML | core-html | Rich text, images and embeds, with a sanitizer between the owner and the page |
| Price History | core-lightweight-charts | A self-hosted chart on TradingView's open-source Lightweight Charts |
| TradingView Chart | core-tradingview | TradingView's chart widgets: advanced chart, mini chart, ticker tape and more |
| 3D Viewer | 3d-viewer | Animated 3D scenes in the token's colour, from a set of presets |
| CHAP Catch | chap-catch | A Canvas 2D arcade game by og_foundry: catch coins, dodge rugs |
| Neon Breaker | neon-breaker | A WebGL brick breaker by og_foundry whose colours follow the token |
| Engine | core-engine | Boots Godot and Unity games; not an app anyone adds |
FLUX GP, our multiplayer racing game, lives in its own repository,
flux-gp-kit, because its client ships with the game server that runs
its races, store and prizes.
The Community Wall's server, the wall of posts a coin's creator runs on their own hosting, with one-click deploys
to Render and Vercel, is in servers/community-wall.
Examples
Widgets
- hello-widget: minimal reference widget covering
appData, theme, settings, breakpoint, and onAction
- price-alert-widget:
storage:local permission and the onDataUpdate lifecycle hook
- sign-message:
wallet:sign and "embed": true: a signature in the app's own name, on a token page and on the coin's own website (our own build is apps/sign-a-message)
- iap-store: a white-label item store, in your own game (a server holding a store key) and as a Crown App (
iap:purchase), with a copyable iap-client.ts helper (our own Item Store is apps/item-store)
- 3d-viewer: bundling a third-party rendering library (Three.js) inside a widget
- godot-game: a Godot engine app that saves the player's progress, signs them in and sells the token's store items, through
window.chaindaddy
- unity-bridge: the same for a Unity WebGL game: a
.jslib and a C# script
Runners
Each runner example has its own README with deployment steps and a .env.example.
Publishing
The walkthrough is Getting started. In short:
Ship with the CLI
Your source stays on your machine; the operator hosts the built bundle. Install the CLI (beta):
npm install -g @chaindaddy/cli@beta
chaindaddy app login
chaindaddy app init widget yield-tracker
cd yield-tracker
npm install
npm run build && npm run pack && npm run sign && npm run submit
npm run submit waits for the automatic checks. When they pass it prints chaindaddy app request-publish <id>; run that to send the build for review. chaindaddy app submit --request-review does both in one step.
The individual steps:
chaindaddy app validate
chaindaddy app build
chaindaddy app scan
chaindaddy app pack
chaindaddy app sign --in-place
chaindaddy app submit --watch
chaindaddy app request-publish <id>
The operator extracts the package, runs the automated scan, hosts the bundle on its apps CDN with immutable cache headers, and rewrites bundleUrl, bundleHash and bundleSize to the hosted copy.
Submit an open-source app
On the developer portal: Apps → New app → Open-source app. Give the manifest, a public bundle URL and a public repository URL. The server fetches the bundle, checks it against bundleHash, and hosts its own copy. Token pages load bundles only from the apps CDN, so the URL you give is only where the server fetches from.
Review and going live
A submission is pending while the automatic checks run, and auto_failed if they fail. request-publish moves a passing build to in_review, and a reviewer sets it to approved or rejected.
Approval makes that build the live version, and the app appears in the app list on every token page, served under its slug. The previous approved build stays live until the next one is approved. Creators install apps from the Apps tab on their token page; a third-party app needs the token owner on Pro+ or above.
Building a Bundle Yourself
chaindaddy app build does this for you. With your own bundler, the bundle must:
- Be one ESM file, with no code splitting
- Export the component named by
entryComponent as a plain named function
- Leave
react, react/jsx-runtime and react-dom external. The host provides them, and they are the only bare imports allowed
- Be at most 2,000,000 bytes
- Not use
eval(), new Function(), sessionStorage or document.cookie
import { build } from 'esbuild';
import { createHash } from 'crypto';
import { readFileSync } from 'fs';
await build({
entryPoints: ['src/index.tsx'],
bundle: true,
format: 'esm',
outfile: 'dist/bundle.js',
external: ['react', 'react-dom', 'react/jsx-runtime'],
jsx: 'automatic',
minify: true,
});
const bundle = readFileSync('dist/bundle.js');
console.log(`Bundle hash: ${createHash('sha256').update(bundle).digest('hex')}`);
console.log(`Bundle size: ${bundle.length} bytes`);
Testing locally
Open https://chaindaddy.io/_dev/widgets and use "Test your own bundle": pick dist/bundle.js and capp.json. It renders the widget through the real loader and sandbox with sample data, and nothing is uploaded. Check both theme modes and your actions there. More in Test locally.
Security Model
Widgets are real React components rendered in the host page, not in iframes. The host checks each bundle's SHA-256 against the manifest before it runs it, and the token page's CSP loads bundles only from the apps CDN.
These are the sandbox rules. Today the DOM rule is enforced live on the page; the others are checked by the automated scan at submission and in review. The name in parentheses is what a breach is reported as.
| DOM | An app may not leave a node in <body> or <head> that it added with a direct DOM call. One still there when the task ends is removed and reported (dom_injection). A node added and removed in the same task, such as a measuring probe, is fine. replaceChild / replaceChildren on <body> or <head> are refused. <style> and <link rel="stylesheet"> in <head> are allowed, for CSS-in-JS. For floating UI, render a React portal (createPortal) from your own tree; no marker attribute is honoured. |
| Network | With fetch:self-api, only the platform API: /api/ on the page origin or the API origin (unauthorized_fetch). credentials: 'include' is refused (credentialed_request); calls go with credentials: 'omit' and carry X-App-ID. Authorization, Cookie, Proxy-Authorization, X-Forwarded-For, X-Forwarded-Host and X-Real-IP are stripped (blocked_header). |
| Storage | localStorage is scoped to app-<id>- (every instance of the app) and app-<id>@<instanceId>- (one instance). |
| Globals | sessionStorage, eval and Function are blocked (blocked_global). document.cookie reads '' and writes are dropped (cookie_access). window.location is read-only (location_set). |
A report names exactly one app: the one whose own code, or a library it bundled, made the call. Nodes from the host, a browser extension or another app are never reported. First-party apps run under the same rules, but their reports never count toward disabling. Reports reach you on the Violations tab of your app's page on the portal. An app reported by many distinct visitors can be disabled automatically, and then drops off token pages.
The docs site has the same rules: Sandbox rules. Read SECURITY.md for runner authentication, permission scopes, approval gates, rate limits, storage isolation and audit logging.
Repository Structure
chaindaddy-apps/
types/widget.ts # Widget contract — props, theme, actions, lifecycle
runner/ # Runner SDK (headless app helpers)
src/
types/ # Event, action, manifest types
crypto/ # HMAC signing and verification
client/ # Action API client
idempotency/ # Event deduplication
middleware/ # Express + generic webhook handlers
validation/ # Manifest validator
__tests__/ # SDK tests
apps/ # Our apps, served to every token page (core-engine boots engine apps)
templates/ # Starter apps for `chaindaddy app init --template` (shared/ + sync script)
examples/ # Reference widgets and deployable runner templates
servers/community-wall/ # The Community Wall server a creator runs on their own hosting (its own package)
schema/
capp.schema.json # JSON Schema for capp.json validation
docs/ # CLI reference, package format, agentic usage, verifying tokens (+ vectors/)
scripts/ # Build, scaffold, and validation tooling
Contributing
Bug reports and changes to the SDK, schema, examples and docs are welcome — see CONTRIBUTING.md and CODE_OF_CONDUCT.md. Apps are submitted with the CLI or from the portal, not by merge request. To report a security issue, follow the disclosure process in SECURITY.md.
License
Apache-2.0, except servers/community-wall, which is MIT under its own LICENSE.