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.dbEvery 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
| Op | Handler | View (POST body) | Notes |
|---|---|---|---|
| Read one key | state.get(key, fallback?) | {op:'get', key, fallback?} | Returns null (or fallback) when absent |
| Write whole value | state.set(key, value) | {op:'set', key, value} | Fires SSE |
| Deep-merge | state.patch(key, partial) | {op:'patch', key, partial} | Arrays replace, nested objects merge |
| Delete one key | state.delete(key) | {op:'delete', key} | Returns whether a row existed |
| List keys | state.keys() | {op:'keys'} | Sorted ascending |
| Append log row | state.append(stream, payload) | {op:'append', stream, payload} | Returns new row id |
| Read log | state.list(stream, {limit,order}) | {op:'list', stream, limit?, order?} | order defaults 'desc', limit defaults 50 |
| Raw SQL | state.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.dbwith0o700on 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_mismatchon 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:
- 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_backtestwould do in an options plugin — call MCPquant.backtest, persist the result tostate.db. - HTTP callback from the MCP server — for advanced cases, the MCP
server can
POSTto/api/plugin-state/<plugin>using a token passed in itsenv. Same endpoint the workspace views use.
Pattern 1 is almost always what you want. Simpler, no extra credentials, handler stays close to the state.