Tutorial: Build Your First App
In this tutorial, you will build a Word Counter mini-program that reads the active chat history, counts words per role (user vs. assistant), and displays statistics. By the end, you will know how to create, test, package, and publish a mini-program.
What you will learn:
- Creating a
manifest.json - Writing app HTML with the
window.aisSDK - Using
ais.chat.getHistory()to read messages - Using
ais.storageto persist data across sessions - Testing locally with sideloading
- Packaging as a
.aisbundle - Publishing to the community registry
Time required: About 15 minutes.
Step 1: Create the Manifest
Every mini-program needs a manifest.json that describes the app, its permissions, and its entry point. Create a new directory and add this file:
mkdir word-counter
cd word-counter
Create manifest.json:
{
"name": "word-counter",
"version": "1.0.0",
"abi": 1,
"type": "mini-program",
"title": "Word Counter",
"description": "Count words in your chat history by role",
"author": { "name": "Your Name" },
"entry": "index.html",
"base_url": "https://localhost:8080/",
"permissions": ["storage", "chat:read", "ui:toast"],
"keywords": ["statistics", "utility"]
}
Key fields:
| Field | Value | Why |
|---|---|---|
name | word-counter | Unique identifier (lowercase, hyphens only) |
abi | 1 | Required -- matches current platform ABI |
type | mini-program | Tells the platform this is a sandboxed iframe app |
entry | index.html | The HTML file to load |
base_url | https://localhost:8080/ | Where assets are hosted (update for production) |
permissions | ["storage", "chat:read", "ui:toast"] | We need to read chat and save stats |
The storage permission is always granted automatically, but it is good practice to list it explicitly so users know your app stores data.
Step 2: Create the HTML
Create index.html in the same directory. This is your entire app -- HTML, CSS, and JavaScript in one file.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
padding: 20px;
color: #e0e0e0;
background: #1a1a2e;
min-height: 100vh;
}
h1 { font-size: 22px; margin-bottom: 16px; }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
background: #16213e;
border: 1px solid #333;
border-radius: 8px;
padding: 16px;
text-align: center;
}
.stat-value {
font-size: 32px;
font-weight: 700;
color: #58a6ff;
font-variant-numeric: tabular-nums;
}
.stat-label {
font-size: 14px;
color: #888;
margin-top: 4px;
}
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
button {
min-height: 48px;
padding: 10px 20px;
font-size: 16px;
border: 1px solid #444;
border-radius: 6px;
background: #2a2a4e;
color: #e0e0e0;
cursor: pointer;
}
button:hover { background: #3a3a5e; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: #1a73e8; border-color: #1a73e8; }
.btn-primary:hover { background: #1557b0; }
.btn-close { background: none; border-color: #666; color: #888; }
#last-updated {
margin-top: 16px;
font-size: 14px;
color: #666;
}
</style>
</head>
<body>
<h1>Word Counter</h1>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="total-words">--</div>
<div class="stat-label">Total Words</div>
</div>
<div class="stat-card">
<div class="stat-value" id="user-words">--</div>
<div class="stat-label">Your Words</div>
</div>
<div class="stat-card">
<div class="stat-value" id="ai-words">--</div>
<div class="stat-label">AI Words</div>
</div>
<div class="stat-card">
<div class="stat-value" id="msg-count">--</div>
<div class="stat-label">Messages</div>
</div>
</div>
<div class="actions">
<button class="btn-primary" id="analyze">Analyze Chat</button>
<button class="btn-close" id="close">Close</button>
</div>
<div id="last-updated"></div>
<script>
// Helper: count words in a string
function countWords(text) {
if (!text || typeof text !== 'string') return 0;
return text.trim().split(/\s+/).filter(Boolean).length;
}
// Format numbers with commas (1234 -> "1,234")
function fmt(n) {
return n.toLocaleString();
}
ais.ready(async function() {
// Set the panel title
ais.ui.setTitle('Word Counter');
// Try to restore last saved stats
var saved = await ais.storage.get('last-stats');
if (saved) {
showStats(saved);
}
// Analyze button
document.getElementById('analyze').addEventListener('click', async function() {
this.disabled = true;
this.textContent = 'Analyzing...';
try {
// Fetch up to 500 messages from chat history
var messages = await ais.chat.getHistory(500);
var userWords = 0;
var aiWords = 0;
var msgCount = messages.length;
for (var i = 0; i < messages.length; i++) {
var msg = messages[i];
var words = countWords(msg.content);
if (msg.role === 'user') {
userWords += words;
} else if (msg.role === 'assistant') {
aiWords += words;
}
}
var stats = {
total: userWords + aiWords,
user: userWords,
ai: aiWords,
messages: msgCount,
timestamp: Date.now()
};
showStats(stats);
// Save stats for next time
await ais.storage.set('last-stats', stats);
ais.ui.toast('Analyzed ' + msgCount + ' messages!');
} catch (err) {
ais.ui.toast('Error: ' + err.message);
}
this.disabled = false;
this.textContent = 'Analyze Chat';
});
// Close button
document.getElementById('close').addEventListener('click', function() {
ais.close();
});
});
function showStats(stats) {
document.getElementById('total-words').textContent = fmt(stats.total);
document.getElementById('user-words').textContent = fmt(stats.user);
document.getElementById('ai-words').textContent = fmt(stats.ai);
document.getElementById('msg-count').textContent = fmt(stats.messages);
if (stats.timestamp) {
var date = new Date(stats.timestamp);
document.getElementById('last-updated').textContent =
'Last analyzed: ' + date.toLocaleString();
}
}
</script>
</body>
</html>
What this code does
ais.ready()-- Waits for the SDK bridge to connect before running any logic.ais.storage.get('last-stats')-- Restores previously saved statistics so the user sees data immediately on launch.ais.chat.getHistory(500)-- Fetches up to 500 messages from the active conversation.- Word counting -- Iterates through messages, splitting content on whitespace and tallying per role.
ais.storage.set('last-stats', stats)-- Persists the results for next time.ais.ui.toast()-- Shows a notification when analysis is complete.ais.close()-- Returns to the chat view when the user clicks Close.
Step 3: Test Locally
You need a local HTTP server to serve the manifest and HTML file. Use any tool you prefer:
# Python 3
cd word-counter
python3 -m http.server 8080
# or Node.js
npx serve -p 8080
# or PHP
php -S localhost:8080
Now install the app in the platform:
- Open aiscouncil.com and sign in
- Click the Apps icon in the left sidebar
- In the Sideload section, paste:
http://localhost:8080/manifest.json - Click Install
- Review the permissions (storage, chat:read, ui:toast) and click Allow
- Click Open on the installed app card
For the fastest development loop, use HTML Upload instead of URL sideloading. Upload your index.html directly -- no server needed. The platform creates a synthetic manifest automatically. You can uninstall and re-upload each time you make changes.
Troubleshooting
| Problem | Solution |
|---|---|
| "Failed to fetch manifest" | Make sure your local server is running and serving CORS headers. Try python3 -m http.server 8080 which serves CORS-safe. |
| App shows blank white page | Check the browser console for errors. The most common issue is calling ais.* methods before ais.ready(). |
| "PermissionDenied: chat:read" | Your manifest does not include chat:read in the permissions array. Update the manifest and reinstall. |
| App does not update after code changes | Uninstall the app first (click the X button on the card), then reinstall. Entry HTML is cached at install time. |
Step 4: Add Some Polish
Let us add a feature: real-time word counting as new messages arrive.
Add this code inside the ais.ready() callback, after the close button handler:
// Subscribe to new messages for real-time counting
ais.chat.onMessage(function(msg) {
// Re-read saved stats and add the new message's words
ais.storage.get('last-stats').then(function(stats) {
if (!stats) return;
var words = countWords(msg.content);
if (msg.role === 'user') stats.user += words;
else if (msg.role === 'assistant') stats.ai += words;
stats.total = stats.user + stats.ai;
stats.messages++;
stats.timestamp = Date.now();
showStats(stats);
ais.storage.set('last-stats', stats);
});
});
Now the counters update live as the user chats without needing to click "Analyze" again.
Step 5: Package as a .ais Bundle
A .ais bundle is a ZIP archive containing your manifest and all app files. The platform extracts the ZIP, reads the manifest, and inlines all assets (CSS, JS, images) into the entry HTML.
For a single-file app like ours, the bundle is simple:
cd word-counter
zip -r ../word-counter.ais manifest.json index.html
That creates word-counter.ais in the parent directory.
Testing the bundle
- In the platform, go to Apps and click Upload App
- Select
word-counter.ais - Review permissions and approve
- The app installs from the bundle with all assets inlined
Bundles are self-contained. The user does not need network access to the original base_url -- everything is inlined at install time. This makes bundles ideal for offline distribution and sharing.
Multi-file bundles
If your app has separate CSS, JavaScript, or image files, include them all in the ZIP:
zip -r ../word-counter.ais manifest.json index.html style.css app.js icon.png
The platform automatically inlines:
<link rel="stylesheet" href="style.css">becomes<style>...</style><script src="app.js"></script>becomes<script>...</script><img src="icon.png">becomes<img src="data:image/png;base64,...">
Step 6: Publish to the Registry
Once your app is ready for others to use, publish it to the community registry.
1. Host your files
Upload your manifest and entry HTML to a public CDN. GitHub Pages is free and easy:
# In your GitHub repo (e.g., github.com/yourname/word-counter)
# Push manifest.json and index.html to the main branch
# Enable GitHub Pages in repo settings (source: main branch, root)
Your files will be available at:
https://yourname.github.io/word-counter/manifest.jsonhttps://yourname.github.io/word-counter/index.html
Update base_url in your manifest to match:
"base_url": "https://yourname.github.io/word-counter/"
2. Fork the aiscouncil repo
Go to github.com/nicholasgasior/bcz and click Fork.
3. Add your package entry
Edit registry/packages.json and add an entry to the packages array:
{
"name": "word-counter",
"type": "mini-program",
"version": "1.0.0",
"manifest": "https://yourname.github.io/word-counter/manifest.json",
"tier": "community",
"category": "utilities",
"description": "Count words in your chat history by role",
"icon": "https://yourname.github.io/word-counter/icon.png",
"added": "2026-02-19",
"price": 0,
"currency": "USD",
"seller": null
}
4. Validate
Run the validation script to check your entry:
python3 registry/validate.py packages
If validation passes, you are ready to submit.
5. Submit a pull request
Push your changes to your fork and create a PR against the main repo. If the automated validation passes, the PR can be merged and your app will appear in the App Store section of the platform.
See Publishing to Registry for full details on pricing, seller setup, and verification tiers.
Tips and Best Practices
Design
- Dark theme default -- Most platform users use dark mode. Design for dark backgrounds (
#1a1a2eor similar) with light text (#e0e0e0). - 48px minimum touch targets -- Buttons and interactive elements should be at least 48px tall for accessibility and VLM-friendly interaction.
- 14px minimum font size -- All text must be at least 14px for readability.
- Responsive layout -- The apps panel width varies. Use CSS Grid or Flexbox with
auto-fitto adapt.
Performance
- Cache results in storage -- Use
ais.storageto save computed results. Restore them on launch so the user sees data immediately. - Limit chat history requests --
ais.chat.getHistory(500)is usually enough. Avoid requesting unlimited history. - No polling -- Use
ais.chat.onMessage()for real-time updates instead of repeatedly callinggetHistory.
Security
- Request minimal permissions -- Only list the permissions your app actually uses. Fewer permissions means more users will trust and install your app.
- Validate all input -- Data from
ais.chat.getHistory()contains user-generated content. Sanitize before inserting into the DOM. - Do not store sensitive data --
ais.storageis not encrypted. Never store passwords, tokens, or API keys (unless your app hassecrets:syncand explicitly handles credential transfer).
Compatibility
- Check
ais.platform.abi-- If your app depends on specific SDK features, check the ABI version and show a helpful message if the platform is older. - Wrap SDK calls in try/catch -- Permission errors and platform version differences can cause rejections. Handle them gracefully.
- Test with the hello-world example -- The platform ships with an example mini-program you can use as a reference.