kanban
v0.1.14File-backed kanban board — tickets, threaded comments, agent assignment, the kanban:change and kanban:comment trigger types, and an injectable console board with agent profiles for planning and review.
Install
iii trigger compose::add worker=kanban@0.1.14Run in your project with the iii engine running. Exact versions are immutable and digest-pinned.
skill doc
iii-browser-sdk
how-toiii-browser-sdk
iii-browser-sdk turns the frontend into an iii worker: one WebSocket to the engine instead
of many HTTP round-trips, backend workers pushing into the UI with trigger(), and the same
primitives used server-side (registerFunction, trigger, registerTrigger). No Node.js
dependencies, no OpenTelemetry — it runs on native WebSocket.
Docs https://iii.dev/docs/tutorials/linkly/frontend · API reference https://iii.dev/docs/reference/sdk-browser · npm https://www.npmjs.com/package/iii-browser-sdk
The installed types are the contract. Before writing SDK code, read the project's
node_modules/iii-browser-sdk/dist/index.d.mts (plus state.d.mts, helpers.d.mts), or the
published file at https://cdn.jsdelivr.net/npm/iii-browser-sdk@.
Never write these calls from memory: the export names and the InitOptions keys are the
exact surface, and the internal type chunk is content-hashed, so re-read it per version.
Install, and the only entry point that matters
pnpm add iii-browser-sdkimport { registerWorker } from 'iii-browser-sdk'
const iii = registerWorker(import.meta.env.VITE_III_WS_URL) // ws://localhost:3111registerWorker(address, options?) is the sole top-level function; everything else hangs off
the returned ISdk — registerFunction, registerTrigger, registerTriggerType,
trigger, addConnectionStateListener, getFatalError, shutdown. Do not destructure
these as top-level imports; they are not exported that way.
InitOptions: workerName, namespace, invocationTimeoutMs (default 30000),
reconnectionConfig, headers (ignored — see auth). The browser has no process.env, so
namespace has no environment fallback: pass it explicitly when you need one.
Registering a function the backend can call
const ref = iii.registerFunction(
'ui::show-notification',
async (data: { title: string; body: string }) => {
toast.push(data.title, data.body)
return { displayed: true }
},
{ description: 'Shows a toast in the open tab' },
)
// later
ref.unregister()- The handler is
async (payload) => resultand runs in the browser tab. - Pass a
description(plusrequest_format/response_formatwhen callers need the contract) — that is whatengine::functions::infoshows. - Return JSON-serializable data; a thrown error surfaces as the caller's error.
- A backend worker reaches it by id:
iii.trigger({ function_id: 'ui::show-notification', payload }).
Calling backend functions
const users = await iii.trigger<{ limit: number }, User[]>({
function_id: 'api::get::users',
payload: { limit: 20 },
timeoutMs: 5000,
})Three routing modes — choose deliberately:
| Mode | Call | Use when |
|---|---|---|
| await (default) | await iii.trigger({ function_id, payload }) |
you need the result |
| fire-and-forget | action: TriggerAction.Void() |
telemetry, non-essential side effects |
| enqueue | action: TriggerAction.Enqueue({ queue }) |
long work; resolves to { messageReceiptId } |
namespace on the request routes the invocation elsewhere; omit it to stay in your own.
Receiving live pushes (there is no polling story)
The engine can invoke a function registered in the tab at any time:
iii.registerFunction('ui::update-dashboard', async (m: Metrics) => {
store.setMetrics(m)
return null
})That is the whole real-time design: a backend worker calls
trigger({ function_id: 'ui::update-dashboard', payload }) and the open tab updates. Do not
add polling, SSE, or a second socket alongside it.
Triggers in the browser
const trigger = iii.registerTrigger({
type: 'state',
function_id: 'ui::refresh', // a function this tab registered
config: { scope: 'orders', key: '*' },
})
trigger.unregister()registerTrigger fills namespace from this worker — correct, because the target function is
one this worker registered. Registering a trigger type (registerTriggerType) from a
browser is rarely right: it only makes sense when the browser itself is the event source. The
RBAC listener also constrains which types a browser session may register.
State, helpers, channels (subpath exports)
iii-browser-sdk/state— the engine's state worker, typed:get/set/delete/list/update, plusStateEventType.Created|Updated|Deletedfor the change events thestatetrigger delivers. Useupdatewith orderedopswhen you need an atomic edit instead of a read-modify-write race.iii-browser-sdk/helpers—createChannel(iii, bufferSize?)returns aChannelwithwriter/readerand serializablewriterRef/readerRef, so a ref can be handed to a backend worker and the bytes stream either direction;createStream(iii, name, impl)wires astream::get/set/delete/list/list_groupsimplementation;isChannelRefandextractChannelRefs(recursive, returns[path, ref]tuples) complete the set.ChannelWriter.sendMessage/sendBinary/close;ChannelReader.onMessage/onBinary/readAll.- Some releases publish
iii-browser-sdk/streamas an empty module with types only. Read the installeddist/for the version in front of you instead of assuming.
Connection, auth, and the failure modes that cost an afternoon
- Auth is RBAC at the listener, not headers. A browser cannot set headers on a WebSocket
handshake, so
headersis ignored: credentials ride as query parameters or cookies and are judged by the RBAC auth function on the upgrade request ({ headers, query_params, ip_address }in,AuthResultout, scoping namespaces, allowed/forbidden function ids, and allowed trigger types). The engine-owned listener must be a public, RBAC-protected instance (e.g.iii-worker-manager#browser); the private listener must never face untrusted browser clients. - Never pin
workerNameacross tabs. The defaultbrowser:is deliberate: the engine allows one live worker per name per namespace, so two tabs sharing a fixed name evict each other. Pin it only for a deliberately single-tab app that needs a stable identity. - Surface connection state.
addConnectionStateListener(handler)fires immediately with the current state and then on every transition (disconnected,connecting,connected,reconnecting,failed); it returns an unsubscribe function. Bind the reconnecting banner and stale-data treatment to it. Reconnection is exponential backoff with jitter — tunereconnectionConfig(initialDelayMs1000,maxDelayMs30000,backoffMultiplier2,jitterFactor0.3,maxRetries-1) rather than writing your own retry loop. - Fatal rejections are terminal. A name or namespace collision raises
RegistrationRejectedError(code,namespace,worker_name,function_id,owner_worker_id), is thrown into every pending invocation, and closes the connection for good;iii.getFatalError()returns it while healthy returnsundefined. Show it; do not retry blindly. shutdown()on teardown, in tests, and before re-registering during a hot reload.
React integration
One worker per app, created outside React and handed down. Never one per component, and never inside a render body — StrictMode double-invokes render, so a socket built there leaks.
// src/iii/client.ts
import { registerWorker, type ISdk } from 'iii-browser-sdk'
let client: ISdk | undefined
export function getIii(): ISdk {
return (client ??= registerWorker(import.meta.env.VITE_III_WS_URL))
}// src/iii/IiiProvider.tsx
export const useIii = () => {
const iii = useContext(IiiContext)
if (!iii) throw new Error('useIii must be used inside <IiiProvider>')
return iii
}
export function IiiProvider({ children }: { children: ReactNode }) {
const iii = getIii()
const [state, setState] = useState<IIIConnectionState>('connecting')
useEffect(() => iii.addConnectionStateListener(setState), [iii])
const fatal = iii.getFatalError()
if (fatal) return <FatalBanner error={fatal} />
return (
<IiiContext.Provider value={iii}>
<ReconnectBanner state={state} />
{children}
</IiiContext.Provider>
)
}Rules that keep this honest:
- Register handlers in an effect; unregister in its cleanup.
registerFunctionreturns aFunctionRefwithunregister(). Skip it and an unmount or HMR leaves a stale handler answering backend calls in a dead tab. - Function ids must be stable and addressable. Name them for what they do
(
ui::notification), never after a component instance. Two tabs registering the same id load-balance, so target a function; use astatekey or a channel when you need one specific tab. trigger()is an imperative async call, not a render input. Route it through a query or mutation layer (queryFn: () => iii.trigger({ ... })) so caching, retries, and loading states live in one place. Never call it during render.- Server state is not UI state. Engine state and trigger results belong in the cache, not
mirrored into
useState. - Errors are values. A rejected
triggershould reach an error boundary or an inline state, not be swallowed in a.catch(() => {}).
Vite
# .env.local
VITE_III_WS_URL=ws://localhost:3111- Only
VITE_-prefixed variables are exposed; read them withimport.meta.env.*. - The package is ESM with
exportsfor.,./helpers,./state, and./stream;importandrequireboth resolve, and no polyfill is needed on nativeWebSocket. - If a dev-server reload ever races the first registration (symptoms: two clients, two
sockets, a duplicated module), add
optimizeDeps: { include: ['iii-browser-sdk'] }and clearnode_modules/.viteonce. - A production build pointing at
ws://localhostis a silent failure that only shows up in the connection banner. Keep the URL in the environment, never committed to source.
Checklist before claiming it works
engine::functions::list { prefix: "ui::" }lists the functions the tab registered.engine::functions::info { function_id: "ui::…" }returns the description you passed.- A real backend
triggerof that id changes the rendered UI — observed in a livebrowser::sessions::starttab, not assumed from the code. - Stopping the engine flips the UI to
reconnecting; restarting it restores registration with no page reload. - No stale handler survives an unmount or HMR (the
registerFunctioncleanup is present). - A name or namespace collision is surfaced through
getFatalError(), not swallowed.