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) over html when 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.