Examples
Annotated plugin examples covering every plugin type and output type.
Copy-paste ready examples for common plugin patterns.
Widgets
Meeting agenda
A simple ordered list with time estimates:
// @stoa-plugin
// @name Meeting Agenda
// @type widget
// @icon FileText
module.exports = {
render(context) {
const agenda = [
{ label: 'Sprint review', subtitle: '10 min' },
{ label: 'Design feedback', subtitle: '15 min' },
{ label: 'Roadmap discussion', subtitle: '20 min' },
{ label: 'Action items', subtitle: '5 min' },
]
if (context.mode === 'full') {
return {
type: 'stack',
direction: 'vertical',
children: [
{ type: 'markdown', content: '# Meeting Agenda\n\nEstimated total: 50 minutes' },
{ type: 'list', items: agenda, ordered: true },
]
}
}
return { type: 'list', items: agenda, ordered: true }
}
}Build info
Key-value pairs with system information:
// @stoa-plugin
// @name Build Info
// @type widget
// @icon Code
module.exports = {
render(context) {
const buildDate = new Date().toISOString().split('T')[0]
if (context.mode === 'full') {
return {
type: 'stack',
direction: 'vertical',
children: [
{ type: 'markdown', content: '# Build Information' },
{
type: 'kv',
entries: [
{ key: 'Date', value: buildDate },
{ key: 'Environment', value: 'production' },
{ key: 'Node', value: process.version },
{ key: 'Platform', value: process.platform },
]
}
]
}
}
return {
type: 'kv',
entries: [
{ key: 'Built', value: buildDate },
{ key: 'Node', value: process.version },
]
}
}
}Pomodoro timer
A fast-ticking timer using client-side execution:
// @stoa-plugin
// @name Pomodoro Timer
// @type widget
// @icon Activity
// @runtime client
// @refresh 1s
module.exports = {
render(context) {
const WORK_MINS = 25
const BREAK_MINS = 5
const CYCLE_SECS = (WORK_MINS + BREAK_MINS) * 60
const now = new Date()
const secondsToday = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds()
const posInCycle = secondsToday % CYCLE_SECS
const isWork = posInCycle < WORK_MINS * 60
const phaseLabel = isWork ? 'Focus' : 'Break'
const phaseSecs = isWork ? WORK_MINS * 60 : BREAK_MINS * 60
const elapsed = isWork ? posInCycle : posInCycle - WORK_MINS * 60
const remaining = phaseSecs - elapsed
const mins = Math.floor(remaining / 60)
const secs = remaining % 60
const timeStr = String(mins).padStart(2, '0') + ':' + String(secs).padStart(2, '0')
const progressPct = Math.round((elapsed / phaseSecs) * 100)
const statusColor = isWork ? 'red' : 'green'
if (context.mode === 'full') {
const barFilled = Math.round(progressPct / 5)
const progressBar = '\u2588'.repeat(barFilled) + '\u2591'.repeat(20 - barFilled)
return {
type: 'stack',
direction: 'vertical',
children: [
{ type: 'markdown', content: '# Pomodoro Timer\n\n**' + phaseLabel + '** — ' + timeStr + ' remaining' },
{ type: 'text', content: '[' + progressBar + '] ' + progressPct + '%' },
{
type: 'kv',
entries: [
{ key: 'Phase', value: phaseLabel },
{ key: 'Time remaining', value: timeStr },
{ key: 'Progress', value: progressPct + '%' },
{ key: 'Work interval', value: WORK_MINS + ' min' },
{ key: 'Break interval', value: BREAK_MINS + ' min' },
]
}
]
}
}
return {
type: 'list',
items: [
{ label: phaseLabel + ': ' + timeStr, subtitle: progressPct + '% complete', iconColor: statusColor },
]
}
}
}Tabbed dashboard
Multiple views in a single widget:
// @stoa-plugin
// @name Project Dashboard
// @type widget
// @icon Activity
module.exports = {
render(context) {
if (context.mode === 'full') {
return {
type: 'tabs',
tabs: [
{
label: 'Status',
content: {
type: 'list',
items: [
{ label: 'API', subtitle: 'Healthy', iconColor: 'green' },
{ label: 'Database', subtitle: 'Healthy', iconColor: 'green' },
{ label: 'Queue', subtitle: 'Degraded', iconColor: 'orange' },
]
}
},
{
label: 'Metrics',
content: {
type: 'kv',
entries: [
{ key: 'Requests/min', value: '1,247' },
{ key: 'P99 latency', value: '142ms' },
{ key: 'Error rate', value: '0.02%' },
]
}
},
{
label: 'Config',
content: {
type: 'json',
data: { region: 'us-east-1', replicas: 3, version: '2.4.1' }
}
}
]
}
}
return {
type: 'list',
items: [
{ label: 'All systems operational', iconColor: 'green' },
{ label: '1,247 req/min', iconColor: 'blue' },
]
}
}
}GitHub CI status
Fetches live data from an external API:
// @stoa-plugin
// @name CI Status
// @type widget
// @icon Activity
// @permissions network:api.github.com
// @refresh 5m
module.exports = {
async render(context) {
const resp = await fetch('https://api.github.com/repos/myorg/myrepo/actions/runs?per_page=5')
const data = await resp.json()
const runs = data.workflow_runs || []
if (context.mode === 'full') {
return {
type: 'table',
columns: ['Workflow', 'Branch', 'Status', 'Duration'],
rows: runs.map(r => [
r.name,
r.head_branch,
r.conclusion || r.status,
r.run_started_at
? Math.round((new Date(r.updated_at) - new Date(r.run_started_at)) / 1000) + 's'
: '-',
])
}
}
return {
type: 'list',
items: runs.slice(0, 3).map(r => ({
label: r.name,
subtitle: r.conclusion || r.status,
iconColor: r.conclusion === 'success' ? 'green' : r.conclusion === 'failure' ? 'red' : 'orange',
}))
}
}
}Renderers
JSON viewer
// @stoa-plugin
// @name JSON Viewer
// @type renderer
// @icon FileText
// @filetype *.json
module.exports = {
render(content) {
try {
const data = JSON.parse(content)
return { type: 'json', data: data, collapsed: false }
} catch (e) {
return { type: 'error', message: 'Invalid JSON', details: e.message }
}
}
}CSV editor
A full editable table with add/delete row support:
// @stoa-plugin
// @name CSV Editor
// @type renderer
// @icon Table
// @filetype *.csv,*.tsv
module.exports = {
render(content, context) {
const delimiter = context.filePath.endsWith('.tsv') ? '\t' : ','
const lines = content.split('\n').filter(l => l.trim())
if (lines.length === 0) {
return { type: 'empty', message: 'Empty file' }
}
const headers = lines[0].split(delimiter)
const rows = lines.slice(1).map(l => l.split(delimiter))
return {
type: 'editableTable',
columns: headers,
rows,
editable: Object.fromEntries(headers.map(h => [h, { type: 'text' }])),
rowOps: { add: true, delete: true },
}
},
onEdit(edit, content, context) {
const delimiter = context.filePath.endsWith('.tsv') ? '\t' : ','
const lines = content.split('\n').filter(l => l.trim())
const headers = lines[0].split(delimiter)
const rows = lines.slice(1).map(l => l.split(delimiter))
if (edit.kind === 'cell') {
rows[edit.rowIndex][edit.columnIndex] = edit.newValue
} else if (edit.kind === 'addRow') {
rows.push(headers.map(() => ''))
} else if (edit.kind === 'deleteRow') {
rows.splice(edit.rowIndex, 1)
}
return {
content: [headers.join(delimiter), ...rows.map(r => r.join(delimiter))].join('\n')
}
}
}Agent plugins
Code review prompts
// @stoa-plugin
// @name Code Prompts
// @type agent
// @icon Code
// @toolbar pills
module.exports = {
render() {
return {
type: 'prompts',
prompts: [
{ label: 'Review {activeFileShort}', prompt: 'Review {activeFile} for bugs and improvements.' },
{ label: 'Write tests', prompt: 'Write comprehensive unit tests for {activeFile}.' },
{ label: 'Explain', prompt: 'Explain how {activeFile} works, step by step.' },
]
}
}
}Sprint tools menu
// @stoa-plugin
// @name Sprint Tools
// @type agent
// @icon BarChart3
// @toolbar menu
module.exports = {
render() {
return {
type: 'prompts',
toolbar: 'menu',
menuLabel: 'Sprint',
prompts: [
{ label: 'Sprint status', prompt: 'What is the current sprint status? Summarize open items, blockers, and progress.', group: 'Status' },
{ label: 'Blockers', prompt: 'List all current blockers and suggest how to unblock each one.', group: 'Status' },
{ label: 'Review PR', prompt: 'Review the most recent pull request for quality and correctness.', group: 'Code' },
{ label: 'Write changelog', prompt: 'Write a changelog entry for the changes made in this sprint.', group: 'Code' },
]
}
}
}Transcript plugins
Live keyword tracker
// @stoa-plugin
// @name Live Keywords
// @type widget
// @icon Activity
// @events transcript:segment
// @refresh 5s
module.exports = {
render(context) {
const segments = context.transcriptSegments || []
const words = {}
for (const seg of segments) {
if (!seg.isFinal) continue
for (const word of seg.text.split(/\s+/)) {
const w = word.toLowerCase().replace(/[^a-z]/g, '')
if (w.length > 3) words[w] = (words[w] || 0) + 1
}
}
const sorted = Object.entries(words)
.sort((a, b) => b[1] - a[1])
.slice(0, 15)
return {
type: 'list',
items: sorted.map(([word, count]) => ({
label: word,
subtitle: count + 'x',
iconColor: count > 5 ? 'orange' : 'gray'
}))
}
}
}