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'
      }))
    }
  }
}