Widgets
Build sidebar panels and full-screen dashboards that render live data in the Stoa workspace.
Widgets render in the Space sidebar section and optionally in a full content panel. Every widget gets two rendering modes:
- Sidebar mode (
context.mode === 'widget'): Compact output inside an expandable accordion section - Full panel mode (
context.mode === 'full'): Expanded output in the main content area, accessed by clicking "Open" on the widget
Basic widget
// @stoa-plugin
// @name Team Status
// @type widget
// @icon Users
module.exports = {
render(context) {
if (context.mode === 'full') {
return {
type: 'table',
columns: ['Name', 'Role', 'Status', 'Last Active'],
rows: [
['Alice', 'Engineering', 'Online', '2 min ago'],
['Bob', 'Design', 'In Meeting', '15 min ago'],
['Carol', 'PM', 'Away', '1 hour ago'],
]
}
}
return {
type: 'list',
items: [
{ label: 'Alice', subtitle: 'Online', iconColor: 'green' },
{ label: 'Bob', subtitle: 'In Meeting', iconColor: 'orange' },
{ label: 'Carol', subtitle: 'Away', iconColor: 'gray' },
]
}
}
}If you don't check context.mode, the same output renders in both places.
Context object
Widget render() receives a context object:
render(context) {
context.mode // 'widget' | 'full'
context.transcriptSegments // array (if @events transcript:segment declared)
}Auto-refresh
Add @refresh to re-execute on a timer. The sidebar and full-panel views both update:
// @stoa-plugin
// @name Pomodoro Timer
// @type widget
// @icon Activity
// @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')
return {
type: 'list',
items: [
{ label: phaseLabel + ': ' + timeStr, subtitle: 'Pomodoro', iconColor: isWork ? 'red' : 'green' },
]
}
}
}Fetching data
Widgets can fetch from external APIs. Declare permissions for documentation:
// @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',
}))
}
}
}Tabbed dashboards
Use the tabs output type for multi-view widgets:
// @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' },
]
}
}
}Design tips
- Sidebar mode: keep it compact — 3-5 list items or a few KV pairs. Users scan, not read.
- Full panel mode: add detail — tables, tabs, charts. This is where users drill in.
- Use native output types (
list,kv,table) overhtmlwhen possible. They match the sidebar design system and respect theming. - Handle empty states: return
{ type: 'empty', message: 'No data yet' }rather than a blank output.