The Rib contract
A rib is the harness’s one extension point. It is a single TypeScript
interface, Rib, exported from @keelson/shared, and a rib package
default-exports one object that implements it. Every hook is optional: a rib
implements the subset it needs and the harness fills in the rest.
For why capabilities are packages and how a contribution reaches every surface without wiring, read The rib model first. What follows is the exact contract: the shapes, the rules, and the order the harness enforces.
import type { Rib } from "@keelson/shared";
const rib: Rib = { id: "demo", displayName: "Demo", // ...any subset of the optional hooks below};
export default rib;Terminology
Section titled “Terminology”| Term | Meaning |
|---|---|
| rib id | The stable identifier, lowercase kebab-case, matching the package basename (@keelson/rib-demo has id demo). Gated by KEELSON_RIBS. |
| namespace | Everything a rib publishes lives under rib:<id> or rib:<id>:*. The harness rejects out-of-namespace keys at activation. |
| snapshot key | A namespaced key under which a rib publishes data. The browser renders the cached payload as a live board. |
| composer | A function registered under a key that produces the payload on demand. A rib’s composeBundle is the composer for rib.id. |
| view | A static declaration that binds a snapshot key to a canvas renderer, so the harness draws it with no per-rib UI code. |
| surface | A primary nav tab that lays out region-bound boards. Each region binds a namespaced key. |
| action | An inbound verb a rib handles over the loopback API, so a board’s buttons can reach the rib. |
The interface
Section titled “The interface”interface Rib { id: string; // required displayName: string; // required registerTools?(ctx: RibContext): readonly ToolDefinition[]; composeBundle?(ctx: RibContext): Promise<unknown>; views?: readonly RibViewDescriptor[]; surfaces?: readonly RibSurfaceDescriptor[]; acceptsIngest?: boolean; contributeWorkflows?(ctx: RibContext): readonly RibWorkflowContribution[]; contributeDocs?(ctx: RibContext): readonly RibDocsSource[]; contributePolicies?(ctx: RibContext): readonly Policy[]; onAction?(action: RibAction, ctx: RibContext): Promise<RibActionResult> | RibActionResult; onRunEvent?(event: RibRunEvent, ctx: RibContext): void | Promise<void>; listAgents?(ctx: RibContext): readonly AgentSummary[] | Promise<readonly AgentSummary[]>; resolveAgent?(slug: string, ctx: RibContext): (OpenChatSeed | null) | Promise<OpenChatSeed | null>; listCommands?(ctx: RibContext): readonly RibCommandDescriptor[] | Promise<readonly RibCommandDescriptor[]>; invokeCommand?(name: string, arg: string, ctx: RibContext): CommandInvokeResult | Promise<CommandInvokeResult>; completeCommand?(name: string, prefix: string, ctx: RibContext): readonly CommandCompletion[] | Promise<readonly CommandCompletion[]>; authStatus?(ctx: RibContext): Promise<RibAuthStatus> | RibAuthStatus; dispose?(): void | Promise<void>;}Only id and displayName are required. Everything else is an optional hook: a
rib implements the subset it needs and the harness fills in the rest. The
rib model groups them by what they contribute.
The context the harness injects
Section titled “The context the harness injects”Hook factories receive a RibContext: the rib’s scoped data layer. Every
accessor except getExec is optional, so a minimal test context still
satisfies the interface, and a rib that needs an absent accessor fails closed.
| Accessor | Returns | Notes |
|---|---|---|
getExec() | RibExec | Process-exec with runJSON / runText. The one accessor always present. |
getSnapshotManager?() | SnapshotManager | Register, recompose, and read snapshot keys. Scoped to the rib’s namespace. |
getCredential?(serviceId) | Promise<string | undefined> | Read-only, resolves a keychain entry under rib_<id>_<serviceId>. A rib cannot read another rib’s secrets. |
getDataDir?() | string | Absolute path to the rib’s private data directory under the keelson home, named rib-<id> (<home>/rib-<rib-id>). Path only: the rib creates it when it writes. |
getProjects?() | readonly Project[] | Read-only snapshot of the operator’s project records at call time. Use to offer project selection and pass project.rootPath as RibAgentTurnRequest.cwd. Optional: a rib built against an older harness degrades to no project selection, not a throw. |
createProject?(body: CreateProjectBody) | Promise<Project> | Create or register an operator-global project through the host’s project service. body is { name, rootPath? }. Rejects with ProjectOperationError; see project creation below. |
cloneProject?(body: CloneProjectBody) | Promise<Project> | Clone and register through the same service as HTTP. body is { url, name? }; omitted names derive from the repository URL. The destination is beneath the workspace root. |
runAgentTurn?(req) | RibAgentTurn | Runs one agent turn through the provider registry, inheriting provider pinning, credentials, and the policy engine’s tool_result/response redaction (the same gates the workflow prompt path runs). The return RibAgentTurn is a settled dual-handle { stream, result }: result settles once with the authoritative text, usage, status, sessionId, and stopReason, and is the source of truth, while stream forwards the provider’s live tool_use/tool_result chunks so a rib can trace a long-running turn as it happens, then appends the settled text tail and a terminal done. When the result carries an error, the stream also yields an { type: "error", message } chunk before that terminal done, so a rib draining the stream never reads a failed turn as clean. A rib takes text from result, never reassembling it from the stream. result.stopReason is "end" | "max_tokens" | "aborted" | "timeout" | "error": it mirrors status on aborted, timeout, and error, while end and max_tokens come only from providers that report a finish reason. Derive truncation as stopReason === "max_tokens". result.sessionId is the provider’s backend session id; pass it as the next turn’s req.resumeSessionId to continue that session. Providers whose capabilities.sessionResume is false ignore it. req.modelClass ("fast" | "balanced" | "deep") picks a model the way a workflow node’s class does: the config file’s modelClasses for the resolved provider, then the provider’s own class map, then its default model. An explicit req.model wins over the class. req.reasoningEffort ("none" | "low" | "medium" | "high" | "xhigh") is a hint, not a pin: it reaches the provider only when that provider’s capabilities report reasoningEffort: true, and is dropped silently otherwise, so the turn still runs at the provider’s own tier. result.model is the model that served the turn as the provider reported it, else the model the turn asked for. req.turnContext is an opaque Readonly<Record<string, unknown>> forwarded verbatim to tool executions and interpreted only by the calling rib. An omitted req.cwd runs the turn in a neutral non-repo directory, never the server’s cwd. A turn that omits tools, allowedTools, and disallowedTools is locked to allowedTools: [], text-only rather than reaching ambient built-ins. A turn that carries real spend records a usage-ledger row under the owning rib id. req.allowedDirectories sets confinement roots for the turn: file and shell path args that canonicalize outside them are denied by the always-registered builtin:path_confinement policy (realpath-resolved against symlink escape), while empty or absent leaves the turn unconfined; it is wired on this rib-agent-turn path via the Claude provider. Requested sibling rib tools project only when an operator grant clears caller:target:tool (or the target’s *), from the config file’s crossRibGrants or KEELSON_CROSS_RIB_GRANTS, and the two union; self tools and SDK built-ins keep their existing behavior. |
callTool?(targetRibId, name, args, opts?) | Promise<CallToolResult> | Governed cross-rib invocation of a sibling rib’s owned tool. Denied by default, requires an operator grant (the config file’s crossRibGrants or KEELSON_CROSS_RIB_GRANTS, unioned) and policy approval, resolves to { ok: false } instead of throwing, and is bounded by KEELSON_CROSS_RIB_CALL_TIMEOUT_MS (default 30000 ms). opts.signal forwards caller cancellation and opts.timeoutMs overrides the timeout for that call. |
getToolReachability?(names) | readonly ToolReachability[] | Pre-flight: would these tool names project onto a turn this rib runs? Synchronous, batched, and non-throwing, so a rib can check the whole set once (at start-up, say) rather than discovering the gap from an agent that reports a capability it never had. Each verdict carries name, a status, and the owning rib as ownerRibId when a rib owns the tool. reachable means the floor clears the name; cross-rib-denied and denylisted mean the floor drops it. unregistered is not a denial: the harness registers no such tool, so the name still rides the turn as an allow-list entry and whether it resolves is the provider’s answer. A provider SDK built-in like Read lands there, and so does a typo, so the caller decides which it is (it knows which names it expects a rib to own). When several could apply, the order is unregistered, then cross-rib-denied, then denylisted. It answers the operator floor only: registration, the cross-rib grant gate, and the denylist. It does not run the policy engine, whose projection is scoped to a resolved provider that does not exist yet at pre-flight, so reachable means the floor clears the tool, not that policy will allow it. |
registerRegion?(surfaceId, region) | () => void | Adds a snapshot-backed region to one of the rib’s own surfaces at runtime, returning an unregister handle. Layout-only: the rib still registers the region’s key, which must be namespaced. Also nudges the client to re-fetch the manifest. |
invalidateManifest?() | void | Nudges the client to re-fetch the manifest, the same nudge registerRegion gives, without registering a region. Needed only for the live-descriptor mutations described under views and surfaces below: the client fetches GET /api/ribs once and caches it, so a rib that appends a view, or changes a declared region’s presentation or mount-defaults, must call this or the client keeps rendering the boot-time descriptor until a reload. Call it after the mutation and only when a value really changed: every call re-fetches for every subscribed client. |
refreshWorkflow?(workflowName, inputs?) | Promise<void> | Re-runs one of the rib’s own snapshot-bound producer workflows by name on demand; fresh output republishes to the bound key through the same publish→recompose bridge the cadence heartbeat uses. Optional string inputs ride to the run; an unbound per-item producer republishes through the rib’s own tools instead of a bound key. Resolves (never throws) for an unknown name or failed run. |
runWorkflow?(definition, inputs?, opts?) | Promise<RibWorkflowRunResult> | Runs an in-memory workflow DAG the rib hands in (the same shape contributeWorkflows uses) on the shared executor, with the provider, memory, and policy gates already wired. Optional inputs ride the run and opts.cwd sets the working dir, defaulting to the keelson home. This seam does not own a worktree lifecycle: a definition with worktree.enabled: true resolves to a failed result with no nodes invoked. Use startWorkflow for catalog-managed isolation, or acquire a workspace, pass its path as cwd, and use an explicitly in-place definition. In the latter case the caller keeps cleanup ownership and must release the lease. Resolves to the run’s terminal RibWorkflowRunResult (never throws) for an invalid definition or a failed run. The caller owns trusting the definition: its bash/script nodes run as given. |
startWorkflow?(name, inputs?, opts?) | Promise<{ runId }> | Starts a catalog workflow by its exact name, on the launch path the workflow_run tool uses, and resolves with the run id as soon as the run is registered. The rib follows the run through onRunEvent and getRunStatus instead of blocking on it. opts.projectId names a registered project: the run resolves that project’s workflow scope and uses its root as the working dir, and absent runs at the keelson home. Denied by default: the operator grants each rib the workflow names it may start (the config file’s ribWorkflowGrants or KEELSON_RIB_WORKFLOW_GRANTS, unioned), and that grant is checked before the policy engine, so an ungranted rib gets a plain rejection and no approval prompt. A granted start then passes policy as a workflow_run call, and a policy that asks the operator holds the call up to KEELSON_CROSS_RIB_CALL_TIMEOUT_MS. With opts.projectId the name resolves in that project’s scope, where a project workflow can shadow the global one. Rejects for a denied or unknown workflow, an unknown project, a requiresProject workflow with no project, and a run that cannot start. |
getRunStatus?(runId) | Promise<RibRunStatus | undefined> | Status of a run the rib started or whose workflow it owns, and undefined for any other run id. status is running | paused | succeeded | failed | cancelled. A paused run carries pendingApproval: { nodeId, prompt, pauseId?, artifacts? }, where pauseId is this pause’s token for respondToRun and artifacts holds the run files the prompt names as $ARTIFACTS_DIR/<path> (the plan a gate asks about), each { path, text?, truncated?, error? }: up to 4 files, each cut to 32,000 characters, with error for one that could not be read. checkout is { path, branch, worktreeEstablished }; the boolean says whether the run established a managed isolated checkout. It is false for explicitly in-place runs and setup failures. It stays true after a finished run’s worktree is cleaned up, when path and branch read null. nodes lists each node’s id, status, output, and error. |
cancelRun?(runId) | Promise<CancelRunResult> | Cancels a live run the rib started, so a rib that owns child runs can stop them when its own op is cancelled. Resolves to { ok: false, error } instead of throwing for a run the rib did not start or one that already settled. |
respondToRun?(runId, nodeId, text, pauseId?) | Promise<RespondToRunResult> | Answers the approval gate nodeId on a paused run the rib started, as the operator’s workflow_respond would: text is approve or feedback, at most 16 KiB. Pass the status’s pauseId as a fourth argument so a late answer can’t resolve a later pause of the same node. Denied by default: the operator grants each rib the workflow names whose gates it may answer (the config file’s ribApprovalGrants or KEELSON_RIB_APPROVAL_GRANTS, unioned), separately from the start grant. That grant is checked before the policy engine, and a granted answer then passes policy as a workflow_respond call. Resolves to { ok: false, error } instead of throwing for an ungranted workflow, a run the rib did not start, a policy denial, a stale pauseId, or a gate that is no longer open. |
acquireWorkspace?(req) | Promise<WorkspaceLease> | Acquires an isolated, dependency-prepared checkout for a project and returns { id, path, branch, release }. The lease is durable and enumerable until released, so mutation-heavy rib work can keep its checkout separate from the source project. This is checkout isolation, not a sandbox; tool policy and cwd choices still belong to the caller. |
acquireMutationLock?(req) | Promise<MutationLock> | Acquires an advisory per-project lock before mutating a project’s live checkout directly. A second holder fails fast naming the current holder and purpose. Lease-held worktrees are isolated and do not need it. |
getMemory?() | MemoryTools | Recall prior decisions, lessons, and work-log rows and write new ones back to the governed memory ledger, the same MemoryTools the workflow executor binds. Writeback is evidence-default and review-gated: it cannot mint an instruction-grade, always-inject row. |
registerOp?(req) | OpHandle | Registers a long-running operation on the durable op registry, returning a handle synchronously so the rib can hand back the op id in the same turn and then work in the background honoring handle.signal. req (RegisterOpRequest) carries kind, optional title/projectId, and an optional onSteer(note) callback that makes the op steerable. The handle exposes log/progress frames and terminal done(result)/error(message). Frames and the terminal result are persisted, so the generic run_status/run_events/run_cancel/run_steer tools reach it over chat + MCP and survive a server restart; cancellation aborts signal, and onSteer lives only in the live registry (a steer fails cleanly once the op is terminal or the server restarted). |
getProviders?() | readonly RibProviderInfo[] | A read-only { id, displayName, defaultModel?, modelClasses? } snapshot of the providers registered at call time, so a rib can make an availability-aware vendor choice. modelClasses is the provider’s class map with the config file’s modelClasses applied, so a rib can show which model each class runs. It only lists what is registered and grants no access beyond the existing runAgentTurn routing. |
getSidecar?() | Promise<unknown> | unknown | Opaque resolver; each rib narrows and casts at its own edge. |
Before a rib mutates a project’s live checkout (Project.rootPath) directly, it
should hold ctx.acquireMutationLock for that project, or lease an isolated
worktree with ctx.acquireWorkspace. Retain the returned handle and release it
in a finally so the lock never leaks, and fail closed when the seam is absent
(an older harness) rather than mutating unguarded:
const lock = await ctx.acquireMutationLock?.({ projectId, purpose: "resolve-review" });if (!lock) throw new Error("mutation lock unavailable; refusing to mutate the live checkout");try { // …mutate the project's checkout…} finally { await lock.release();}A second holder fails fast, naming the current holder and purpose.
KEELSON_DISABLE_MUTATION_LOCK is an emergency operator bypass, not a normal
coordination path.
Project creation
Section titled “Project creation”CreateProjectBody, CloneProjectBody, Project, and ProjectOperationError
are exported from @keelson/shared. Both mutation methods are optional,
independently of getProjects. Check for the capability before using it on an
older host:
import type { RibContext } from "@keelson/shared";
async function createProject(ctx: RibContext) { if (!ctx.createProject) { throw new Error("project creation unavailable on this host"); } const project = await ctx.createProject({ name: "demo" }); return project;}Without rootPath, creation uses <workspaceRoot>/<name>, where the host resolves
KEELSON_WORKSPACE (default ~/keelson), not its process cwd or a rib’s data
directory. Explicit paths are trimmed, expand a leading ~ or ~/, and must be
absolute. The persisted result includes the resolved absolute rootPath;
getProjects() sees the registration immediately.
For a missing or empty target, the host creates any missing directories, runs
git init, then git commit --allow-empty -m "Initialize project" using your
configured Git identity. It touches no project files. The empty commit gives the
repository a HEAD that worktrees can branch from immediately. Existing Git
repositories, including unborn repositories, and populated non-Git folders are
registered untouched. A non-Git project cannot host Write agents until it is a
Git repository with a commit; existing unborn repositories also need a commit.
See Projects and worktrees.
Names must be 1 to 64 characters, start with a lowercase letter or digit, and
contain only lowercase letters, digits, -, or _. Both bodies reject unknown
fields. Duplicate names and exact canonical roots, including symlink aliases,
conflict across HTTP and rib calls. A project nested beneath the default project’s
root is allowed. Cloning refuses any existing destination, including an empty
directory, and runs noninteractively with a 60-second Git timeout.
Rejections carry ProjectOperationError.status and message: 400 for invalid
input or targets, 409 for conflicts, 500 for initialization or registration
failures, 502 for clone failures, and 503 when the host service is not ready.
Calls during activation can receive 503; retry after readiness, not by waiting
inside an activation hook. A missing Git identity produces an initialization
failure naming user.name or user.email. Set the missing keys with
git config --global user.name "Your Name" and
git config --global user.email "you@example.com". On failure, cleanup removes
only operation-created state; pre-existing folders and unexpected user content
are retained, and cleanup failures appear alongside the original error.
-
registerTools(ctx)returns the rib’s tools for the shared registry. They reach the chat agent through the provider tool adapters with no further wiring; a workflowpromptnode treats rib tools as default-off and runs one only when it names it inallowed_tools. Tool names are global: a name already claimed by another rib is skipped with a warning, which is why rib tools carry a family prefix (the substring before the first underscore, sodemo_pinghas familydemo). AToolDefinitionisname,description, a zodinputSchema, optional advisorystate_changing/requires_confirmation, and anexecute(input, ctx)that returns nothing and emits results astool_resultchunks. Emit atool_resultwithisError: truerather than letting a throw bubble through the SDK, and checkctx.abortSignal.abortedat every meaningful await. -
composeBundle(ctx)is registered as the snapshot composer underrib.idafterregisterToolsreturns. It is invoked lazily, only when something callsSnapshotManager.recompose(rib.id), never eagerly at boot. For a warm initial snapshot, callctx.getSnapshotManager?.().recompose(rib.id)from insideregisterTools. A rib can also register additional keys imperatively withctx.getSnapshotManager?.().register(key, compose, opts). -
viewsare declarativeRibViewDescriptors:{ key, canvasKind, title? }.keymust be namespaced.canvasKindis the closed enummarkdown|view|html|log(htmlrenders untrusted, rib-authored markup in a sandboxed iframe; the frame can post structured actions back to the host, which are relayed to the owning rib’sonActionwithorigin: "canvas-html"so the rib can gate them to a safe subset. The reply to a frame action may carryopen-canvasoropen-chat; the host drops any other effect.logrenders terminal output verbatim in a monospace block with ANSI escapes resolved — never parsed as markdown; its payload should be a string, and a non-string payload renders as formatted JSON). The payload published underkeymust satisfy the renderer for that kind; the client gate fail-closes on a mismatch. Views are plain metadata, so the manifest is built without invoking rib code.The host re-reads
viewsper manifest request, so a rib may hold that array live and append to it at runtime, which is how a rib adds a per-subjecthtmlview, since the host resolves a key’scanvasKindby exact match. A view is pure presentation (key,canvasKind,title), so nothing about it is derived at boot and a live one is a complete one. The client caches the manifest, so a rib that appends must callctx.invalidateManifest?.()afterwards or the client keeps rendering the boot-time descriptor until a reload.The activation parse and namespace checks run again per request, so a descriptor pushed later is held to the identical rule: one that is malformed, whose key is outside the rib’s namespace, or that duplicates a surface id, is dropped from the response (and warned) rather than served, and cannot fail the manifest for other ribs.
-
surfacesare declarativeRibSurfaceDescriptors: a top-level nav tab with alayoutof an optionalheader, an optionalbanner, zero or morerowsofcolumns, and an optionalfooter. A column entry is a single region or an array of regions. An array renders as one vertical stack whose height flows independently of its row siblings, so two columns of unequal boards pack without cross-column whitespace (columnRegions()is the shared normalizer consumers walk both shapes with). The header, footer, and row-columns each collapse (collapsible, withcollapsedfor the state they open in); a banner never does, andbannerRegionSchemaomits those flags along withhideWhenEmpty, so a region that must fold belongs anywhere but there. Beyondid,title, andlayout, the descriptor carries four optional flags:headingsets the page H1 independently oftitle(which only names the nav tab), so a surface opts into an H1 (and may make it differ from the tab) rather than the host deriving one;subtitleadds a header subtitle line;projectScopedopts the surface into the host’s sharedProjectChip, so on select the host dispatches the rib’sselect-projectaction with the chosen project id and a per-project rib need not hand-roll a picker; andhideRegionActionsopts an authoring-console surface out of the host’s per-region explore, select, and open-full chrome (board actions and head-action menus still flow).badgeKeynames a snapshot key in the rib’s namespace holding{ count, title? }; a count above zero shows as a pip on the surface’s tab, withtitleas its hover text. Each region is{ key, workflow?, workflowArgs?, serverRefresh?, cadenceMs?, title?, glyph?, group?, groupTitle?, byline?, collapsible?, collapsed?, live?, hideWhenEmpty?, headActions? }:keyis namespaced;workflowis the catalog workflow a region’s refresh re-runs;workflowArgsare string inputs that ride each of those refresh runs, so one producer can serve many regions (e.g. a per-item re-author workflow keyed by the item id); the heartbeat cannot supply args, so args-bearing regions refresh only while the surface is open and one boot summary warning lists every affected key;serverRefresh: falseexplicitly opts a region out of the heartbeat without a warning, while an absent value keeps server refresh enabled;cadenceMsis the auto-refresh interval (floored to 30s and run on open). For server-refreshed regions, the heartbeat uses that cadence while the key has a live subscriber and six times that cadence while idle;glyphis a{ char, tone? }chip for the region head;groupis a clustering hint used when adding regions at runtime viaregisterRegion; regions sharing agroupstring are kept contiguous in the surface rather than interleaved by arrival order;groupTitleis a section heading rendered for that group’s zone;bylineis a context line shown beneath the region head’s title (e.g. a per-item subject);collapsibleandcollapsedcontrol whether the region can be collapsed and its initial collapse state (honored on header, footer, and row-column regions; banner regions cannot collapse);liveopts the region head into a freshness dot the host pulses while frames stream in on the region’s key and quiets once they stop (reduced-motion keeps the lit dot without the motion; default off);hideWhenEmptyrenders the region (head and body) only once its snapshot is live with content: a board that composessections: []stays hidden, so a rib can gate first-run panels on having something to say (banner regions can’t carry it, and avoid it on on-demand workflow regions, whose Load affordance would be unreachable; default off);headActions(1–8 items) are rib-supplied verbs on the region head itself, rendered as a ⋯ menu and dispatched to the rib’sonActionexactly like board actions. Destructive ones, and any that setconfirm, ask first, the menu renders even when the surface setshideRegionActions(that flag suppresses the host’s explore/select/expand chrome, not the rib’s own verbs) and while the region is collapsed, and the board-card affordancesfields/expanded/inline/selectedare rejected by the schema (a menu has no form to collect fields with, and its items are plain buttons carrying no pressed state for a toggle to sit in).Surfaces are read per manifest request like
views, but they are not live the same way, because the host derives things from them once at boot: a region’sworkflowname hoists into a set that gates whether a refresh may run, andcadenceMsplusserverRefreshdetermine its heartbeat schedule. So a rib may mutate a declared region’s presentation and mount-defaults at runtime (collapsed,title,glyph,byline) and callctx.invalidateManifest?.(), and the client picks them up on its next mount. Adding a region at runtime, or changing aworkflowcadenceMs, orserverRefreshafter boot does not work that way:/api/ribswould show it while its refresh is rejected as unbound and no heartbeat is ever scheduled. Add regions withregisterRegioninstead: it is the runtime path the workflow and heartbeat consumers actually track. -
contributeWorkflows(ctx)returnsRibWorkflowContributions merged into the catalog at activation:{ definition, bindSnapshotKey?, validate? }.definitionstaysunknownat the contract floor and the server validates it against the workflow schema when it merges. WhenbindSnapshotKeyis set, the run’s structured output republishes to that key, andvalidateis the producer-side fail-closed gate (a zod.parse) run before the frame is cached or broadcast.A rib can also ship static workflows with no hook code: YAML files in a
workflows/folder at the package root are discovered at activation, validated by the same loader as every other YAML source, and merged into the catalog with the rib’s provenance (include the folder in the package’s npmfiles). The package still needs its default-exportedRibobject to be discoverable; only the workflows themselves need no code. Use the folder for fixed definitions; usecontributeWorkflowswhen a definition is computed at activation or bound to a snapshot key, since binding needs the code-sidevalidate. On a name collision the rib’s code entry wins over its own YAML file, a collision across ribs keeps the earlier-activated rib’s entry, and any same-named workflow file in the bundled, global, or project scope overrides a rib entry. Folder workflows load at server boot, like the rest of rib activation. -
contributeDocs(ctx)returns theRibDocsSources the rib adds to the harness docs catalog, collected once at activation likecontributeWorkflows. Each source is{ title, summary, llmsFullUrl?, content? }: either anllms-full.txtcorpus the harness fetches and caches, or inlinecontent. Thekeelson_docstool surfaces every source: it lists them, indexes one into its H1 topics, and returns a single topic on demand, so the whole corpus never enters a turn. The harness namespaces each source under the owning rib id, so an installed rib extends what the agent can read about itself and the core never names a rib. -
contributePolicies(ctx)returns the governancePolicys the rib adds to the harness, collected once at activation likecontributeWorkflows. EachPolicyis{ id, on?, evaluate(event, ctx) };evaluatereturnsallow,denywith a reason, orask, and the harness composes rib policies with its builtins behind one engine evaluated at each turn’s hook points (tool call, tool result, request, response) across the chat, workflow, and rib surfaces. On theworkflowsurface thectxalso carriesworkflowNameandnodeId, so a rib policy can scope a decision to its own workflow’s node rather than firing on every workflow-surface turn (which is allctx.surface === "workflow"tells it). Both are absent on other surfaces and on an older harness that doesn’t populate them, where they read asundefined. A scope check such asctx.workflowName === "x"then simply stops matching, so a policy that must stay correct against an older harness chooses its own absent-field fallback. -
onAction(action, ctx)handles inbound actions overPOST /api/ribs/:id/action.actionis{ type, payload?, origin? }:typeis a rib-defined verb the base never enumerates;payloadstays opaque for the rib to narrow;originis"board" | "canvas-html"and is stamped by the host, not the caller; a sandboxedcanvas-htmliframe cannot claim"board". Treat absent or"board"as a trusted host-UI dispatch; gatecanvas-html-origin verbs to a safe subset, because iframe markup and scripts are untrusted (rib- or LLM-authored, and a frame script can post on load without a user gesture). It returns{ ok: true, data? }or{ ok: false, error }. This path is loopback-trusted.A successful action may place a recognized client effect in
data:open-chatopens a fresh seeded conversation,run-workflowlaunches a catalog workflow (its optionalstayflag keeps the operator on the current surface instead of focusing the Workflows tab: the run streams into a slide-over drawer beside the board that launched it, and the board’s own snapshot key updates in place when the workflow publishes), andopen-canvasopens a snapshot with{ effect: "open-canvas", key, title?, placement? }. Placement defaults to"center", the full reading sheet. Set"side"for a structuredviewdocument to dock a non-modal, 520px inspector at the right edge. The surface stays scrollable and clickable; a later side reply swaps the document in place. Inspector columns stack to one, and the drawer fills the viewport below 720px. Omitting placement on a later reply opens centered again.html,markdown, andlogdocuments always open centered, even when"side"is requested.open-surfaceswitches the active surface tab. Theopen-surfaceshape is{ effect: "open-surface", surfaceId, regionKey? }, wheresurfaceIdis the browser tab id (surface:<ribId>:<surfaceId>) andregionKeyoptionally focuses one region after the tab switch.open-runopens a run that already exists in the same slide-over run drawer, approval composer included:{ effect: "open-run", runId, workflow }, whereworkflowis the run’s workflow name. Unknown or malformed effects are rejected at the surface edge rather than treated as successful navigation.acceptsIngest?: booleanadvertises that a rib handles the conventionalingestaction. When set, chat can send a persisted message to that rib with{ type: "ingest", payload: { text, sourceConversationId? } }.textis the message body, capped byribIngestPayloadSchema;sourceConversationIdidentifies the conversation that produced it when one exists. Treat the payload as untrusted user data and narrow it at the rib boundary. -
onRunEvent(event, ctx)delivers run-lifecycle notifications for the rib’s own contributed workflows: every run stamped with the rib’s id, whoever started it (a board effect, the Workflows surface, a cadence refresh). A rib also receives the events of any run it started throughctx.startWorkflow, withstartedByRibIdset, so it can follow a catalog workflow it does not own.eventis{ workflowName, runId, status, inputs, startedAt, completedAt?, error?, pendingApproval?, startedByRibId? }withstatusone ofrunning | paused | succeeded | failed | cancelled: onerunningat each launch and one terminal event when that launch settles. The rib that started the run also sees its pause transitions:pausedeach time the run stops on a human gate, carryingpendingApproval: { nodeId, prompt }, andrunningagain once the last open gate is answered. An owner that did not start the run keeps the launch and terminal pair only, so a hook written beforepausedexisted never receives it. Delivery is once per launch, not once per run: resuming a failed or cancelled run emits a fresh pair with the samerunId, and launches never interleave: a launch’s terminal event arrives before any later launch’srunning, so the latest event is always current.inputsare the run’s inputs, so a rib can reconstruct dispatch context for runs it didn’t start;errorcarries the run-level failure message. The hook is fail-soft and fire-and-forget: a throwing or rejecting hook is caught and logged, never affecting the run. Invocations for one run are serialized, so a slow async handler settles before the next event’s invocation starts. Delivery is not guaranteed (a server restart mid-run drops the terminal event), so derive state that tolerates a missed event (pair it with the observed system state or a staleness window). -
listAgents(ctx)/resolveAgent(slug, ctx)offer named chat agents, reusable turn templates of a system prompt plus an optional model, surfaced atGET /api/agents.listAgentsis cheap and returnsAgentSummarys;resolveAgentlazily builds one slug’sOpenChatSeedon selection and returnsnullfor an unknown slug. A surface opens the seed as a fresh, seeded chat. -
listCommands(ctx)/invokeCommand(name, arg, ctx)/completeCommand(name, prefix, ctx)add slash commands to the chat composer, surfaced atGET /api/commands.listCommandsreturns static descriptors.invokeCommandis a side-effect-free resolver: it decides which closed effect the surface performs (open one of the rib’s agents, run a workflow, or show a message), and the surface, not the rib, performs it.completeCommandbacks argument type-ahead and must return the full candidate set for an emptyprefix. -
authStatus(ctx)is a credential probe surfaced inGET /api/ribs(and optionallydoctor). It returns{ authenticated, statusMessage? }. The probe is not expected to throw; a throwing probe is caught at the seam and reported as{ authenticated: false, statusMessage: <error> }. -
dispose()is teardown, sync or async. The harness awaits it during shutdown, before the database closes, so a rib holding sockets, watchers, or child processes can tear down cleanly.
Validation rules
Section titled “Validation rules”The harness validates each candidate against the contract and fails closed, but where a failure lands depends on when it is caught.
- At discovery, a shape failure costs you that rib and nothing else: a bad
id, a bad
displayName, a hook that is not a function, an import that throws, or a duplicate id is warned and skipped, and the harness boots without it. - At activation, a key outside
rib:<id>:*, a rib whose declarediddiverges from its manifest key, a duplicate rib id, or a duplicate surface id throws, and the throw is not caught: the server fails to boot. This is deliberate. Those are code bugs, and a thrown error in test or development beats a silently inactive rib in production. - Per request, the view and surface descriptors served from
GET /api/ribsare checked again, because a rib may mutate its own arrays after activation. A descriptor that is malformed, out of namespace, or a duplicate surface id is dropped and warned rather than served, so it cannot blank the manifest for every other rib.
| Rule | Enforced where |
|---|---|
id is lowercase kebab-case, 1 to 64 chars, matching ^[a-z][a-z0-9-]*$, and matches the package suffix. | ribIdSchema, discovery |
displayName is 1 to 80 chars. | ribDisplayNameSchema |
Every views, surfaces, and region key lives under rib:<id> or rib:<id>:*. | activation, assertInNamespace |
| Snapshot keys a rib registers are namespaced; registering outside the namespace throws, and reading another rib’s keys returns nothing. | SnapshotManager, scoped per rib |
Credentials resolve only under rib_<id>_*, read-only. | getCredential |
| Tool names are global; a collision loses (skipped with a warning). | shared tool registry |
canvasKind is the closed markdown / view / html / log enum; for view and html a payload that mismatches its renderer is rejected client-side, while log tolerates a non-string payload and formats it as JSON. | canvasKindSchema, client gate |
A bound workflow’s output is run through validate before caching or broadcast; an invalid payload is dropped and the prior value kept. | SnapshotValidator, fail-closed |
cadenceMs is an integer of at least 30000. | surfaceRegionSchema |
serverRefresh, when present, is boolean; false opts the region out of the server heartbeat. | surfaceRegionSchema, scheduler derivation |
headActions carries 1–8 board-action-shaped items with no fields/expanded/inline/selected (a menu has no form and no pressed state); workflowArgs values are strings. | surfaceRegionSchema |
Lifecycle
Section titled “Lifecycle”-
Discovery.
bootstrapRibs()discovers installed@keelson/rib-*packages fromnode_modules/@keelson/at boot. Embedders can bypass discovery by passing an explicitbootstrapRibs({ available })map (the path tests use). -
Filter.
KEELSON_RIBS(comma-separated rib ids) selects which discovered ribs activate. Unset means activate all. Installed and active are distinct states: a package can be present in the home but filtered out per process. -
Validate. Each candidate is checked against the rules above. A shape failure caught at discovery is one warning and a skipped rib. A namespace violation, an id that diverges from its manifest key, a duplicate rib id, or a duplicate surface id throws at activation and fails boot.
-
Register.
registerToolsruns and its tools join the shared registry.composeBundle, if declared, is registered as the composer underrib.id(lazy).contributeWorkflowsentries and any YAML files in the package’sworkflows/folder are merged into the catalog.views,surfaces, andauthStatusbecome discoverable throughGET /api/ribs. -
Run. Tools serve chat and workflow
promptnodes. The browser renders views and surfaces from cached snapshots and re-runs region workflows on their cadence.onActionhandles board actions over the loopback API. -
Dispose. At shutdown the harness awaits each rib’s
disposebefore closing the database.
Wire shapes
Section titled “Wire shapes”A rib never edits the SPA. The browser discovers active ribs through two
endpoints, both typed in @keelson/shared:
GET /api/ribsreturnsListRibsResponse({ ribs: RibSummary[], crossRibGrants? }). EachRibSummaryis{ id, displayName, registered, views, surfaces, hasOnAction, acceptsIngest?, auth? }.viewsandsurfacesare always present (possibly empty);authappears only when the rib declaresauthStatus. Consumers treat absentacceptsIngestasfalse.crossRibGrantsis the operator’s grant map,Record<caller, Record<target, string[]>>. Absent and empty differ: absent means “not reported” (an older server), an empty map means “reported, and there are none”.POST /api/ribs/:id/actionreturnsRibActionResponse, the discriminatedRibActionResultthe rib’sonActionproduced.GET /api/agentsreturnsListAgentsResponse({ agents: AgentRef[] }), the named chat agents ribs offer;POST /api/agents/:ribId/:slug/resolveresolves one slug to anOpenChatSeed.GET /api/commandslists the rib slash commands the composer shows;/api/commands/:ribId/:name/completeand/api/commands/:ribId/:name/invokeback argument type-ahead and invocation.
Minimal example
Section titled “Minimal example”The smallest useful rib registers one tool. Tools reach chat and workflow
prompt nodes with no further wiring.
import type { Rib, ToolDefinition } from "@keelson/shared";import { z } from "zod";
const ping: ToolDefinition = { name: "demo_ping", // family "demo" (the substring before the first underscore) description: "Echo a message back to the agent.", inputSchema: z.object({ message: z.string() }), async execute(input, ctx) { const { message } = input as { message: string }; ctx.emit({ type: "tool_result", toolUseId: "", content: `pong: ${message}` }); },};
const rib: Rib = { id: "demo", displayName: "Demo", registerTools: () => [ping],};
export default rib;Full surface-producing example
Section titled “Full surface-producing example”A rib that publishes a snapshot, declares a view and a surface for it, contributes a bound workflow that refreshes it, handles a board action, and reports auth status. The payload builder and its schema live in the rib’s own module; the canvas payload shape itself is covered in Snapshots and surfaces.
import type { Rib, RibAction, RibActionResult, RibContext,} from "@keelson/shared";import { buildBoard, boardSchema } from "./board.ts"; // your pure builder + zod schema
const KEY = "rib:demo";
const rib: Rib = { id: "demo", displayName: "Demo",
registerTools(ctx: RibContext) { ctx.getSnapshotManager?.().recompose(rib.id); // warm the initial board return []; },
// Composer for `rib.id`, invoked lazily on recompose. async composeBundle(ctx: RibContext) { const res = await ctx.getExec().runJSON<unknown>("demo-cli", ["status", "--json"]); return buildBoard(res.ok ? res.data : null); // degrades to a valid empty board },
views: [{ key: KEY, canvasKind: "view", title: "Demo" }],
surfaces: [ { id: "demo", title: "Demo", layout: { rows: [ { columns: [ { key: KEY, workflow: "demo-refresh", cadenceMs: 60000, title: "Status" }, ], }, ], }, }, ],
contributeWorkflows() { return [ { definition: demoRefreshWorkflow, // a workflow whose node prints the board JSON bindSnapshotKey: KEY, validate: (data) => boardSchema.parse(data), // producer-side, fail-closed }, ]; },
async onAction(action: RibAction, ctx: RibContext): Promise<RibActionResult> { if (action.type === "refresh") { await ctx.getSnapshotManager?.().recompose(rib.id); return { ok: true }; } return { ok: false, error: `unknown action: ${action.type}` }; },
async authStatus(ctx: RibContext) { const res = await ctx.getExec().runText("demo-cli", ["whoami"]); return res.ok ? { authenticated: true } : { authenticated: false, statusMessage: "demo-cli is not authenticated" }; },};
export default rib;Related
Section titled “Related”- The rib model: why capabilities are packages and how a contribution reaches every surface.
- Snapshots and surfaces: the
publishing substrate behind
composeBundle, views, and surfaces. - Snapshots and the canvas: the precise
SnapshotManagerinterface and the canvas payload vocabulary aviewmust satisfy.