PluginsShared Blackboard

The Shared Blackboard

A plugin isn’t just a bag of tools bolted onto a chat. It’s a shared workspace where the tool is the atomic action, the human clicks a button OR the agent issues a tool_use, and both hit the exact same underlying store. The result is a UI that updates live as the agent works — no “run this then paste the answer into a form.”

The store is a SQLite sidecar per plugin instance, opened on first use at:

~/.bahulam/data/<plugin-name>/state.db

Every plugin gets its own DB file with two bootstrap tables ready to go (kv for structured values, records for append-only event logs) and can CREATE TABLE more via the raw-SQL escape hatch. The store is completely private to the plugin — plugin A cannot read plugin B’s DB, enforced at the API layer.

Handler side — options.state

Handlers opt in by naming state in their signature. The CLI injects a handle backed by the plugin’s SQLite file:

export async function call(args, { state }) {
  const s = await state;                     // first access resolves the handle
 
  // Small structured values
  const watchlist = s.get('watchlist', []);
  s.set('watchlist', [...watchlist, args.ticker]);
 
  // Deep-merge into an existing object (arrays replace, nested objects merge)
  s.patch('prefs', { display: { compact: true } });
 
  // Append to an event log
  const runId = s.append('backtests', {
    strategy: args.name, sharpe: 2.14, drawdown: -0.08,
  });
 
  // Read the log (newest first by default)
  const recent = s.list('backtests', { limit: 20 });
 
  // Raw SQL for the plugin's own advanced use
  const winners = s.query(
    'SELECT * FROM records WHERE json_extract(payload,"$.sharpe") > ?',
    [2],
  );
 
  return { success: true, output: { runId, count: recent.length } };
}

Every write fires a debounced plugin_state_changed event on the workspace SSE bus (see below), so any open view re-renders live.

View side — POST /api/plugin-state/<plugin>

Workspace views talk to the same store via HTTP. Same ops, same debounce, same isolation:

async function state(op, extra = {}) {
  const res = await fetch('/api/plugin-state/' + PLUGIN, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-Bahulam-Local-Token': token },
    body: JSON.stringify({ op, ...extra }),
  });
  return (await res.json()).result;
}
 
// Read
const runs = await state('list', { stream: 'backtests', limit: 20 });
// Write — fires SSE event
await state('set', { key: 'watchlist', value: ['AAPL', 'TSLA'] });

Op names match the handler API (get, set, patch, delete, keys, append, list, query).

The reactive pulse — SSE plugin_state_changed

Every write emits an event on /api/events, filtered per plugin. Views subscribe and re-render when the agent (or another view) writes:

const es = new EventSource('/api/events?token=' + token);
es.addEventListener('plugin_state_changed', (ev) => {
  const evt = JSON.parse(ev.data);   // { plugin, kind, target, op, at }
  if (evt.plugin !== 'my-plugin') return;
  if (evt.target === 'backtests') refreshBacktestTable();
  if (evt.kind === '*') refreshEverything();   // coarse pulse — see below
});

Events are debounced 50ms per (plugin, kind, target) on the server, so a burst of appends from a loop coalesces into one wake-up. Add another 100–200ms debounce on the client for good measure.

Always handle the coarse kind: '*' pulse. Writes that happen in the workspace process carry precise {kind, target}. But your state can also be written from other processes — the agent running in the terminal REPL, a headless run, a workflow job node. The workspace server watches the state files themselves and emits { kind: '*', target: '*', source: 'fswatch' } when any outside writer commits. You don’t know what changed, so re-query everything you display — views fetch state on every event anyway, so the coarse pulse costs one extra query and buys you a canvas that is never stale, no matter where the agent ran.

Worked example — Collatz history in hello-world

The reference plugin uses all of it end-to-end. The collatz_sequence handler persists each run to a records stream:

const state = options.state ? await options.state : null;
if (state) state.append('collatz_runs', { start, steps, peak, converged });

A companion tool list_collatz_runs exposes read access so the agent can reason over history. The workspace view subscribes to SSE:

es.addEventListener('plugin_state_changed', (ev) => {
  const evt = JSON.parse(ev.data);
  if (evt.plugin === 'hello-world' && evt.target === 'collatz_runs') {
    reloadHistoryTable();
  }
});

Ask the agent “do collatz on 27, 15, and 7” and watch the workspace table light up in real time as each run lands — that’s the shared blackboard in action. ~/.bahulam/data/hello-world/state.db persists across sessions, and sqlite3 on it works fine if you want to peek.

Contract summary

OpHandlerView (POST body)Notes
Read one keystate.get(key, fallback?){op:'get', key, fallback?}Returns null (or fallback) when absent
Write whole valuestate.set(key, value){op:'set', key, value}Fires SSE
Deep-mergestate.patch(key, partial){op:'patch', key, partial}Arrays replace, nested objects merge
Delete one keystate.delete(key){op:'delete', key}Returns whether a row existed
List keysstate.keys(){op:'keys'}Sorted ascending
Append log rowstate.append(stream, payload){op:'append', stream, payload}Returns new row id
Read logstate.list(stream, {limit,order}){op:'list', stream, limit?, order?}order defaults 'desc', limit defaults 50
Raw SQLstate.query(sql, params?){op:'query', sql, params?}SELECT/PRAGMA returns rows; DML returns {changes, lastInsertRowid}

Safety notes

  • The DB file lives at ~/.bahulam/data/<plugin>/state.db with 0o700 on the directory. It contains whatever the plugin persists — treat it the same as any file the plugin writes.
  • query() is a raw-SQL escape hatch intended for the plugin’s own tools and views. Always parameterize — never string-concat user input into the SQL string.
  • Views cannot access other plugins’ state; the workspace server refuses cross-plugin URLs (403 plugin_scope_mismatch on a session pinned to a specific plugin).

When MCP tools want to write to the blackboard

An MCP tool from an external server doesn’t have access to options.state directly (it’s in a different process). Two clean patterns:

  1. Wrap the MCP call in a JS tool — the JS tool calls the MCP tool internally, then writes the result to state. This is what save_backtest would do in an options plugin — call MCP quant.backtest, persist the result to state.db.
  2. HTTP callback from the MCP server — for advanced cases, the MCP server can POST to /api/plugin-state/<plugin> using a token passed in its env. Same endpoint the workspace views use.

Pattern 1 is almost always what you want. Simpler, no extra credentials, handler stays close to the state.