Skip to content

Real example: line-sentinel (line sentry)

A real plugin covering most of the SDK surface — the source is the documentation: sdk/examples/line-sentinel/ (you can scaffold an equivalent with aw plugin create).

What it does

  • watches DAQ samples and counts per-node alarms once a threshold is crossed
  • a 5 s heartbeat proving liveness plus REST loopback channel probing (manifest ping)
  • line start/stop tracking and config-change awareness
  • plugin API: GET /api/plugins/line-sentinel/report · POST /threshold
  • a live badge in the bottom-right corner of the browser (sample count + alarm count)

Server (index.mjs)

Consuming platform services (ctx.api)

js
async setup(ctx) {
  ctx.logger.info(`loaded (scope=${ctx.scope}, sdk=${ctx.sdkVersion})`)
  try {
    const { lines } = await ctx.api.lines.list()  // loopback REST: returns { lines, states }, not an array (may run before listen; self-heals)
    ctx.kv.set('linesTotal', Array.isArray(lines) ? lines.length : 0)
  }
  catch (err) { ctx.logger.warn('line list not ready (heartbeat will retry):', err?.message) }
}

Enhanced event consumption (ctx.hooks / ctx.events)

js
ctx.hooks.on('daq:sample', (s) => {
  ctx.kv.bump('samples')
  const threshold = Number(ctx.kv.get('threshold')) || 180
  if (typeof s?.value === 'number' && s.value > threshold) {
    const key = `alarm:${s.nodeId}`
    const prev = ctx.kv.get(key) ?? { count: 0 }
    ctx.kv.set(key, { count: (prev.count ?? 0) + 1, value: s.value, at: s.at })
  }
})
ctx.hooks.on('line:start', (p) => { ctx.kv.set('running', true); ctx.kv.bump('runningCount') })
ctx.hooks.on('line:stop', () => ctx.kv.set('running', false))
ctx.events.on('daq.node.changed', (p) => ctx.logger.debug('node changed', p?.op))

daq:sample is a server hook (publish-level cadence, payload carries lineId); to subscribe to scene events by type, go through ctx.events, which adds the event: prefix for you.

Config-change awareness (ctx.config.onChange)

js
ctx.config.onChange(() => {
  ctx.logger.info(`config changed, current theme colour: ${ctx.config.get('theme.primaryColor')}`)
})

Timers and cleanup (ctx.timer / ctx.onDispose)

js
ctx.timer.setInterval(() => {                       // collected automatically on shutdown and hot reload
  ctx.kv.set('heartbeat', new Date().toISOString())
  ctx.api.plugins.manifest()                        // REST loopback channel probe (unauthenticated)
    .then(() => ctx.kv.set('apiOk', true))
    .catch(() => ctx.kv.set('apiOk', false))
}, 5000)
ctx.onDispose(() => ctx.logger.info('sentry cleanup: alarm state already flushed with KV'))

Plugin API (ctx.route)

js
ctx.route('GET', '/report', () => {
  const alarms = Object.entries(ctx.kv.all())
    .filter(([k]) => k.startsWith('alarm:'))
    .map(([k, v]) => ({ nodeId: k.slice(6), ...v }))
  return {
    plugin: ctx.name,
    sdkVersion: ctx.sdkVersion,          // ctx has no plugin version; the manifest records it
    running: ctx.kv.get('running') ?? false,
    samplesWatched: ctx.kv.get('samples') ?? 0,
    threshold: Number(ctx.kv.get('threshold')) || 180,
    alarms,
  }
})

ctx.route('POST', '/threshold', (event) => {
  const v = Number(event.awBody?.threshold) || 180   // the host catch-all pre-read the body
  ctx.kv.set('threshold', v)
  return { ok: true, threshold: v }
})

Both routes are mounted at /api/plugins/line-sentinel<path> (exact match); authentication is enforced declaratively by the entry's auth field.

Browser (client.mjs)

js
export function setup(ctx) {
  let samples = 0
  let alarms = 0
  const badge = ctx.el('div', {
    id: 'line-sentinel-badge',
    style: 'padding:8px 12px;border:1px solid rgba(53,224,160,.5);border-radius:10px;'
      + 'background:rgba(6,18,14,.85);color:#35e0a0;font:600 12px/1 ui-monospace,monospace',
  }, ['line-sentinel · idle'])

  ctx.root().append(badge)

  // ctx.on(type) takes the scene event name itself (internally event:<type>).
  // Server hooks (daq:sample / line:start / line:stop) never cross to the browser — subscribing to them just idles silently.
  ctx.on('daq.reading', () => {
    samples += 1
    badge.textContent = `line-sentinel · ${samples} samples · ${alarms} alarms`
  })
  ctx.on('daq.alarm', () => {
    alarms += 1
    badge.style.borderColor = '#ff6b6b'
    badge.textContent = `line-sentinel · ${samples} samples · ${alarms} alarms`
  })
  ctx.on('daq.alarm.changed', () => { badge.style.borderColor = 'rgba(53,224,160,.5)' })

  ctx.log.info('sentry badge mounted (bottom right)')
}

The entry must declare client: './client.mjs' in the manifest, otherwise the loader never fetches this file.

Measured record

CheckResult
Automatic load from the user scope (with client enhancement)pass
Manifest routes (/report · /threshold)pass
Sample counting after starting a line (publish-level cadence)pass
Over-threshold alarms (threshold 100)pass, per-node counting continues
line:start/stop runtime statepass, running: true → false
Heartbeat + REST loopback channelpass, heartbeat age < 1 s
Browser badgepass, mounted with zero pageerror
No KV or timer leak after a hot reloadpass (triggered by an enable/disable round trip)
ctx.onDisposeimplemented on the shutdown path (a forced kill on Windows skips graceful hooks, see boundaries)

Install and try it

bash
mkdir -p ~/.AgentWorkShop/plugins && cp -r sdk/examples/line-sentinel ~/.AgentWorkShop/plugins/  # user-scope install
aw plugin list                                  # line-sentinel should appear (enabled, +client)
curl -X POST http://localhost:3001/api/plugins/line-sentinel/threshold \
  -H 'content-type: application/json' -d '{"threshold":100}'
curl http://localhost:3001/api/plugins/line-sentinel/report

依据 PolyForm Noncommercial 1.0.0 开源 · Source-available, noncommercial